A while back, I worked on a new feature that allows me to send photos in app chat. The feature itself is huge and includes a lot of things, but in fact, uploading animations and unuploading was not originally designed. When I used this part, I decided to add the image upload animation, so let’s give them this functionality 🙂

View vs. Drawable

I used a Drawable there. In my opinion, StackOverflow has a good, concise answer here.

Drawable responds only to draw operations, whereas a View responds to draw and user interfaces, such as touch events, closing the screen, and so on.

Now let’s analyze what we want to do. We want to animate a circle with an infinitely rotating arc that keeps increasing its central Angle until it equals 2 PI. I thought a Drawable would help me, and I really should have, but I didn’t.

The reason I didn’t do this is because of the animation of the three little dots to the right of the text in the sample image above. I have done this animation with a custom View, and I have prepared the background for the infinite loop animation. It would have been easier for me to extract the animation preparation logic into the parent View and reuse it instead of overwriting everything as Drawable. So I’m not saying that my solution is right (nothing is right), but that it meets my needs.

Base InfiniteAnimationView

For my own needs, I’ll split the desired progress view into two views:

  • ProgressView — is responsible for drawing the required ProgressView

  • InfiniteAnimateView – Abstract View, which is responsible for preparing, starting, and stopping animations. Since the progress includes infinite rotation, we need to know when to start the animation and when to stop the animation

After looking at the source code for Android’s ProgressBar, we can end up with something like this:

// 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 } }Copy the code

Unfortunately, mainly for reasons of onVisibilityAggregated method and it can’t work, because [the method in the API more than 24 was support] (developer.android.com/reference/a… ! isVisible || windowVisibility ! = problem on VISIBLE when the view is VISIBLE but its container is not. So I decided to rewrite this:

// InfiniteAnimateView.kt abstract class InfiniteAnimateView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private var animation: Animator? = null /** * We can't use 'onVisibilityAggregated' since it's only supported after SDK 24, And our minimum SDK is 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 } }Copy the code

Unfortunately, this doesn’t work either (although I think it should work). To be honest, I don’t know the exact cause of the problem. It might work in normal cases, but not in RecyclerView. I ran into this problem some time ago: if you use isShown to track whether something is displayed in RecyclerView. So maybe my final solution isn’t the right one, but at least in my solution it works as I expect:

// InfiniteAnimateView.kt abstract class InfiniteAnimateView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private var animation: Animator? = null /** * We can't use 'onVisibilityAggregated' since it's only supported after SDK 24, And our minimum SDK is 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} /** * It is possible that view. isShown is implemented on this function, but I notice some problems with it. * I ran into these problems in Lottie Lib as well. However, since we always didn't have time for in-depth research * I decided to use this simple method to solve the problem temporarily, just to make sure it worked properly * what exactly do I need = = * * Update: I tried to use isShown instead of this method, but failed. So if you know * how to improve it, feel free to discuss it in the comments section */ 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 }Copy the code

Progress of the animation

To prepare

So first let’s talk about the structure of our View. What painting components should it contain? The best way to express this in the current situation is to declare a different 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 }Copy the code

I’m going to change the width of the stroke and other things to show you, so you’ll see some of the differences. The three paints are associated with progress in three key areas:

Left: background; In: stroke; Right: the progress

You may be wondering why I used paint.cap.butt. Well, to make this progress more “Telegram” (at least on iOS devices), you should use paint.cap.round. Let me demonstrate the difference between the three possible styles (I’ve added stroke width here to make the difference more obvious).

Left: Cap.BUTT, middle: Cap.ROUND, right: Cap.SQUARE

The main difference, therefore, is that cap.round gives the corners of the stroke special rounded corners, while Cap.butt and Cap.square just cut. Cap.SQUARE has the same extra space as Cap.ROUND, but without the rounded corners. This may result in Cap.square displaying the same Angle as Cap.butt but reserving extra space.

Try to show 90 degrees with Cap.BUTT and Cap.SQUARE.

