前段时间,我钻研了一个新性能:在 app 外部聊天中发送图片。这个性能自身很大,包含了多种货色,但实际上,最后并没有设计上传动画与勾销上传的性能。当我用到这部分的时候,我决定减少图片上传动画,所以咱们就给它们这个性能吧:)

View vs. Drawable

我在那里用了一个 Drawable。在我集体看来,StackOverflow 这里就有个很好的简洁的答案。

Drawable 只响应绘制操作,而 View 响应绘制和用户界面,比方触摸事件和敞开屏幕等等。

当初咱们来剖析一下,咱们想要做什么。咱们心愿有一条有限旋转的弧线做圆形动画,并且弧线的圆心角一直减少直到圆心角等于 2。我感觉一个 Drawable 应该可能帮上我的忙,而且实际上我也应该那样做,但我没有。

我没有这样做的起因在下面示例图片中的文字左边那三个小的点点的动画上。我曾经用自定义 View 实现了这个动画,并且我曾经为有限循环的动画筹备了背景。对我来说把动画筹备逻辑提取到父 View 中重用,而不是把所有货色都重写成 Drawable,应该是更简略的。所以我并不是说我的解决方案是正确的(其实没有什么是正确的),而是它满足了我的需要。

Base InfiniteAnimationView

为了本人的须要,我将把想要的进度视图分成两个视图:

  • ProgressView —— 负责绘制所需的进度 View
  • InfiniteAnimateView  —— 形象 View,它负责动画的筹备、启动和进行。因为进度中蕴含了有限旋转的局部,咱们须要理解什么时候须要启动这个动画,什么时候须要进行这个动画

在查看了 Android 的 ProgressBar 的源代码后,咱们能够最终失去这样的后果:

// InfiniteAnimateView.ktabstract class InfiniteAnimateView @JvmOverloads constructor(    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {    private var isAggregatedVisible: Boolean = false    private var animation: Animator? = null    override fun onVisibilityAggregated(isVisible: Boolean) {        super.onVisibilityAggregated(isVisible)        if (isAggregatedVisible != isVisible) {            isAggregatedVisible = isVisible            if (isVisible) startAnimation() else stopAnimation()        }    }    override fun onAttachedToWindow() {        super.onAttachedToWindow()        startAnimation()    }    override fun onDetachedFromWindow() {        stopAnimation()        super.onDetachedFromWindow()    }    private fun startAnimation() {        if (!isVisible || windowVisibility != VISIBLE) return        if (animation == null) animation = createAnimation().apply { start() }    }    protected abstract fun createAnimation(): Animator    private fun stopAnimation() {        animation?.cancel()        animation = null    }}

