Android實(shí)現(xiàn)可折疊的TextView

最近接到一個(gè)需求血淌,需要實(shí)現(xiàn)可以自動(dòng)折疊的TextView,如下圖所示:


fte3p-rxvyq.gif

重點(diǎn)主要有兩個(gè):如何測(cè)量文本顯示的行數(shù)财剖;動(dòng)畫適合實(shí)現(xiàn)悠夯;
下面就先就這兩個(gè)問(wèn)題展示一下核心代碼

測(cè)量文本行數(shù)

這里主要通過(guò)StaticLayout來(lái)得到文本的行數(shù)等信息:

/**
 * 根據(jù)[source]創(chuàng)建一個(gè)[StaticLayout]對(duì)象,用于輔助計(jì)算文本可顯示行數(shù)躺坟、高度等
 */
private fun <T : CharSequence> createStaticLayout(source: T): Layout =
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
        StaticLayout.Builder.obtain(source, 0, source.length, paint, mMeasuredWidth)
            .setAlignment(Layout.Alignment.ALIGN_NORMAL)
            .setIncludePad(includeFontPadding)
            .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
            .build()
    } else {
        @Suppress("DEPRECATION")
        StaticLayout(
            source,
            paint,
            mMeasuredWidth,
            Layout.Alignment.ALIGN_NORMAL,
            lineSpacingMultiplier,
            lineSpacingExtra,
            includeFontPadding
        )
    }

canFold = layout.lineCount > mFoldedLines  //lineCount 文本行數(shù)
 mExpandedHeight = createStaticLayout(mExpandedText).height + paddingTop + paddingBottom //height 文本高度

實(shí)現(xiàn)動(dòng)畫

通過(guò)屬性動(dòng)畫可以實(shí)現(xiàn)折疊和展開效果:

private fun createAnimation(
    start: Int,
    end: Int,
    startCallback: (() -> Unit)?,
    endCallback: (() -> Unit)?
): ObjectAnimator {
    val animator = ObjectAnimator.ofInt(this, "layoutHeight", start, end)
    animator.duration = mDuration
    animator.interpolator = AccelerateDecelerateInterpolator()
    animator.addListener(object : Animator.AnimatorListener {
        override fun onAnimationStart(animation: Animator?) {
            isAnimating = true
            startCallback?.invoke()
        }

        override fun onAnimationEnd(animation: Animator?) {
            isAnimating = false
            endCallback?.invoke()
        }

        override fun onAnimationCancel(animation: Animator?) {
        }

        override fun onAnimationRepeat(animation: Animator?) {
        }

    })
    return animator
}

手動(dòng)支持CLickableSpann

這里“展開”和“折疊”按鈕是通過(guò)SpannableString實(shí)現(xiàn)沦补,要實(shí)現(xiàn)點(diǎn)擊事件除了加上ClikableSpann是不行的,看到網(wǎng)上的方法一般都是設(shè)置一個(gè)LinkMovementMethod咪橙,但是這樣的話當(dāng)使用動(dòng)畫的時(shí)候夕膀,文本整體都被向上挪動(dòng)了,最后也是在網(wǎng)上找的解決方案:重寫onTouch方法來(lái)自己實(shí)現(xiàn)對(duì)clickableSpann的支持:

/**
 * 重寫方法以支持ClickSpan的點(diǎn)擊事件
 * 直接設(shè)置LinkMovementMethod的話會(huì)導(dǎo)致TextView可以滑動(dòng)美侦,當(dāng)執(zhí)行折疊動(dòng)畫時(shí)整個(gè)文本會(huì)被向上推产舞,達(dá)不到預(yù)期效果
 */
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
    val curText = text
    val action = event?.action
    when {
        curText is Spanned && (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) -> {
            val x = (event.x - totalPaddingLeft + scrollX).toInt()
            val y = (event.y - totalPaddingTop + scrollY).toInt()
            val line = layout.getLineForVertical(y)
            val off = layout.getOffsetForHorizontal(line, x.toFloat())
            val link = curText.getSpans(off, off, ClickableSpan::class.java)
            if (link.isNotEmpty()) {
                if (action == MotionEvent.ACTION_UP) link[0].onClick(this)
                return true
            }
        }
        else -> return super.onTouchEvent(event)
    }
    return super.onTouchEvent(event)
}

