- Telegram-like uploading animation
- By Michael Spitsin
- The Nuggets translation Project
- Permanent link to this article: github.com/xitu/gold-m…
- Translator: Hoarfroster
- Proofreader: Kimhooo, Greycodee
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 function 🙂
View vs. Drawable
Actually, that’s a good question. Because if we look at my other article on sonar animation, 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
— Responsible for drawing the required progress ViewInfiniteAnimateView
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 = isVisibleif (isVisible) startAnimation() else stopAnimation()
}
}
override fun onAttachedToWindow(a) {
super.onAttachedToWindow()
startAnimation()
}
override fun onDetachedFromWindow(a) {
stopAnimation()
super.onDetachedFromWindow()
}
private fun startAnimation(a) {
if(! isVisible || windowVisibility ! = VISIBLE)return
if (animation == null) animation = createAnimation().apply { start() }
}
protected abstract fun createAnimation(a): Animator
private fun stopAnimation(a){ 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 past SDK 24, and aggregated about our minimum SDK is 21 */
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
if (isShown) startAnimation() else stopAnimation()
}
override fun onAttachedToWindow(a) {
super.onAttachedToWindow()
startAnimation()
}
override fun onDetachedFromWindow(a) {
stopAnimation()
super.onDetachedFromWindow()
}
private fun startAnimation(a) {
if(! isShown)return
if (animation == null) animation = createAnimation().apply { start() }
}
protected abstract fun createAnimation(a): Animator
private fun stopAnimation(a){ 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 past SDK 24, and aggregated about our minimum SDK is 21 */
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
if (isDeepVisible()) startAnimation() else stopAnimation()
}
override fun onAttachedToWindow(a) {
super.onAttachedToWindow()
startAnimation()
}
override fun onDetachedFromWindow(a) {
stopAnimation()
super.onDetachedFromWindow()
}
private fun startAnimation(a) {
if(! isAttachedToWindow || ! isDeepVisible())return
if (animation == null) animation = createAnimation().apply { start() }
}
protected abstract fun createAnimation(a): Animator
private fun stopAnimation(a){ animation? .cancel() animation =null
}
/** * This function implements view. isShown, but it has some problems. * 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, please feel free to discuss */ in the comments section
private fun isDeepVisible(a): 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 valbgStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE color = defaultBgStrokeColor strokeWidth = context.resources.getDimension(R.dimen.chat_progress_bg_stroke_width) }private valprogressPaint = 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 into the range 0, 2π. For example, for the 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 plotting
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 valbgStrokePaint = 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
// Since the stroke is in the center of the line, we need to leave half the safe space for it, otherwise it will be truncated by the boundary
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(a): Animator = ValueAnimator.ofFloat(currentAngle, currentAngle + MAX_ANGLE).apply {
interpolator = LinearInterpolator()
duration = SPIN_DURATION_MS
repeatCount = ValueAnimator.INFINITE
addUpdateListener { currentAngle = normalize(it.animatedValue as Float)}}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}}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, the arc "increases" in only one direction
drawArc(progressRect, currentAngle, sweepAngle, false, progressPaint)
// 2. In this case, the arc "increases" in both directions
drawArc(progressRect, currentAngle - sweepAngle / 2f, sweepAngle, false, progressPaint)
// 3. In this case, the arc "increases" in the other direction
drawArc(progressRect, currentAngle - sweepAngle, sweepAngle, false, progressPaint)
Copy the code
The result:
** left: ** first case; In ** : ** 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.
Afterword.
Animation is great. Pixels are great. The shape is great. We just need to treat them with love. Because details are the most valuable thing in a product;)
If you liked this article, don’t forget to like it! If you have any questions, feel free to comment and we’ll talk about it. Have fun programming!
If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.
The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.