【Android 自定义 View】-->TextView 的展开 & 收起(文本折叠)

不断学习,做更好的自己!💪

视频号CSDN简书
欢迎打开微信,关注我的视频号:KevinDev点我点我

前言

我们经常会碰到这样一个需求:文本内容过多,可以展开和收起。

效果图

在这里插入图片描述
在这里插入图片描述
注意

  • 显示 “…展开” 时,是截取的一定行数之后,在最后一行的末尾直接显示;
  • “收起” 显示在全部文本的下一行,并且是右对齐;
  • 展开和收起的动画效果。

代码实现

1. ExpandTextView.java

public class ExpandTextView extends AppCompatTextView {
    public static final String ELLIPSIS_STRING = new String(new char[]{'\u2026'});
    private static final int DEFAULT_MAX_LINE = 3;
    private static final String DEFAULT_OPEN_SUFFIX = " 展开";
    private static final String DEFAULT_CLOSE_SUFFIX = " 收起";
    volatile boolean animating = false;
    boolean isClosed = false;
    private int mMaxLines = DEFAULT_MAX_LINE;
    /** TextView可展示宽度,包含paddingLeft和paddingRight */
    private int initWidth = 0;
    /** 原始文本 */
    private CharSequence originalText;

    private SpannableStringBuilder mOpenSpannableStr, mCloseSpannableStr;

    private boolean hasAnimation = false;
    private Animation mOpenAnim, mCloseAnim;
    private int mOpenHeight, mCLoseHeight;
    private boolean mExpandable;
    private boolean mCloseInNewLine;
    @Nullable
    private SpannableString mOpenSuffixSpan, mCloseSuffixSpan;
    private String mOpenSuffixStr = DEFAULT_OPEN_SUFFIX;
    private String mCloseSuffixStr = DEFAULT_CLOSE_SUFFIX;
    private int mOpenSuffixColor, mCloseSuffixColor;

    private View.OnClickListener mOnClickListener;

    private CharSequenceToSpannableHandler mCharSequenceToSpannableHandler;

    public ExpandTextView(Context context) {
        this(context,null);
    }

    public ExpandTextView(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public ExpandTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize();
    }

    /** 初始化 */
    private void initialize() {
        mOpenSuffixColor = mCloseSuffixColor = Color.parseColor("#F23030");
        setMovementMethod(OverLinkMovementMethod.getInstance());
        setIncludeFontPadding(false);
        updateOpenSuffixSpan();
        updateCloseSuffixSpan();
    }

    @Override
    public boolean hasOverlappingRendering() {
        return false;
    }