完整代碼

class ExpandableTextView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
    AppCompatTextView(context, attrs, defStyleAttr) {

    private var mOriginText: CharSequence? = null
    private var mExpandedText: SpannableStringBuilder = createSpannableStringBuilder("")
    private var mFoldedText: SpannableStringBuilder = createSpannableStringBuilder("")

    private var mMeasuredWidth: Int = 0
    private var mFoldedHeight: Int = 0 //折疊后的高度
    private var mFoldAnimator: Animator? = null //折疊動(dòng)畫
    private var mExpandedHeight: Int = 0
    private var mExpandAnimator: Animator? = null

    /**
     * 折疊行數(shù)閾值,本文行數(shù)超過(guò)閾值時(shí)才可已折疊
     */
    private val mFoldedLines: Int
    private val mSuffixTextColor: Int
    private val mFoldedSuffix: SpannableString //折疊狀態(tài)下的文本后綴
    private val mExpandedSuffix: SpannableString//展開狀態(tài)下的文本后綴

    private var canFold: Boolean = true
    private var isFolded: Boolean = false

    private val mDuration: Long
    private var isAnimating: Boolean = false

    @Suppress("unused")
    var layoutHeight: Int = 0
        set(value) {
            field = value
            Log.d(TAG, "set layoutHeight: $value")
            layoutParams.height = value
            requestLayout()
        }

    constructor(context: Context) : this(context, null)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    init {
        val typedValue = context.obtainStyledAttributes(attrs, R.styleable.ExpandableTextView)
        mOriginText = typedValue.getString(R.styleable.ExpandableTextView_expandableText)
        mDuration = typedValue.getInt(
            R.styleable.ExpandableTextView_expandDuration,
            DEFAULT_DURATION_TIME
        ).toLong()
        mFoldedLines = typedValue.getInt(
            R.styleable.ExpandableTextView_foldLines,
            DEFAULT_EXPANDABLE_LINES
        )
        mSuffixTextColor = typedValue.getColor(
            R.styleable.ExpandableTextView_suffixTextColor,
            DEFAULT_SUFFIX_TEXT_COLOR
        )
        val foldSuffix = typedValue.getString(R.styleable.ExpandableTextView_foldedSuffixText)
            ?: DEFAULT_ACTION_TEXT_EXPAND
        mFoldedSuffix = createClickedSpannableString(
            ELLIPSIS_STRING + foldSuffix,
            ELLIPSIS_STRING.length
        )
        val expandText = typedValue.getString(R.styleable.ExpandableTextView_expandedSuffixText)
            ?: DEFAULT_ACTION_TEXT_FOLD
        mExpandedSuffix = createClickedSpannableString(expandText)
        //取消clickableSpan點(diǎn)擊背景
        highlightColor = Color.argb(0, 0, 0, 0)
        typedValue.recycle()
    }

    /**
     * 設(shè)置原始文本
     * 若文本所需顯示的函數(shù)小于[mFoldedLines]菠剩,則不會(huì)做任何處理易猫,
     * 否則文本可以展開與折疊
     *
     * @param text TextView所需顯示的原始文本
     */
    fun setExpandableText(text: CharSequence) {
        mOriginText = text
        if (mMeasuredWidth <= 0) return
        val layout = createStaticLayout(text)
        canFold = layout.lineCount > mFoldedLines
        isFolded = canFold
        if (canFold) {
            buildExpandableText(layout)
            setText(if (isFolded) mFoldedText else mExpandedText)
        } else {
            setText(mOriginText)
        }
    }

    /**
     * 展開/折疊
     */
    fun toggleExpand() {
        if (!canFold || isAnimating) return
        isFolded = !isFolded
        if (isFolded) {
            mFoldAnimator?.start()
        } else {
            mExpandAnimator?.start()
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        if ((mMeasuredWidth == 0 || mMeasuredWidth != measuredWidth) && !isAnimating) {
            Log.d(TAG, "width $mMeasuredWidth changed to $measuredWidth , $canFold")
            mMeasuredWidth = measuredWidth
            if (canFold) mOriginText?.let { setExpandableText(it) }
        }
    }

    /**
     * 重寫方法以支持ClickSpan的點(diǎn)擊事件
     * 直接設(shè)置LinkMovementMethod的話會(huì)導(dǎo)致TextView可以滑動(dòng),當(dāng)執(zhí)行折疊動(dòng)畫時(shí)整個(gè)文本會(huì)被向上推具壮,達(dá)不到預(yù)期效果
     */
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val curText = text
        val action = event?.action
        when {
            curText is Spanned && (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) -> {
                val x = (event.x - totalPaddingLeft + scrollX).toInt()
                val y = (event.y - totalPaddingTop + scrollY).toInt()
                val line = layout.getLineForVertical(y)
                val off = layout.getOffsetForHorizontal(line, x.toFloat())
                val link = curText.getSpans(off, off, ClickableSpan::class.java)
                if (link.isNotEmpty()) {
                    if (action == MotionEvent.ACTION_UP) link[0].onClick(this)
                    return true
                }
            }
            else -> return super.onTouchEvent(event)
        }
        return super.onTouchEvent(event)
    }

    /**
     * 構(gòu)建展開/折疊狀態(tài)下的文本
     */
    private fun buildExpandableText(originLayout: Layout) {
        if (TextUtils.isEmpty(mOriginText)) return
        mExpandedText = createSpannableStringBuilder(mOriginText!!).append(mExpandedSuffix)
        mExpandedHeight = createStaticLayout(mExpandedText).height + paddingTop + paddingBottom
        Log.d(TAG, "build ExpandedText: $mExpandedText")

        val lineEnd = originLayout.getLineEnd(mFoldedLines - 1)
        var foldText = mOriginText!!.subSequence(0, lineEnd)
        var builder = createSpannableStringBuilder(foldText).append(mFoldedSuffix)
        while (createStaticLayout(builder).lineCount > mFoldedLines) {
            foldText = foldText.subSequence(0, foldText.length - 1)
            builder = createSpannableStringBuilder(foldText).append(mFoldedSuffix)
        }
//        val foldText =
//            mOriginText!!.subSequence(0, lineEnd - mFoldedSuffix.length - 1)
//        mFoldedText = createSpannableStringBuilder(foldText).append(mFoldedSuffix)
        mFoldedText = createSpannableStringBuilder(builder)
        mFoldedHeight = createStaticLayout(mFoldedText).height + paddingTop + paddingBottom
        Log.d(TAG, "build FoldedText: $mFoldedText")

        mFoldAnimator = createAnimation(mExpandedHeight, mFoldedHeight, null, {
            text = mFoldedText
        })
        mExpandAnimator = createAnimation(mFoldedHeight, mExpandedHeight, {
            text = mExpandedText
        }, null)
    }

    /**
     * 根據(jù)[source]創(chuàng)建一個(gè)[StaticLayout]對(duì)象准颓,用于輔助計(jì)算文本可顯示行數(shù)、高度等
     */
    private fun <T : CharSequence> createStaticLayout(source: T): Layout =
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
            StaticLayout.Builder.obtain(source, 0, source.length, paint, mMeasuredWidth)
                .setAlignment(Layout.Alignment.ALIGN_NORMAL)
                .setIncludePad(includeFontPadding)
                .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
                .build()
        } else {
            @Suppress("DEPRECATION")
            StaticLayout(
                source,
                paint,
                mMeasuredWidth,
                Layout.Alignment.ALIGN_NORMAL,
                lineSpacingMultiplier,
                lineSpacingExtra,
                includeFontPadding
            )
        }

    private fun createClickedSpannableString(
        charSequence: CharSequence, start: Int = 0
    ): SpannableString = SpannableString(charSequence).apply {
        setSpan(object : ClickableSpan() {
            override fun onClick(widget: View) {
                toggleExpand()
            }

            override fun updateDrawState(ds: TextPaint) {
                super.updateDrawState(ds)
                ds.color = mSuffixTextColor
                ds.isUnderlineText = false
            }
        }, start, charSequence.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
    }

    private fun createSpannableStringBuilder(charSequence: CharSequence) =
        SpannableStringBuilder(charSequence)

    private fun createAnimation(
        start: Int,
        end: Int,
        startCallback: (() -> Unit)?,
        endCallback: (() -> Unit)?
    ): ObjectAnimator {
        val animator = ObjectAnimator.ofInt(this, "layoutHeight", start, end)
        animator.duration = mDuration
        animator.interpolator = AccelerateDecelerateInterpolator()
        animator.addListener(object : Animator.AnimatorListener {
            override fun onAnimationStart(animation: Animator?) {
                isAnimating = true
                startCallback?.invoke()
            }

            override fun onAnimationEnd(animation: Animator?) {
                isAnimating = false
                endCallback?.invoke()
            }

            override fun onAnimationCancel(animation: Animator?) {
            }

            override fun onAnimationRepeat(animation: Animator?) {
            }

        })
        return animator
    }

    companion object {
        const val TAG = "ExpandableTextView"

        val ELLIPSIS_STRING = String(charArrayOf('\u2026')) //省略號(hào)
        const val DEFAULT_EXPANDABLE_LINES = 4
        const val DEFAULT_DURATION_TIME = 300
        val DEFAULT_SUFFIX_TEXT_COLOR = Color.rgb(255, 97, 46)

        const val DEFAULT_ACTION_TEXT_FOLD = "收起"
        const val DEFAULT_ACTION_TEXT_EXPAND = "展開"
    }

}

