package com.airbnb.lottie.animation.content; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.DashPathEffect; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PathMeasure; import android.graphics.RectF; import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import com.airbnb.lottie.L; import com.airbnb.lottie.LottieDrawable; import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.animation.LPaint; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.FloatKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.IntegerKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation; import com.airbnb.lottie.model.KeyPath; import com.airbnb.lottie.model.animatable.AnimatableFloatValue; import com.airbnb.lottie.model.animatable.AnimatableIntegerValue; import com.airbnb.lottie.model.content.ShapeTrimPath; import com.airbnb.lottie.model.layer.BaseLayer; import com.airbnb.lottie.utils.MiscUtils; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; import java.util.ArrayList; import java.util.List; import static com.airbnb.lottie.utils.MiscUtils.clamp; public abstract class BaseStrokeContent implements BaseKeyframeAnimation.AnimationListener, KeyPathElementContent, DrawingContent { private final PathMeasure pm = new PathMeasure(); private final Path path = new Path(); private final Path trimPathPath = new Path(); private final RectF rect = new RectF(); private final LottieDrawable lottieDrawable; protected final BaseLayer layer; private final List<PathGroup> pathGroups = new ArrayList<>(); private final float[] dashPatternValues; final Paint paint = new LPaint(Paint.ANTI_ALIAS_FLAG); private final BaseKeyframeAnimation<?, Float> widthAnimation; private final BaseKeyframeAnimation<?, Integer> opacityAnimation; private final List<BaseKeyframeAnimation<?, Float>> dashPatternAnimations; @Nullable private final BaseKeyframeAnimation<?, Float> dashPatternOffsetAnimation; @Nullable private BaseKeyframeAnimation<ColorFilter, ColorFilter> colorFilterAnimation; BaseStrokeContent(final LottieDrawable lottieDrawable, BaseLayer layer, Paint.Cap cap, Paint.Join join, float miterLimit, AnimatableIntegerValue opacity, AnimatableFloatValue width, List<AnimatableFloatValue> dashPattern, AnimatableFloatValue offset) { this.lottieDrawable = lottieDrawable; this.layer = layer; paint.setStyle(Paint.Style.STROKE); paint.setStrokeCap(cap); paint.setStrokeJoin(join); paint.setStrokeMiter(miterLimit); opacityAnimation = opacity.createAnimation(); widthAnimation = width.createAnimation(); if (offset == null) { dashPatternOffsetAnimation = null; } else { dashPatternOffsetAnimation = offset.createAnimation(); } dashPatternAnimations = new ArrayList<>(dashPattern.size()); dashPatternValues = new float[dashPattern.size()]; for (int i = 0; i < dashPattern.size(); i++) { dashPatternAnimations.add(dashPattern.get(i).createAnimation()); } layer.addAnimation(opacityAnimation); layer.addAnimation(widthAnimation); for (int i = 0; i < dashPatternAnimations.size(); i++) { layer.addAnimation(dashPatternAnimations.get(i)); } if (dashPatternOffsetAnimation != null) { layer.addAnimation(dashPatternOffsetAnimation); } opacityAnimation.addUpdateListener(this); widthAnimation.addUpdateListener(this); for (int i = 0; i < dashPattern.size(); i++) { dashPatternAnimations.get(i).addUpdateListener(this); } if (dashPatternOffsetAnimation != null) { dashPatternOffsetAnimation.addUpdateListener(this); } } @Override public void onValueChanged() { lottieDrawable.invalidateSelf(); } @Override public void setContents(List<Content> contentsBefore, List<Content> contentsAfter) { TrimPathContent trimPathContentBefore = null; for (int i = contentsBefore.size() - 1; i >= 0; i--) { Content content = contentsBefore.get(i); if (content instanceof TrimPathContent && ((TrimPathContent) content).getType() == ShapeTrimPath.Type.INDIVIDUALLY) { trimPathContentBefore = (TrimPathContent) content; } } if (trimPathContentBefore != null) { trimPathContentBefore.addListener(this); } PathGroup currentPathGroup = null; for (int i = contentsAfter.size() - 1; i >= 0; i--) { Content content = contentsAfter.get(i); if (content instanceof TrimPathContent && ((TrimPathContent) content).getType() == ShapeTrimPath.Type.INDIVIDUALLY) { if (currentPathGroup != null) { pathGroups.add(currentPathGroup); } currentPathGroup = new PathGroup((TrimPathContent) content); ((TrimPathContent) content).addListener(this); } else if (content instanceof PathContent) { if (currentPathGroup == null) { currentPathGroup = new PathGroup(trimPathContentBefore); } currentPathGroup.paths.add((PathContent) content); } } if (currentPathGroup != null) { pathGroups.add(currentPathGroup); } } @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { L.beginSection("StrokeContent#draw"); if (Utils.hasZeroScaleAxis(parentMatrix)) { L.endSection("StrokeContent#draw"); return; } int alpha = (int) ((parentAlpha / 255f * ((IntegerKeyframeAnimation) opacityAnimation).getIntValue() / 100f) * 255); paint.setAlpha(clamp(alpha, 0, 255)); paint.setStrokeWidth(((FloatKeyframeAnimation) widthAnimation).getFloatValue() * Utils.getScale(parentMatrix)); if (paint.getStrokeWidth() <= 0) { // Android draws a hairline stroke for 0, After Effects doesn't. L.endSection("StrokeContent#draw"); return; } applyDashPatternIfNeeded(parentMatrix); if (colorFilterAnimation != null) { paint.setColorFilter(colorFilterAnimation.getValue()); } for (int i = 0; i < pathGroups.size(); i++) { PathGroup pathGroup = pathGroups.get(i); if (pathGroup.trimPath != null) { applyTrimPath(canvas, pathGroup, parentMatrix); } else { L.beginSection("StrokeContent#buildPath"); path.reset(); for (int j = pathGroup.paths.size() - 1; j >= 0; j--) { path.addPath(pathGroup.paths.get(j).getPath(), parentMatrix); } L.endSection("StrokeContent#buildPath"); L.beginSection("StrokeContent#drawPath"); canvas.drawPath(path, paint); L.endSection("StrokeContent#drawPath"); } } L.endSection("StrokeContent#draw"); } private void applyTrimPath(Canvas canvas, PathGroup pathGroup, Matrix parentMatrix) { L.beginSection("StrokeContent#applyTrimPath"); if (pathGroup.trimPath == null) { L.endSection("StrokeContent#applyTrimPath"); return; } path.reset(); for (int j = pathGroup.paths.size() - 1; j >= 0; j--) { path.addPath(pathGroup.paths.get(j).getPath(), parentMatrix); } pm.setPath(path, false); float totalLength = pm.getLength(); while (pm.nextContour()) { totalLength += pm.getLength(); } float offsetLength = totalLength * pathGroup.trimPath.getOffset().getValue() / 360f; float startLength = totalLength * pathGroup.trimPath.getStart().getValue() / 100f + offsetLength; float endLength = totalLength * pathGroup.trimPath.getEnd().getValue() / 100f + offsetLength; float currentLength = 0; for (int j = pathGroup.paths.size() - 1; j >= 0; j--) { trimPathPath.set(pathGroup.paths.get(j).getPath()); trimPathPath.transform(parentMatrix); pm.setPath(trimPathPath, false); float length = pm.getLength(); if (endLength > totalLength && endLength - totalLength < currentLength + length && currentLength < endLength - totalLength) { // Draw the segment when the end is greater than the length which wraps around to the // beginning. float startValue; if (startLength > totalLength) { startValue = (startLength - totalLength) / length; } else { startValue = 0; } float endValue = Math.min((endLength - totalLength) / length, 1); Utils.applyTrimPathIfNeeded(trimPathPath, startValue, endValue, 0); canvas.drawPath(trimPathPath, paint); } else //noinspection StatementWithEmptyBody if (currentLength + length < startLength || currentLength > endLength) { // Do nothing } else if (currentLength + length <= endLength && startLength < currentLength) { canvas.drawPath(trimPathPath, paint); } else { float startValue; if (startLength < currentLength) { startValue = 0; } else { startValue = (startLength - currentLength) / length; } float endValue; if (endLength > currentLength + length) { endValue = 1f; } else { endValue = (endLength - currentLength) / length; } Utils.applyTrimPathIfNeeded(trimPathPath, startValue, endValue, 0); canvas.drawPath(trimPathPath, paint); } currentLength += length; } L.endSection("StrokeContent#applyTrimPath"); } @Override public void getBounds(RectF outBounds, Matrix parentMatrix, boolean applyParents) { L.beginSection("StrokeContent#getBounds"); path.reset(); for (int i = 0; i < pathGroups.size(); i++) { PathGroup pathGroup = pathGroups.get(i); for (int j = 0; j < pathGroup.paths.size(); j++) { path.addPath(pathGroup.paths.get(j).getPath(), parentMatrix); } } path.computeBounds(rect, false); float width = ((FloatKeyframeAnimation) widthAnimation).getFloatValue(); rect.set(rect.left - width / 2f, rect.top - width / 2f, rect.right + width / 2f, rect.bottom + width / 2f); outBounds.set(rect); // Add padding to account for rounding errors. outBounds.set( outBounds.left - 1, outBounds.top - 1, outBounds.right + 1, outBounds.bottom + 1 ); L.endSection("StrokeContent#getBounds"); } private void applyDashPatternIfNeeded(Matrix parentMatrix) { L.beginSection("StrokeContent#applyDashPattern"); if (dashPatternAnimations.isEmpty()) { L.endSection("StrokeContent#applyDashPattern"); return; } float scale = Utils.getScale(parentMatrix); for (int i = 0; i < dashPatternAnimations.size(); i++) { dashPatternValues[i] = dashPatternAnimations.get(i).getValue(); // If the value of the dash pattern or gap is too small, the number of individual sections // approaches infinity as the value approaches 0. // To mitigate this, we essentially put a minimum value on the dash pattern size of 1px // and a minimum gap size of 0.01. if (i % 2 == 0) { if (dashPatternValues[i] < 1f) { dashPatternValues[i] = 1f; } } else { if (dashPatternValues[i] < 0.1f) { dashPatternValues[i] = 0.1f; } } dashPatternValues[i] *= scale; } float offset = dashPatternOffsetAnimation == null ? 0f : dashPatternOffsetAnimation.getValue() * scale; paint.setPathEffect(new DashPathEffect(dashPatternValues, offset)); L.endSection("StrokeContent#applyDashPattern"); } @Override public void resolveKeyPath( KeyPath keyPath, int depth, List<KeyPath> accumulator, KeyPath currentPartialKeyPath) { MiscUtils.resolveKeyPath(keyPath, depth, accumulator, currentPartialKeyPath, this); } @SuppressWarnings("unchecked") @Override @CallSuper public <T> void addValueCallback(T property, @Nullable LottieValueCallback<T> callback) { if (property == LottieProperty.OPACITY) { opacityAnimation.setValueCallback((LottieValueCallback<Integer>) callback); } else if (property == LottieProperty.STROKE_WIDTH) { widthAnimation.setValueCallback((LottieValueCallback<Float>) callback); } else if (property == LottieProperty.COLOR_FILTER) { if (colorFilterAnimation != null) { layer.removeAnimation(colorFilterAnimation); } if (callback == null) { colorFilterAnimation = null; } else { colorFilterAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback<ColorFilter>) callback); colorFilterAnimation.addUpdateListener(this); layer.addAnimation(colorFilterAnimation); } } } /** * Data class to help drawing trim paths individually. */ private static final class PathGroup { private final List<PathContent> paths = new ArrayList<>(); @Nullable private final TrimPathContent trimPath; private PathGroup(@Nullable TrimPathContent trimPath) { this.trimPath = trimPath; } } }