Skip to content

Commit 0f47e78

Browse files
pekingmepaulfthomas
authored andcommitted
[ProgressIndicator] Added waggle animation (wave speed) to active indicator in both Linear and Circular types.
PiperOrigin-RevId: 620305285
1 parent b32512a commit 0f47e78

File tree

9 files changed

+122
-5
lines changed

9 files changed

+122
-5
lines changed

lib/java/com/google/android/material/progressindicator/BaseProgressIndicator.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,26 @@ public void setWavelength(@Px int wavelength) {
722722
}
723723
}
724724

725+
/**
726+
* Returns the speed of the indicator's waveform in pixels.
727+
*
728+
* @see #setSpeed(int)
729+
*/
730+
@Px
731+
public int getSpeed() {
732+
return spec.speed;
733+
}
734+
735+
/**
736+
* Sets the speed of the indicator's waveform in pixels.
737+
*
738+
* @param speed The new speed in pixels.
739+
* @see #getSpeed()
740+
*/
741+
public void setSpeed(@Px int speed) {
742+
spec.speed = speed;
743+
}
744+
725745
/**
726746
* Returns the show animation behavior used in this progress indicator.
727747
*

lib/java/com/google/android/material/progressindicator/BaseProgressIndicatorSpec.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ public abstract class BaseProgressIndicatorSpec {
7979
/** The size of the amplitude, if a wave effect is configured. */
8080
@Px public int amplitude;
8181

82+
/** The speed of the waveform, if a wave effect is configured. */
83+
@Px public int speed;
84+
8285
/**
8386
* Instantiates BaseProgressIndicatorSpec.
8487
*
@@ -119,6 +122,7 @@ protected BaseProgressIndicatorSpec(
119122

120123
wavelength = abs(a.getDimensionPixelSize(R.styleable.BaseProgressIndicator_wavelength, 0));
121124
amplitude = abs(a.getDimensionPixelSize(R.styleable.BaseProgressIndicator_amplitude, 0));
125+
speed = a.getDimensionPixelSize(R.styleable.BaseProgressIndicator_speed, 0);
122126

123127
loadIndicatorColors(context, a);
124128
loadTrackColor(context, a);

lib/java/com/google/android/material/progressindicator/CircularDrawingDelegate.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static androidx.core.math.MathUtils.clamp;
1919
import static com.google.android.material.math.MathUtils.lerp;
2020
import static com.google.android.material.progressindicator.BaseProgressIndicator.HIDE_ESCAPE;
21+
import static java.lang.Math.PI;
2122
import static java.lang.Math.max;
2223
import static java.lang.Math.min;
2324
import static java.lang.Math.toDegrees;
@@ -172,6 +173,7 @@ void fillIndicator(
172173
color,
173174
activeIndicator.gapSize,
174175
activeIndicator.gapSize,
176+
activeIndicator.phaseFraction,
175177
/* shouldDrawActiveIndicator= */ true);
176178
}
177179

@@ -193,6 +195,7 @@ void fillTrack(
193195
color,
194196
gapSize,
195197
gapSize,
198+
/* phaseFraction= */ 0f,
196199
/* shouldDrawActiveIndicator= */ false);
197200
}
198201

