Today, we will learn custom a view designed by Oleg Frolov on Dribble .
Mathematics things …
First we need to identify the axis for Light animation
. As you can see, it is in the middle of the dot of the word ” i ” . We will perform this animation with text “Loading” and now we will calculate to find the coordinates:
- Calculate the width of text ( w1 ) with the word “i” ( Loadi )
- Calculate the width of text ( w2 ) when there is no word “i” ( Load )
- Calculate the width of the word “i” ( w3 ) with a double space of the word, w3 = w1 – w2
- Calculate pivotX coordinates: pivotX = w1 – w3 / 2
- Calculate the width of the word “i” ( w4 ) when there is no space. Suppose w4 = the diameter of the dot on the head i
- Calculate coordinates pivotY : pivotY = (-text.ascent – text.height + w4 / 2) . Can you find out about ascent, descent, …
I changed the word Light to Loading, but their calculation is the same.
Some drawing
To draw a bright white light, we need to calculate the trapezoidal structure.
1 | val topY = textPaint.ascent() * -1 - textBounds.height() lightPath.moveTo(lightPivotX - letterWidth / 2f, topY) lightPath.moveTo(lightPivotX + letterWidth / 2f, topY) lightPath.lineTo(lightPivotX + width / 2f, width.toFloat()) lightPath.lineTo(lightPivotX - width / 2f, width.toFloat()) lightPath.lineTo(lightPivotX - letterWidth / 2f, topY) lightPath.close() |
Let’s light it up
Now we need to run the animation:
- Create an animator object:
1 | 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 } |
- Update angles and invalidate view
1 | private var angle = 0f set(value) { field = value if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { postInvalidateOnAnimation() } else { invalidate() } } |
- Animation animation:
=> Not what we want?
Android PorterDuff.Mode
We will use PorterDuff.Mode to achieve the following result:
Code …
- LightProgress.kt
1 | 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(widthMeasureSpec: Int, heightMeasureSpec: Int) { val w = textLayout.width 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 = width / 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.width textLayout = createLayout(textBeforeLetter) val widthWithoutLetter = textLayout.width textPaint.getTextBounds(LIGHT_LETTER, 0, 1, textBounds) letterWidth = textBounds.width()// one "i" letter width textPaint.getTextBounds(text, 0, text.length - 1, textBounds) val letterWidthWithIndent = withWithLetter - widthWithoutLetter 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 + width / 2f, width.toFloat()) lightPath.lineTo(lightPivotX - width / 2f, width.toFloat()) lightPath.lineTo(lightPivotX - letterWidth / 2f, topY) lightPath.close() } private fun textToBitmap(text: String): Bitmap { val baseline = -textPaint.ascent() val bitmap = Bitmap.createBitmap(width, 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
1 | 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() } } |