package io.codetail.animation; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.Region; import android.os.Build; import android.util.Property; import android.view.View; import java.util.HashMap; import java.util.Map; @SuppressWarnings("WeakerAccess") public class ViewRevealManager { public static final ClipRadiusProperty REVEAL = new ClipRadiusProperty(); private final ViewTransformation viewTransformation; private final Map<View, RevealValues> targets = new HashMap<>(); private final Map<Animator, RevealValues> animators = new HashMap<>(); private final AnimatorListenerAdapter animatorCallback = new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { final RevealValues values = getValues(animation); values.clip(true); } @Override public void onAnimationCancel(Animator animation) { endAnimation(animation); } @Override public void onAnimationEnd(Animator animation) { endAnimation(animation); } private void endAnimation(Animator animation) { final RevealValues values = getValues(animation); values.clip(false); // Clean up after animation is done targets.remove(values.target); animators.remove(animation); } }; public ViewRevealManager() { this(new PathTransformation()); } public ViewRevealManager(ViewTransformation transformation) { this.viewTransformation = transformation; } Animator dispatchCreateAnimator(RevealValues data) { final Animator animator = createAnimator(data); // Before animation is started keep them targets.put(data.target(), data); animators.put(animator, data); return animator; } /** * Create custom animator of circular reveal * * @param data RevealValues contains information of starting & ending points, animation target and * current animation values * @return Animator to manage reveal animation */ protected Animator createAnimator(RevealValues data) { final ObjectAnimator animator = ObjectAnimator.ofFloat(data, REVEAL, data.startRadius, data.endRadius); animator.addListener(getAnimatorCallback()); return animator; } protected final AnimatorListenerAdapter getAnimatorCallback() { return animatorCallback; } /** * @return Retruns Animator */ protected final RevealValues getValues(Animator animator) { return animators.get(animator); } /** * @return Map of started animators */ protected final RevealValues getValues(View view) { return targets.get(view); } /** * @return True if you don't want use Android native reveal animator in order to use your own * custom one */ protected boolean overrideNativeAnimator() { return false; } /** * @return True if animation was started and it is still running, otherwise returns False */ public boolean isClipped(View child) { final RevealValues data = getValues(child); return data != null && data.isClipping(); } /** * Applies path clipping on a canvas before drawing child, * you should save canvas state before viewTransformation and * restore it afterwards * * @param canvas Canvas to apply clipping before drawing * @param child Reveal animation target * @return True if viewTransformation was successfully applied on referenced child, otherwise * child be not the target and therefore animation was skipped */ public final boolean transform(Canvas canvas, View child) { final RevealValues revealData = targets.get(child); // Target doesn't has animation values if (revealData == null) { return false; } // Check whether target consistency else if (revealData.target != child) { throw new IllegalStateException("Inconsistency detected, contains incorrect target view"); } // View doesn't wants to be clipped therefore transformation is useless else if (!revealData.clipping) { return false; } return viewTransformation.transform(canvas, child, revealData); } public static final class RevealValues { private static final Paint debugPaint = new Paint(Paint.ANTI_ALIAS_FLAG); static { debugPaint.setColor(Color.GREEN); debugPaint.setStyle(Paint.Style.FILL); debugPaint.setStrokeWidth(2); } final int centerX; final int centerY; final float startRadius; final float endRadius; // Flag that indicates whether view is clipping now, mutable boolean clipping; // Revealed radius float radius; // Animation target View target; public RevealValues(View target, int centerX, int centerY, float startRadius, float endRadius) { this.target = target; this.centerX = centerX; this.centerY = centerY; this.startRadius = startRadius; this.endRadius = endRadius; } public void radius(float radius) { this.radius = radius; } /** @return current clipping radius */ public float radius() { return radius; } /** @return Animating view */ public View target() { return target; } public void clip(boolean clipping) { this.clipping = clipping; } /** @return View clip status */ public boolean isClipping() { return clipping; } } /** * Custom View viewTransformation extension used for applying different reveal * techniques */ interface ViewTransformation { /** * Apply view viewTransformation * * @param canvas Main canvas * @param child Target to be clipped & revealed * @return True if viewTransformation is applied, otherwise return fAlse */ boolean transform(Canvas canvas, View child, RevealValues values); } public static class PathTransformation implements ViewTransformation { // Android Canvas is tricky, we cannot clip circles directly with Canvas API // but it is allowed using Path, therefore we use it :| private final Path path = new Path(); private Region.Op op = Region.Op.REPLACE; /** @see Canvas#clipPath(Path, Region.Op) */ public Region.Op op() { return op; } /** @see Canvas#clipPath(Path, Region.Op) */ public void op(Region.Op op) { this.op = op; } @Override public boolean transform(Canvas canvas, View child, RevealValues values) { path.reset(); // trick to applyTransformation animation, when even x & y translations are running path.addCircle(child.getX() + values.centerX, child.getY() + values.centerY, values.radius, Path.Direction.CW); canvas.clipPath(path, op); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { child.invalidateOutline(); } return false; } } /** * Property animator. For performance improvements better to use * directly variable member (but it's little enhancement that always * caught as dangerous, let's see) */ private static final class ClipRadiusProperty extends Property<RevealValues, Float> { ClipRadiusProperty() { super(Float.class, "supportCircularReveal"); } @Override public void set(RevealValues data, Float value) { data.radius = value; data.target.invalidate(); } @Override public Float get(RevealValues v) { return v.radius(); } } /** * As class name cue's it changes layer type of {@link View} on animation createAnimator * in order to improve animation smooth & performance and returns original value * on animation end */ static class ChangeViewLayerTypeAdapter extends AnimatorListenerAdapter { private RevealValues viewData; private int featuredLayerType; private int originalLayerType; ChangeViewLayerTypeAdapter(RevealValues viewData, int layerType) { this.viewData = viewData; this.featuredLayerType = layerType; this.originalLayerType = viewData.target.getLayerType(); } @Override public void onAnimationStart(Animator animation) { viewData.target().setLayerType(featuredLayerType, null); } @Override public void onAnimationCancel(Animator animation) { viewData.target().setLayerType(originalLayerType, null); } @Override public void onAnimationEnd(Animator animation) { viewData.target().setLayerType(originalLayerType, null); } } }