20/07/2019, 10:16

LightProgress Animation

Hôm nay, chúng ta sẽ tìm hiểu custom một view được thiết kế bởi Oleg Frolov trên Dribble. Mathematics things... Đầu tiên chúng ta cần phải xác định được trục cho Light animation. Như các bạn thấy thì nó nằm ở giữa dấu chấm của từ ' i ' . Ta sẽ thực hiện animation này với text ...

Hôm nay, chúng ta sẽ tìm hiểu custom một view được thiết kế bởi Oleg Frolov trên Dribble.

Mathematics things...

Đầu tiên chúng ta cần phải xác định được trục cho Light animation. Như các bạn thấy thì nó nằm ở giữa dấu chấm của từ ' i '. Ta sẽ thực hiện animation này với text "Loading" và bây giờ chúng ta sẽ tính toán để tìm được tọa độ đó:

  1. Tính chiều rộng của text (w1) với từ " i " (Loadi)
  2. Tính chiều rộng của text (w2) khi không có từ " i " (Load)
  3. Tính chiều rộng của từ " i " (w3) với dấu cách 2 bên của từ, w3 = w1 - w2
  4. Tính tọa độ pivotX: pivotX = w1 - w3 / 2
  5. Tính chiều rộng của từ " i " (w4) khi không có dấu cách. Giả sử w4 = đường kính của dấu chấm trên đầu từ i
  6. Tính tọa độ pivotY: pivotY = (-text.ascent - text.height + w4/2). Các bạn có thể tìm hiểu về ascent, descent,...

Mình đã thay chữ Light thành chữ Loading nhưng cách tính của chúng đều giống nhau.

Some drawing

Để vẽ được được đường đèn sáng màu trắng, chúng ta cần phải tính được cấu trúc hình thang.

val topY = textPaint.ascent() * -1 - textBounds.height()
lightPath.moveTo(lightPivotX - letterWidth / 2f, topY)
lightPath.moveTo(lightPivotX + letterWidth / 2f, topY)
lightPath.lineTo(lightPivotX + awidth / 2f, awidth.toFloat())
lightPath.lineTo(lightPivotX - awidth / 2f, awidth.toFloat())
lightPath.lineTo(lightPivotX - letterWidth / 2f, topY)
lightPath.close()

Let's light it up

Bây giờ chúng ta cần chạy animation:

  • Tạo một animator object:
private var animator = ValueAnimator.ofFloat(0f, 1f).apply {
    addUpdateListener {
        val value = it.animatedValue as Float
        angle = lerp(0f, FULL_CIRCLE, value)
    }
    interpolator = CustomSpringInterpolator(INTERPOLATOR_FACTOR)
    repeatMode = ValueAnimator.RESTART
    repeatCount = ValueAnimator.INFINITE
    duration = ANIMATION_DURATION
}
  • Cập nhật góc độ và invalidate view
private var angle = 0f
    set(value) {
        field = value
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            postInvalidateOnAnimation()
        } else {
            invalidate()
        }
    }
  • Chạy animation:

=> Không phải thứ ta muốn?

Android PorterDuff.Mode

Chúng ta sẽ sử dụng PorterDuff.Mode để đạt được kết quả như sau:

Code...

  • LightProgress.kt
