最近接到一個(gè)需求血淌,需要實(shí)現(xiàn)可以自動(dòng)折疊的TextView,如下圖所示:
重點(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í)在找不到了 = =