1. 程式人生 > 其它 >Android 仿 Telegram 一樣的上傳檔案炫酷動畫!

Android 仿 Telegram 一樣的上傳檔案炫酷動畫!

前段時間,我研究了一個新功能:在 app 內部聊天中傳送圖片。這個功能本身很大,包括了多種東西,但實際上,最初並沒有設計上傳動畫與取消上傳的功能。當我用到這部分的時候,我決定增加圖片上傳動畫,所以我們就給它們這個功能吧:)

View vs. Drawable

我在那裡用了一個 Drawable。在我個人看來,StackOverflow這裡就有個很好的簡潔的答案。

Drawable 只響應繪製操作,而 View 響應繪製和使用者介面,比如觸控事件和關閉螢幕等等。

現在我們來分析一下,我們想要做什麼。我們希望有一條無限旋轉的弧線做圓形動畫,並且弧線的圓心角不斷增加直到圓心角等於。我覺得一個Drawable

應該能夠幫上我的忙,而且實際上我也應該那樣做,但我沒有。

我沒有這樣做的原因在上面示例圖片中的文字右邊那三個小的點點的動畫上。我已經用自定義 View 完成了這個動畫,並且我已經為無限迴圈的動畫準備了背景。對我來說把動畫準備邏輯提取到父 View 中重用,而不是把所有東西都重寫成 Drawable,應該是更簡單的。所以我並不是說我的解決方案是正確的(其實沒有什麼是正確的),而是它滿足了我的需求。

Base InfiniteAnimationView

為了自己的需要,我將把想要的進度檢視分成兩個檢視:

  • ProgressView—— 負責繪製所需的進度 View

  • InfiniteAnimateView

    —— 抽象 View,它負責動畫的準備、啟動和停止。由於進度中包含了無限旋轉的部分,我們需要了解什麼時候需要啟動這個動畫,什麼時候需要停止這個動畫

在查看了 Android 的ProgressBar的原始碼後,我們可以最終得到這樣的結果:

// InfiniteAnimateView.kt

abstract 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.kt

abstract 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.kt

abstract 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.kt

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
    strokeCap = Paint.Cap.BUTT
    strokeWidth = context.resources.getDimension(R.dimen.chat_progress_stroke_width)
    color = defaultProgressColor
}

為了展示我將改變筆觸的寬度和其他東西,所以你會看到某些方面的不同。這3Paint就與3個關鍵部分的進度相關聯:

左:background; 中:stroke; 右:progress

你可能想知道為什麼我要用Paint.Cap.BUTT。好吧,為了讓這個進度更 "Telegram"(至少在 iOS 裝置上是這樣),你應該使用Paint.Cap.ROUND。讓我來演示一下這三種可能的樣式之間的區別(這裡增加了描邊寬度以讓差異更明顯)。

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

因此,主要的區別是,Cap.ROUND給筆畫的角以特殊的圓角,而Cap.BUTTCap.SQUARE只是切割。Cap.SQUARE也和Cap.ROUND一樣預留了額外的空間,但沒有圓角效果。這可能導致Cap.SQUARE顯示的角度與Cap.BUTT相同但預留了額外的空間。

試圖用Cap.BUTTCap.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.kt

class 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-miner/blob/master/article/2021/telegram-like-uploading-animation.md

原文連結:https://proandroiddev.com/telegram-like-uploading-animation-e284f1404f63

文末

您的點贊收藏就是對我最大的鼓勵!
歡迎關注我,分享Android乾貨,交流Android技術。
對文章有何見解,或者有何技術問題,歡迎在評論區一起留言討論!