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 độ đó:
- Tính chiều rộng của text (w1) với từ " i " (Loadi)
- Tính chiều rộng của text (w2) khi không có từ " i " (Load)
- 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
- Tính tọa độ pivotX: pivotX = w1 - w3 / 2
- 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
- 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() } }