├── .gitignore ├── .idea ├── .name ├── compiler.xml ├── copyright │ └── profiles_settings.xml ├── encodings.xml ├── gradle.xml ├── misc.xml ├── modules.xml └── runConfigurations.xml ├── README.md ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── library ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── cn │ │ └── carbs │ │ └── android │ │ └── expandabletextview │ │ └── library │ │ └── ExampleInstrumentationTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── cn │ │ │ └── carbs │ │ │ └── android │ │ │ └── expandabletextview │ │ │ └── library │ │ │ └── ExpandableTextView.java │ └── res │ │ ├── values-zh-rCN │ │ └── strings.xml │ │ └── values │ │ ├── attr_expandable_text_view.xml │ │ └── strings.xml │ └── test │ └── java │ └── cn │ └── carbs │ └── android │ └── expandabletextview │ └── library │ └── ExampleUnitTest.java ├── sample ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── cn │ │ └── carbs │ │ └── android │ │ └── expandabletextview │ │ └── ExampleInstrumentationTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── cn │ │ │ └── carbs │ │ │ └── android │ │ │ └── expandabletextview │ │ │ ├── ActivityListView.java │ │ │ └── ActivityMain.java │ └── res │ │ ├── layout │ │ ├── activity_listview.xml │ │ ├── activity_main.xml │ │ └── item.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ ├── values-w820dp │ │ └── dimens.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── cn │ └── carbs │ └── android │ └── expandabletextview │ └── ExampleUnitTest.java └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | ExpandableTextView -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 1.8 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExpandableTextView 2 | an ExpandableTextView on Android platform which can shrink TextView height if its line count is greater than a certain number, it also can toggle state between expand and shrink 3 | 4 | * [English](#english) 5 | 6 | # 前言: 7 | 为了保持界面UI的整洁以及将尽可能多的内容显示在有限的空间中,往往需要将长度过长的TextView进行内容截取。本控件满足了TextView可在"完整内容"与"截取内容"两种模式下进行切换的需求,且可应用在ListView/RecyclerView中并可以动态更新内容。 8 | 9 | ## 截图: 10 | #### 静态截图如下: 11 | 12 | ![hint1][1]
13 | 14 | #### 动态效果图可点击如下链接: 15 | 16 | ![hint2][2]
17 | 18 | 19 | ## 主要功能: 20 | 1. 限制行数,行尾添加`ClickSpan`,点击可以"展开"/"收起"两种状态切换; 21 | 2. 可使用在`ListView`/`RecyclerView`中,效率较高; 22 | 3. 可在任意时刻更新`ExpandableTextView`内容(布局显示之前或者显示之后); 23 | 4. 可自定义行数限制,默认最多显示2行; 24 | 5. 可自定义行尾`ClickSpan`是否显示,颜色,文字,按下的背景颜色; 25 | 6. 可添加点击此view后是否在"展开"/"收起"状态间切换; 26 | 7. 文字不足最大限制行数时,不截断文字,不显示末尾的"展开"/"收起"的指示标识; 27 | 8. 可自定义行尾省略语与行尾"展开"/"收起"的指示标识之间的gap文字; 28 | 29 | ## 说明: 30 | 1. 效果参考了jQuery的readmore.js,部分代码参考了[ReadMoreTextView][3] 31 | 2. 与Github上star数最多的[ExpandableTextView][4]实现原理及UI完全不同。 32 | 3. 暂时未添加"收缩"/"展开"时的动画效果。 33 | 34 | ## 优化: 35 | 1. 解决末尾显示的指示标识文字与原来文字宽度不一致时的显示问题(如原始文字与行尾指示标识文字为不同语言)。如当结尾指示标识文字较宽时,可能会显示到下一行。以此优化UI体验。 36 | 2. 解决末尾单词过长或者跟随标点后,换行留下的空白问题。此问题源于TextView自带的一个属性:当结尾为完整单词或者跟随标点时会连同之前的部分文字一起换行。 37 | 3. 解决文字过短时,截取文字超出边界的问题。 38 | 4. 解决任何时刻为`ExpandableTextView`更新文字的问题。 39 | 40 | ## 不具有的功能: 41 | 1. 限制字符长度。此控件只限制最大行数,不限制字符长度; 42 | 2. 省略标识的位置自定义。省略标识的位置暂时只能显示在行尾,不能够指定是否在"行首"/"行中"/"行尾" 43 | 3. 暂时未添加"收缩"/"展开"时的动画效果。 44 | 45 | ## 添加依赖 46 | ```groovy 47 | compile 'cn.carbs.android:ExpandableTextView:1.0.3' 48 | ``` 49 | 50 | ## 使用方法: 51 | 有两种方法设置文字: 52 | (1)在java中更新文字 53 | ```java 54 | //普通视图中的更新 55 | etv.setText(text); 56 | //在ListView/RecyclerView中的应用 57 | etv.updateForRecyclerView(text, etvWidth, state);//etvWidth为控件的真实宽度,state是控件所处的状态,“收缩”/“伸展”状态 58 | ``` 59 | (2)在xml中直接设置文字 60 | ```xml 61 | 66 | ``` 67 | (3)可配置的属性有如下几项 68 | ```xml 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | ``` 89 | 90 | ## 实现原理: 91 | 1. 控件继承自`TextView`,`TextView`中的`setText(CharSequence text)`方法为 `final` 类型,且其内部最终调用了`setText(CharSequence text, BufferType type)`,因此`ExpandableTextView` Override了`setText(CharSequence text, BufferType type)`方法,且`TextView`在通过xml布局文件设置text时,同样最终是通过`setText(CharSequence text, BufferType type)`进行赋值,因此通过Override此方法达到自定义显示text的效果; 92 | 2. 采用android.text.Layout类来确定在一定宽度下,特定的文本所达到的行数,如果超过最大行数,则添加收缩/展开效果; 93 | 3. 为文本特定位置添加ClickableSpan,以此添加点击部分文本的响应效果;自定义`ClickableSpan`和`LinkMovementMethod`,达到添加点击`ClickableSpan`文字背景颜色改变的效果,感谢stackoverflow的解答; 94 | 4. 通过`Paint.measureText(String text)`方法,找到文本截取的最优位置,使得在行尾添加了ClickableSpan后,不会出现因文字宽度不同而导致的文本换行或者文本末尾空余过大的现象; 95 | 96 | 97 | ## 感谢 98 | #### [ReadMoreTextView][3] 99 | --------------------- 100 | # English 101 | 102 | 103 | ## License 104 | 105 | Copyright 2016 Carbs.Wang (ExpandableTextView) 106 | 107 | Licensed under the Apache License, Version 2.0 (the "License"); 108 | you may not use this file except in compliance with the License. 109 | You may obtain a copy of the License at 110 | 111 | http://www.apache.org/licenses/LICENSE-2.0 112 | 113 | Unless required by applicable law or agreed to in writing, software 114 | distributed under the License is distributed on an "AS IS" BASIS, 115 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 116 | See the License for the specific language governing permissions and 117 | limitations under the License. 118 | 119 | 120 | 121 | [1]: https://github.com/Carbs0126/Screenshot/blob/master/expandabletextview.jpg 122 | [2]: https://github.com/Carbs0126/Screenshot/blob/master/expandabletextview.gif 123 | [3]: https://github.com/borjabravo10/ReadMoreTextView 124 | [4]: https://github.com/Manabu-GT/ExpandableTextView 125 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | jcenter() 6 | } 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:2.0.0' 9 | classpath 'com.novoda:bintray-release:0.3.4' 10 | // NOTE: Do not place your application dependencies here; they belong 11 | // in the individual module build.gradle files 12 | } 13 | } 14 | 15 | allprojects { 16 | repositories { 17 | jcenter() 18 | } 19 | } 20 | 21 | task clean(type: Delete) { 22 | delete rootProject.buildDir 23 | } 24 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | org.gradle.jvmargs=-Xmx1536m 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | # org.gradle.parallel=true 18 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Carbs0126/ExpandableTextView/b5b464ff5f9917e9c6d0076fe920e13bfab99a78/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Dec 28 10:00:20 PST 2015 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /library/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /library/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'com.novoda.bintray-release' 3 | 4 | android { 5 | compileSdkVersion 24 6 | buildToolsVersion "24.0.0" 7 | 8 | defaultConfig { 9 | minSdkVersion 8 10 | targetSdkVersion 24 11 | versionCode 1 12 | versionName "1.0" 13 | 14 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | lintOptions { 25 | abortOnError false 26 | } 27 | } 28 | 29 | dependencies { 30 | compile fileTree(dir: 'libs', include: ['*.jar']) 31 | compile 'com.android.support:appcompat-v7:24.0.0' 32 | testCompile 'junit:junit:4.12' 33 | androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2' 34 | androidTestCompile 'com.android.support.test:runner:0.5' 35 | androidTestCompile 'com.android.support:support-annotations:24.0.0' 36 | } 37 | 38 | publish { 39 | userOrg = 'carbs' 40 | groupId = 'cn.carbs.android' 41 | artifactId = 'ExpandableTextView' 42 | publishVersion = '1.0.2' 43 | desc = 'an ExpandableTextView on Android platform which can shrink TextView height if its line count is greater than a certain number, it also can toggle state between expand and shrink' 44 | website = 'https://github.com/Carbs0126/ExpandableTextView' 45 | } -------------------------------------------------------------------------------- /library/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in C:\Android\SDK/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /library/src/androidTest/java/cn/carbs/android/expandabletextview/library/ExampleInstrumentationTest.java: -------------------------------------------------------------------------------- 1 | package cn.carbs.android.expandabletextview.library; 2 | 3 | import android.content.Context; 4 | import android.support.test.InstrumentationRegistry; 5 | import android.support.test.filters.MediumTest; 6 | import android.support.test.runner.AndroidJUnit4; 7 | 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | 11 | 12 | import static org.junit.Assert.*; 13 | 14 | /** 15 | * Instrumentation test, which will execute on an Android device. 16 | * 17 | * @see Testing documentation 18 | */ 19 | @MediumTest 20 | @RunWith(AndroidJUnit4.class) 21 | public class ExampleInstrumentationTest { 22 | @Test 23 | public void useAppContext() throws Exception { 24 | // Context of the app under test. 25 | Context appContext = InstrumentationRegistry.getTargetContext(); 26 | 27 | assertEquals("cn.carbs.android.expandabletextview.library.test", appContext.getPackageName()); 28 | } 29 | } -------------------------------------------------------------------------------- /library/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /library/src/main/java/cn/carbs/android/expandabletextview/library/ExpandableTextView.java: -------------------------------------------------------------------------------- 1 | package cn.carbs.android.expandabletextview.library; 2 | 3 | /** 4 | * Created by Carbs.Wang on 2016/7/16. 5 | * website: https://github.com/Carbs0126/ 6 | * 7 | * Thanks to : 8 | * 1.ReadMoreTextView 9 | * https://github.com/borjabravo10/ReadMoreTextView 10 | * 2.TouchableSpan 11 | * http://stackoverflow.com/questions 12 | * /20856105/change-the-text-color-of-a-single-clickablespan-when-pressed-without-affecting-o 13 | * 3.FlatUI 14 | * http://www.bootcss.com/p/flat-ui/ 15 | */ 16 | import android.content.Context; 17 | import android.content.res.TypedArray; 18 | import android.os.Build; 19 | import android.text.DynamicLayout; 20 | import android.text.Layout; 21 | import android.text.Selection; 22 | import android.text.Spannable; 23 | import android.text.SpannableStringBuilder; 24 | import android.text.Spanned; 25 | import android.text.TextPaint; 26 | import android.text.TextUtils; 27 | import android.text.method.LinkMovementMethod; 28 | import android.text.style.ClickableSpan; 29 | import android.util.AttributeSet; 30 | import android.view.MotionEvent; 31 | import android.view.View; 32 | import android.view.ViewTreeObserver; 33 | import android.widget.TextView; 34 | 35 | import java.lang.reflect.Field; 36 | 37 | public class ExpandableTextView extends TextView{ 38 | 39 | public static final int STATE_SHRINK = 0; 40 | public static final int STATE_EXPAND = 1; 41 | 42 | private static final String CLASS_NAME_VIEW = "android.view.View"; 43 | private static final String CLASS_NAME_LISTENER_INFO = "android.view.View$ListenerInfo"; 44 | private static final String ELLIPSIS_HINT = ".."; 45 | private static final String GAP_TO_EXPAND_HINT = " "; 46 | private static final String GAP_TO_SHRINK_HINT = " "; 47 | private static final int MAX_LINES_ON_SHRINK = 2; 48 | private static final int TO_EXPAND_HINT_COLOR = 0xFF3498DB; 49 | private static final int TO_SHRINK_HINT_COLOR = 0xFFE74C3C; 50 | private static final int TO_EXPAND_HINT_COLOR_BG_PRESSED = 0x55999999; 51 | private static final int TO_SHRINK_HINT_COLOR_BG_PRESSED = 0x55999999; 52 | private static final boolean TOGGLE_ENABLE = true; 53 | private static final boolean SHOW_TO_EXPAND_HINT = true; 54 | private static final boolean SHOW_TO_SHRINK_HINT = true; 55 | 56 | private String mEllipsisHint; 57 | private String mToExpandHint; 58 | private String mToShrinkHint; 59 | private String mGapToExpandHint = GAP_TO_EXPAND_HINT; 60 | private String mGapToShrinkHint = GAP_TO_SHRINK_HINT; 61 | private boolean mToggleEnable = TOGGLE_ENABLE; 62 | private boolean mShowToExpandHint = SHOW_TO_EXPAND_HINT; 63 | private boolean mShowToShrinkHint = SHOW_TO_SHRINK_HINT; 64 | private int mMaxLinesOnShrink = MAX_LINES_ON_SHRINK; 65 | private int mToExpandHintColor = TO_EXPAND_HINT_COLOR; 66 | private int mToShrinkHintColor = TO_SHRINK_HINT_COLOR; 67 | private int mToExpandHintColorBgPressed = TO_EXPAND_HINT_COLOR_BG_PRESSED; 68 | private int mToShrinkHintColorBgPressed = TO_SHRINK_HINT_COLOR_BG_PRESSED; 69 | private int mCurrState = STATE_SHRINK; 70 | 71 | // used to add to the tail of modified text, the "shrink" and "expand" text 72 | private TouchableSpan mTouchableSpan; 73 | private BufferType mBufferType = BufferType.NORMAL; 74 | private TextPaint mTextPaint; 75 | private Layout mLayout; 76 | private int mTextLineCount = -1; 77 | private int mLayoutWidth = 0; 78 | private int mFutureTextViewWidth = 0; 79 | 80 | // the original text of this view 81 | private CharSequence mOrigText; 82 | 83 | // used to judge if the listener of corresponding to the onclick event of ExpandableTextView 84 | // is specifically for inner toggle 85 | private ExpandableClickListener mExpandableClickListener; 86 | private OnExpandListener mOnExpandListener; 87 | 88 | public ExpandableTextView(Context context) { 89 | super(context); 90 | init(); 91 | } 92 | 93 | public ExpandableTextView(Context context, AttributeSet attrs) { 94 | super(context, attrs); 95 | initAttr(context,attrs); 96 | init(); 97 | } 98 | 99 | public ExpandableTextView(Context context, AttributeSet attrs, int defStyleAttr) { 100 | super(context, attrs, defStyleAttr); 101 | initAttr(context,attrs); 102 | init(); 103 | } 104 | 105 | private void initAttr(Context context, AttributeSet attrs) { 106 | if (attrs == null) { 107 | return; 108 | } 109 | TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ExpandableTextView); 110 | if (a == null) { 111 | return; 112 | } 113 | int n = a.getIndexCount(); 114 | for (int i = 0; i < n; i++) { 115 | int attr = a.getIndex(i); 116 | if (attr == R.styleable.ExpandableTextView_etv_MaxLinesOnShrink) { 117 | mMaxLinesOnShrink = a.getInteger(attr, MAX_LINES_ON_SHRINK); 118 | }else if (attr == R.styleable.ExpandableTextView_etv_EllipsisHint){ 119 | mEllipsisHint = a.getString(attr); 120 | }else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHint) { 121 | mToExpandHint = a.getString(attr); 122 | }else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHint) { 123 | mToShrinkHint = a.getString(attr); 124 | }else if (attr == R.styleable.ExpandableTextView_etv_EnableToggle) { 125 | mToggleEnable = a.getBoolean(attr, TOGGLE_ENABLE); 126 | }else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHintShow){ 127 | mShowToExpandHint = a.getBoolean(attr, SHOW_TO_EXPAND_HINT); 128 | }else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHintShow){ 129 | mShowToShrinkHint = a.getBoolean(attr, SHOW_TO_SHRINK_HINT); 130 | }else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHintColor){ 131 | mToExpandHintColor = a.getInteger(attr, TO_EXPAND_HINT_COLOR); 132 | }else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHintColor){ 133 | mToShrinkHintColor = a.getInteger(attr, TO_SHRINK_HINT_COLOR); 134 | }else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHintColorBgPressed){ 135 | mToExpandHintColorBgPressed = a.getInteger(attr, TO_EXPAND_HINT_COLOR_BG_PRESSED); 136 | }else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHintColorBgPressed){ 137 | mToShrinkHintColorBgPressed = a.getInteger(attr, TO_SHRINK_HINT_COLOR_BG_PRESSED); 138 | }else if (attr == R.styleable.ExpandableTextView_etv_InitState){ 139 | mCurrState = a.getInteger(attr, STATE_SHRINK); 140 | }else if (attr == R.styleable.ExpandableTextView_etv_GapToExpandHint){ 141 | mGapToExpandHint = a.getString(attr); 142 | }else if (attr == R.styleable.ExpandableTextView_etv_GapToShrinkHint){ 143 | mGapToShrinkHint = a.getString(attr); 144 | } 145 | } 146 | a.recycle(); 147 | } 148 | 149 | private void init() { 150 | mTouchableSpan = new TouchableSpan(); 151 | setMovementMethod(new LinkTouchMovementMethod()); 152 | if(TextUtils.isEmpty(mEllipsisHint)) { 153 | mEllipsisHint = ELLIPSIS_HINT; 154 | } 155 | if(TextUtils.isEmpty(mToExpandHint)){ 156 | mToExpandHint = getResources().getString(R.string.to_expand_hint); 157 | } 158 | if(TextUtils.isEmpty(mToShrinkHint)){ 159 | mToShrinkHint = getResources().getString(R.string.to_shrink_hint); 160 | } 161 | if(mToggleEnable){ 162 | mExpandableClickListener = new ExpandableClickListener(); 163 | setOnClickListener(mExpandableClickListener); 164 | } 165 | getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { 166 | @Override 167 | public void onGlobalLayout() { 168 | ViewTreeObserver obs = getViewTreeObserver(); 169 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 170 | obs.removeOnGlobalLayoutListener(this); 171 | } else { 172 | obs.removeGlobalOnLayoutListener(this); 173 | } 174 | setTextInternal(getNewTextByConfig(), mBufferType); 175 | } 176 | }); 177 | } 178 | 179 | /** 180 | * used in ListView or RecyclerView to update ExpandableTextView 181 | * @param text 182 | * original text 183 | * @param futureTextViewWidth 184 | * the width of ExpandableTextView in px unit, 185 | * used to get max line number of original text by given the width 186 | * @param expandState 187 | * expand or shrink 188 | */ 189 | public void updateForRecyclerView(CharSequence text, int futureTextViewWidth, int expandState){ 190 | mFutureTextViewWidth = futureTextViewWidth; 191 | mCurrState = expandState; 192 | setText(text); 193 | } 194 | 195 | public void updateForRecyclerView(CharSequence text, BufferType type, int futureTextViewWidth){ 196 | mFutureTextViewWidth = futureTextViewWidth; 197 | setText(text, type); 198 | } 199 | 200 | public void updateForRecyclerView(CharSequence text, int futureTextViewWidth){ 201 | mFutureTextViewWidth = futureTextViewWidth; 202 | setText(text); 203 | } 204 | 205 | /** 206 | * get the current state of ExpandableTextView 207 | * @return 208 | * STATE_SHRINK if in shrink state 209 | * STATE_EXPAND if in expand state 210 | */ 211 | public int getExpandState(){ 212 | return mCurrState; 213 | } 214 | 215 | /** 216 | * refresh and get a will-be-displayed text by current configuration 217 | * @return 218 | * get a will-be-displayed text 219 | */ 220 | private CharSequence getNewTextByConfig(){ 221 | if(TextUtils.isEmpty(mOrigText)){ 222 | return mOrigText; 223 | } 224 | 225 | mLayout = getLayout(); 226 | if(mLayout != null){ 227 | mLayoutWidth = mLayout.getWidth(); 228 | } 229 | 230 | if(mLayoutWidth <= 0){ 231 | if(getWidth() == 0) { 232 | if (mFutureTextViewWidth == 0) { 233 | return mOrigText; 234 | } else { 235 | mLayoutWidth = mFutureTextViewWidth - getPaddingLeft() - getPaddingRight(); 236 | } 237 | }else{ 238 | mLayoutWidth = getWidth() - getPaddingLeft() - getPaddingRight(); 239 | } 240 | } 241 | 242 | mTextPaint = getPaint(); 243 | 244 | mTextLineCount = -1; 245 | switch (mCurrState){ 246 | case STATE_SHRINK: { 247 | mLayout = new DynamicLayout(mOrigText, mTextPaint, mLayoutWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); 248 | mTextLineCount = mLayout.getLineCount(); 249 | 250 | if (mTextLineCount <= mMaxLinesOnShrink) { 251 | return mOrigText; 252 | } 253 | int indexEnd = getValidLayout().getLineEnd(mMaxLinesOnShrink - 1); 254 | int indexStart = getValidLayout().getLineStart(mMaxLinesOnShrink - 1); 255 | int indexEndTrimmed = indexEnd 256 | - getLengthOfString(mEllipsisHint) 257 | - (mShowToExpandHint ? getLengthOfString(mToExpandHint) + getLengthOfString(mGapToExpandHint) : 0); 258 | 259 | if (indexEndTrimmed <= indexStart) { 260 | indexEndTrimmed = indexEnd; 261 | } 262 | 263 | int remainWidth = getValidLayout().getWidth() - 264 | (int) (mTextPaint.measureText(mOrigText.subSequence(indexStart, indexEndTrimmed).toString()) + 0.5); 265 | float widthTailReplaced = mTextPaint.measureText(getContentOfString(mEllipsisHint) 266 | + (mShowToExpandHint ? (getContentOfString(mToExpandHint) + getContentOfString(mGapToExpandHint)) : "")); 267 | 268 | int indexEndTrimmedRevised = indexEndTrimmed; 269 | if (remainWidth > widthTailReplaced) { 270 | int extraOffset = 0; 271 | int extraWidth = 0; 272 | while (remainWidth > widthTailReplaced + extraWidth) { 273 | extraOffset++; 274 | if (indexEndTrimmed + extraOffset <= mOrigText.length()) { 275 | extraWidth = (int) (mTextPaint.measureText( 276 | mOrigText.subSequence(indexEndTrimmed, indexEndTrimmed + extraOffset).toString()) + 0.5); 277 | } else { 278 | break; 279 | } 280 | } 281 | indexEndTrimmedRevised += extraOffset - 1; 282 | } else { 283 | int extraOffset = 0; 284 | int extraWidth = 0; 285 | while (remainWidth + extraWidth < widthTailReplaced) { 286 | extraOffset--; 287 | if (indexEndTrimmed + extraOffset > indexStart) { 288 | extraWidth = (int) (mTextPaint.measureText(mOrigText.subSequence(indexEndTrimmed + extraOffset, indexEndTrimmed).toString()) + 0.5); 289 | } else { 290 | break; 291 | } 292 | } 293 | indexEndTrimmedRevised += extraOffset; 294 | } 295 | 296 | CharSequence fixText = removeEndLineBreak(mOrigText.subSequence(0, indexEndTrimmedRevised)); 297 | SpannableStringBuilder ssbShrink = new SpannableStringBuilder(fixText) 298 | .append(mEllipsisHint); 299 | if (mShowToExpandHint) { 300 | ssbShrink.append(getContentOfString(mGapToExpandHint) + getContentOfString(mToExpandHint)); 301 | ssbShrink.setSpan(mTouchableSpan, ssbShrink.length() - getLengthOfString(mToExpandHint), ssbShrink.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 302 | } 303 | return ssbShrink; 304 | } 305 | case STATE_EXPAND: { 306 | if (!mShowToShrinkHint) { 307 | return mOrigText; 308 | } 309 | mLayout = new DynamicLayout(mOrigText, mTextPaint, mLayoutWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); 310 | mTextLineCount = mLayout.getLineCount(); 311 | 312 | if (mTextLineCount <= mMaxLinesOnShrink) { 313 | return mOrigText; 314 | } 315 | 316 | SpannableStringBuilder ssbExpand = new SpannableStringBuilder(mOrigText) 317 | .append(mGapToShrinkHint).append(mToShrinkHint); 318 | ssbExpand.setSpan(mTouchableSpan, ssbExpand.length() - getLengthOfString(mToShrinkHint), ssbExpand.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 319 | return ssbExpand; 320 | } 321 | } 322 | return mOrigText; 323 | } 324 | 325 | private CharSequence removeEndLineBreak(CharSequence text) { 326 | while (text.toString().endsWith("\n")) { 327 | text = text.subSequence(0, text.length() - 1); 328 | } 329 | return text; 330 | } 331 | 332 | public void setExpandListener(OnExpandListener listener){ 333 | mOnExpandListener = listener; 334 | } 335 | 336 | private Layout getValidLayout(){ 337 | return mLayout != null ? mLayout : getLayout(); 338 | } 339 | 340 | private void toggle(){ 341 | switch (mCurrState){ 342 | case STATE_SHRINK: 343 | mCurrState = STATE_EXPAND; 344 | if(mOnExpandListener != null){ 345 | mOnExpandListener.onExpand(this); 346 | } 347 | break; 348 | case STATE_EXPAND: 349 | mCurrState = STATE_SHRINK; 350 | if(mOnExpandListener != null){ 351 | mOnExpandListener.onShrink(this); 352 | } 353 | break; 354 | } 355 | setTextInternal(getNewTextByConfig(), mBufferType); 356 | } 357 | 358 | @Override 359 | public void setText(CharSequence text, BufferType type) { 360 | mOrigText = text; 361 | mBufferType = type; 362 | setTextInternal(getNewTextByConfig(), type); 363 | } 364 | 365 | private void setTextInternal(CharSequence text, BufferType type){ 366 | super.setText(text, type); 367 | } 368 | 369 | private int getLengthOfString(String string){ 370 | if(string == null) 371 | return 0; 372 | return string.length(); 373 | } 374 | 375 | private String getContentOfString(String string){ 376 | if(string == null) 377 | return ""; 378 | return string; 379 | } 380 | 381 | public interface OnExpandListener{ 382 | void onExpand(ExpandableTextView view); 383 | void onShrink(ExpandableTextView view); 384 | } 385 | 386 | private class ExpandableClickListener implements View.OnClickListener{ 387 | @Override 388 | public void onClick(View view) { 389 | toggle(); 390 | } 391 | } 392 | 393 | public View.OnClickListener getOnClickListener(View view) { 394 | if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { 395 | return getOnClickListenerV14(view); 396 | } else { 397 | return getOnClickListenerV(view); 398 | } 399 | } 400 | 401 | private View.OnClickListener getOnClickListenerV(View view) { 402 | View.OnClickListener retrievedListener = null; 403 | try { 404 | Field field = Class.forName(CLASS_NAME_VIEW).getDeclaredField("mOnClickListener"); 405 | field.setAccessible(true); 406 | retrievedListener = (View.OnClickListener) field.get(view); 407 | } catch (Exception e) { 408 | e.printStackTrace(); 409 | } 410 | 411 | return retrievedListener; 412 | } 413 | 414 | private View.OnClickListener getOnClickListenerV14(View view) { 415 | View.OnClickListener retrievedListener = null; 416 | try { 417 | Field listenerField = Class.forName(CLASS_NAME_VIEW).getDeclaredField("mListenerInfo"); 418 | Object listenerInfo = null; 419 | 420 | if (listenerField != null) { 421 | listenerField.setAccessible(true); 422 | listenerInfo = listenerField.get(view); 423 | } 424 | 425 | Field clickListenerField = Class.forName(CLASS_NAME_LISTENER_INFO).getDeclaredField("mOnClickListener"); 426 | 427 | if (clickListenerField != null && listenerInfo != null) { 428 | clickListenerField.setAccessible(true); 429 | retrievedListener = (View.OnClickListener) clickListenerField.get(listenerInfo); 430 | } 431 | } catch (Exception e) { 432 | e.printStackTrace(); 433 | } 434 | 435 | return retrievedListener; 436 | } 437 | 438 | 439 | /** 440 | * Copy from: 441 | * http://stackoverflow.com/questions 442 | * /20856105/change-the-text-color-of-a-single-clickablespan-when-pressed-without-affecting-o 443 | * By: 444 | * Steven Meliopoulos 445 | */ 446 | private class TouchableSpan extends ClickableSpan { 447 | private boolean mIsPressed; 448 | public void setPressed(boolean isSelected) { 449 | mIsPressed = isSelected; 450 | } 451 | 452 | @Override 453 | public void onClick(View widget) { 454 | if(hasOnClickListeners() 455 | && (getOnClickListener(ExpandableTextView.this) instanceof ExpandableClickListener)) { 456 | }else{ 457 | toggle(); 458 | } 459 | } 460 | 461 | @Override 462 | public void updateDrawState(TextPaint ds) { 463 | super.updateDrawState(ds); 464 | switch (mCurrState){ 465 | case STATE_SHRINK: 466 | ds.setColor(mToExpandHintColor); 467 | ds.bgColor = mIsPressed ? mToExpandHintColorBgPressed : 0; 468 | break; 469 | case STATE_EXPAND: 470 | ds.setColor(mToShrinkHintColor); 471 | ds.bgColor = mIsPressed ? mToShrinkHintColorBgPressed : 0; 472 | break; 473 | } 474 | ds.setUnderlineText(false); 475 | } 476 | } 477 | 478 | /** 479 | * Copy from: 480 | * http://stackoverflow.com/questions 481 | * /20856105/change-the-text-color-of-a-single-clickablespan-when-pressed-without-affecting-o 482 | * By: 483 | * Steven Meliopoulos 484 | */ 485 | public class LinkTouchMovementMethod extends LinkMovementMethod { 486 | private TouchableSpan mPressedSpan; 487 | 488 | @Override 489 | public boolean onTouchEvent(TextView textView, Spannable spannable, MotionEvent event) { 490 | if (event.getAction() == MotionEvent.ACTION_DOWN) { 491 | mPressedSpan = getPressedSpan(textView, spannable, event); 492 | if (mPressedSpan != null) { 493 | mPressedSpan.setPressed(true); 494 | Selection.setSelection(spannable, spannable.getSpanStart(mPressedSpan), 495 | spannable.getSpanEnd(mPressedSpan)); 496 | } 497 | } else if (event.getAction() == MotionEvent.ACTION_MOVE) { 498 | TouchableSpan touchedSpan = getPressedSpan(textView, spannable, event); 499 | if (mPressedSpan != null && touchedSpan != mPressedSpan) { 500 | mPressedSpan.setPressed(false); 501 | mPressedSpan = null; 502 | Selection.removeSelection(spannable); 503 | } 504 | } else { 505 | if (mPressedSpan != null) { 506 | mPressedSpan.setPressed(false); 507 | super.onTouchEvent(textView, spannable, event); 508 | } 509 | mPressedSpan = null; 510 | Selection.removeSelection(spannable); 511 | } 512 | return true; 513 | } 514 | 515 | private TouchableSpan getPressedSpan(TextView textView, Spannable spannable, MotionEvent event) { 516 | 517 | int x = (int) event.getX(); 518 | int y = (int) event.getY(); 519 | 520 | x -= textView.getTotalPaddingLeft(); 521 | y -= textView.getTotalPaddingTop(); 522 | 523 | x += textView.getScrollX(); 524 | y += textView.getScrollY(); 525 | 526 | Layout layout = textView.getLayout(); 527 | int line = layout.getLineForVertical(y); 528 | int off = layout.getOffsetForHorizontal(line, x); 529 | 530 | TouchableSpan[] link = spannable.getSpans(off, off, TouchableSpan.class); 531 | TouchableSpan touchedSpan = null; 532 | if (link.length > 0) { 533 | touchedSpan = link[0]; 534 | } 535 | return touchedSpan; 536 | } 537 | } 538 | } 539 | -------------------------------------------------------------------------------- /library/src/main/res/values-zh-rCN/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 展开 3 | 收起 4 | -------------------------------------------------------------------------------- /library/src/main/res/values/attr_expandable_text_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /library/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ExpandableTextView 3 | ExpandableTextView in ListView 4 | Expand 5 | Shrink 6 | 7 | -------------------------------------------------------------------------------- /library/src/test/java/cn/carbs/android/expandabletextview/library/ExampleUnitTest.java: -------------------------------------------------------------------------------- 1 | package cn.carbs.android.expandabletextview.library; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.junit.Assert.*; 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * @see Testing documentation 11 | */ 12 | public class ExampleUnitTest { 13 | @Test 14 | public void addition_isCorrect() throws Exception { 15 | assertEquals(4, 2 + 2); 16 | } 17 | } -------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /sample/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 24 5 | buildToolsVersion "24.0.0" 6 | defaultConfig { 7 | applicationId "cn.carbs.android.expandabletextview" 8 | minSdkVersion 15 9 | targetSdkVersion 24 10 | versionCode 1 11 | versionName "1.0" 12 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 13 | } 14 | buildTypes { 15 | release { 16 | minifyEnabled false 17 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 18 | } 19 | } 20 | } 21 | 22 | dependencies { 23 | compile project(':library') 24 | compile fileTree(dir: 'libs', include: ['*.jar']) 25 | compile 'com.android.support:appcompat-v7:24.0.0' 26 | testCompile 'junit:junit:4.12' 27 | androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2' 28 | androidTestCompile 'com.android.support.test:runner:0.5' 29 | androidTestCompile 'com.android.support:support-annotations:24.0.0' 30 | } 31 | -------------------------------------------------------------------------------- /sample/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in C:\Android\SDK/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/cn/carbs/android/expandabletextview/ExampleInstrumentationTest.java: -------------------------------------------------------------------------------- 1 | package cn.carbs.android.expandabletextview; 2 | 3 | import android.content.Context; 4 | import android.support.test.InstrumentationRegistry; 5 | import android.support.test.filters.MediumTest; 6 | import android.support.test.runner.AndroidJUnit4; 7 | 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | 11 | 12 | import static org.junit.Assert.*; 13 | 14 | /** 15 | * Instrumentation test, which will execute on an Android device. 16 | * 17 | * @see Testing documentation 18 | */ 19 | @MediumTest 20 | @RunWith(AndroidJUnit4.class) 21 | public class ExampleInstrumentationTest { 22 | @Test 23 | public void useAppContext() throws Exception { 24 | // Context of the app under test. 25 | Context appContext = InstrumentationRegistry.getTargetContext(); 26 | 27 | assertEquals("cn.carbs.android.expandabletextview", appContext.getPackageName()); 28 | } 29 | } -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /sample/src/main/java/cn/carbs/android/expandabletextview/ActivityListView.java: -------------------------------------------------------------------------------- 1 | package cn.carbs.android.expandabletextview; 2 | 3 | import android.content.Context; 4 | import android.os.Bundle; 5 | import android.support.v7.app.AppCompatActivity; 6 | import android.util.SparseArray; 7 | import android.view.LayoutInflater; 8 | import android.view.View; 9 | import android.view.ViewGroup; 10 | import android.widget.BaseAdapter; 11 | import android.widget.Button; 12 | import android.widget.ListView; 13 | 14 | import java.util.ArrayList; 15 | import java.util.List; 16 | 17 | import cn.carbs.android.expandabletextview.library.ExpandableTextView; 18 | 19 | /** 20 | * Created by carbs on 2016/7/23. 21 | */ 22 | public class ActivityListView extends AppCompatActivity implements View.OnClickListener{ 23 | 24 | private ListView mListView; 25 | private TheBaseAdapter mAdapter; 26 | private Button mButton; 27 | 28 | private List mStrings = new ArrayList<>(); 29 | private boolean mFlag = true; 30 | private CharSequence[] mPoems; 31 | private CharSequence[] mProses; 32 | 33 | @Override 34 | protected void onCreate(Bundle savedInstanceState) { 35 | super.onCreate(savedInstanceState); 36 | setContentView(R.layout.activity_listview); 37 | mListView = (ListView)this.findViewById(R.id.listview); 38 | mButton = (Button)this.findViewById(R.id.button_update_list); 39 | 40 | mPoems = getResources().getStringArray(R.array.poems_2); 41 | mProses = getResources().getStringArray(R.array.prose); 42 | 43 | mAdapter = new TheBaseAdapter(this, mStrings); 44 | mListView.setAdapter(mAdapter); 45 | mButton.setOnClickListener(this); 46 | getWindow().getDecorView().postDelayed(new Runnable() { 47 | @Override 48 | public void run() { 49 | inflateListViews(); 50 | } 51 | }, 0); 52 | } 53 | 54 | @Override 55 | public void onClick(View view) { 56 | switch (view.getId()){ 57 | case R.id.button_update_list: 58 | inflateListViews(); 59 | break; 60 | } 61 | } 62 | 63 | private void inflateListViews(){ 64 | mListView.smoothScrollByOffset(0); 65 | mStrings.clear(); 66 | if(mFlag){ 67 | for(CharSequence cs : mPoems){ 68 | mStrings.add(cs.toString()); 69 | } 70 | }else{ 71 | for(CharSequence cs : mProses){ 72 | mStrings.add(cs.toString()); 73 | } 74 | } 75 | mAdapter.notifyDataSetChanged(); 76 | if(mAdapter.getCount() > 0) { 77 | mListView.setSelection(0); 78 | } 79 | mFlag = !mFlag; 80 | } 81 | 82 | class TheBaseAdapter extends BaseAdapter implements ExpandableTextView.OnExpandListener{ 83 | 84 | private SparseArray mPositionsAndStates = new SparseArray<>(); 85 | private List mList; 86 | private LayoutInflater inflater; 87 | 88 | public TheBaseAdapter(Context context, List list){ 89 | mList = list; 90 | this.inflater = LayoutInflater.from(context); 91 | } 92 | 93 | @Override 94 | public int getCount() { 95 | return mList.size(); 96 | } 97 | 98 | @Override 99 | public Object getItem(int i) { 100 | return mList.get(i); 101 | } 102 | 103 | @Override 104 | public long getItemId(int i) { 105 | return i; 106 | } 107 | 108 | //只要在getview时为其赋值为准确的宽度值即可,无论采用何种方法 109 | private int etvWidth; 110 | @Override 111 | public View getView(int position, View convertView, ViewGroup parent) { 112 | final ViewHolder viewHolder; 113 | if (null == convertView){ 114 | viewHolder = new ViewHolder(); 115 | convertView = inflater.inflate(R.layout.item, parent, false); 116 | viewHolder.etv = (ExpandableTextView) convertView.findViewById(R.id.etv); 117 | convertView.setTag(viewHolder); 118 | } 119 | else{ 120 | viewHolder = (ViewHolder) convertView.getTag(); 121 | } 122 | 123 | String content = (String)getItem(position); 124 | if(etvWidth == 0){ 125 | viewHolder.etv.post(new Runnable() { 126 | @Override 127 | public void run() { 128 | etvWidth = viewHolder.etv.getWidth(); 129 | } 130 | }); 131 | } 132 | viewHolder.etv.setTag(position); 133 | viewHolder.etv.setExpandListener(this); 134 | Integer state = mPositionsAndStates.get(position); 135 | 136 | viewHolder.etv.updateForRecyclerView(content.toString(), etvWidth, state== null ? 0 : state);//第一次getview时肯定为etvWidth为0 137 | 138 | return convertView; 139 | } 140 | 141 | @Override 142 | public void onExpand(ExpandableTextView view) { 143 | Object obj = view.getTag(); 144 | if(obj != null && obj instanceof Integer){ 145 | mPositionsAndStates.put((Integer)obj, view.getExpandState()); 146 | } 147 | } 148 | 149 | @Override 150 | public void onShrink(ExpandableTextView view) { 151 | Object obj = view.getTag(); 152 | if(obj != null && obj instanceof Integer){ 153 | mPositionsAndStates.put((Integer)obj, view.getExpandState()); 154 | } 155 | } 156 | } 157 | 158 | static class ViewHolder{ 159 | ExpandableTextView etv; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /sample/src/main/java/cn/carbs/android/expandabletextview/ActivityMain.java: -------------------------------------------------------------------------------- 1 | package cn.carbs.android.expandabletextview; 2 | 3 | import android.content.Intent; 4 | import android.os.Bundle; 5 | import android.support.v7.app.AppCompatActivity; 6 | import android.view.View; 7 | import android.widget.Button; 8 | import android.widget.TextView; 9 | 10 | import java.util.Random; 11 | 12 | import cn.carbs.android.expandabletextview.library.ExpandableTextView; 13 | 14 | /** 15 | * Created by carbs on 2016/7/23. 16 | */ 17 | public class ActivityMain extends AppCompatActivity implements View.OnClickListener{ 18 | 19 | private TextView mTVComparison; 20 | private Button mBtnUpdateText; 21 | private Button mBtnToListView; 22 | 23 | private ExpandableTextView mETV; 24 | private CharSequence[] mPoems = null; 25 | 26 | @Override 27 | protected void onCreate(Bundle savedInstanceState) { 28 | super.onCreate(savedInstanceState); 29 | setContentView(R.layout.activity_main); 30 | 31 | mPoems = getResources().getStringArray(R.array.poems); 32 | 33 | mTVComparison = (TextView)this.findViewById(R.id.tv_comparison); 34 | mBtnUpdateText = (Button)this.findViewById(R.id.button_update_text); 35 | mBtnToListView = (Button)this.findViewById(R.id.button_to_list_view); 36 | mETV = (ExpandableTextView)this.findViewById(R.id.etv); 37 | 38 | mBtnUpdateText.setOnClickListener(this); 39 | mBtnToListView.setOnClickListener(this); 40 | 41 | // 测试添加OnClickListener的情况,功能正常。添加外部的onClick事件后,原来的点击toggle功能自动屏蔽, 42 | // 点击尾部的ClickableSpan仍然有效 43 | /*mETV.setOnClickListener(new View.OnClickListener(){ 44 | @Override 45 | public void onClick(View view) { 46 | switch (mETV.getExpandState()){ 47 | case ExpandableTextView.STATE_SHRINK: 48 | Toast.makeText(getApplicationContext(),"ExpandableTextView clicked, STATE_SHRINK", 49 | Toast.LENGTH_SHORT).show(); 50 | break; 51 | case ExpandableTextView.STATE_EXPAND: 52 | Toast.makeText(getApplicationContext(),"ExpandableTextView clicked, STATE_EXPAND", 53 | Toast.LENGTH_SHORT).show(); 54 | break; 55 | } 56 | } 57 | });*/ 58 | // mETV.setText(mPoems[0]);//在ExpandableTextView在创建完成之前改变文字,功能正常 59 | } 60 | 61 | @Override 62 | public void onClick(View view) { 63 | switch (view.getId()){ 64 | case R.id.button_to_list_view: 65 | gotoCheckInListView(); 66 | break; 67 | case R.id.button_update_text: 68 | updateText(); 69 | break; 70 | } 71 | } 72 | 73 | private void gotoCheckInListView(){ 74 | Intent intent = new Intent(ActivityMain.this, ActivityListView.class); 75 | startActivity(intent); 76 | } 77 | 78 | private Random mRandom = new Random(); 79 | private int prevRandomInt = -1; 80 | private int currRandomInt = -1; 81 | 82 | private void updateText(){ 83 | currRandomInt = mRandom.nextInt(mPoems.length); 84 | while (prevRandomInt == currRandomInt){ 85 | currRandomInt = mRandom.nextInt(mPoems.length); 86 | } 87 | prevRandomInt = currRandomInt; 88 | CharSequence newCS = mPoems[currRandomInt]; 89 | 90 | mTVComparison.setText(newCS);//作为对比示例 91 | mETV.setText(newCS);//效果显示 92 | } 93 | } -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_listview.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 |