class LightProgress @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private lateinit var text: String
    private lateinit var textLayout: StaticLayout

    private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
    private val lightPaint = Paint(Paint.ANTI_ALIAS_FLAG)

    private val lightPath = Path()

    private lateinit var textBitmap: Bitmap

    private var lightPivotX = 0f
    private var lightPivotY = 0f
    private var letterWidth = 0

    private var angle = 0f
        set(value) {
            field = value
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                postInvalidateOnAnimation()
            } else {
                invalidate()
            }
        }

    private var animator = ValueAnimator.ofFloat(0f, 1f).apply {
        addUpdateListener {
            val value = it.animatedValue as Float
            angle = lerp(0f, FULL_CIRCLE, value)
        }
        interpolator = CustomSpringInterpolator(INTERPOLATOR_FACTOR)
        repeatMode = ValueAnimator.RESTART
        repeatCount = ValueAnimator.INFINITE
        duration = ANIMATION_DURATION
    }

    init {
        attrs?.let { retrieveAttributes(attrs, defStyleAttr) }
        setLayerType(LAYER_TYPE_SOFTWARE, null)
    }

    private fun retrieveAttributes(attrs: AttributeSet, defStyleAttr: Int) {
        val typedArray =
                context.obtainStyledAttributes(attrs, R.styleable.LightProgress, defStyleAttr,
                        R.style.LightProgress)

        text = typedArray.getStringOrThrow(R.styleable.LightProgress_android_text)

        textPaint.apply {
            color = typedArray.getColorOrThrow(R.styleable.LightProgress_android_textColor)
            textSize = typedArray.getDimensionOrThrow(R.styleable.LightProgress_android_textSize)
            typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
            xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_ATOP)
        }

        textLayout = createLayout(text)

        lightPaint.color = typedArray.getColorOrThrow(R.styleable.LightProgress_light_color)

        typedArray.recycle()
    }

    override fun onMeasure(awidthMeasureSpec: Int, heightMeasureSpec: Int) {
        val w = textLayout.awidth
        val h = textLayout.height
        setMeasuredDimension(w, h)
    }
    
     override fun onDraw(canvas: Canvas?) {
        canvas?.withRotation(angle, lightPivotX, lightPivotY) {
            drawPath(lightPath, lightPaint)
        }
        canvas?.drawBitmap(textBitmap, 0f, 0f, textPaint)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        initLight()
        textBitmap = textToBitmap(text)
    }

    private fun initLight() {
        val textBounds = Rect()
        val iPos = text.indexOf(LIGHT_LETTER)
        if (iPos == -1) {
            lightPivotX = awidth / 2f
            lightPivotY = 0f
            textPaint.getTextBounds(text, 0, text.length - 1, textBounds)
        } else {
            val textWithLetter = text.substring(0, iPos + 1)
            val textBeforeLetter = text.substring(0, iPos)

            var textLayout = createLayout(textWithLetter)
            val withWithLetter = textLayout.awidth

            textLayout = createLayout(textBeforeLetter)
            val awidthWithoutLetter = textLayout.awidth

            textPaint.getTextBounds(LIGHT_LETTER, 0, 1, textBounds)

            letterWidth = textBounds.awidth()// one "i" letter awidth

            textPaint.getTextBounds(text, 0, text.length - 1, textBounds)

            val letterWidthWithIndent = withWithLetter - awidthWithoutLetter

            lightPivotX = withWithLetter - letterWidthWithIndent / 2f
            lightPivotY = ((textPaint.ascent() * -1) - textBounds.height()) + letterWidth / 2f
        }

        val topY = textPaint.ascent() * -1 - textBounds.height()
        lightPath.moveTo(lightPivotX - letterWidth / 2f, topY)
        lightPath.moveTo(lightPivotX + letterWidth / 2f, topY)
        lightPath.lineTo(lightPivotX + awidth / 2f, awidth.toFloat())
        lightPath.lineTo(lightPivotX - awidth / 2f, awidth.toFloat())
        lightPath.lineTo(lightPivotX - letterWidth / 2f, topY)
        lightPath.close()
    }
    
    private fun textToBitmap(text: String): Bitmap {
        val baseline = -textPaint.ascent()
        val bitmap = Bitmap.createBitmap(awidth, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        canvas.drawText(text, 0f, baseline, textPaint)
        return bitmap
    }

    private fun createLayout(text: String): StaticLayout {
        return text.let {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                StaticLayout.Builder.obtain(
                        it,
                        0,
                        it.length,
                        textPaint,
                        textPaint.measureText(it).toInt()
                )
                        .build()
            } else {
                StaticLayout(
                        text,
                        textPaint,
                        textPaint.measureText(it).toInt(),
                        Layout.Alignment.ALIGN_CENTER,
                        1f,
                        0f,
                        true
                )
            }
        }
    }

    private fun lerp(a: Float, b: Float, t: Float): Float {
        return a + (b - a) * t
    }

    /**
     * Start the light animation.
     */
    fun on() {
        animator?.start()
    }

    /**
     * Stop the light animation.
     */
    fun off() {
        animator?.cancel()
        angle = 0f
    }

    /**
     * @return Whether the light animation is currently running.
     */
    fun isOn() = animator?.isRunning == true
    
    companion object {
        private const val ANIMATION_DURATION = 1800L
        private const val INTERPOLATOR_FACTOR = 0.6f
        private const val FULL_CIRCLE = 360f
        private const val LIGHT_LETTER = "i"
    }
}
  • CustomSpringInterpolator.kt
class CustomSpringInterpolator(private var factor: Float) : Interpolator {

    override fun getInterpolation(input: Float): Float {
        return (Math.pow(2.0, -6.5 * input) * Math.sin(2 * Math.PI * (input - factor / 4) / factor) + 1).toFloat()
    }
}
0