@@ -207,6 +210,7 @@ void fillTrack(
207210
* @param paintColor The color used to draw the indicator.
208211
* @param startGapSize The gap size applied to the start (rotating behind) of the drawing part.
209212
* @param endGapSize The gap size applied to the end (rotating ahead) of the drawing part.
213+
* @param phaseFraction The fraction [0, 1] of initial phase in one cycle.
210214
* @param shouldDrawActiveIndicator Whether this part should be drawn as an active indicator.
211215
*/
212216
private void drawArc(
@@ -217,6 +221,7 @@ private void drawArc(
217221
@ColorInt int paintColor,
218222
@Px int startGapSize,
219223
@Px int endGapSize,
224+
float phaseFraction,
220225
boolean shouldDrawActiveIndicator) {
221226
float arcFraction =
222227
endFraction >= startFraction
@@ -237,6 +242,7 @@ private void drawArc(
237242
paintColor,
238243
startGapSize,
239244
/* endGapSize= */ 0,
245+
phaseFraction,
240246
shouldDrawActiveIndicator);
241247
drawArc(
242248
canvas,
@@ -246,6 +252,7 @@ private void drawArc(
246252
paintColor,
247253
/* startGapSize= */ 0,
248254
endGapSize,
255+
phaseFraction,
249256
shouldDrawActiveIndicator);
250257
return;
251258
}
@@ -322,7 +329,8 @@ private void drawArc(
322329
activePathMeasure,
323330
displayedActivePath,
324331
startDegreeWithoutCorners / 360,
325-
arcDegreeWithoutCorners / 360);
332+
arcDegreeWithoutCorners / 360,
333+
phaseFraction);
326334
canvas.drawPath(displayedActivePath, paint);
327335
}
328336

@@ -471,7 +479,11 @@ private void appendCubicPerHalfCycle(
471479

472480
@NonNull
473481
private Pair<PathPoint, PathPoint> getDisplayedPath(
474-
@NonNull PathMeasure pathMeasure, @NonNull Path displayedPath, float start, float span) {
482+
@NonNull PathMeasure pathMeasure,
483+
@NonNull Path displayedPath,
484+
float start,
485+
float span,
486+
float phaseFraction) {
475487
if (adjustedRadius != cachedRadius
476488
|| (pathMeasure == activePathMeasure && displayedAmplitude != cachedAmplitude)) {
477489
cachedAmplitude = displayedAmplitude;
@@ -481,6 +493,12 @@ private Pair<PathPoint, PathPoint> getDisplayedPath(
481493
displayedPath.rewind();
482494
span = clamp(span, 0, 1);
483495
float resultRotation = 0;
496+
if (spec.hasWavyEffect()) {
497+
float cycleCount = (float) (2 * PI * adjustedRadius / adjustedWavelength);
498+
float phaseFractionInOneCycle = phaseFraction / cycleCount;
499+
start += phaseFractionInOneCycle;
500+
resultRotation -= phaseFractionInOneCycle * 360;
501+
}
484502
start %= 1;
485503
float startDistance = start * pathMeasure.getLength() / 2;
486504
float endDistance = (start + span) * pathMeasure.getLength() / 2;

lib/java/com/google/android/material/progressindicator/DeterminateDrawable.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.android.material.progressindicator;
1818

19+
import android.animation.ValueAnimator;
1920
import android.content.Context;
2021
import android.graphics.Canvas;
2122
import android.graphics.Paint.Style;
@@ -39,6 +40,8 @@ public final class DeterminateDrawable<S extends BaseProgressIndicatorSpec>
3940
// If the progress is less than 1%, the gap will be proportional to the progress. So that, it
4041
// draws a full track at 0%.
4142
static final float GAP_RAMP_DOWN_THRESHOLD = 0.01f;
43+
// The duration of repeated initial phase animation in ms. It can be any positive values.
44+
private static final int PHASE_ANIMATION_DURATION_MS = 1000;
4245

4346
// Drawing delegate object.
4447
private DrawingDelegate<S> drawingDelegate;
@@ -51,6 +54,8 @@ public final class DeterminateDrawable<S extends BaseProgressIndicatorSpec>
5154
// Whether to skip the spring animation on level change event.
5255
private boolean skipAnimationOnLevelChange = false;
5356

57+
@NonNull private final ValueAnimator phaseAnimator;
58+
5459
DeterminateDrawable(
5560
@NonNull Context context,
5661
@NonNull BaseProgressIndicatorSpec baseSpec,
@@ -60,6 +65,7 @@ public final class DeterminateDrawable<S extends BaseProgressIndicatorSpec>
6065
setDrawingDelegate(drawingDelegate);
6166
activeIndicator = new ActiveIndicator();
6267

68+
// Initializes a spring animator for progress animation.
6369
springForce = new SpringForce();
6470

6571
springForce.setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
@@ -68,6 +74,19 @@ public final class DeterminateDrawable<S extends BaseProgressIndicatorSpec>
6874
springAnimation = new SpringAnimation(this, INDICATOR_LENGTH_IN_LEVEL);
6975
springAnimation.setSpring(springForce);
7076

77+
// Initializes a linear animator to enforce phase animation when progress is unchanged.
78+
phaseAnimator = new ValueAnimator();
79+
phaseAnimator.setDuration(PHASE_ANIMATION_DURATION_MS);
80+
phaseAnimator.setFloatValues(0, 1);
81+
phaseAnimator.setRepeatCount(ValueAnimator.INFINITE);
82+
phaseAnimator.addUpdateListener(
83+
animation -> {
84+
if (baseSpec.speed != 0 && isVisible()) {
85+
invalidateSelf();
86+
}
87+
});
88+
phaseAnimator.start();
89+
7190
setGrowFraction(1f);
7291
}
7392

@@ -236,6 +255,8 @@ public void draw(@NonNull Canvas canvas) {
236255
drawingDelegate.validateSpecAndAdjustCanvas(
237256
canvas, getBounds(), getGrowFraction(), isShowing(), isHiding());
238257

258+
activeIndicator.phaseFraction = getPhaseFraction();
259+
239260
paint.setStyle(Style.FILL);
240261
paint.setAntiAlias(true);
241262

lib/java/com/google/android/material/progressindicator/DrawableWithAnimatedVisibilityChange.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
*/
4343
abstract class DrawableWithAnimatedVisibilityChange extends Drawable implements Animatable2Compat {
4444

45+
// Constant for mock values used in testing.
46+
private static final float DEFAULT_MOCK_PHASE_FRACTION = -1f;
47+
4548
// Argument restart used in Drawable setVisible() doesn't matter in implementation.
4649
private static final boolean DEFAULT_DRAWABLE_RESTART = false;
4750

@@ -64,6 +67,7 @@ abstract class DrawableWithAnimatedVisibilityChange extends Drawable implements
6467
private boolean mockShowAnimationRunning;
6568
private boolean mockHideAnimationRunning;
6669
private float mockGrowFraction;
70+
private float mockPhaseFraction = DEFAULT_MOCK_PHASE_FRACTION;
6771

6872
// List of AnimationCallback to be called at the end of show/hide animation.
6973
private List<AnimationCallback> animationCallbacks;
@@ -439,6 +443,29 @@ void setMockHideAnimationRunning(
439443
mockGrowFraction = fraction;
440444
}
441445

446+
@VisibleForTesting
447+
void setMockPhaseFraction(@FloatRange(from = 0.0, to = 1.0) float fraction) {
448+
mockPhaseFraction = fraction;
449+
}
450+
451+
float getPhaseFraction() {
452+
if (mockPhaseFraction > 0) {
453+
return mockPhaseFraction;
454+
}
455+
float phaseFraction = 0f;
456+
if (baseSpec.speed != 0) {
457+
float durationScale =
458+
animatorDurationScaleProvider.getSystemAnimatorDurationScale(
459+
context.getContentResolver());
460+
int cycleInMs = (int) (1000f * baseSpec.wavelength / baseSpec.speed * durationScale);
461+
phaseFraction = (float) (System.currentTimeMillis() % cycleInMs) / cycleInMs;
462+
if (phaseFraction < 0f) {
463+
phaseFraction = (phaseFraction % 1) + 1f;
464+
}
465+
}
466+
return phaseFraction;
467+
}
468+
442469
// ******************* Properties *******************
443470

444471
private static final Property<DrawableWithAnimatedVisibilityChange, Float> GROW_FRACTION =

lib/java/com/google/android/material/progressindicator/DrawingDelegate.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ protected static class ActiveIndicator {
168168
// active indicator. But for linear contiguous indeterminate mode, the indicators are connecting
169169
// to each other. Gaps are needed in this case.
170170
@Px int gapSize;
171+
172+
// The fraction [0, 1] of the initial phase [0, 2 * PI] on indicator.
173+
@FloatRange(from = 0.0, to = 1.0)
174+
float phaseFraction;
171175
}
172176

173177
/** An entity class for a point on a path, with the support of fundamental operations. */

lib/java/com/google/android/material/progressindicator/IndeterminateDrawable.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ public void draw(@NonNull Canvas canvas) {
250250
indicatorIndex < animatorDelegate.activeIndicators.size();
251251
indicatorIndex++) {
252252
ActiveIndicator curIndicator = animatorDelegate.activeIndicators.get(indicatorIndex);
253+
curIndicator.phaseFraction = getPhaseFraction();
253254
// Draws indicators.
254255
drawingDelegate.fillIndicator(canvas, paint, curIndicator, getAlpha());
255256

lib/java/com/google/android/material/progressindicator/LinearDrawingDelegate.java

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ void fillIndicator(
158158
color,
159159
activeIndicator.gapSize,
160160
activeIndicator.gapSize,
161+
activeIndicator.phaseFraction,
161162
/* drawingActiveIndicator= */ true);
162163
}
163164

@@ -179,6 +180,7 @@ void fillTrack(
179180
color,
180181
gapSize,
181182
gapSize,
183+
/* phaseFraction= */ 0f,
182184
/* drawingActiveIndicator= */ false);
183185
}
184186

@@ -192,6 +194,7 @@ void fillTrack(
192194
* @param paintColor The color used to draw the indicator.
193195
* @param startGapSize The gap size applied to the start (left) of the drawing part.
194196
* @param endGapSize The gap size applied to the end (right) of the drawing part.
197+
* @param phaseFraction The fraction [0, 1] of initial phase in one cycle.
195198
* @param drawingActiveIndicator Whether this part should be drawn as an active indicator.
196199
*/
197200
private void drawLine(
@@ -202,6 +205,7 @@ private void drawLine(
202205
@ColorInt int paintColor,
203206
@Px int startGapSize,
204207
@Px int endGapSize,
208+
float phaseFraction,
205209
boolean drawingActiveIndicator) {
206210
startFraction = clamp(startFraction, 0f, 1f);
207211
endFraction = clamp(endFraction, 0f, 1f);
@@ -270,7 +274,8 @@ private void drawLine(
270274
activePathMeasure,
271275
displayedActivePath,
272276
startBlockCenterX / trackLength,
273-
endBlockCenterX / trackLength);
277+
endBlockCenterX / trackLength,
278+
phaseFraction);
274279
canvas.drawPath(displayedActivePath, paint);
275280
}
276281
if (!useStrokeCap && displayedCornerRadius > 0) {
@@ -354,7 +359,7 @@ void invalidateCachedPaths() {
354359
int cycleCount = (int) (trackLength / spec.wavelength);
355360
adjustedWavelength = trackLength / cycleCount;
356361
float smoothness = SINE_WAVE_FORM_SMOOTHNESS;
357-
for (int i = 0; i < cycleCount; i++) {
362+
for (int i = 0; i <= cycleCount; i++) {
358363
cachedActivePath.cubicTo(2 * i + smoothness, 0, 2 * i + 1 - smoothness, 1, 2 * i + 1, 1);
359364
cachedActivePath.cubicTo(
360365
2 * i + 1 + smoothness, 1, 2 * i + 2 - smoothness, 0, 2 * i + 2, 0);
@@ -373,9 +378,21 @@ void invalidateCachedPaths() {
373378

374379
@NonNull
375380
private Pair<PathPoint, PathPoint> getDisplayedPath(
376-
@NonNull PathMeasure pathMeasure, @NonNull Path displayedPath, float start, float end) {
381+
@NonNull PathMeasure pathMeasure,
382+
@NonNull Path displayedPath,
383+
float start,
384+
float end,
385+
float phaseFraction) {
377386
displayedPath.rewind();
378387
float resultTranslationX = -trackLength / 2;
388+
if (spec.hasWavyEffect()) {
389+
float cycleCount = trackLength / adjustedWavelength;
390+
float phaseFractionInPath = phaseFraction / cycleCount;
391+
float ratio = cycleCount / (cycleCount + 1);
392+
start = (start + phaseFractionInPath) * ratio;
393+
end = (end + phaseFractionInPath) * ratio;
394+
resultTranslationX -= phaseFraction * adjustedWavelength;
395+
}
379396
float startDistance = start * pathMeasure.getLength();
380397
float endDistance = end * pathMeasure.getLength();
381398
pathMeasure.getSegment(startDistance, endDistance, displayedPath, true);

lib/java/com/google/android/material/progressindicator/res/values/attrs.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@
9797
Defines the amplitude (in dp) of the wave effect.
9898
-->
9999
<attr name="amplitude" format="dimension"/>
100+
<!--
101+
Defines the wave speed (in dp/s) of the wavy effect. If positive, wave moves towards 100%; if
102+
negative, wave moves towards 0%.
103+
-->
104+
<attr name="speed" format="dimension"/>
100105
</declare-styleable>
101106

102107
<declare-styleable name="LinearProgressIndicator">

0 commit comments

Comments
 (0)