    public void setOriginalText(CharSequence originalText) {
        this.originalText = originalText;
        mExpandable = false;
        mCloseSpannableStr = new SpannableStringBuilder();
        final int maxLines = mMaxLines;
        SpannableStringBuilder tempText = charSequenceToSpannable(originalText);
        mOpenSpannableStr = charSequenceToSpannable(originalText);

        if (maxLines != -1) {
            Layout layout = createStaticLayout(tempText);
            mExpandable = layout.getLineCount() > maxLines;
            if (mExpandable) {
                //拼接展开内容
                if (mCloseInNewLine) {
                    mOpenSpannableStr.append("\n");
                }
                if (mCloseSuffixSpan != null) {
                    mOpenSpannableStr.append(mCloseSuffixSpan);
                }
                //计算原文截取位置
                int endPos = layout.getLineEnd(maxLines - 1);
                if (originalText.length() <= endPos) {
                    mCloseSpannableStr = charSequenceToSpannable(originalText);
                } else {
                    mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, endPos));
                }
                SpannableStringBuilder tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
                if (mOpenSuffixSpan != null) {
                    tempText2.append(mOpenSuffixSpan);
                }
                //循环判断,收起内容添加展开后缀后的内容
                Layout tempLayout = createStaticLayout(tempText2);
                while (tempLayout.getLineCount() > maxLines) {
                    int lastSpace = mCloseSpannableStr.length() - 1;
                    if (lastSpace == -1) {
                        break;
                    }
                    if (originalText.length() <= lastSpace) {
                        mCloseSpannableStr = charSequenceToSpannable(originalText);
                    } else {
                        mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace));
                    }
                    tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
                    if (mOpenSuffixSpan != null) {
                        tempText2.append(mOpenSuffixSpan);
                    }
                    tempLayout = createStaticLayout(tempText2);

                }
                int lastSpace = mCloseSpannableStr.length() - mOpenSuffixSpan.length();
                if(lastSpace >= 0 && originalText.length() > lastSpace){
                    CharSequence redundantChar = originalText.subSequence(lastSpace, lastSpace + mOpenSuffixSpan.length());
                    int offset = hasEnCharCount(redundantChar) - hasEnCharCount(mOpenSuffixSpan) + 1;
                    lastSpace = offset <= 0 ? lastSpace : lastSpace - offset;
                    mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace));
                }
                //计算收起的文本高度
                mCLoseHeight = tempLayout.getHeight() + getPaddingTop() + getPaddingBottom();

                mCloseSpannableStr.append(ELLIPSIS_STRING);
                if (mOpenSuffixSpan != null) {
                    mCloseSpannableStr.append(mOpenSuffixSpan);
                }
            }
        }
        isClosed = mExpandable;
        if (mExpandable) {
            setText(mCloseSpannableStr);
            //设置监听
            super.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
//                    switchOpenClose();
//                    if (mOnClickListener != null) {
//                        mOnClickListener.onClick(v);
//                    }
                }
            });
        } else {
            setText(mOpenSpannableStr);
        }
    }

    private int hasEnCharCount(CharSequence str){
        int count = 0;
        if(!TextUtils.isEmpty(str)){
            for (int i = 0; i < str.length(); i++) {
                char c = str.charAt(i);
                if(c >= ' ' && c <= '~'){
                    count++;
                }
            }
        }
        return count;
    }

    private void switchOpenClose() {
        if (mExpandable) {
            isClosed = !isClosed;
            if (isClosed) {
                close();
            } else {
                open();
            }
        }
    }

    /**
     * 设置是否有动画
     *
     * @param hasAnimation
     */
    public void setHasAnimation(boolean hasAnimation) {
        this.hasAnimation = hasAnimation;
    }

    /** 展开 */
    private void open() {
        if (hasAnimation) {
            Layout layout = createStaticLayout(mOpenSpannableStr);
            mOpenHeight = layout.getHeight() + getPaddingTop() + getPaddingBottom();
            executeOpenAnim();
        } else {
            ExpandTextView.super.setMaxLines(Integer.MAX_VALUE);
            setText(mOpenSpannableStr);
            if (mOpenCloseCallback != null){
                mOpenCloseCallback.onOpen();
            }
        }
    }

    /** 收起 */
    private void close() {
        if (hasAnimation) {
            executeCloseAnim();
        } else {
            ExpandTextView.super.setMaxLines(mMaxLines);
            setText(mCloseSpannableStr);
            if (mOpenCloseCallback != null){
                mOpenCloseCallback.onClose();
            }
        }
    }

    /** 执行展开动画 */
    private void executeOpenAnim() {
        //创建展开动画
        if (mOpenAnim == null) {
            mOpenAnim = new ExpandCollapseAnimation(this, mCLoseHeight, mOpenHeight);
            mOpenAnim.setFillAfter(true);
            mOpenAnim.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
                    ExpandTextView.super.setMaxLines(Integer.MAX_VALUE);
                    setText(mOpenSpannableStr);
                }

                @Override
                public void onAnimationEnd(Animation animation) {
                    //  动画结束后textview设置展开的状态
                    getLayoutParams().height = mOpenHeight;
                    requestLayout();
                    animating = false;
                }

                @Override
                public void onAnimationRepeat(Animation animation) {

                }
            });
        }

        if (animating) {
            return;
        }
        animating = true;
        clearAnimation();
        //  执行动画
        startAnimation(mOpenAnim);
    }

    /** 执行收起动画 */
    private void executeCloseAnim() {
        //创建收起动画
        if (mCloseAnim == null) {
            mCloseAnim = new ExpandCollapseAnimation(this, mOpenHeight, mCLoseHeight);
            mCloseAnim.setFillAfter(true);
            mCloseAnim.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {

                }

                @Override
                public void onAnimationEnd(Animation animation) {
                    animating = false;
                    ExpandTextView.super.setMaxLines(mMaxLines);
                    setText(mCloseSpannableStr);
                    getLayoutParams().height = mCLoseHeight;
                    requestLayout();
                }

                @Override
                public void onAnimationRepeat(Animation animation) {

                }
            });
        }

        if (animating) {
            return;
        }
        animating = true;
        clearAnimation();
        //  执行动画
        startAnimation(mCloseAnim);
    }

    /**
     * @param spannable
     *
     * @return
     */
    private Layout createStaticLayout(SpannableStringBuilder spannable) {
        int contentWidth = initWidth - getPaddingLeft() - getPaddingRight();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
            StaticLayout.Builder builder = StaticLayout.Builder.obtain(spannable, 0, spannable.length(), getPaint(), contentWidth);
            builder.setAlignment(Layout.Alignment.ALIGN_NORMAL);
            builder.setIncludePad(getIncludeFontPadding());
            builder.setLineSpacing(getLineSpacingExtra(), getLineSpacingMultiplier());
            return builder.build();
        }else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL,
                    getLineSpacingMultiplier(), getLineSpacingExtra(), getIncludeFontPadding());
        }else{
            return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL,
                    getFloatField("mSpacingMult",1f), getFloatField("mSpacingAdd",0f), getIncludeFontPadding());
        }
    }

    private float getFloatField(String fieldName,float defaultValue){
        float value = defaultValue;
        if(TextUtils.isEmpty(fieldName)){
            return value;
        }
        try {
            // 获取该类的所有属性值域
            Field[] fields = this.getClass().getDeclaredFields();
            for (Field field:fields) {
                if(TextUtils.equals(fieldName,field.getName())){
                    value = field.getFloat(this);
                    break;
                }
            }
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return value;
    }


    /**
     * @param charSequence
     *
     * @return
     */
    private SpannableStringBuilder charSequenceToSpannable(@NonNull CharSequence charSequence) {
        SpannableStringBuilder spannableStringBuilder = null;
        if (mCharSequenceToSpannableHandler != null) {
            spannableStringBuilder = mCharSequenceToSpannableHandler.charSequenceToSpannable(charSequence);
        }
        if (spannableStringBuilder == null) {
            spannableStringBuilder = new SpannableStringBuilder(charSequence);
        }
        return spannableStringBuilder;
    }

    /**
     * 初始化TextView的可展示宽度
     *
     * @param width
     */
    public void initWidth(int width) {
        initWidth = width;
    }

    @Override
    public void setMaxLines(int maxLines) {
        this.mMaxLines = maxLines;
        super.setMaxLines(maxLines);
    }

    /**
     * 设置展开后缀text
     *
     * @param openSuffix
     */
    public void setOpenSuffix(String openSuffix) {
        mOpenSuffixStr = openSuffix;
        updateOpenSuffixSpan();
    }

    /**
     * 设置展开后缀文本颜色
     *
     * @param openSuffixColor
     */
    public void setOpenSuffixColor(@ColorInt int openSuffixColor) {
        mOpenSuffixColor = openSuffixColor;
        updateOpenSuffixSpan();
    }

    /**
     * 设置收起后缀text
     *
     * @param closeSuffix
     */
    public void setCloseSuffix(String closeSuffix) {
        mCloseSuffixStr = closeSuffix;
        updateCloseSuffixSpan();
    }

    /**
     * 设置收起后缀文本颜色
     *
     * @param closeSuffixColor
     */
    public void setCloseSuffixColor(@ColorInt int closeSuffixColor) {
        mCloseSuffixColor = closeSuffixColor;
        updateCloseSuffixSpan();
    }

    /**
     * 收起后缀是否另起一行
     *
     * @param closeInNewLine
     */
    public void setCloseInNewLine(boolean closeInNewLine) {
        mCloseInNewLine = closeInNewLine;
        updateCloseSuffixSpan();
    }

    /** 更新展开后缀Spannable */
    private void updateOpenSuffixSpan() {
        if (TextUtils.isEmpty(mOpenSuffixStr)) {
            mOpenSuffixSpan = null;
            return;
        }
        mOpenSuffixSpan = new SpannableString(mOpenSuffixStr);
        mOpenSuffixSpan.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        mOpenSuffixSpan.setSpan(new ClickableSpan() {
            @Override
            public void onClick(@NonNull View widget) {
                switchOpenClose();
            }

            @Override
            public void updateDrawState(@NonNull TextPaint ds) {
                super.updateDrawState(ds);
                ds.setColor(mOpenSuffixColor);
                ds.setUnderlineText(false);
            }
        },0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
    }

    /** 更新收起后缀Spannable */
    private void updateCloseSuffixSpan() {
        if (TextUtils.isEmpty(mCloseSuffixStr)) {
            mCloseSuffixSpan = null;
            return;
        }
        mCloseSuffixSpan = new SpannableString(mCloseSuffixStr);
        mCloseSuffixSpan.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        if (mCloseInNewLine) {
            AlignmentSpan alignmentSpan = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE);
            mCloseSuffixSpan.setSpan(alignmentSpan, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
        mCloseSuffixSpan.setSpan(new ClickableSpan() {
            @Override
            public void onClick(@NonNull View widget) {
                switchOpenClose();
            }

            @Override
            public void updateDrawState(@NonNull TextPaint ds) {
                super.updateDrawState(ds);
                ds.setColor(mCloseSuffixColor);
                ds.setUnderlineText(false);
            }
        },1, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    @Override
    public void setOnClickListener(View.OnClickListener onClickListener) {
        mOnClickListener = onClickListener;
    }

    public OpenAndCloseCallback mOpenCloseCallback;
    public void setOpenAndCloseCallback(OpenAndCloseCallback callback){
        this.mOpenCloseCallback = callback;
    }

    public interface OpenAndCloseCallback{
        void onOpen();
        void onClose();
    }
    /**
     * 设置文本内容处理
     *
     * @param handler
     */
    public void setCharSequenceToSpannableHandler(CharSequenceToSpannableHandler handler) {
        mCharSequenceToSpannableHandler = handler;
    }

    public interface CharSequenceToSpannableHandler {
        @NonNull
        SpannableStringBuilder charSequenceToSpannable(CharSequence charSequence);
    }

    class ExpandCollapseAnimation extends Animation {
        private final View mTargetView;//动画执行view
        private final int mStartHeight;//动画执行的开始高度
        private final int mEndHeight;//动画结束后的高度

        ExpandCollapseAnimation(View target, int startHeight, int endHeight) {
            mTargetView = target;
            mStartHeight = startHeight;
            mEndHeight = endHeight;
            setDuration(400);
        }

        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            mTargetView.setScrollY(0);
            //计算出每次应该显示的高度,改变执行view的高度,实现动画
            mTargetView.getLayoutParams().height = (int) ((mEndHeight - mStartHeight) * interpolatedTime + mStartHeight);
            mTargetView.requestLayout();
        }
    }
}

2. OverLinkMovementMethod.java

public class OverLinkMovementMethod extends LinkMovementMethod {
    public static boolean canScroll = false;

    @Override
    public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
        int action = event.getAction();
        if(action == MotionEvent.ACTION_MOVE){
            if(!canScroll){
                return true;
            }
        }
        return super.onTouchEvent(widget, buffer, event);
    }

    public static MovementMethod getInstance() {
        if (sInstance == null)
            sInstance = new OverLinkMovementMethod();

        return sInstance;
    }

    private static OverLinkMovementMethod sInstance;

    private static Object FROM_BELOW = new NoCopySpan.Concrete();
}

3. 使用

  • 布局文件
<com.hkt.bottomsimples.widget.ExpandTextView
        android:id="@+id/etv_content"
        android:layout_margin="10dp"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        tools:text="Hello World!"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>
  • 代码使用
public class WidgetActivity extends AppCompatActivity {
    @BindView(R.id.etv_content)
    ExpandTextView mContent;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_widget);

        ButterKnife.bind(this);

        int width = getWindowManager().getDefaultDisplay().getWidth() - dp2px(this, 20f);
        mContent.initWidth(width);
        mContent.setMaxLines(3);
        mContent.setHasAnimation(true);
        mContent.setCloseInNewLine(true);
        mContent.setOpenSuffixColor(getResources().getColor(R.color.teal_200));
        mContent.setCloseSuffixColor(getResources().getColor(R.color.teal_200));
        mContent.setOriginalText("世界上总有一些人的出生是不被期待的。\n" +
                "在那个年代如火如荼的计划生育中,总有一两条漏网之鱼。\n" +
                "南方的春天阴雨绵绵,空气中弥漫着浓郁的湿气,让人不由得觉得烦躁,难受。\n" +
                "一个女人挺着大肚子,东躲西藏,四处打听,吃尽了闭门羹。所幸皇天不负有心人,\n " +
                "几番周折后,她终于在临盆前的一天找到了愿意接生的医生。"
        );
    }

    public static int dp2px(Context context, float dpValue) {
        int res = 0;
        final float scale = context.getResources().getDisplayMetrics().density;
        if (dpValue < 0)
            res = -(int) (-dpValue * scale + 0.5f);
        else
            res = (int) (dpValue * scale + 0.5f);
        return res;
    }
}
  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