遗憾的是,次要出于 onVisibilityAggregated 办法的起因,它并无奈工作 —— 因为[这个办法在 API 24 以上才被反对](developer.android.com/reference/a… !isVisible || windowVisibility != VISIBLE 上的问题,当视图是可见的,但它的容器却不可见。所以我决定重写这个:

// InfiniteAnimateView.ktabstract class InfiniteAnimateView @JvmOverloads constructor(    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {    private var animation: Animator? = null    /**     * 咱们不能够应用 `onVisibilityAggregated` 办法,因为它只在 SDK 24 以上被反对,而咱们的最低 SDK 是 21     */    override fun onVisibilityChanged(changedView: View, visibility: Int) {        super.onVisibilityChanged(changedView, visibility)        if (isShown) startAnimation() else stopAnimation()    }    override fun onAttachedToWindow() {        super.onAttachedToWindow()        startAnimation()    }    override fun onDetachedFromWindow() {        stopAnimation()        super.onDetachedFromWindow()    }    private fun startAnimation() {        if (!isShown) return        if (animation == null) animation = createAnimation().apply { start() }    }    protected abstract fun createAnimation(): Animator    private fun stopAnimation() {        animation?.cancel()        animation = null    }}

可怜的是,这也没有用(尽管我感觉它应该可能失常工作的)。说实话,我不晓得问题的具体起因。可能在一般的状况下会无效,然而对于 RecyclerView 就不行了。前段时间我就遇到了这个问题:如果应用 isShown 来跟踪一些货色是否在 RecyclerView 中显示。因而可能我的最终解决方案并不正确,但至多在我的计划中,它能依照我的冀望工作:

// InfiniteAnimateView.ktabstract class InfiniteAnimateView @JvmOverloads constructor(    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {    private var animation: Animator? = null    /**     * 咱们不能够应用 `onVisibilityAggregated` 办法,因为它只在 SDK 24 以上被反对,而咱们的最低 SDK 是 21     */    override fun onVisibilityChanged(changedView: View, visibility: Int) {        super.onVisibilityChanged(changedView, visibility)        if (isDeepVisible()) startAnimation() else stopAnimation()    }    override fun onAttachedToWindow() {        super.onAttachedToWindow()        startAnimation()    }    override fun onDetachedFromWindow() {        stopAnimation()        super.onDetachedFromWindow()    }    private fun startAnimation() {        if (!isAttachedToWindow || !isDeepVisible()) return        if (animation == null) animation = createAnimation().apply { start() }    }    protected abstract fun createAnimation(): Animator    private fun stopAnimation() {        animation?.cancel()        animation = null    }    /**     * 可能这个函数上实现了 View.isShown,但我察觉到它有一些问题。     * 我在 Lottie lib 中也遇到了这些问题。不过因为咱们总是没有工夫去深入研究     * 我决定应用了这个简略的办法临时解决这个问题,只为确保它可能失常运行     * 我到底须要什么 = =     *     * 更新:尝试应用 isShown 代替这个办法,但没有胜利。所以如果你晓得     * 如何改良,欢送评论区讨论一下     */    private fun isDeepVisible(): Boolean {        var isVisible = isVisible        var parent = parentView        while (parent != null && isVisible) {            isVisible = isVisible && parent.isVisible            parent = parent.parentView        }        return isVisible    }    private val View.parentView: ViewGroup? get() = parent as? ViewGroup}

进度动画

筹备

那么首先咱们来谈谈咱们 View 的构造。它应该蕴含哪些绘画组件?在以后情境下最好的表达方式就是申明不同的 Paint

// progress_paints.ktprivate val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {    style = Paint.Style.FILL    color = defaultBgColor}private val bgStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {    style = Paint.Style.STROKE    color = defaultBgStrokeColor    strokeWidth = context.resources.getDimension(R.dimen.chat_progress_bg_stroke_width)}private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {    style = Paint.Style.STROKE    strokeCap = Paint.Cap.BUTT    strokeWidth = context.resources.getDimension(R.dimen.chat_progress_stroke_width)    color = defaultProgressColor}

为了展现我将扭转笔触的宽度和其余货色,所以你会看到某些方面的不同。这 3 个 Paint 就与 3 个要害局部的进度相关联:

左:background; 中:stroke; 右:progress

你可能想晓得为什么我要用 Paint.Cap.BUTT。好吧,为了让这个进度更 "Telegram"(至多在 iOS 设施上是这样),你应该应用 Paint.Cap.ROUND。让我来演示一下这三种可能的款式之间的区别(这里减少了描边宽度以让差别更显著)。

左:Cap.BUTT,中:Cap.ROUND,右:Cap.SQUARE

因而,次要的区别是,Cap.ROUND给笔画的角以非凡的圆角,而 Cap.BUTT 和 Cap.SQUARE只是切割。Cap.SQUARE 也和 Cap.ROUND 一样预留了额定的空间,但没有圆角成果。这可能导致 Cap.SQUARE 显示的角度与 Cap.BUTT 雷同但预留了额定的空间。

试图用 Cap.BUTT 和 Cap.SQUARE 来显示 90 度。

思考到所有这些状况,咱们最好应用 Cap.BUTT,因为它比 Cap.SQUARE 显示的角度示意更失当。

顺便说一下 Cap.BUTT 是画笔默认的笔刷类型。这里有一个官网的文档链接。但我想向你展现真正的区别,因为最后我想让它变成 ROUND,而后我开始应用 SQUARE,但我留神到了一些个性。

Base Spinning

动画自身其实很简略,因为咱们有 InfiniteAnimateView

ValueAnimator.ofFloat(currentAngle, currentAngle + MAX_ANGLE)    .apply {        interpolator = LinearInterpolator()        duration = SPIN_DURATION_MS        repeatCount = ValueAnimator.INFINITE        addUpdateListener {             currentAngle = normalize(it.animatedValue as Float)        }    }

其中 normalize 是一种简略的办法用于将任意角放大回 [0, 2)` 区间内。例如,对于角度400.54normalize后就是 40.54

private fun normalize(angle: Float): Float {    val decimal = angle - angle.toInt()    return (angle.toInt() % MAX_ANGLE) + decimal}

测量与绘制

咱们将依附由父视图提供的测量尺寸或应用在 xml中定义的准确的 layout_widthlayout_height 值进行绘制。因而,咱们在 View 的测量方面不须要任何事件,但咱们会应用测量的尺寸来筹备进度矩形并在其中绘制 View。

嗯,这并不难,但咱们须要记住一些事件:

咱们不能只拿 measuredWidthmeasuredHeight 来画圆圈背景、进度、描边(次要是描边的起因)。如果咱们不思考描边的宽度,也不从尺寸计算中减去它的一半,咱们最终会失去看起来像切开的边界:

如果咱们不思考笔触的宽度,咱们可能最终会在绘图阶段将其重叠。(这对于不通明的色彩来说是能够的)

然而,如果你将应用半透明的色彩,你就会看到很奇怪的重叠(我减少了笔触宽度以更清晰地展现问题所在)。

扫描动画的角度

好了,最初是进度自身。假如咱们能够把它从 0 改成 1:

@FloatRange(from = .0, to = 1.0, toInclusive = false)var progress.Float = 0f Float = 0f

为了绘制弧线,咱们须要计算一个非凡的扫描动画的角度,而它就是绘图局部的一个非凡角度。360 —— 一个残缺的圆将被绘制。90—— 将画出圆的四分之一。

所以咱们须要将进度转换为度数,同时,咱们须要放弃扫描角不为 0。也就是说即使 progress 值等于0,咱们也要绘制一小块的进度。

private fun convertToSweepAngle(progress: Float): Float =    MIN_SWEEP_ANGLE + progress * (MAX_ANGLE - MIN_SWEEP_ANGLE)

其中 MAX_ANGLE = 360(当然你能够自定义为任何角度),MIN_SWEEP_ANGLE 是最小的进度,以度数为单位。最小进度会在 progress = 0 就会代替 progress 值。

代码放一起!

当初将所有的代码合并一起,咱们就能够构建残缺的 View 了:

// ChatProgressView.ktclass ChatProgressView @JvmOverloads constructor(    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : InfiniteAnimateView(context, attrs, defStyleAttr) {    private val defaultBgColor: Int = context.getColorCompat(R.color.chat_progress_bg)    private val defaultBgStrokeColor: Int = context.getColorCompat(R.color.chat_progress_bg_stroke)    private val defaultProgressColor: Int = context.getColorCompat(R.color.white)    private val progressPadding = context.resources.getDimension(R.dimen.chat_progress_padding)    private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {        style = Paint.Style.FILL        color = defaultBgColor    }    private val bgStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {        style = Paint.Style.STROKE        color = defaultBgStrokeColor        strokeWidth = context.resources.getDimension(R.dimen.chat_progress_bg_stroke_width)    }    private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {        style = Paint.Style.STROKE        strokeWidth = context.resources.getDimension(R.dimen.chat_progress_stroke_width)        color = defaultProgressColor    }    @FloatRange(from = .0, to = 1.0, toInclusive = false)    var progress: Float = 0f        set(value) {            field = when {                value < 0f -> 0f                value > 1f -> 1f                else -> value            }            sweepAngle = convertToSweepAngle(field)            invalidate()        }    // [0, 360)    private var currentAngle: Float by observable(0f) { _, _, _ -> invalidate() }    private var sweepAngle: Float by observable(MIN_SWEEP_ANGLE) { _, _, _ -> invalidate() }    private val progressRect: RectF = RectF()    private var bgRadius: Float = 0f    init {        attrs?.parseAttrs(context, R.styleable.ChatProgressView) {            bgPaint.color = getColor(R.styleable.ChatProgressView_bgColor, defaultBgColor)            bgStrokePaint.color = getColor(R.styleable.ChatProgressView_bgStrokeColor, defaultBgStrokeColor)            progressPaint.color = getColor(R.styleable.ChatProgressView_progressColor, defaultProgressColor)        }    }    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {        super.onMeasure(widthMeasureSpec, heightMeasureSpec)        val horizHalf = (measuredWidth - padding.horizontal) / 2f        val vertHalf = (measuredHeight - padding.vertical) / 2f        val progressOffset = progressPadding + progressPaint.strokeWidth / 2f        // 因为笔画在线的核心,咱们须要为它留出一半的平安空间,否则它将被截断的界线        bgRadius = min(horizHalf, vertHalf) - bgStrokePaint.strokeWidth / 2f        val progressRectMinSize = 2 * (min(horizHalf, vertHalf) - progressOffset)        progressRect.apply {            left = (measuredWidth - progressRectMinSize) / 2f            top = (measuredHeight - progressRectMinSize) / 2f            right = (measuredWidth + progressRectMinSize) / 2f            bottom = (measuredHeight + progressRectMinSize) / 2f        }    }    override fun onDraw(canvas: Canvas) {        super.onDraw(canvas)        with(canvas) {            //(radius - strokeWidth) - because we don't want to overlap colors (since they by default translucent)            drawCircle(progressRect.centerX(), progressRect.centerY(), bgRadius - bgStrokePaint.strokeWidth / 2f, bgPaint)            drawCircle(progressRect.centerX(), progressRect.centerY(), bgRadius, bgStrokePaint)            drawArc(progressRect, currentAngle, sweepAngle, false, progressPaint)        }    }    override fun createAnimation(): Animator = ValueAnimator.ofFloat(currentAngle, currentAngle + MAX_ANGLE).apply {        interpolator = LinearInterpolator()        duration = SPIN_DURATION_MS        repeatCount = ValueAnimator.INFINITE        addUpdateListener { currentAngle = normalize(it.animatedValue as Float) }    }    /**     * 将任意角转换至 [0, 360)     * 比如说 angle = 400.54 => return 40.54     * angle = 360 => return 0     */    private fun normalize(angle: Float): Float {        val decimal = angle - angle.toInt()        return (angle.toInt() % MAX_ANGLE) + decimal    }    private fun convertToSweepAngle(progress: Float): Float =        MIN_SWEEP_ANGLE + progress * (MAX_ANGLE - MIN_SWEEP_ANGLE)    private companion object {        const val SPIN_DURATION_MS = 2_000L        const val MIN_SWEEP_ANGLE = 10f //in degrees        const val MAX_ANGLE = 360 //in degrees    }}

补充!

补充一下,咱们能够在 drawArc 这个办法上拓展一下。你看咱们有一个 currentAngle代表了绘制圆弧的起始点的角度,还有一个 sweepAngle 代表了咱们须要绘制多少度数的圆弧。

当进度减少时,咱们只扭转 sweepAngle,也就是说,如果 currentAngle 是动态值(不变),那么咱们将看到减少的圆弧只有一个方向。咱们能够试着批改一下。考虑一下三种状况并看看后果别离是怎么的:

// 1. 在这种状况下,弧线只在一个方向上 "减少"drawArc(progressRect, currentAngle, sweepAngle, false, progressPaint)// 2. 在这种状况下,弧线在两个方向上 "减少"drawArc(progressRect, currentAngle - sweepAngle / 2f, sweepAngle, false, progressPaint)// 3. 在这种状况下,弧线向另一个方向 "减少"drawArc(progressRect, currentAngle - sweepAngle, sweepAngle, false, progressPaint)

而后果是:

左: 第一种状况;中: 第二种状况;右: 第三种状况

如你所见,右边和左边的动画(计划一、三)在速度上并不统一。第一个给人的感觉是旋转速度放慢,进度减少,而最初一个则相同,给人的感觉是旋转速度变慢。而反之则是进度递加。

不过两头的动画在旋转速度上是统一的。所以,如果你不是减少进度(比方上传文件),或者只是缩小进度(比方倒计时),那么我倡议应用第二个计划。

地址:https://github.com/xitu/gold-...

原文链接:https://proandroiddev.com/tel...

文末

您的点赞珍藏就是对我最大的激励!
欢送关注我,分享Android干货,交换Android技术。
对文章有何见解,或者有何技术问题,欢送在评论区一起留言探讨!