最近做项目,仿豆包和机器人对话的时候,机器人返回数据是流式返回的,需要在文本结尾添加加载动画,于是自己实现了自定义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属性
感谢大家的支持,如有错误请指正,如需转载请标明原文出处!