可以使用以下代码实现TextView部分文本显示和展开/收起功能: ```kotlin class ExpandableTextView(context: Context, attrs: AttributeSet?) : TextView(context, attrs) { private var isExpanded: Boolean = false private var collapsedMaxLines: Int = 2 init { // 设置TextView默认的最大行数 maxLines = collapsedMaxLines // 设置TextView默认的Ellipsize ellipsize = TextUtils.TruncateAt.END // 设置TextView点击事件 setOnClickListener { toggle() } } /** * 设置TextView默认的最大行数 */ fun setCollapsedMaxLines(maxLines: Int) { this.collapsedMaxLines = maxLines this.maxLines = maxLines } /** * 切换TextView展开/收起状态 */ private fun toggle() { isExpanded = !isExpanded setText(if (isExpanded) getFullText() else getCollapsedText()) // 将TextView改为可聚焦状态,以便展开/收起时接收触摸事件 isFocusable = true isFocusableInTouchMode = true } /** * 获取TextView展开状态下的完整文本 */ private fun getFullText(): CharSequence { val text = text movementMethod = LinkMovementMethod.getInstance() return text } /** * 获取TextView收起状态下的文本 */ private fun getCollapsedText(): CharSequence { val text = text val layout = layout ?: return text movementMethod = LinkMovementMethod.getInstance() val end = layout.getLineEnd(collapsedMaxLines - 1) val ellipsis = "..." val textTrimmed = text.subSequence(0, end) val lastSpace = textTrimmed.lastIndexOf(' ') val trimmedLength = textTrimmed.length return if (lastSpace == -1 || trimmedLength <= end - ellipsis.length) { "$textTrimmed$ellipsis" } else { val index = if (lastSpace + ellipsis.length > end) lastSpace else end - ellipsis.length "${textTrimmed.subSequence(0, index)}$ellipsis" } } /** * 保存TextView的状态 */ override fun onSaveInstanceState(): Parcelable? { val superState = super.onSaveInstanceState() return SavedState(superState).apply { isExpanded = this@ExpandableTextView.isExpanded } } /** * 恢复TextView的状态 */ override fun onRestoreInstanceState(state: Parcelable?) { var savedState = state if (savedState is SavedState) { isExpanded = savedState.isExpanded super.onRestoreInstanceState(savedState.superState) setText(if (isExpanded) getFullText() else getCollapsedText()) } else { super.onRestoreInstanceState(savedState) } } private class SavedState : BaseSavedState { var isExpanded: Boolean = false constructor(superState: Parcelable?) : super(superState) constructor(source: Parcel) : super(source) { isExpanded = source.readInt() == 1 } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeInt(if (isExpanded) 1 else 0) } companion object { @JvmField val CREATOR = object : Parcelable.Creator<SavedState> { override fun createFromParcel(source: Parcel): SavedState { return SavedState(source) } override fun newArray(size: Int): Array<SavedState?> { return arrayOfNulls(size) } } } } } ``` 使用方法: 在布局文件中使用该自定义TextView: ```xml <com.example.ExpandableTextView android:id="@+id/tv_content" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/lorem_ipsum" /> ``` 在代码中设置TextView的最大行数和展开/收起状态: ```kotlin val tvContent = findViewById<ExpandableTextView>(R.id.tv_content) tvContent.setCollapsedMaxLines(2) // 设置收起状态下的最大行数 tvContent.text = resources.getString(R.string.lorem_ipsum) // 设置TextView文本 ``` 如果需要在RecyclerView中使用该自定义TextView,可以在Adapter中设置TextView展开/收起状态: ```kotlin override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = itemList[position] holder.itemView.apply { tvContent.setCollapsedMaxLines(2) // 设置收起状态下的最大行数 tvContent.text = item.text // 设置TextView文本 tvContent.isExpanded = item.isExpanded // 设置TextView展开/收起状态 } } ```
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kevin-Dev

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值