Fix typing indicator animation.

The Android animators were getting out of sync when frames were dropped
(despite my best efforts), so now we just manually render each animation
frame as a function of time, so it never gets screwed up.

Fixes #8388
This commit is contained in:
Greyson Parrelli 2018-11-26 09:33:31 -08:00
parent 36b24d0a20
commit 5d1fcdaded

View File

@ -1,35 +1,35 @@
package org.thoughtcrime.securesms.components; package org.thoughtcrime.securesms.components;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context; import android.content.Context;
import android.content.res.TypedArray; import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.PorterDuff; import android.graphics.PorterDuff;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.View; import android.view.View;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
public class TypingIndicatorView extends LinearLayout { public class TypingIndicatorView extends LinearLayout {
private static final long DURATION = 300; private static final long DURATION = 300;
private static final long PRE_DELAY = 500; private static final long PRE_DELAY = 500;
private static final long POST_DELAY = 500; private static final long POST_DELAY = 500;
private static final long CYCLE_DURATION = 1500;
private static final long DOT_DURATION = 600;
private static final float MIN_ALPHA = 0.4f;
private static final float MIN_SCALE = 0.75f;
private boolean isActive;
private long startTime;
private View dot1; private View dot1;
private View dot2; private View dot2;
private View dot3; private View dot3;
private AnimatorSet animation1;
private AnimatorSet animation2;
private AnimatorSet animation3;
public TypingIndicatorView(Context context) { public TypingIndicatorView(Context context) {
super(context); super(context);
initialize(null); initialize(null);
@ -43,6 +43,8 @@ public class TypingIndicatorView extends LinearLayout {
private void initialize(@Nullable AttributeSet attrs) { private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.typing_indicator_view, this); inflate(getContext(), R.layout.typing_indicator_view, this);
setWillNotDraw(false);
dot1 = findViewById(R.id.typing_dot1); dot1 = findViewById(R.id.typing_dot1);
dot2 = findViewById(R.id.typing_dot2); dot2 = findViewById(R.id.typing_dot2);
dot3 = findViewById(R.id.typing_dot3); dot3 = findViewById(R.id.typing_dot3);
@ -56,60 +58,66 @@ public class TypingIndicatorView extends LinearLayout {
dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
} }
}
animation1 = getAnimation(dot1, DURATION, 0 ); @Override
animation2 = getAnimation(dot2, DURATION, DURATION / 2); protected void onDraw(Canvas canvas) {
animation3 = getAnimation(dot3, DURATION, DURATION ); if (!isActive) {
super.onDraw(canvas);
return;
}
animation3.addListener(new AnimatorListenerAdapter() { long timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION;
@Override
public void onAnimationEnd(Animator animation) { render(dot1, timeInCycle, 0);
postDelayed(TypingIndicatorView.this::startAnimation, POST_DELAY); render(dot2, timeInCycle, 150);
} render(dot3, timeInCycle, 300);
});
super.onDraw(canvas);
postInvalidate();
}
private void render(View dot, long timeInCycle, long start) {
long end = start + DOT_DURATION;
long peak = start + (DOT_DURATION / 2);
if (timeInCycle < start || timeInCycle > end) {
renderDefault(dot);
} else if (timeInCycle < peak) {
renderFadeIn(dot, timeInCycle, start);
} else {
renderFadeOut(dot, timeInCycle, peak);
}
}
private void renderDefault(View dot) {
dot.setAlpha(MIN_ALPHA);
dot.setScaleX(MIN_SCALE);
dot.setScaleY(MIN_SCALE);
}
private void renderFadeIn(View dot, long timeInCycle, long fadeInStart) {
float percent = (float) (timeInCycle - fadeInStart) / 300;
dot.setAlpha(MIN_ALPHA + (1 - MIN_ALPHA) * percent);
dot.setScaleX(MIN_SCALE + (1 - MIN_SCALE) * percent);
dot.setScaleY(MIN_SCALE + (1 - MIN_SCALE) * percent);
}
private void renderFadeOut(View dot, long timeInCycle, long fadeOutStart) {
float percent = (float) (timeInCycle - fadeOutStart) / 300;
dot.setAlpha(1 - (1 - MIN_ALPHA) * percent);
dot.setScaleX(1 - (1 - MIN_SCALE) * percent);
dot.setScaleY(1 - (1 - MIN_SCALE) * percent);
} }
public void startAnimation() { public void startAnimation() {
stopAnimation(); isActive = true;
postDelayed(() -> { startTime = System.currentTimeMillis();
animation1.start();
animation2.start(); postInvalidate();
animation3.start();
}, PRE_DELAY);
} }
public void stopAnimation() { public void stopAnimation() {
animation1.cancel(); isActive = false;
animation2.cancel();
animation3.cancel();
reset(dot1);
reset(dot2);
reset(dot3);
}
private AnimatorSet getAnimation(@NonNull View view, long duration, long startDelay) {
AnimatorSet grow = new AnimatorSet();
grow.playTogether(ObjectAnimator.ofFloat(view, View.SCALE_X, 0.5f, 1).setDuration(duration),
ObjectAnimator.ofFloat(view, View.SCALE_Y, 0.5f, 1).setDuration(duration),
ObjectAnimator.ofFloat(view, View.ALPHA, 0.5f, 1).setDuration(duration));
AnimatorSet shrink = new AnimatorSet();
shrink.playTogether(ObjectAnimator.ofFloat(view, View.SCALE_X, 1, 0.5f).setDuration(duration),
ObjectAnimator.ofFloat(view, View.SCALE_Y, 1, 0.5f).setDuration(duration),
ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0.5f).setDuration(duration));
AnimatorSet all = new AnimatorSet();
all.playSequentially(grow, shrink);
all.setStartDelay(startDelay);
return all;
}
private void reset(View view) {
view.clearAnimation();
view.setScaleX(0.5f);
view.setScaleY(0.5f);
view.setAlpha(0.5f);
} }
} }