Given all of this, it is best to use Cap.butt because it is more appropriate than the Angle representation shown by Cap.Square.

By the way, Cap.BUTT is the default brush type for brushes. Here’s a link to the official documentation. But I want to show you the real difference, because initially I wanted it to be ROUND, and then I started using SQUARE, but I noticed some features.

Base Spinning

The animation itself is actually quite simple because we have InfiniteAnimateView:

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

Normalize is a simple way to shrink any Angle back to the range [0, 2π]. For example, for Angle 400.54, normalize is 40.54.

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

## Measurement and drawing

We will draw depending on the measured dimensions provided by the superview or using the exact layout_width and layout_height values defined in XML. Therefore, we don’t need anything in terms of measuring the View, but we will use the measured dimensions to prepare the progress rectangle and draw the View in it.

Well, it’s not hard, but we need to keep a few things in mind:

We can’t just take measuredWidth, measuredHeight to draw the circle background, progress, stroke (mainly the reason for the stroke). If we ignore the width of the stroke and don’t subtract half of it from the dimensional calculation, we end up with a boundary that looks like a cut:

If we do not consider the width of the stroke, we may end up overlapping it in the drawing phase. (This is ok for opaque colors)

However, if you are going to use translucent colors, you will see a strange overlap (I increased the stroke width to show the problem more clearly).

Scan the Angle of the animation

Okay, finally, the schedule itself. Suppose we could change it from 0 to 1:

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

To draw arcs, we need to calculate the Angle of a special scan animation, which is a special Angle of the drawing section. 360 — A complete circle will be drawn. 90 — will draw a quarter of the circle.

So we need to convert the progress to degrees, and at the same time, we need to keep the scanning Angle from being zero. That is, even if progress is equal to zero, we are going to plot a small block of progress.

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

Where MAX_ANGLE = 360 (of course you can customize it to any Angle), MIN_SWEEP_ANGLE is the smallest progress in degrees. The minimum progress will replace the progress value when progress = 0.

Code together!

Now, by combining all the code together, we can build the complete 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) / 2 f val progressOffset = progressPadding + progressPaint. StrokeWidth / 2 / f/due to the center of the stroke online, We need to leave half a safe space for it, Otherwise it will be truncated bgRadius = min(Horizon half, 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)}} /** * Convert any Angle to [0, Private fun normalize(Float): private fun normalize(Float): private fun normalize(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 } }Copy the code

Added!

By the way, we can extend the drawArc method. You see we have a currentAngle which is the Angle at which the arc starts, and a sweepAngle which is how many degrees we need to draw the arc.

As the progress increases, we only change the sweepAngle, that is, if the currentAngle is static (unchanged), then we will see that the arc increases in only one direction. We can try to fix it. Consider three scenarios and see what the results look like:

// 1. In this case, arcs "increment" drawArc(progressRect, currentAngle, sweepAngle, false, progressPaint) in only one direction. In this case, arcs "increment" drawArc(progressRect, currentAngle - sweepAngle / 2f, sweepAngle, false, progressPaint) // 3. In this case, the arc "increses" drawArc(progressRect, currentAngle - sweepAngle, sweepAngle, false, progressPaint) in the other directionCopy the code

The result:

Left: the first case; Middle: the second case; Right: The third case

As you can see, the animations on the left and right (options 1 and 3) are inconsistent in speed. The first one gives the impression of faster rotation and more progress, while the last one does the opposite, giving the impression of slower rotation. And vice versa is diminishing progress.

But the animations in the middle have the same rotation speed. So, if you’re not increasing progress (like uploading a file), or just decreasing progress (like counting down), I recommend the second option.

Address: github.com/xitu/gold-m…

The original link: proandroiddev.com/telegram-li…

At the end of the article

Your likes collection is the biggest encouragement to me! Welcome to follow me, share Android dry goods, exchange Android technology. If you have any comments or technical questions about this article, please leave a comment in the comments section.