github:ExpandableTextView

參考文章

需求做完了之后蠻久才補(bǔ)的筆記嘴办,還有些博客實(shí)在找不到了 = =

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末瞬场,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子涧郊,更是在濱河造成了極大的恐慌,老刑警劉巖眼五,帶你破解...
    沈念sama閱讀 216,544評(píng)論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件妆艘,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡看幼,警方通過(guò)查閱死者的電腦和手機(jī)批旺,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,430評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)诵姜,“玉大人汽煮,你說(shuō)我怎么就攤上這事。” “怎么了暇赤?”我有些...
    開封第一講書人閱讀 162,764評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵心例,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我鞋囊,道長(zhǎng)止后,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,193評(píng)論 1 292
  • 正文 為了忘掉前任溜腐,我火速辦了婚禮译株,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘挺益。我一直安慰自己歉糜,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,216評(píng)論 6 388
  • 文/花漫 我一把揭開白布望众。 她就那樣靜靜地躺著现恼,像睡著了一般。 火紅的嫁衣襯著肌膚如雪黍檩。 梳的紋絲不亂的頭發(fā)上叉袍,一...
    開封第一講書人閱讀 51,182評(píng)論 1 299
  • 那天,我揣著相機(jī)與錄音刽酱,去河邊找鬼喳逛。 笑死,一個(gè)胖子當(dāng)著我的面吹牛棵里,可吹牛的內(nèi)容都是我干的润文。 我是一名探鬼主播,決...
    沈念sama閱讀 40,063評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼殿怜,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼典蝌!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起头谜,我...
    開封第一講書人閱讀 38,917評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤骏掀,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后柱告,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體截驮,經(jīng)...
    沈念sama閱讀 45,329評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,543評(píng)論 2 332
  • 正文 我和宋清朗相戀三年际度,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了葵袭。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片镰踏。...
    茶點(diǎn)故事閱讀 39,722評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡柔袁,死狀恐怖貌嫡,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情脸狸,我是刑警寧澤宫仗,帶...
    沈念sama閱讀 35,425評(píng)論 5 343
  • 正文 年R本政府宣布迹栓,位于F島的核電站覆获,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏贸弥。R本人自食惡果不足惜窟坐,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,019評(píng)論 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望绵疲。 院中可真熱鬧哲鸳,春花似錦、人聲如沸盔憨。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,671評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)郁岩。三九已至婿奔,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間问慎,已是汗流浹背萍摊。 一陣腳步聲響...
    開封第一講書人閱讀 32,825評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留如叼,地道東北人冰木。 一個(gè)月前我還...
    沈念sama閱讀 47,729評(píng)論 2 368
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像笼恰,于是被迫代替她去往敵國(guó)和親踊沸。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,614評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容