最近做项目,仿豆包和机器人对话的时候,机器人返回数据是流式返回的,需要在文本结尾添加加载动画,于是自己实现了自定义TextView控件。
源码如下:
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.TypedValue
import androidx.annotation.Px
import androidx.appcompat.widget.AppCompatTextView
import kotlin.math.roundToIntclass LoadingTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {var isLoading = trueset(value) {field = valueif (value) {startAnimation()} else {stopAnimation()}requestLayout()invalidate()}private lateinit var loadingDrawable: Drawableprivate var maxLineWidth: Float = 0finit {setLoadingDrawable(BallLoadingDrawable().also {it.color = Color.BLACK}, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 36f, context.resources.displayMetrics).toInt(), TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 22f, context.resources.displayMetrics).toInt())}fun setLoadingDrawable(drawable: Drawable, @Px width: Int, @Px height: Int) {loadingDrawable = drawableloadingDrawable.setBounds(0, 0, width, height)requestLayout()invalidate()}override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {super.onMeasure(widthMeasureSpec, heightMeasureSpec)if (!isLoading) returnvar widthSize = MeasureSpec.getSize(widthMeasureSpec)var heightSize = MeasureSpec.getSize(heightMeasureSpec)val widthMode = MeasureSpec.getMode(widthMeasureSpec)val heightMode = MeasureSpec.getMode(heightMeasureSpec)layout?.apply {val loadingWidth = loadingDrawable.intrinsicWidthval loadingHeight = loadingDrawable.intrinsicHeightif (lineCount > 0) {val lastLine = lineCount - 1val top = getLineTop(0)val bottom = getLineBottom(lineCount - 1)val textHeight: Int = bottom - topfor (line in 0 until lineCount) {val width = getLineWidth(line)maxLineWidth = maxOf(maxLineWidth, width)}val end = getLineEnd(lastLine)val lastCharIndex = end - 1val lastCharX = getPrimaryHorizontal(lastCharIndex)if ((lastCharX + compoundDrawablePadding + loadingWidth) > maxWidth) {widthSize =(maxLineWidth.roundToInt() + compoundDrawablePadding + loadingWidth).coerceAtMost(maxWidth)heightSize = (loadingHeight + textHeight).coerceAtLeast(heightSize)} else {widthSize =(maxLineWidth.roundToInt() + compoundDrawablePadding + loadingWidth).coerceAtMost(maxWidth)heightSize = textHeight.coerceAtLeast(heightSize)}} else {widthSize = loadingWidthheightSize = loadingHeight}}setMeasuredDimension(MeasureSpec.makeMeasureSpec(widthSize, widthMode),MeasureSpec.makeMeasureSpec(heightSize, heightMode))}override fun onDraw(canvas: Canvas) {super.onDraw(canvas)if (isLoading) {drawLoading(canvas)} else {stopAnimation()}}private fun drawLoading(canvas: Canvas) {startAnimation()layout?.apply {val loadingWidth = loadingDrawable.intrinsicWidthval loadingHeight = loadingDrawable.intrinsicHeightif (lineCount > 0) {val lastLine = lineCount - 1val end = getLineEnd(lastLine)val lastCharIndex = end - 1val lastCharX = getPrimaryHorizontal(lastCharIndex)val top = getLineTop(lastLine)val bottom = getLineBottom(lastLine)val translateX: Floatval translateY: Floatif (lastCharX + compoundDrawablePadding + loadingWidth > maxWidth) {translateX = 0ftranslateY = bottom.toFloat()} else {translateX = lastCharX + compoundDrawablePaddingtranslateY = (bottom + top - loadingHeight) / 2f}canvas.save()canvas.translate(translateX, translateY)loadingDrawable.draw(canvas)canvas.restore()}}}override fun onAttachedToWindow() {super.onAttachedToWindow()startAnimation()}override fun onDetachedFromWindow() {stopAnimation()super.onDetachedFromWindow()}private fun startAnimation() {if (!isLoading || visibility != VISIBLE) {return}if (loadingDrawable is Animatable) {(loadingDrawable as Animatable).start()postInvalidate()}}private fun stopAnimation() {if (loadingDrawable is Animatable) {(loadingDrawable as Animatable).stop()postInvalidate()}}
}
其中BallLoadingDrawable是自定义Drawable,也可以换成其他自定义的Drawable实现不一样的动画效果。
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawableclass BallLoadingDrawable : Drawable(), Animatable {private val scaleFloats = floatArrayOf(1.0f, 1.0f, 1.0f)private var animators: ArrayList<ValueAnimator>? = nullprivate var drawBounds = Rect()private val paint = Paint()var color: Int = Color.WHITEset(value) {field = valuepaint.color = colorinvalidateSelf()}init {paint.color = Color.WHITEpaint.style = Paint.Style.FILLpaint.isAntiAlias = true}override fun draw(canvas: Canvas) {val circleSpacing = 4fval radius = (getWidth().coerceAtMost(getHeight()) - circleSpacing * 2) / 6val x = getWidth() / 2 - (radius * 2 + circleSpacing)val y = (getHeight() / 2).toFloat()for (i in 0..2) {canvas.save()val translateX = x + radius * 2 * i + circleSpacing * icanvas.translate(translateX, y)canvas.scale(scaleFloats[i], scaleFloats[i])canvas.drawCircle(0f, 0f, radius, paint)canvas.restore()}}fun getWidth(): Int {return drawBounds.width()}fun getHeight(): Int {return drawBounds.height()}override fun setAlpha(alpha: Int) {}override fun setColorFilter(colorFilter: ColorFilter?) {}override fun getOpacity(): Int {return PixelFormat.OPAQUE}override fun start() {if (isStarted()) {return}if (animators.isNullOrEmpty()) {animators = arrayListOf()val delays = intArrayOf(120, 240, 360)for (i in 0..2) {val scaleAnim = ValueAnimator.ofFloat(1f, 0.3f, 1f)scaleAnim.setDuration(750)scaleAnim.repeatCount = -1scaleAnim.startDelay = delays[i].toLong()scaleAnim.addUpdateListener { animation ->scaleFloats[i] = animation.animatedValue as FloatinvalidateSelf()}animators!!.add(scaleAnim)}}animators?.forEach {it.start()}}override fun stop() {animators?.forEach {it.end()}}override fun isRunning(): Boolean {return animators?.any { it.isRunning } ?: false}private fun isStarted(): Boolean {return animators?.any { it.isStarted } ?: false}override fun onBoundsChange(bounds: Rect) {drawBounds = Rect(bounds.left, bounds.top, bounds.right, bounds.bottom)}override fun getIntrinsicHeight(): Int {return drawBounds.height()}override fun getIntrinsicWidth(): Int {return drawBounds.width()}
}
对应的布局文件为:
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"tools:context=".FirstFragment"><androidx.constraintlayout.widget.ConstraintLayoutandroid:layout_width="match_parent"android:layout_height="match_parent"android:padding="16dp"><Buttonandroid:id="@+id/button_first"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="@string/next"app:layout_constraintBottom_toTopOf="@id/textview_first"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toTopOf="parent" /><com.zhupeng.ai.pdf.gpt.LoadingTextViewandroid:id="@+id/textview_first"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_marginTop="16dp"android:maxWidth="300dp"android:text="@string/lorem_ipsum"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintTop_toBottomOf="@id/button_first" /></androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
注意:使用该控件必须设置android:maxWidth属性
感谢大家的支持,如有错误请指正,如需转载请标明原文出处!