自定义TextView实现结尾加载动画

最近做项目,仿豆包和机器人对话的时候,机器人返回数据是流式返回的,需要在文本结尾添加加载动画,于是自己实现了自定义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.roundToInt

class LoadingTextView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {

    var isLoading = true
        set(value) {
            field = value
            if (value) {
                startAnimation()
            } else {
                stopAnimation()
            }
            requestLayout()
            invalidate()
        }

    private lateinit var loadingDrawable: Drawable

    private var maxLineWidth: Float = 0f

    init {
        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 = drawable
        loadingDrawable.setBounds(0, 0, width, height)
        requestLayout()
        invalidate()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        if (!isLoading) return

        var widthSize = MeasureSpec.getSize(widthMeasureSpec)
        var heightSize = MeasureSpec.getSize(heightMeasureSpec)
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        layout?.apply {
            val loadingWidth = loadingDrawable.intrinsicWidth
            val loadingHeight = loadingDrawable.intrinsicHeight
            if (lineCount > 0) {
                val lastLine = lineCount - 1
                val top = getLineTop(0)
                val bottom = getLineBottom(lineCount - 1)
                val textHeight: Int = bottom - top
                for (line in 0 until lineCount) {
                    val width = getLineWidth(line)
                    maxLineWidth = maxOf(maxLineWidth, width)
                }
                val end = getLineEnd(lastLine)
                val lastCharIndex = end - 1
                val 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 = loadingWidth
                heightSize = 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.intrinsicWidth
            val loadingHeight = loadingDrawable.intrinsicHeight
            if (lineCount > 0) {
                val lastLine = lineCount - 1
                val end = getLineEnd(lastLine)
                val lastCharIndex = end - 1
                val lastCharX = getPrimaryHorizontal(lastCharIndex)
                val top = getLineTop(lastLine)
                val bottom = getLineBottom(lastLine)
                val translateX: Float
                val translateY: Float
                if (lastCharX + compoundDrawablePadding + loadingWidth > maxWidth) {
                    translateX = 0f
                    translateY = bottom.toFloat()
                } else {
                    translateX = lastCharX + compoundDrawablePadding
                    translateY = (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.Drawable

class BallLoadingDrawable : Drawable(), Animatable {

    private val scaleFloats = floatArrayOf(
        1.0f, 1.0f, 1.0f
    )

    private var animators: ArrayList<ValueAnimator>? = null
    private var drawBounds = Rect()
    private val paint = Paint()

    var color: Int = Color.WHITE
        set(value) {
            field = value
            paint.color = color
            invalidateSelf()
        }

    init {
        paint.color = Color.WHITE
        paint.style = Paint.Style.FILL
        paint.isAntiAlias = true
    }

    override fun draw(canvas: Canvas) {
        val circleSpacing = 4f
        val radius = (getWidth().coerceAtMost(getHeight()) - circleSpacing * 2) / 6
        val 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 * i
            canvas.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 = -1
                scaleAnim.startDelay = delays[i].toLong()
                scaleAnim.addUpdateListener { animation ->
                    scaleFloats[i] = animation.animatedValue as Float
                    invalidateSelf()
                }
                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.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="16dp">

        <Button
            android: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.LoadingTextView
            android: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属性

感谢大家的支持,如有错误请指正,如需转载请标明原文出处!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值