├── .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 |
--------------------------------------------------------------------------------