├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── mrtrying │ │ └── widget │ │ └── expandabletext │ │ └── example │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── mrtrying │ │ │ └── widget │ │ │ └── expandabletext │ │ │ ├── ExpandableTextView.java │ │ │ ├── OverLinkMovementMethod.java │ │ │ └── example │ │ │ └── MainActivity.java │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── mrtrying │ └── widget │ └── expandabletext │ └── example │ └── ExampleUnitTest.java ├── build.gradle └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | /gradlew.bat 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExpandableText-Example 2 | 3 | 4 | ---------- 5 | ## 一.简介 6 | 7 | 仿小红书实现的文本展开/收起的功能 8 | 9 | 10 | ## 二.方法说明 11 | 12 | 在设置文本之前,必须手动调用`initWidth`初始化文本显示的宽 13 | 14 | |方法 |说明 | 15 | | :---- | :---- | 16 | |`initWidth(int width)` |**初始化`ExpandableText`宽度,必须在`setOriginalText()`之前调用** | 17 | |`setMaxLines(int maxLines)`|设置最多显示行数| 18 | |`setOpenSuffix(String openSuffix)`|设置**需要展开**时显示的文字,默认为`展开`| 19 | |`setOpenSuffixColor(@ColorInt int openSuffixColor)`|设置**需要展开**时显示的文字的文字颜色| 20 | |`setCloseSuffix(String closeSuffix)`|设置**需要收起**时显示的文字,默认为`收起`| 21 | |`setCloseSuffixColor(@ColorInt int closeSuffixColor)`|设置**需要收起**时显示的文字的文字颜色| 22 | |`setCloseInNewLine(boolean closeInNewLine)`|设置**需要收起**时收起文字是否另起一行| 23 | |`setOpenAndCloseCallback(OpenAndCloseCallback callback)`|设置展开&收起的点击Callback| 24 | |`setCharSequenceToSpannableHandler(CharSequenceToSpannableHandler handler)`|设置文本转换成`Spannable`的预处理回调,可以处理特殊的文本样式| -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 29 5 | defaultConfig { 6 | applicationId "com.mrtrying.widget.expandabletext.example" 7 | minSdkVersion 19 8 | targetSdkVersion 29 9 | versionCode 1 10 | versionName "1.0" 11 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 12 | } 13 | buildTypes { 14 | release { 15 | minifyEnabled false 16 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 17 | } 18 | } 19 | } 20 | 21 | dependencies { 22 | implementation fileTree(dir: 'libs', include: ['*.jar']) 23 | implementation 'androidx.appcompat:appcompat:1.0.2' 24 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3' 25 | testImplementation 'junit:junit:4.12' 26 | androidTestImplementation 'androidx.test:runner:1.2.0' 27 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 28 | } 29 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/mrtrying/widget/expandabletext/example/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.mrtrying.widget.expandabletext.example; 2 | 3 | import android.content.Context; 4 | 5 | import androidx.test.InstrumentationRegistry; 6 | import androidx.test.runner.AndroidJUnit4; 7 | 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | 11 | import static org.junit.Assert.*; 12 | 13 | /** 14 | * Instrumented test, which will execute on an Android device. 15 | * 16 | * @see Testing documentation 17 | */ 18 | @RunWith(AndroidJUnit4.class) 19 | public class ExampleInstrumentedTest { 20 | @Test 21 | public void useAppContext() { 22 | // Context of the app under test. 23 | Context appContext = InstrumentationRegistry.getTargetContext(); 24 | 25 | assertEquals("com.mrtrying.widget.expandabletext.example", appContext.getPackageName()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrtrying/widget/expandabletext/ExpandableTextView.java: -------------------------------------------------------------------------------- 1 | package com.mrtrying.widget.expandabletext; 2 | 3 | import android.content.Context; 4 | import android.graphics.Color; 5 | import android.os.Build; 6 | import android.text.Layout; 7 | import android.text.SpannableString; 8 | import android.text.SpannableStringBuilder; 9 | import android.text.Spanned; 10 | import android.text.StaticLayout; 11 | import android.text.TextPaint; 12 | import android.text.TextUtils; 13 | import android.text.style.AlignmentSpan; 14 | import android.text.style.ClickableSpan; 15 | import android.text.style.StyleSpan; 16 | import android.util.AttributeSet; 17 | import android.view.View; 18 | import android.view.animation.Animation; 19 | import android.view.animation.Transformation; 20 | 21 | import androidx.annotation.ColorInt; 22 | import androidx.annotation.NonNull; 23 | import androidx.annotation.Nullable; 24 | import androidx.appcompat.widget.AppCompatTextView; 25 | 26 | import java.lang.reflect.Field; 27 | 28 | /** 29 | * Description : 30 | * PackageName : com.mrtrying.widget 31 | * Created by mrtrying on 2019/4/17 17:21. 32 | * e_mail : ztanzeyu@gmail.com 33 | */ 34 | public class ExpandableTextView extends AppCompatTextView { 35 | private static final String TAG = ExpandableTextView.class.getSimpleName(); 36 | 37 | public static final String ELLIPSIS_STRING = new String(new char[]{'\u2026'}); 38 | private static final int DEFAULT_MAX_LINE = 3; 39 | private static final String DEFAULT_OPEN_SUFFIX = " 展开"; 40 | private static final String DEFAULT_CLOSE_SUFFIX = " 收起"; 41 | volatile boolean animating = false; 42 | boolean isClosed = false; 43 | private int mMaxLines = DEFAULT_MAX_LINE; 44 | /** TextView可展示宽度,包含paddingLeft和paddingRight */ 45 | private int initWidth = 0; 46 | /** 原始文本 */ 47 | private CharSequence originalText; 48 | 49 | private SpannableStringBuilder mOpenSpannableStr, mCloseSpannableStr; 50 | 51 | private boolean hasAnimation = false; 52 | private Animation mOpenAnim, mCloseAnim; 53 | private int mOpenHeight, mCLoseHeight; 54 | private boolean mExpandable; 55 | private boolean mCloseInNewLine; 56 | @Nullable 57 | private SpannableString mOpenSuffixSpan, mCloseSuffixSpan; 58 | private String mOpenSuffixStr = DEFAULT_OPEN_SUFFIX; 59 | private String mCloseSuffixStr = DEFAULT_CLOSE_SUFFIX; 60 | private int mOpenSuffixColor, mCloseSuffixColor; 61 | 62 | private View.OnClickListener mOnClickListener; 63 | 64 | private CharSequenceToSpannableHandler mCharSequenceToSpannableHandler; 65 | 66 | public ExpandableTextView(Context context) { 67 | super(context); 68 | initialize(); 69 | } 70 | 71 | public ExpandableTextView(Context context, AttributeSet attrs) { 72 | super(context, attrs); 73 | initialize(); 74 | } 75 | 76 | public ExpandableTextView(Context context, AttributeSet attrs, int defStyleAttr) { 77 | super(context, attrs, defStyleAttr); 78 | initialize(); 79 | } 80 | 81 | /** 初始化 */ 82 | private void initialize() { 83 | mOpenSuffixColor = mCloseSuffixColor = Color.parseColor("#F23030"); 84 | setMovementMethod(OverLinkMovementMethod.getInstance()); 85 | setIncludeFontPadding(false); 86 | updateOpenSuffixSpan(); 87 | updateCloseSuffixSpan(); 88 | } 89 | 90 | @Override 91 | public boolean hasOverlappingRendering() { 92 | return false; 93 | } 94 | 95 | public void setOriginalText(CharSequence originalText) { 96 | this.originalText = originalText; 97 | mExpandable = false; 98 | mCloseSpannableStr = new SpannableStringBuilder(); 99 | final int maxLines = mMaxLines; 100 | SpannableStringBuilder tempText = charSequenceToSpannable(originalText); 101 | mOpenSpannableStr = charSequenceToSpannable(originalText); 102 | 103 | if (maxLines != -1) { 104 | Layout layout = createStaticLayout(tempText); 105 | mExpandable = layout.getLineCount() > maxLines; 106 | if (mExpandable) { 107 | //拼接展开内容 108 | if (mCloseInNewLine) { 109 | mOpenSpannableStr.append("\n"); 110 | } 111 | if (mCloseSuffixSpan != null) { 112 | mOpenSpannableStr.append(mCloseSuffixSpan); 113 | } 114 | //计算原文截取位置 115 | int endPos = layout.getLineEnd(maxLines - 1); 116 | if (originalText.length() <= endPos) { 117 | mCloseSpannableStr = charSequenceToSpannable(originalText); 118 | } else { 119 | mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, endPos)); 120 | } 121 | SpannableStringBuilder tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING); 122 | if (mOpenSuffixSpan != null) { 123 | tempText2.append(mOpenSuffixSpan); 124 | } 125 | //循环判断,收起内容添加展开后缀后的内容 126 | Layout tempLayout = createStaticLayout(tempText2); 127 | while (tempLayout.getLineCount() > maxLines) { 128 | int lastSpace = mCloseSpannableStr.length() - 1; 129 | if (lastSpace == -1) { 130 | break; 131 | } 132 | if (originalText.length() <= lastSpace) { 133 | mCloseSpannableStr = charSequenceToSpannable(originalText); 134 | } else { 135 | mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace)); 136 | } 137 | tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING); 138 | if (mOpenSuffixSpan != null) { 139 | tempText2.append(mOpenSuffixSpan); 140 | } 141 | tempLayout = createStaticLayout(tempText2); 142 | 143 | } 144 | int lastSpace = mCloseSpannableStr.length() - mOpenSuffixSpan.length(); 145 | if(lastSpace >= 0 && originalText.length() > lastSpace){ 146 | CharSequence redundantChar = originalText.subSequence(lastSpace, lastSpace + mOpenSuffixSpan.length()); 147 | int offset = hasEnCharCount(redundantChar) - hasEnCharCount(mOpenSuffixSpan) + 1; 148 | lastSpace = offset <= 0 ? lastSpace : lastSpace - offset; 149 | mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace)); 150 | } 151 | //计算收起的文本高度 152 | mCLoseHeight = tempLayout.getHeight() + getPaddingTop() + getPaddingBottom(); 153 | 154 | mCloseSpannableStr.append(ELLIPSIS_STRING); 155 | if (mOpenSuffixSpan != null) { 156 | mCloseSpannableStr.append(mOpenSuffixSpan); 157 | } 158 | } 159 | } 160 | isClosed = mExpandable; 161 | if (mExpandable) { 162 | setText(mCloseSpannableStr); 163 | //设置监听 164 | super.setOnClickListener(new OnClickListener() { 165 | @Override 166 | public void onClick(View v) { 167 | // switchOpenClose(); 168 | // if (mOnClickListener != null) { 169 | // mOnClickListener.onClick(v); 170 | // } 171 | } 172 | }); 173 | } else { 174 | setText(mOpenSpannableStr); 175 | } 176 | } 177 | 178 | private int hasEnCharCount(CharSequence str){ 179 | int count = 0; 180 | if(!TextUtils.isEmpty(str)){ 181 | for (int i = 0; i < str.length(); i++) { 182 | char c = str.charAt(i); 183 | if(c >= ' ' && c <= '~'){ 184 | count++; 185 | } 186 | } 187 | } 188 | return count; 189 | } 190 | 191 | private void switchOpenClose() { 192 | if (mExpandable) { 193 | isClosed = !isClosed; 194 | if (isClosed) { 195 | close(); 196 | } else { 197 | open(); 198 | } 199 | } 200 | } 201 | 202 | /** 203 | * 设置是否有动画 204 | * 205 | * @param hasAnimation 206 | */ 207 | public void setHasAnimation(boolean hasAnimation) { 208 | this.hasAnimation = hasAnimation; 209 | } 210 | 211 | /** 展开 */ 212 | private void open() { 213 | if (hasAnimation) { 214 | Layout layout = createStaticLayout(mOpenSpannableStr); 215 | mOpenHeight = layout.getHeight() + getPaddingTop() + getPaddingBottom(); 216 | executeOpenAnim(); 217 | } else { 218 | ExpandableTextView.super.setMaxLines(Integer.MAX_VALUE); 219 | setText(mOpenSpannableStr); 220 | if (mOpenCloseCallback != null){ 221 | mOpenCloseCallback.onOpen(); 222 | } 223 | } 224 | } 225 | 226 | /** 收起 */ 227 | private void close() { 228 | if (hasAnimation) { 229 | executeCloseAnim(); 230 | } else { 231 | ExpandableTextView.super.setMaxLines(mMaxLines); 232 | setText(mCloseSpannableStr); 233 | if (mOpenCloseCallback != null){ 234 | mOpenCloseCallback.onClose(); 235 | } 236 | } 237 | } 238 | 239 | /** 执行展开动画 */ 240 | private void executeOpenAnim() { 241 | //创建展开动画 242 | if (mOpenAnim == null) { 243 | mOpenAnim = new ExpandCollapseAnimation(this, mCLoseHeight, mOpenHeight); 244 | mOpenAnim.setFillAfter(true); 245 | mOpenAnim.setAnimationListener(new Animation.AnimationListener() { 246 | @Override 247 | public void onAnimationStart(Animation animation) { 248 | ExpandableTextView.super.setMaxLines(Integer.MAX_VALUE); 249 | setText(mOpenSpannableStr); 250 | } 251 | 252 | @Override 253 | public void onAnimationEnd(Animation animation) { 254 | // 动画结束后textview设置展开的状态 255 | getLayoutParams().height = mOpenHeight; 256 | requestLayout(); 257 | animating = false; 258 | } 259 | 260 | @Override 261 | public void onAnimationRepeat(Animation animation) { 262 | 263 | } 264 | }); 265 | } 266 | 267 | if (animating) { 268 | return; 269 | } 270 | animating = true; 271 | clearAnimation(); 272 | // 执行动画 273 | startAnimation(mOpenAnim); 274 | } 275 | 276 | /** 执行收起动画 */ 277 | private void executeCloseAnim() { 278 | //创建收起动画 279 | if (mCloseAnim == null) { 280 | mCloseAnim = new ExpandCollapseAnimation(this, mOpenHeight, mCLoseHeight); 281 | mCloseAnim.setFillAfter(true); 282 | mCloseAnim.setAnimationListener(new Animation.AnimationListener() { 283 | @Override 284 | public void onAnimationStart(Animation animation) { 285 | 286 | } 287 | 288 | @Override 289 | public void onAnimationEnd(Animation animation) { 290 | animating = false; 291 | ExpandableTextView.super.setMaxLines(mMaxLines); 292 | setText(mCloseSpannableStr); 293 | getLayoutParams().height = mCLoseHeight; 294 | requestLayout(); 295 | } 296 | 297 | @Override 298 | public void onAnimationRepeat(Animation animation) { 299 | 300 | } 301 | }); 302 | } 303 | 304 | if (animating) { 305 | return; 306 | } 307 | animating = true; 308 | clearAnimation(); 309 | // 执行动画 310 | startAnimation(mCloseAnim); 311 | } 312 | 313 | /** 314 | * @param spannable 315 | * 316 | * @return 317 | */ 318 | private Layout createStaticLayout(SpannableStringBuilder spannable) { 319 | int contentWidth = initWidth - getPaddingLeft() - getPaddingRight(); 320 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){ 321 | StaticLayout.Builder builder = StaticLayout.Builder.obtain(spannable, 0, spannable.length(), getPaint(), contentWidth); 322 | builder.setAlignment(Layout.Alignment.ALIGN_NORMAL); 323 | builder.setIncludePad(getIncludeFontPadding()); 324 | builder.setLineSpacing(getLineSpacingExtra(), getLineSpacingMultiplier()); 325 | return builder.build(); 326 | }else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 327 | return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL, 328 | getLineSpacingMultiplier(), getLineSpacingExtra(), getIncludeFontPadding()); 329 | }else{ 330 | return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL, 331 | getFloatField("mSpacingMult",1f), getFloatField("mSpacingAdd",0f), getIncludeFontPadding()); 332 | } 333 | } 334 | 335 | private float getFloatField(String fieldName,float defaultValue){ 336 | float value = defaultValue; 337 | if(TextUtils.isEmpty(fieldName)){ 338 | return value; 339 | } 340 | try { 341 | // 获取该类的所有属性值域 342 | Field[] fields = this.getClass().getDeclaredFields(); 343 | for (Field field:fields) { 344 | if(TextUtils.equals(fieldName,field.getName())){ 345 | value = field.getFloat(this); 346 | break; 347 | } 348 | } 349 | } catch (IllegalAccessException e) { 350 | e.printStackTrace(); 351 | } 352 | return value; 353 | } 354 | 355 | 356 | /** 357 | * @param charSequence 358 | * 359 | * @return 360 | */ 361 | private SpannableStringBuilder charSequenceToSpannable(@NonNull CharSequence charSequence) { 362 | SpannableStringBuilder spannableStringBuilder = null; 363 | if (mCharSequenceToSpannableHandler != null) { 364 | spannableStringBuilder = mCharSequenceToSpannableHandler.charSequenceToSpannable(charSequence); 365 | } 366 | if (spannableStringBuilder == null) { 367 | spannableStringBuilder = new SpannableStringBuilder(charSequence); 368 | } 369 | return spannableStringBuilder; 370 | } 371 | 372 | /** 373 | * 初始化TextView的可展示宽度 374 | * 375 | * @param width 376 | */ 377 | public void initWidth(int width) { 378 | initWidth = width; 379 | } 380 | 381 | @Override 382 | public void setMaxLines(int maxLines) { 383 | this.mMaxLines = maxLines; 384 | super.setMaxLines(maxLines); 385 | } 386 | 387 | /** 388 | * 设置展开后缀text 389 | * 390 | * @param openSuffix 391 | */ 392 | public void setOpenSuffix(String openSuffix) { 393 | mOpenSuffixStr = openSuffix; 394 | updateOpenSuffixSpan(); 395 | } 396 | 397 | /** 398 | * 设置展开后缀文本颜色 399 | * 400 | * @param openSuffixColor 401 | */ 402 | public void setOpenSuffixColor(@ColorInt int openSuffixColor) { 403 | mOpenSuffixColor = openSuffixColor; 404 | updateOpenSuffixSpan(); 405 | } 406 | 407 | /** 408 | * 设置收起后缀text 409 | * 410 | * @param closeSuffix 411 | */ 412 | public void setCloseSuffix(String closeSuffix) { 413 | mCloseSuffixStr = closeSuffix; 414 | updateCloseSuffixSpan(); 415 | } 416 | 417 | /** 418 | * 设置收起后缀文本颜色 419 | * 420 | * @param closeSuffixColor 421 | */ 422 | public void setCloseSuffixColor(@ColorInt int closeSuffixColor) { 423 | mCloseSuffixColor = closeSuffixColor; 424 | updateCloseSuffixSpan(); 425 | } 426 | 427 | /** 428 | * 收起后缀是否另起一行 429 | * 430 | * @param closeInNewLine 431 | */ 432 | public void setCloseInNewLine(boolean closeInNewLine) { 433 | mCloseInNewLine = closeInNewLine; 434 | updateCloseSuffixSpan(); 435 | } 436 | 437 | /** 更新展开后缀Spannable */ 438 | private void updateOpenSuffixSpan() { 439 | if (TextUtils.isEmpty(mOpenSuffixStr)) { 440 | mOpenSuffixSpan = null; 441 | return; 442 | } 443 | mOpenSuffixSpan = new SpannableString(mOpenSuffixStr); 444 | mOpenSuffixSpan.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 445 | mOpenSuffixSpan.setSpan(new ClickableSpan() { 446 | @Override 447 | public void onClick(@NonNull View widget) { 448 | switchOpenClose(); 449 | } 450 | 451 | @Override 452 | public void updateDrawState(@NonNull TextPaint ds) { 453 | super.updateDrawState(ds); 454 | ds.setColor(mOpenSuffixColor); 455 | ds.setUnderlineText(false); 456 | } 457 | },0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE); 458 | } 459 | 460 | /** 更新收起后缀Spannable */ 461 | private void updateCloseSuffixSpan() { 462 | if (TextUtils.isEmpty(mCloseSuffixStr)) { 463 | mCloseSuffixSpan = null; 464 | return; 465 | } 466 | mCloseSuffixSpan = new SpannableString(mCloseSuffixStr); 467 | mCloseSuffixSpan.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 468 | if (mCloseInNewLine) { 469 | AlignmentSpan alignmentSpan = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE); 470 | mCloseSuffixSpan.setSpan(alignmentSpan, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 471 | } 472 | mCloseSuffixSpan.setSpan(new ClickableSpan() { 473 | @Override 474 | public void onClick(@NonNull View widget) { 475 | switchOpenClose(); 476 | } 477 | 478 | @Override 479 | public void updateDrawState(@NonNull TextPaint ds) { 480 | super.updateDrawState(ds); 481 | ds.setColor(mCloseSuffixColor); 482 | ds.setUnderlineText(false); 483 | } 484 | },1, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 485 | } 486 | 487 | @Override 488 | public void setOnClickListener(View.OnClickListener onClickListener) { 489 | mOnClickListener = onClickListener; 490 | } 491 | 492 | public OpenAndCloseCallback mOpenCloseCallback; 493 | public void setOpenAndCloseCallback(OpenAndCloseCallback callback){ 494 | this.mOpenCloseCallback = callback; 495 | } 496 | 497 | public interface OpenAndCloseCallback{ 498 | void onOpen(); 499 | void onClose(); 500 | } 501 | /** 502 | * 设置文本内容处理 503 | * 504 | * @param handler 505 | */ 506 | public void setCharSequenceToSpannableHandler(CharSequenceToSpannableHandler handler) { 507 | mCharSequenceToSpannableHandler = handler; 508 | } 509 | 510 | public interface CharSequenceToSpannableHandler { 511 | @NonNull 512 | SpannableStringBuilder charSequenceToSpannable(CharSequence charSequence); 513 | } 514 | 515 | class ExpandCollapseAnimation extends Animation { 516 | private final View mTargetView;//动画执行view 517 | private final int mStartHeight;//动画执行的开始高度 518 | private final int mEndHeight;//动画结束后的高度 519 | 520 | ExpandCollapseAnimation(View target, int startHeight, int endHeight) { 521 | mTargetView = target; 522 | mStartHeight = startHeight; 523 | mEndHeight = endHeight; 524 | setDuration(400); 525 | } 526 | 527 | @Override 528 | protected void applyTransformation(float interpolatedTime, Transformation t) { 529 | mTargetView.setScrollY(0); 530 | //计算出每次应该显示的高度,改变执行view的高度,实现动画 531 | mTargetView.getLayoutParams().height = (int) ((mEndHeight - mStartHeight) * interpolatedTime + mStartHeight); 532 | mTargetView.requestLayout(); 533 | } 534 | } 535 | } 536 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrtrying/widget/expandabletext/OverLinkMovementMethod.java: -------------------------------------------------------------------------------- 1 | package com.mrtrying.widget.expandabletext; 2 | 3 | import android.text.NoCopySpan; 4 | import android.text.Spannable; 5 | import android.text.method.LinkMovementMethod; 6 | import android.text.method.MovementMethod; 7 | import android.view.MotionEvent; 8 | import android.widget.TextView; 9 | 10 | public class OverLinkMovementMethod extends LinkMovementMethod { 11 | 12 | public static boolean canScroll = false; 13 | 14 | @Override 15 | public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { 16 | int action = event.getAction(); 17 | 18 | if(action == MotionEvent.ACTION_MOVE){ 19 | if(!canScroll){ 20 | return true; 21 | } 22 | } 23 | 24 | return super.onTouchEvent(widget, buffer, event); 25 | } 26 | 27 | public static MovementMethod getInstance() { 28 | if (sInstance == null) 29 | sInstance = new OverLinkMovementMethod(); 30 | 31 | return sInstance; 32 | } 33 | 34 | private static OverLinkMovementMethod sInstance; 35 | private static Object FROM_BELOW = new NoCopySpan.Concrete(); 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrtrying/widget/expandabletext/example/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.mrtrying.widget.expandabletext.example; 2 | 3 | import android.content.Context; 4 | import android.graphics.Color; 5 | import android.os.Bundle; 6 | 7 | import com.mrtrying.widget.expandabletext.ExpandableTextView; 8 | 9 | import androidx.appcompat.app.AppCompatActivity; 10 | 11 | public class MainActivity extends AppCompatActivity { 12 | 13 | @Override 14 | protected void onCreate(Bundle savedInstanceState) { 15 | super.onCreate(savedInstanceState); 16 | setContentView(R.layout.activity_main); 17 | 18 | ExpandableTextView expandableTextView = findViewById(R.id.expanded_text); 19 | int viewWidth = getWindowManager().getDefaultDisplay().getWidth() - dp2px(this, 20f); 20 | expandableTextView.initWidth(viewWidth); 21 | expandableTextView.setMaxLines(3); 22 | expandableTextView.setHasAnimation(true); 23 | expandableTextView.setCloseInNewLine(true); 24 | expandableTextView.setOpenSuffixColor(getResources().getColor(R.color.colorAccent)); 25 | expandableTextView.setCloseSuffixColor(getResources().getColor(R.color.colorAccent)); 26 | expandableTextView.setOriginalText("在全球,随着Flutter被越来越多的知名公司应用在自己的商业APP中," + 27 | "Flutter这门新技术也逐渐进入了移动开发者的视野,尤其是当Google在2018年IO大会上发布了第一个" + 28 | "Preview版本后,国内刮起来一股学习Flutter的热潮。\n\n为了更好的方便帮助中国开发者了解这门新技术" + 29 | ",我们,Flutter中文网,前后发起了Flutter翻译计划、Flutter开源计划,前者主要的任务是翻译" + 30 | "Flutter官方文档,后者则主要是开发一些常用的包来丰富Flutter生态,帮助开发者提高开发效率。而时" + 31 | "至今日,这两件事取得的效果还都不错!" 32 | ); 33 | 34 | } 35 | 36 | /** 37 | * 根据手机的分辨率从 dp 的单位 转成为 px(像素) 38 | */ 39 | public static int dp2px(Context context, float dpValue) { 40 | int res = 0; 41 | final float scale = context.getResources().getDisplayMetrics().density; 42 | if (dpValue < 0) 43 | res = -(int) (-dpValue * scale + 0.5f); 44 | else 45 | res = (int) (dpValue * scale + 0.5f); 46 | return res; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 14 | 20 | 23 | 26 | 27 | 28 | 29 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 56 | 61 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 121 | 126 | 131 | 136 | 141 | 146 | 151 | 156 | 161 | 166 | 171 | 172 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrTrying/ExpandableText-Example/88e60996b20b0c2aa8538ac23e301b34deaea36e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ExpandableText-Example 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/test/java/com/mrtrying/widget/expandabletext/example/ExampleUnitTest.java: -------------------------------------------------------------------------------- 1 | package com.mrtrying.widget.expandabletext.example; 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() { 15 | assertEquals(4, 2 + 2); 16 | } 17 | } -------------------------------------------------------------------------------- /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 | google() 6 | jcenter() 7 | 8 | } 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:3.4.1' 11 | 12 | // NOTE: Do not place your application dependencies here; they belong 13 | // in the individual module build.gradle files 14 | } 15 | } 16 | 17 | allprojects { 18 | repositories { 19 | google() 20 | jcenter() 21 | 22 | } 23 | } 24 | 25 | task clean(type: Delete) { 26 | delete rootProject.buildDir 27 | } 28 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------