From 1aa5977f893e5a201bd5c9c40731acd623441c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Davidovi=C4=87?= Date: Thu, 5 Sep 2024 21:52:31 +0200 Subject: [PATCH] Drop shadow overhaul: improve correctness and perf This commit introduces a large change to how drop shadows are rendered, introducing an `applyShadowsToLayers` flag which, by analogy to `applyOpacitiesToLayers`, allows layers to be treated as a whole for the purposes of drop shadows, improving the accuracy and bringing lottie-android in line with other renderers (lottie-web and lottie-ios). Several different codepaths for different hardware/software combinations are introduced to ensure the fastest rendering available, even on legacy devices. The calculation of shadow direction with respect to transforms is improved so that the output matches lottie-web and lottie-ios. Image layers now cast shadows correctly thanks to a workaround to device-specific issues when combining `Paint.setShadowLayer()` and bitmap rendering. Even in non-`applyShadowsToLayers` mode, correctness is improved by allowing the shadow-to-be-applied to propagate in a similar way as alpha. This allows some amount of visual fidelity to be recovered for animations or environments where enabling `applyShadowsToLayers` is not possible. A number of issues that caused incorrect rendering in some other cases have been fixed. --- .../airbnb/lottie/compose/LottieAnimation.kt | 6 + .../airbnb/lottie/LottieAnimationView.java | 23 + .../com/airbnb/lottie/LottieDrawable.java | 27 +- .../animation/content/BaseStrokeContent.java | 28 +- .../animation/content/ContentGroup.java | 41 +- .../animation/content/DrawingContent.java | 5 +- .../lottie/animation/content/FillContent.java | 32 +- .../content/GradientFillContent.java | 27 +- .../content/GradientStrokeContent.java | 6 +- .../animation/content/RepeaterContent.java | 8 +- .../animation/content/StrokeContent.java | 6 +- .../keyframe/DropShadowKeyframeAnimation.java | 53 +- .../airbnb/lottie/model/layer/BaseLayer.java | 16 +- .../lottie/model/layer/CompositionLayer.java | 63 ++- .../airbnb/lottie/model/layer/ImageLayer.java | 60 ++- .../airbnb/lottie/model/layer/NullLayer.java | 5 +- .../airbnb/lottie/model/layer/ShapeLayer.java | 44 +- .../airbnb/lottie/model/layer/SolidLayer.java | 9 +- .../airbnb/lottie/model/layer/TextLayer.java | 4 +- .../com/airbnb/lottie/utils/DropShadow.java | 128 +++++ .../airbnb/lottie/utils/OffscreenLayer.java | 494 ++++++++++++++++++ lottie/src/main/res/values/attrs.xml | 2 + 22 files changed, 933 insertions(+), 154 deletions(-) create mode 100644 lottie/src/main/java/com/airbnb/lottie/utils/DropShadow.java create mode 100644 lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt index c4644009b3..d372b46405 100644 --- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt +++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt @@ -83,6 +83,7 @@ fun LottieAnimation( modifier: Modifier = Modifier, outlineMasksAndMattes: Boolean = false, applyOpacityToLayers: Boolean = false, + applyShadowToLayers: Boolean = true, enableMergePaths: Boolean = false, renderMode: RenderMode = RenderMode.AUTOMATIC, maintainOriginalImageBounds: Boolean = false, @@ -130,6 +131,7 @@ fun LottieAnimation( } drawable.setOutlineMasksAndMattes(outlineMasksAndMattes) drawable.isApplyingOpacityToLayersEnabled = applyOpacityToLayers + drawable.isApplyingShadowToLayersEnabled = applyShadowToLayers drawable.maintainOriginalImageBounds = maintainOriginalImageBounds drawable.clipToCompositionBounds = clipToCompositionBounds drawable.clipTextToBoundingBox = clipTextToBoundingBox @@ -158,6 +160,7 @@ fun LottieAnimation( modifier: Modifier = Modifier, outlineMasksAndMattes: Boolean = false, applyOpacityToLayers: Boolean = false, + applyShadowToLayers: Boolean = true, enableMergePaths: Boolean = false, renderMode: RenderMode = RenderMode.AUTOMATIC, maintainOriginalImageBounds: Boolean = false, @@ -174,6 +177,7 @@ fun LottieAnimation( modifier = modifier, outlineMasksAndMattes = outlineMasksAndMattes, applyOpacityToLayers = applyOpacityToLayers, + applyShadowToLayers = applyShadowToLayers, enableMergePaths = enableMergePaths, renderMode = renderMode, maintainOriginalImageBounds = maintainOriginalImageBounds, @@ -205,6 +209,7 @@ fun LottieAnimation( iterations: Int = 1, outlineMasksAndMattes: Boolean = false, applyOpacityToLayers: Boolean = false, + applyShadowToLayers: Boolean = true, enableMergePaths: Boolean = false, renderMode: RenderMode = RenderMode.AUTOMATIC, reverseOnRepeat: Boolean = false, @@ -233,6 +238,7 @@ fun LottieAnimation( modifier = modifier, outlineMasksAndMattes = outlineMasksAndMattes, applyOpacityToLayers = applyOpacityToLayers, + applyShadowToLayers = applyShadowToLayers, enableMergePaths = enableMergePaths, renderMode = renderMode, maintainOriginalImageBounds = maintainOriginalImageBounds, diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java index 6152fef7b7..328d6b3e04 100644 --- a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java +++ b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java @@ -223,6 +223,11 @@ private void init(@Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { enableMergePathsForKitKatAndAbove(ta.getBoolean( R.styleable.LottieAnimationView_lottie_enableMergePathsForKitKatAndAbove, false)); + setApplyingOpacityToLayersEnabled(ta.getBoolean( + R.styleable.LottieAnimationView_lottie_applyOpacityToLayers, false)); + setApplyingShadowToLayersEnabled(ta.getBoolean( + R.styleable.LottieAnimationView_lottie_applyShadowToLayers, true)); + if (ta.hasValue(R.styleable.LottieAnimationView_lottie_colorFilter)) { int colorRes = ta.getResourceId(R.styleable.LottieAnimationView_lottie_colorFilter, -1); ColorStateList csl = AppCompatResources.getColorStateList(getContext(), colorRes); @@ -1247,6 +1252,24 @@ public void setApplyingOpacityToLayersEnabled(boolean isApplyingOpacityToLayersE lottieDrawable.setApplyingOpacityToLayersEnabled(isApplyingOpacityToLayersEnabled); } + /** + * Sets whether to apply drop shadows to each layer instead of shape. + *

+ * When true, the behavior will be more correct: it will mimic lottie-web and other renderers, in that drop shadows will be applied to a layer + * as a whole, no matter its contents. + * + * When false, the performance will be better at the expense of correctness: for each shape element individually, the first drop shadow upwards + * in the hierarchy is applied to it directly. Visually, this may manifest as phantom shadows or artifacts where the artist has intended to treat a + * layer as a whole, and this option exposes its internal structure. + *

+ * The default value is true. + * + * @see LottieAnimationView::setApplyingOpacityToLayersEnabled + */ + public void setApplyingShadowToLayersEnabled(boolean isApplyingShadowToLayersEnabled) { + lottieDrawable.setApplyingShadowToLayersEnabled(isApplyingShadowToLayersEnabled); + } + /** * @see #setClipTextToBoundingBox(boolean) */ diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java index 2ef5259b03..e43371581f 100644 --- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java +++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java @@ -152,6 +152,7 @@ private enum OnVisibleAction { private boolean performanceTrackingEnabled; private boolean outlineMasksAndMattes; private boolean isApplyingOpacityToLayersEnabled; + private boolean isApplyingShadowToLayersEnabled; private boolean clipTextToBoundingBox = false; private RenderMode renderMode = RenderMode.AUTOMATIC; @@ -563,6 +564,24 @@ public void setApplyingOpacityToLayersEnabled(boolean isApplyingOpacityToLayersE this.isApplyingOpacityToLayersEnabled = isApplyingOpacityToLayersEnabled; } + /** + * Sets whether to apply drop shadows to each layer instead of shape. + *

+ * When true, the behavior will be more correct: it will mimic lottie-web and other renderers, in that drop shadows will be applied to a layer + * as a whole, no matter its contents. + * + * When false, the performance will be better at the expense of correctness: for each shape element individually, the first drop shadow upwards + * in the hierarchy is applied to it directly. Visually, this may manifest as phantom shadows or artifacts where the artist has intended to treat a + * layer as a whole, and this option exposes its internal structure. + *

+ * The default value is true. + * + * @see LottieDrawable::setApplyingOpacityToLayersEnabled + */ + public void setApplyingShadowToLayersEnabled(boolean isApplyingShadowsToLayersEnabled) { + this.isApplyingShadowToLayersEnabled = isApplyingShadowsToLayersEnabled; + } + /** * This API no longer has any effect. */ @@ -574,6 +593,8 @@ public boolean isApplyingOpacityToLayersEnabled() { return isApplyingOpacityToLayersEnabled; } + public boolean isApplyingShadowToLayersEnabled() { return isApplyingShadowToLayersEnabled; } + /** * @see #setClipTextToBoundingBox(boolean) */ @@ -772,7 +793,7 @@ public void draw(Canvas canvas, Matrix matrix) { renderAndDrawAsBitmap(canvas, compositionLayer); canvas.restore(); } else { - compositionLayer.draw(canvas, matrix, alpha); + compositionLayer.draw(canvas, matrix, alpha, null); } isDirty = false; } catch (InterruptedException e) { @@ -1708,7 +1729,7 @@ private void drawDirectlyToCanvas(Canvas canvas) { renderingMatrix.preScale(scaleX, scaleY); renderingMatrix.preTranslate(bounds.left, bounds.top); } - compositionLayer.draw(canvas, renderingMatrix, alpha); + compositionLayer.draw(canvas, renderingMatrix, alpha, null); } /** @@ -1772,7 +1793,7 @@ private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compo renderingMatrix.postTranslate(-softwareRenderingTransformedBounds.left, -softwareRenderingTransformedBounds.top); softwareRenderingBitmap.eraseColor(0); - compositionLayer.draw(softwareRenderingCanvas, renderingMatrix, alpha); + compositionLayer.draw(softwareRenderingCanvas, renderingMatrix, alpha, null); // Calculate the dst bounds. // We need to map the rendered coordinates back to the canvas's coordinates. To do so, we need to invert the transform diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/BaseStrokeContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/BaseStrokeContent.java index 3c0e5ebda5..4eabd7992b 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/BaseStrokeContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/BaseStrokeContent.java @@ -29,6 +29,7 @@ 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.DropShadow; import com.airbnb.lottie.utils.MiscUtils; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; @@ -58,8 +59,6 @@ public abstract class BaseStrokeContent @Nullable private BaseKeyframeAnimation blurAnimation; float blurMaskFilterRadius = 0f; - @Nullable private DropShadowKeyframeAnimation dropShadowAnimation; - BaseStrokeContent(final LottieDrawable lottieDrawable, BaseLayer layer, Paint.Cap cap, Paint.Join join, float miterLimit, AnimatableIntegerValue opacity, AnimatableFloatValue width, List dashPattern, AnimatableFloatValue offset) { @@ -110,9 +109,6 @@ public abstract class BaseStrokeContent blurAnimation.addUpdateListener(this); layer.addAnimation(blurAnimation); } - if (layer.getDropShadowEffect() != null) { - dropShadowAnimation = new DropShadowKeyframeAnimation(this, layer, layer.getDropShadowEffect()); - } } @Override public void onValueChanged() { @@ -154,7 +150,7 @@ public abstract class BaseStrokeContent } } - @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { if (L.isTraceEnabled()) { L.beginSection("StrokeContent#draw"); } @@ -164,8 +160,10 @@ public abstract class BaseStrokeContent } return; } - int alpha = (int) ((parentAlpha / 255f * ((IntegerKeyframeAnimation) opacityAnimation).getIntValue() / 100f) * 255); - paint.setAlpha(clamp(alpha, 0, 255)); + float strokeAlpha = opacityAnimation.getValue() / 100f; + int alpha = (int) (parentAlpha * strokeAlpha); + alpha = clamp(alpha, 0, 255); + paint.setAlpha(alpha); paint.setStrokeWidth(((FloatKeyframeAnimation) widthAnimation).getFloatValue()); if (paint.getStrokeWidth() <= 0) { // Android draws a hairline stroke for 0, After Effects doesn't. @@ -190,8 +188,8 @@ public abstract class BaseStrokeContent } blurMaskFilterRadius = blurRadius; } - if (dropShadowAnimation != null) { - dropShadowAnimation.applyTo(paint, parentMatrix, Utils.mixOpacities(parentAlpha, alpha)); + if (shadowToApply != null) { + shadowToApply.applyWithAlpha((int)(strokeAlpha * 255), paint); } canvas.save(); @@ -407,16 +405,6 @@ public void addValueCallback(T property, @Nullable LottieValueCallback ca blurAnimation.addUpdateListener(this); layer.addAnimation(blurAnimation); } - } else if (property == LottieProperty.DROP_SHADOW_COLOR && dropShadowAnimation != null) { - dropShadowAnimation.setColorCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_OPACITY && dropShadowAnimation != null) { - dropShadowAnimation.setOpacityCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_DIRECTION && dropShadowAnimation != null) { - dropShadowAnimation.setDirectionCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_DISTANCE && dropShadowAnimation != null) { - dropShadowAnimation.setDistanceCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_RADIUS && dropShadowAnimation != null) { - dropShadowAnimation.setRadiusCallback((LottieValueCallback) callback); } } diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/ContentGroup.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/ContentGroup.java index cc3d1661e9..770a7c100b 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/ContentGroup.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/ContentGroup.java @@ -4,14 +4,15 @@ import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Path; +import android.graphics.Rect; import android.graphics.RectF; import androidx.annotation.Nullable; import com.airbnb.lottie.LottieComposition; import com.airbnb.lottie.LottieDrawable; -import com.airbnb.lottie.animation.LPaint; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.TransformKeyframeAnimation; import com.airbnb.lottie.model.KeyPath; import com.airbnb.lottie.model.KeyPathElement; @@ -19,7 +20,8 @@ import com.airbnb.lottie.model.content.ContentModel; import com.airbnb.lottie.model.content.ShapeGroup; import com.airbnb.lottie.model.layer.BaseLayer; -import com.airbnb.lottie.utils.Utils; +import com.airbnb.lottie.utils.DropShadow; +import com.airbnb.lottie.utils.OffscreenLayer; import com.airbnb.lottie.value.LottieValueCallback; import java.util.ArrayList; @@ -28,8 +30,9 @@ public class ContentGroup implements DrawingContent, PathContent, BaseKeyframeAnimation.AnimationListener, KeyPathElement { - private final Paint offScreenPaint = new LPaint(); + private final OffscreenLayer.ComposeOp offscreenOp = new OffscreenLayer.ComposeOp(); private final RectF offScreenRectF = new RectF(); + private final OffscreenLayer offscreenLayer = new OffscreenLayer(); private static List contentsFromModels(LottieDrawable drawable, LottieComposition composition, BaseLayer layer, List contentModels) { @@ -160,7 +163,7 @@ Matrix getTransformationMatrix() { return path; } - @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { if (hidden) { return; } @@ -175,24 +178,40 @@ Matrix getTransformationMatrix() { } // Apply off-screen rendering only when needed in order to improve rendering performance. - boolean isRenderingWithOffScreen = lottieDrawable.isApplyingOpacityToLayersEnabled() && hasTwoOrMoreDrawableContent() && layerAlpha != 255; + boolean isRenderingWithOffScreen = + (lottieDrawable.isApplyingOpacityToLayersEnabled() && hasTwoOrMoreDrawableContent() && layerAlpha != 255) || + (shadowToApply != null && lottieDrawable.isApplyingShadowToLayersEnabled() && hasTwoOrMoreDrawableContent()); + int childAlpha = isRenderingWithOffScreen ? 255 : layerAlpha; + + Canvas contentCanvas = canvas; if (isRenderingWithOffScreen) { offScreenRectF.set(0, 0, 0, 0); - getBounds(offScreenRectF, matrix, true); - offScreenPaint.setAlpha(layerAlpha); - Utils.saveLayerCompat(canvas, offScreenRectF, offScreenPaint); + getBounds(offScreenRectF, parentMatrix, true); + offscreenOp.alpha = layerAlpha; + if (shadowToApply != null) { + shadowToApply.applyTo(offscreenOp); + shadowToApply = null; // Don't pass it to children - OffscreenLayer now takes care of this + } else { + offscreenOp.shadow = null; + } + + contentCanvas = offscreenLayer.start(canvas, offScreenRectF, offscreenOp); + } else { + if (shadowToApply != null) { + shadowToApply = new DropShadow(shadowToApply); + shadowToApply.multiplyOpacity(childAlpha); + } } - int childAlpha = isRenderingWithOffScreen ? 255 : layerAlpha; for (int i = contents.size() - 1; i >= 0; i--) { Object content = contents.get(i); if (content instanceof DrawingContent) { - ((DrawingContent) content).draw(canvas, matrix, childAlpha); + ((DrawingContent) content).draw(contentCanvas, matrix, childAlpha, shadowToApply); } } if (isRenderingWithOffScreen) { - canvas.restore(); + offscreenLayer.finish(); } } diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/DrawingContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/DrawingContent.java index c6501e3094..79c2ae33dd 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/DrawingContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/DrawingContent.java @@ -3,9 +3,12 @@ import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.RectF; +import androidx.annotation.Nullable; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; +import com.airbnb.lottie.utils.DropShadow; public interface DrawingContent extends Content { - void draw(Canvas canvas, Matrix parentMatrix, int alpha); + void draw(Canvas canvas, Matrix parentMatrix, int alpha, @Nullable DropShadow shadowToApply); void getBounds(RectF outBounds, Matrix parentMatrix, boolean applyParents); } diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java index 5a59fc104a..487ba99fa9 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java @@ -23,6 +23,7 @@ import com.airbnb.lottie.model.KeyPath; import com.airbnb.lottie.model.content.ShapeFill; import com.airbnb.lottie.model.layer.BaseLayer; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.utils.MiscUtils; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; @@ -46,8 +47,6 @@ public class FillContent @Nullable private BaseKeyframeAnimation blurAnimation; float blurMaskFilterRadius; - @Nullable private DropShadowKeyframeAnimation dropShadowAnimation; - public FillContent(final LottieDrawable lottieDrawable, BaseLayer layer, ShapeFill fill) { this.layer = layer; name = fill.getName(); @@ -58,9 +57,6 @@ public FillContent(final LottieDrawable lottieDrawable, BaseLayer layer, ShapeFi blurAnimation.addUpdateListener(this); layer.addAnimation(blurAnimation); } - if (layer.getDropShadowEffect() != null) { - dropShadowAnimation = new DropShadowKeyframeAnimation(this, layer, layer.getDropShadowEffect()); - } if (fill.getColor() == null || fill.getOpacity() == null) { colorAnimation = null; @@ -95,7 +91,7 @@ public FillContent(final LottieDrawable lottieDrawable, BaseLayer layer, ShapeFi return name; } - @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { if (hidden) { return; } @@ -103,8 +99,10 @@ public FillContent(final LottieDrawable lottieDrawable, BaseLayer layer, ShapeFi L.beginSection("FillContent#draw"); } int color = ((ColorKeyframeAnimation) this.colorAnimation).getIntValue(); - int alpha = (int) ((parentAlpha / 255f * opacityAnimation.getValue() / 100f) * 255); - paint.setColor((clamp(alpha, 0, 255) << 24) | (color & 0xFFFFFF)); + float fillAlpha = opacityAnimation.getValue() / 100f; + int alpha = (int) (parentAlpha * fillAlpha); + alpha = clamp(alpha, 0, 255); + paint.setColor((alpha << 24) | (color & 0xFFFFFF)); if (colorFilterAnimation != null) { paint.setColorFilter(colorFilterAnimation.getValue()); @@ -120,8 +118,12 @@ public FillContent(final LottieDrawable lottieDrawable, BaseLayer layer, ShapeFi } blurMaskFilterRadius = blurRadius; } - if (dropShadowAnimation != null) { - dropShadowAnimation.applyTo(paint, parentMatrix, Utils.mixOpacities(parentAlpha, alpha)); + if (shadowToApply != null) { + Matrix layerInv = new Matrix(); + layer.transform.getMatrix().invert(layerInv); + shadowToApply.applyWithAlpha((int)(fillAlpha * 255), paint); + } else { + paint.clearShadowLayer(); } path.reset(); @@ -185,16 +187,6 @@ public void addValueCallback(T property, @Nullable LottieValueCallback ca blurAnimation.addUpdateListener(this); layer.addAnimation(blurAnimation); } - } else if (property == LottieProperty.DROP_SHADOW_COLOR && dropShadowAnimation != null) { - dropShadowAnimation.setColorCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_OPACITY && dropShadowAnimation != null) { - dropShadowAnimation.setOpacityCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_DIRECTION && dropShadowAnimation != null) { - dropShadowAnimation.setDirectionCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_DISTANCE && dropShadowAnimation != null) { - dropShadowAnimation.setDistanceCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_RADIUS && dropShadowAnimation != null) { - dropShadowAnimation.setRadiusCallback((LottieValueCallback) callback); } } } diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientFillContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientFillContent.java index 944d769ac1..f4e7847d08 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientFillContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientFillContent.java @@ -31,6 +31,7 @@ import com.airbnb.lottie.model.content.GradientFill; import com.airbnb.lottie.model.content.GradientType; import com.airbnb.lottie.model.layer.BaseLayer; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.utils.MiscUtils; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; @@ -64,7 +65,6 @@ public class GradientFillContent private final int cacheSteps; @Nullable private BaseKeyframeAnimation blurAnimation; float blurMaskFilterRadius = 0f; - @Nullable private DropShadowKeyframeAnimation dropShadowAnimation; public GradientFillContent(final LottieDrawable lottieDrawable, LottieComposition composition, BaseLayer layer, GradientFill fill) { this.layer = layer; @@ -96,9 +96,6 @@ public GradientFillContent(final LottieDrawable lottieDrawable, LottieCompositio blurAnimation.addUpdateListener(this); layer.addAnimation(blurAnimation); } - if (layer.getDropShadowEffect() != null) { - dropShadowAnimation = new DropShadowKeyframeAnimation(this, layer, layer.getDropShadowEffect()); - } } @Override public void onValueChanged() { @@ -114,7 +111,7 @@ public GradientFillContent(final LottieDrawable lottieDrawable, LottieCompositio } } - @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { if (hidden) { return; } @@ -152,11 +149,13 @@ public GradientFillContent(final LottieDrawable lottieDrawable, LottieCompositio blurMaskFilterRadius = blurRadius; } - int alpha = (int) ((parentAlpha / 255f * opacityAnimation.getValue() / 100f) * 255); - paint.setAlpha(clamp(alpha, 0, 255)); + float fillAlpha = opacityAnimation.getValue() / 100f; + int alpha = (int) (parentAlpha * fillAlpha); + alpha = clamp(alpha, 0, 255); + paint.setAlpha(alpha); - if (dropShadowAnimation != null) { - dropShadowAnimation.applyTo(paint, parentMatrix, Utils.mixOpacities(parentAlpha, alpha)); + if (shadowToApply != null) { + shadowToApply.applyWithAlpha((int)(fillAlpha * 255), paint); } canvas.drawPath(path, paint); @@ -306,16 +305,6 @@ public void addValueCallback(T property, @Nullable LottieValueCallback ca blurAnimation.addUpdateListener(this); layer.addAnimation(blurAnimation); } - } else if (property == LottieProperty.DROP_SHADOW_COLOR && dropShadowAnimation != null) { - dropShadowAnimation.setColorCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_OPACITY && dropShadowAnimation != null) { - dropShadowAnimation.setOpacityCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_DIRECTION && dropShadowAnimation != null) { - dropShadowAnimation.setDirectionCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_DISTANCE && dropShadowAnimation != null) { - dropShadowAnimation.setDistanceCallback((LottieValueCallback) callback); - } else if (property == LottieProperty.DROP_SHADOW_RADIUS && dropShadowAnimation != null) { - dropShadowAnimation.setRadiusCallback((LottieValueCallback) callback); } } } diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientStrokeContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientStrokeContent.java index d5f78ddd90..51055457b4 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientStrokeContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/GradientStrokeContent.java @@ -14,11 +14,13 @@ import com.airbnb.lottie.LottieDrawable; import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation; import com.airbnb.lottie.model.content.GradientColor; import com.airbnb.lottie.model.content.GradientStroke; import com.airbnb.lottie.model.content.GradientType; import com.airbnb.lottie.model.layer.BaseLayer; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.value.LottieValueCallback; public class GradientStrokeContent extends BaseStrokeContent { @@ -64,7 +66,7 @@ public GradientStrokeContent( layer.addAnimation(endPointAnimation); } - @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha, DropShadow shadowToApply) { if (hidden) { return; } @@ -78,7 +80,7 @@ public GradientStrokeContent( } paint.setShader(shader); - super.draw(canvas, parentMatrix, parentAlpha); + super.draw(canvas, parentMatrix, parentAlpha, shadowToApply); } @Override public String getName() { diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/RepeaterContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/RepeaterContent.java index 4a721fb5ee..09b2b8205f 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/RepeaterContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/RepeaterContent.java @@ -10,10 +10,12 @@ import com.airbnb.lottie.LottieDrawable; import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.TransformKeyframeAnimation; import com.airbnb.lottie.model.KeyPath; import com.airbnb.lottie.model.content.Repeater; import com.airbnb.lottie.model.layer.BaseLayer; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.utils.MiscUtils; import com.airbnb.lottie.value.LottieValueCallback; @@ -105,7 +107,7 @@ public RepeaterContent(LottieDrawable lottieDrawable, BaseLayer layer, Repeater return path; } - @Override public void draw(Canvas canvas, Matrix parentMatrix, int alpha) { + @Override public void draw(Canvas canvas, Matrix parentMatrix, int alpha, @Nullable DropShadow shadowToApply) { float copies = this.copies.getValue(); float offset = this.offset.getValue(); //noinspection ConstantConditions @@ -116,7 +118,9 @@ public RepeaterContent(LottieDrawable lottieDrawable, BaseLayer layer, Repeater matrix.set(parentMatrix); matrix.preConcat(transform.getMatrixForRepeater(i + offset)); float newAlpha = alpha * MiscUtils.lerp(startOpacity, endOpacity, i / copies); - contentGroup.draw(canvas, matrix, (int) newAlpha); + // A repeater renders its contents as if by simple re-rendering, so it should be fine to pass shadowToApply + // here, even when we have more than 1 copy. + contentGroup.draw(canvas, matrix, (int) newAlpha, shadowToApply); } } diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/StrokeContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/StrokeContent.java index 212aa55f4a..ee7c86936f 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/StrokeContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/StrokeContent.java @@ -12,9 +12,11 @@ import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ColorKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation; import com.airbnb.lottie.model.content.ShapeStroke; import com.airbnb.lottie.model.layer.BaseLayer; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.value.LottieValueCallback; public class StrokeContent extends BaseStrokeContent { @@ -37,7 +39,7 @@ public StrokeContent(final LottieDrawable lottieDrawable, BaseLayer layer, Shape layer.addAnimation(colorAnimation); } - @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { if (hidden) { return; } @@ -45,7 +47,7 @@ public StrokeContent(final LottieDrawable lottieDrawable, BaseLayer layer, Shape if (colorFilterAnimation != null) { paint.setColorFilter(colorFilterAnimation.getValue()); } - super.draw(canvas, parentMatrix, parentAlpha); + super.draw(canvas, parentMatrix, parentAlpha, shadowToApply); } @Override public String getName() { diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/DropShadowKeyframeAnimation.java b/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/DropShadowKeyframeAnimation.java index 425b8a4bf5..17f59475fc 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/DropShadowKeyframeAnimation.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/DropShadowKeyframeAnimation.java @@ -6,6 +6,8 @@ import androidx.annotation.Nullable; import com.airbnb.lottie.model.layer.BaseLayer; import com.airbnb.lottie.parser.DropShadowEffect; +import com.airbnb.lottie.utils.DropShadow; +import com.airbnb.lottie.utils.OffscreenLayer; import com.airbnb.lottie.value.LottieFrameInfo; import com.airbnb.lottie.value.LottieValueCallback; @@ -26,13 +28,6 @@ public class DropShadowKeyframeAnimation implements BaseKeyframeAnimation.Animat private final FloatKeyframeAnimation distance; private final FloatKeyframeAnimation radius; - // Cached paint values. - private float paintRadius = Float.NaN; - private float paintX = Float.NaN; - private float paintY = Float.NaN; - // 0 is a valid color but it is transparent so it will not draw anything anyway. - private int paintColor = 0; - private final float[] matrixValues = new float[9]; public DropShadowKeyframeAnimation(BaseKeyframeAnimation.AnimationListener listener, BaseLayer layer, DropShadowEffect dropShadowEffect) { @@ -59,48 +54,28 @@ public DropShadowKeyframeAnimation(BaseKeyframeAnimation.AnimationListener liste listener.onValueChanged(); } - /** - * Applies a shadow to the provided Paint object, which will be applied to the Canvas behind whatever is drawn - * (a shape, bitmap, path, etc.) - * - * @param parentAlpha A value between 0 and 255 representing the combined alpha of all parents of this drop shadow effect. - * E.g. The layer via transform, the fill/stroke via its opacity, etc. - */ - public void applyTo(Paint paint, Matrix parentMatrix, int parentAlpha) { + public DropShadow evaluate(Matrix parentMatrix, int parentAlpha) { float directionRad = this.direction.getFloatValue() * DEG_TO_RAD; float distance = this.distance.getValue(); float rawX = ((float) Math.sin(directionRad)) * distance; float rawY = ((float) Math.cos(directionRad + Math.PI)) * distance; - - // The x and y coordinates are relative to the shape that is being drawn. - // The distance in the animation is relative to the original size of the shape. - // If the shape will be drawn scaled, we need to scale the distance we draw the shadow. - layer.transform.getMatrix().getValues(matrixValues); - float layerScaleX = matrixValues[Matrix.MSCALE_X]; - float layerScaleY = matrixValues[Matrix.MSCALE_Y]; - parentMatrix.getValues(matrixValues); - float parentScaleX = matrixValues[Matrix.MSCALE_X]; - float parentScaleY = matrixValues[Matrix.MSCALE_Y]; - float scaleX = parentScaleX / layerScaleX; - float scaleY = parentScaleY / layerScaleY; - float x = rawX * scaleX; - float y = rawY * scaleY; + float rawRadius = radius.getValue(); int baseColor = color.getValue(); int opacity = Math.round(this.opacity.getValue() * parentAlpha / 255f); int color = Color.argb(opacity, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor)); - // Paint.setShadowLayer() removes the shadow if radius is 0, so we use a small nonzero value in that case - float radius = Math.max(this.radius.getValue() * scaleX * AFTER_EFFECT_SOFTNESS_SCALE_FACTOR, Float.MIN_VALUE); + DropShadow shadow = new DropShadow(rawRadius * AFTER_EFFECT_SOFTNESS_SCALE_FACTOR, rawX, rawY, color); + shadow.transformBy(parentMatrix); - if (paintRadius == radius && paintX == x && paintY == y && paintColor == color) { - return; - } - paintRadius = radius; - paintX = x; - paintY = y; - paintColor = color; - paint.setShadowLayer(radius, x, y, color); + // Since the shadow parameters are relative to the layer on which the shadow resides, correct for this + // by undoing the layer's own transform. For example, if the layer is scaled, the screen-space blur + // radius should stay constant. + Matrix layerInv = new Matrix(); + layer.transform.getMatrix().invert(layerInv); + shadow.transformBy(layerInv); + + return shadow; } public void setColorCallback(@Nullable LottieValueCallback callback) { diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java index fdaefb9019..2e600044ca 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java @@ -1,5 +1,6 @@ package com.airbnb.lottie.model.layer; +import android.graphics.Bitmap; import android.graphics.BlurMaskFilter; import android.graphics.Canvas; import android.graphics.Color; @@ -23,6 +24,7 @@ import com.airbnb.lottie.animation.content.Content; import com.airbnb.lottie.animation.content.DrawingContent; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.FloatKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.MaskKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.TransformKeyframeAnimation; @@ -33,6 +35,7 @@ import com.airbnb.lottie.model.content.Mask; import com.airbnb.lottie.model.content.ShapeData; import com.airbnb.lottie.parser.DropShadowEffect; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.utils.Logger; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; @@ -91,7 +94,7 @@ static BaseLayer forModel( private final RectF matteBoundsRect = new RectF(); private final RectF tempMaskBoundsRect = new RectF(); private final String drawTraceName; - final Matrix boundsMatrix = new Matrix(); + protected final Matrix boundsMatrix = new Matrix(); final LottieDrawable lottieDrawable; final Layer layerModel; @Nullable @@ -231,7 +234,7 @@ public void getBounds( } @Override - public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { L.beginSection(drawTraceName); if (!visible || layerModel.isHidden()) { L.endSection(drawTraceName); @@ -266,7 +269,7 @@ public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { if (L.isTraceEnabled()) { L.beginSection("Layer#drawLayer"); } - drawLayer(canvas, matrix, alpha); + drawLayer(canvas, matrix, alpha, shadowToApply); if (L.isTraceEnabled()) { L.endSection("Layer#drawLayer"); } @@ -311,6 +314,7 @@ public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { } contentPaint.setAlpha(255); PaintCompat.setBlendMode(contentPaint, getBlendMode().toNativeBlendMode()); + Utils.saveLayerCompat(canvas, rect, contentPaint); if (L.isTraceEnabled()) { L.endSection("Layer#saveLayer"); @@ -338,7 +342,7 @@ public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { if (L.isTraceEnabled()) { L.beginSection("Layer#drawLayer"); } - drawLayer(canvas, matrix, alpha); + drawLayer(canvas, matrix, alpha, shadowToApply); if (L.isTraceEnabled()) { L.endSection("Layer#drawLayer"); } @@ -358,7 +362,7 @@ public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { } clearCanvas(canvas); //noinspection ConstantConditions - matteLayer.draw(canvas, parentMatrix, alpha); + matteLayer.draw(canvas, parentMatrix, alpha, null); if (L.isTraceEnabled()) { L.beginSection("Layer#restoreLayer"); } @@ -483,7 +487,7 @@ private void intersectBoundsWithMatte(RectF rect, Matrix matrix) { } } - abstract void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha); + abstract void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply); private void applyMasks(Canvas canvas, Matrix matrix) { if (L.isTraceEnabled()) { diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java index 92745ab799..9271fc2918 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/CompositionLayer.java @@ -2,9 +2,7 @@ import android.graphics.Canvas; import android.graphics.Matrix; -import android.graphics.Paint; import android.graphics.RectF; -import android.util.Log; import androidx.annotation.FloatRange; import androidx.annotation.Nullable; @@ -15,9 +13,12 @@ import com.airbnb.lottie.LottieDrawable; import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation; import com.airbnb.lottie.model.KeyPath; import com.airbnb.lottie.model.animatable.AnimatableFloatValue; +import com.airbnb.lottie.utils.DropShadow; +import com.airbnb.lottie.utils.OffscreenLayer; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; @@ -29,7 +30,8 @@ public class CompositionLayer extends BaseLayer { private final List layers = new ArrayList<>(); private final RectF rect = new RectF(); private final RectF newClipRect = new RectF(); - private final Paint layerPaint = new Paint(); + private final OffscreenLayer offscreenLayer = new OffscreenLayer(); + private final OffscreenLayer.ComposeOp offscreenOp = new OffscreenLayer.ComposeOp(); @Nullable private Boolean hasMatte; @Nullable private Boolean hasMasks; @@ -37,6 +39,8 @@ public class CompositionLayer extends BaseLayer { private boolean clipToCompositionBounds = true; + @Nullable private DropShadowKeyframeAnimation dropShadowAnimation; + public CompositionLayer(LottieDrawable lottieDrawable, Layer layerModel, List layerModels, LottieComposition composition) { super(lottieDrawable, layerModel); @@ -90,6 +94,10 @@ public CompositionLayer(LottieDrawable lottieDrawable, Layer layerModel, List 1 && parentAlpha != 255; + boolean hasShadow = parentShadowToApply != null || dropShadowAnimation != null; + boolean isDrawingWithOffScreen = + (lottieDrawable.isApplyingOpacityToLayersEnabled() && layers.size() > 1 && parentAlpha != 255) || + (hasShadow && lottieDrawable.isApplyingShadowToLayersEnabled()); + int childAlpha = isDrawingWithOffScreen ? 255 : parentAlpha; + + // If we've reached this path with a parentShadowToApply, prioritize the one on our own layer, since we can only support one shadow + // at a time for primitive shapes. If applyShadowsToLayers was true, this would never happen, since it forces the next + // parentShadowToApply to be null (see below). + DropShadow shadowToApply = dropShadowAnimation != null + ? dropShadowAnimation.evaluate(parentMatrix, childAlpha) + : parentShadowToApply; + + Canvas targetCanvas = canvas; if (isDrawingWithOffScreen) { - layerPaint.setAlpha(parentAlpha); - Utils.saveLayerCompat(canvas, newClipRect, layerPaint); - } else { - canvas.save(); + offscreenOp.reset(); + offscreenOp.alpha = parentAlpha; + if (shadowToApply != null) { + shadowToApply.applyTo(offscreenOp); + shadowToApply = null; // OffscreenLayer takes care of shadows when we use it + } + targetCanvas = offscreenLayer.start(canvas, newClipRect, offscreenOp); } - int childAlpha = isDrawingWithOffScreen ? 255 : parentAlpha; + canvas.save(); for (int i = layers.size() - 1; i >= 0; i--) { boolean nonEmptyClip = true; // Only clip precomps. This mimics the way After Effects renders animations. boolean ignoreClipOnThisLayer = !clipToCompositionBounds && "__container".equals(layerModel.getName()); if (!ignoreClipOnThisLayer && !newClipRect.isEmpty()) { - nonEmptyClip = canvas.clipRect(newClipRect); + nonEmptyClip = targetCanvas.clipRect(newClipRect); } if (nonEmptyClip) { BaseLayer layer = layers.get(i); - layer.draw(canvas, parentMatrix, childAlpha); + layer.draw(targetCanvas, parentMatrix, childAlpha, shadowToApply); } } + + if (isDrawingWithOffScreen) { + offscreenLayer.finish(); + } canvas.restore(); + if (L.isTraceEnabled()) { L.endSection("CompositionLayer#draw"); } @@ -241,6 +270,16 @@ public void addValueCallback(T property, @Nullable LottieValueCallback ca timeRemapping.addUpdateListener(this); addAnimation(timeRemapping); } + } else if (property == LottieProperty.DROP_SHADOW_COLOR && dropShadowAnimation != null) { + dropShadowAnimation.setColorCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_OPACITY && dropShadowAnimation != null) { + dropShadowAnimation.setOpacityCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_DIRECTION && dropShadowAnimation != null) { + dropShadowAnimation.setDirectionCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_DISTANCE && dropShadowAnimation != null) { + dropShadowAnimation.setDistanceCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_RADIUS && dropShadowAnimation != null) { + dropShadowAnimation.setRadiusCallback((LottieValueCallback) callback); } } } diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/ImageLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/ImageLayer.java index e99a120f9a..7d71917a04 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/ImageLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/ImageLayer.java @@ -18,6 +18,8 @@ import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation; +import com.airbnb.lottie.utils.DropShadow; +import com.airbnb.lottie.utils.OffscreenLayer; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; @@ -30,6 +32,8 @@ public class ImageLayer extends BaseLayer { @Nullable private BaseKeyframeAnimation colorFilterAnimation; @Nullable private BaseKeyframeAnimation imageAnimation; @Nullable private DropShadowKeyframeAnimation dropShadowAnimation; + @Nullable private OffscreenLayer offscreenLayer; + @Nullable private OffscreenLayer.ComposeOp offscreenOp; ImageLayer(LottieDrawable lottieDrawable, Layer layerModel) { super(lottieDrawable, layerModel); @@ -40,7 +44,7 @@ public class ImageLayer extends BaseLayer { } } - @Override public void drawLayer(@NonNull Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void drawLayer(@NonNull Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow parentShadowToApply) { Bitmap bitmap = getBitmap(); if (bitmap == null || bitmap.isRecycled() || lottieImageAsset == null) { return; @@ -51,8 +55,45 @@ public class ImageLayer extends BaseLayer { if (colorFilterAnimation != null) { paint.setColorFilter(colorFilterAnimation.getValue()); } + canvas.save(); canvas.concat(parentMatrix); + + DropShadow shadowToApply = dropShadowAnimation != null + ? dropShadowAnimation.evaluate(parentMatrix, parentAlpha) + : parentShadowToApply; + + // Render off-screen if we have a drop shadow, because: + // - Android does not apply drop-shadows to bitmaps properly in HW accelerated contexts (blur is ignored) + // - On some newer phones (empirically verified), no shadow is rendered at all even in software contexts. + boolean renderOffScreen = shadowToApply != null; + Canvas targetCanvas = canvas; + if (renderOffScreen) { + if (offscreenLayer == null) offscreenLayer = new OffscreenLayer(); + if (offscreenOp == null) offscreenOp = new OffscreenLayer.ComposeOp(); + offscreenOp.reset(); + if (shadowToApply != null) { + // We don't use offscreenOp for compositing here, so we still need to account for its alpha + // when drawing the shadow. + shadowToApply.applyWithAlpha(parentAlpha, offscreenOp); + } else { + offscreenOp.shadow = null; + } + + RectF bounds = new RectF(0, 0, 0, 0); + getBounds(bounds, parentMatrix, true); + + // Unlike shapes and other layer types, we draw images by first applying the parentMatrix to the + // screen and then drawing an untransformed image. We also apply density scaling manually. + // To replicate this in an OffscreenLayer, we have to let it know about the density multiply + // beforehand, so it know how big of a bitmap it *really* needs to allocate for crisp drawing. + // Then, we undo it on the returned targetCanvas. + canvas.scale(density, density); + RectF scaledBounds = new RectF(0, 0, bounds.width() * density, bounds.height() * density); + targetCanvas = offscreenLayer.start(canvas, scaledBounds, offscreenOp); + targetCanvas.scale(1.0f / density, 1.0f / density); + } + src.set(0, 0, bitmap.getWidth(), bitmap.getHeight()); if (lottieDrawable.getMaintainOriginalImageBounds()) { dst.set(0, 0, (int) (lottieImageAsset.getWidth() * density), (int) (lottieImageAsset.getHeight() * density)); @@ -60,11 +101,12 @@ public class ImageLayer extends BaseLayer { dst.set(0, 0, (int) (bitmap.getWidth() * density), (int) (bitmap.getHeight() * density)); } - if (dropShadowAnimation != null) { - dropShadowAnimation.applyTo(paint, parentMatrix, parentAlpha); + targetCanvas.drawBitmap(bitmap, src, dst, paint); + + if (renderOffScreen) { + offscreenLayer.finish(); } - canvas.drawBitmap(bitmap, src, dst, paint); canvas.restore(); } @@ -117,6 +159,16 @@ public void addValueCallback(T property, @Nullable LottieValueCallback ca imageAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback) callback); } + } else if (property == LottieProperty.DROP_SHADOW_COLOR && dropShadowAnimation != null) { + dropShadowAnimation.setColorCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_OPACITY && dropShadowAnimation != null) { + dropShadowAnimation.setOpacityCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_DIRECTION && dropShadowAnimation != null) { + dropShadowAnimation.setDirectionCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_DISTANCE && dropShadowAnimation != null) { + dropShadowAnimation.setDistanceCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_RADIUS && dropShadowAnimation != null) { + dropShadowAnimation.setRadiusCallback((LottieValueCallback) callback); } } } diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/NullLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/NullLayer.java index 045cabfed4..9aece053bb 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/NullLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/NullLayer.java @@ -4,14 +4,17 @@ import android.graphics.Matrix; import android.graphics.RectF; +import androidx.annotation.Nullable; import com.airbnb.lottie.LottieDrawable; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; +import com.airbnb.lottie.utils.DropShadow; public class NullLayer extends BaseLayer { NullLayer(LottieDrawable lottieDrawable, Layer layerModel) { super(lottieDrawable, layerModel); } - @Override void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { // Do nothing. } diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/ShapeLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/ShapeLayer.java index 3a82138f2c..33ffab8282 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/ShapeLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/ShapeLayer.java @@ -4,17 +4,22 @@ import android.graphics.Matrix; import android.graphics.RectF; +import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.airbnb.lottie.LottieComposition; import com.airbnb.lottie.LottieDrawable; +import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.animation.content.Content; import com.airbnb.lottie.animation.content.ContentGroup; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.model.KeyPath; import com.airbnb.lottie.model.content.BlurEffect; import com.airbnb.lottie.model.content.ShapeGroup; import com.airbnb.lottie.parser.DropShadowEffect; +import com.airbnb.lottie.utils.DropShadow; +import com.airbnb.lottie.value.LottieValueCallback; import java.util.Collections; import java.util.List; @@ -23,6 +28,8 @@ public class ShapeLayer extends BaseLayer { private final ContentGroup contentGroup; private final CompositionLayer compositionLayer; + @Nullable private DropShadowKeyframeAnimation dropShadowAnimation; + ShapeLayer(LottieDrawable lottieDrawable, Layer layerModel, CompositionLayer compositionLayer, LottieComposition composition) { super(lottieDrawable, layerModel); this.compositionLayer = compositionLayer; @@ -31,10 +38,18 @@ public class ShapeLayer extends BaseLayer { ShapeGroup shapeGroup = new ShapeGroup("__container", layerModel.getShapes(), false); contentGroup = new ContentGroup(lottieDrawable, this, shapeGroup, composition); contentGroup.setContents(Collections.emptyList(), Collections.emptyList()); + + if (getDropShadowEffect() != null) { + dropShadowAnimation = new DropShadowKeyframeAnimation(this, this, getDropShadowEffect()); + } } - @Override void drawLayer(@NonNull Canvas canvas, Matrix parentMatrix, int parentAlpha) { - contentGroup.draw(canvas, parentMatrix, parentAlpha); + @Override void drawLayer(@NonNull Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow parentShadowToApply) { + // If a parent composition layer has a shadow and we have one too, prioritize our own. + DropShadow shadowToApply = dropShadowAnimation != null + ? dropShadowAnimation.evaluate(parentMatrix, parentAlpha) + : parentShadowToApply; + contentGroup.draw(canvas, parentMatrix, parentAlpha, shadowToApply); } @Override public void getBounds(RectF outBounds, Matrix parentMatrix, boolean applyParents) { @@ -50,17 +65,26 @@ public class ShapeLayer extends BaseLayer { return compositionLayer.getBlurEffect(); } - @Nullable @Override public DropShadowEffect getDropShadowEffect() { - DropShadowEffect layerDropShadow = super.getDropShadowEffect(); - if (layerDropShadow != null) { - return layerDropShadow; - } - return compositionLayer.getDropShadowEffect(); - } - @Override protected void resolveChildKeyPath(KeyPath keyPath, int depth, List accumulator, KeyPath currentPartialKeyPath) { contentGroup.resolveKeyPath(keyPath, depth, accumulator, currentPartialKeyPath); } + + @Override + @CallSuper + public void addValueCallback(T property, @Nullable LottieValueCallback callback) { + super.addValueCallback(property, callback); + if (property == LottieProperty.DROP_SHADOW_COLOR && dropShadowAnimation != null) { + dropShadowAnimation.setColorCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_OPACITY && dropShadowAnimation != null) { + dropShadowAnimation.setOpacityCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_DIRECTION && dropShadowAnimation != null) { + dropShadowAnimation.setDirectionCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_DISTANCE && dropShadowAnimation != null) { + dropShadowAnimation.setDistanceCallback((LottieValueCallback) callback); + } else if (property == LottieProperty.DROP_SHADOW_RADIUS && dropShadowAnimation != null) { + dropShadowAnimation.setRadiusCallback((LottieValueCallback) callback); + } + } } diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/SolidLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/SolidLayer.java index 19e7d53bd3..463fd4df59 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/SolidLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/SolidLayer.java @@ -14,7 +14,9 @@ import com.airbnb.lottie.LottieProperty; import com.airbnb.lottie.animation.LPaint; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.value.LottieValueCallback; public class SolidLayer extends BaseLayer { @@ -36,7 +38,7 @@ public class SolidLayer extends BaseLayer { paint.setColor(layerModel.getSolidColor()); } - @Override public void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + @Override public void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply) { int backgroundAlpha = Color.alpha(layerModel.getSolidColor()); if (backgroundAlpha == 0) { return; @@ -52,6 +54,11 @@ public class SolidLayer extends BaseLayer { int opacity = transform.getOpacity() == null ? 100 : transform.getOpacity().getValue(); int alpha = (int) (parentAlpha / 255f * (backgroundAlpha / 255f * opacity / 100f) * 255); paint.setAlpha(alpha); + if (shadowToApply != null) { + shadowToApply.applyTo(paint); + } else { + paint.clearShadowLayer(); + } if (colorFilterAnimation != null) { paint.setColorFilter(colorFilterAnimation.getValue()); diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java index 309950cec8..bc7ec223cd 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java @@ -18,6 +18,7 @@ import com.airbnb.lottie.TextDelegate; import com.airbnb.lottie.animation.content.ContentGroup; import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation; +import com.airbnb.lottie.animation.keyframe.DropShadowKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.TextKeyframeAnimation; import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation; import com.airbnb.lottie.model.DocumentData; @@ -26,6 +27,7 @@ import com.airbnb.lottie.model.animatable.AnimatableTextProperties; import com.airbnb.lottie.model.content.ShapeGroup; import com.airbnb.lottie.model.content.TextRangeUnits; +import com.airbnb.lottie.utils.DropShadow; import com.airbnb.lottie.utils.Utils; import com.airbnb.lottie.value.LottieValueCallback; @@ -158,7 +160,7 @@ public void getBounds(RectF outBounds, Matrix parentMatrix, boolean applyParents } @Override - void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) { + void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha, @Nullable DropShadow shadowToApply /* ignored for now */) { DocumentData documentData = textAnimation.getValue(); Font font = composition.getFonts().get(documentData.fontName); if (font == null) { diff --git a/lottie/src/main/java/com/airbnb/lottie/utils/DropShadow.java b/lottie/src/main/java/com/airbnb/lottie/utils/DropShadow.java new file mode 100644 index 0000000000..e23d70ad09 --- /dev/null +++ b/lottie/src/main/java/com/airbnb/lottie/utils/DropShadow.java @@ -0,0 +1,128 @@ +package com.airbnb.lottie.utils; + +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import androidx.annotation.Nullable; + +/** + * Settings for a drop shadow to apply. + */ +public class DropShadow { + private float radius = 0.0f; + private float dx = 0.0f; + private float dy = 0.0f; + private int color = 0x0; + + public DropShadow() { + } + + public DropShadow(float radius, float dx, float dy, int color) { + this.radius = radius; + this.dx = dx; + this.dy = dy; + this.color = color; + } + + public DropShadow(DropShadow other) { + this.radius = other.radius; + this.dx = other.dx; + this.dy = other.dy; + this.color = other.color; + } + + public float getRadius() { + return radius; + } + + public void setRadius(float radius) { + this.radius = radius; + } + + public float getDx() { + return dx; + } + + public void setDx(float dx) { + this.dx = dx; + } + + public float getDy() { + return dy; + } + + public void setDy(float dy) { + this.dy = dy; + } + + public int getColor() { + return color; + } + + public void setColor(int color) { + this.color = color; + } + + public boolean sameAs(DropShadow other) { + return radius == other.radius && dx == other.dx && dy == other.dy && color == other.color; + } + + public void transformBy(Matrix matrix) { + float[] vecs = {dx, dy}; + matrix.mapVectors(vecs); + + dx = vecs[0]; + dy = vecs[1]; + radius = matrix.mapRadius(radius); + } + + public void multiplyOpacity(int newAlpha) { + int opacity = Math.round(Color.alpha(color) * MiscUtils.clamp(newAlpha, 0, 255) / 255f); + color = Color.argb(opacity, Color.red(color), Color.green(color), Color.blue(color)); + } + + /** + * Applies a shadow to the provided Paint object. + */ + public void applyTo(Paint paint) { + if (Color.alpha(color) > 0) { + // Paint.setShadowLayer() removes the shadow if radius is 0, so we use a small nonzero value in that case + paint.setShadowLayer(Math.max(radius, Float.MIN_VALUE), dx, dy, color); + } else { + paint.clearShadowLayer(); + } + } + + /** + * Applies a shadow to the provided Paint object, mixing its alpha by the provided value. + */ + public void applyWithAlpha(int alpha, Paint paint) { + int finalAlpha = Utils.mixOpacities(Color.alpha(color), MiscUtils.clamp(alpha, 0, 255)); + if (finalAlpha > 0) { + int newColor = Color.argb(finalAlpha, Color.red(color), Color.green(color), Color.blue(color)); + // Paint.setShadowLayer() removes the shadow if radius is 0, so we use a small nonzero value in that case + paint.setShadowLayer(Math.max(radius, Float.MIN_VALUE), dx, dy, newColor); + } else { + paint.clearShadowLayer(); + } + } + + /** + * Applies a shadow to the provided ComposeOp, mixing its alpha by the provided value. + */ + public void applyWithAlpha(int alpha, OffscreenLayer.ComposeOp op) { + op.shadow = new DropShadow(this); + op.shadow.multiplyOpacity(alpha); + } + + /** + * Applies a shadow to the provided ComposeOp, to be used with OffscreenLayer. + */ + public void applyTo(OffscreenLayer.ComposeOp op) { + if (Color.alpha(color) > 0) { + op.shadow = this; + } else { + op.shadow = null; + } + } +} diff --git a/lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java b/lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java new file mode 100644 index 0000000000..20359cbd09 --- /dev/null +++ b/lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java @@ -0,0 +1,494 @@ +package com.airbnb.lottie.utils; + +import android.graphics.Bitmap; +import android.graphics.BlendMode; +import android.graphics.BlurMaskFilter; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.RenderEffect; +import android.graphics.RenderNode; +import android.graphics.Shader; +import android.os.Build; +import androidx.annotation.Nullable; +import androidx.core.graphics.BlendModeCompat; +import androidx.core.graphics.PaintCompat; +import com.airbnb.lottie.animation.LPaint; + +/** + * An OffscreenLayer encapsulates a "child surface" onto which canvas draw calls can be issued. + * At the end, the result of these draw calls will be composited onto the parent surface, with + * user-provided alpha, blend mode, color filter, and drop shadow. + * + * To use the OffscreenLayer, call its start() method with the necessary parameters, draw + * to the returned canvas, and then call finish() to composite the result onto the main canvas. + * + * In this sense, using an OffscreenLayer is very similar to the Canvas.saveLayer() family + * of functions, and in fact forwards to Canvas.saveLayer() when appropriate. + * + * Unlike Canvas.saveLayer(), an OffscreenLayer also supports compositing with a drop-shadow, + * and uses a hardware-accelerated target when available. It attempts to choose the fastest + * approach to render and composite the contents, up to and including simply rendering + * directly, if possible. + */ +public class OffscreenLayer { + /** + * Encapsulates configuration for compositing the layer contents on the parent. Similar to + * Paint, but only includes operations that the OffscreenLayer can support. + */ + public static class ComposeOp { + public int alpha; + @Nullable public BlendModeCompat blendMode; + @Nullable public ColorFilter colorFilter; + @Nullable public DropShadow shadow; + + public ComposeOp() { + reset(); + } + + public ComposeOp(int alpha, BlendModeCompat blendMode, DropShadow shadow) { + this.alpha = alpha; + this.blendMode = blendMode; + this.colorFilter = colorFilter; + this.shadow = shadow; + } + + public boolean isTranslucent() { + return alpha < 255; + } + + public boolean hasBlendMode() { + return blendMode != null && blendMode != BlendModeCompat.SRC_OVER; + } + + public boolean hasShadow() { + return shadow != null; + } + + public boolean hasColorFilter() { + return colorFilter != null; + } + + public boolean isNoop() { + return !isTranslucent() && !hasBlendMode() && !hasShadow() && !hasColorFilter(); + } + + public void reset() { + alpha = 255; + blendMode = null; + colorFilter = null; + shadow = null; + } + } + + protected enum RenderStrategy { + /** No-op: simply render to the underlying canvas directly. */ + DIRECT, + /** Use Canvas.saveLayer() and compose using Canvas.restore(). */ + SAVE_LAYER, + /** Render everything onto an off-screen bitmap and then draw it onto the main canvas */ + BITMAP, + /** Render into a RenderNode's display-list and then draw the render node. (Hardware accelerated) */ + RENDER_NODE + }; + + /** Parent render surface should compose onto. null if no rendering is in progress. */ + @Nullable private Canvas parentCanvas; + /** Configuration for compositing the layer onto parentCanvas */ + @Nullable private ComposeOp op; + /** Strategy that we've chosen for rendering this pass */ + private RenderStrategy currentStrategy; + /** Rectangle that the final composition will occupy in the screen */ + @Nullable private Rect targetRect; + + // For RenderStrategy.SAVE_LAYER: + /** Paint passed to Utils.saveLayerCompat(). */ + @Nullable private Paint composePaint; + + // For RenderStrategy.BITMAP: + @Nullable private Bitmap bitmap; + @Nullable private Canvas bitmapCanvas; + private LPaint clearPaint; + + /** parentCanvas' pre-existing matrix when start() was called */ + @Nullable float[] preExistingTransform; + + // Android doesn't render shadows on arbitrary bitmaps correctly. Instead, if we're using + // render-to-bitmap, we have to draw them manually. To do so, we need an additional bitmap, an + // associated canvas, paint, and some other data, which we define as members to avoid + // reallocation. (And also because in the render-node case, we won't need them.) + @Nullable private Bitmap shadowBitmap; + @Nullable private Bitmap shadowMaskBitmap; + @Nullable private Canvas shadowBitmapCanvas; + @Nullable private Canvas shadowMaskBitmapCanvas; + @Nullable private LPaint shadowPaint; + @Nullable private BlurMaskFilter shadowBlurFilter; + private float lastShadowBlurRadius = 0.0f; + + // For RenderStrategy.RENDER_NODE: + @Nullable private RenderNode renderNode; // Render node with the initial contents of the layer + @Nullable private RenderNode shadowRenderNode; // Render node for the shadow + @Nullable private Canvas renderNodeCanvas; + @Nullable private DropShadow lastRenderNodeShadow; + + public OffscreenLayer() { + this.clearPaint = new LPaint(); + this.clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + } + + private RenderStrategy chooseRenderStrategy(Canvas parentCanvas, RectF bounds, ComposeOp op) { + if (op.isNoop()) { + // Can draw directly onto the final canvas, results will look the same. + return RenderStrategy.DIRECT; + } + + if (!op.hasShadow()) { + // Canvas.saveLayer() supports alpha-compositing, blend modes, and color filters, which is + // sufficient for this case, and is faster than manually maintaining an off-screen bitmap. + // It is not clear if it's faster than RENDER_NODE and when, but this is what we've been + // doing prior to OffscreenLayer, so keep that behavior to be safe and avoid regressions. + return RenderStrategy.SAVE_LAYER; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && parentCanvas.isHardwareAccelerated()) { + if (op.hasShadow() && Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) { + // RenderEffect, which we need for shadows, was only introduced in S. This means that, pre-S, + // we have to do renderShadow() on a bitmap, like in the BITMAP case. However, since it's not + // possible to draw a RenderNode to a software-rendering canvas, there would be no way to get + // our RenderNode contents onto a bitmap where we can renderShadow(). Therefore, fall back to + // full bitmap mode. + return RenderStrategy.BITMAP; + } + + return RenderStrategy.RENDER_NODE; + } + + // Slowest path: render to a bitmap, add a shadow manually. + return RenderStrategy.BITMAP; + } + + private Bitmap allocateBitmap(RectF bounds, Bitmap.Config cfg) { + // Add 5% to the width and height before allocating. This avoids repeated + // reallocations in the worst cases of a bitmap growing every frame, while + // still being relatively speedy to blit and operate on. + int width = (int)Math.ceil(bounds.width() * 1.05); + int height = (int)Math.ceil(bounds.height() * 1.05); + Bitmap bmp = Bitmap.createBitmap(width, height, cfg); + return bmp; + } + + private void deallocateBitmap(Bitmap bitmap) { + bitmap.recycle(); + } + + private boolean needNewBitmap(@Nullable Bitmap bitmap, RectF bounds) { + if (bitmap == null) { + return true; + } + + if (bounds.width() >= bitmap.getWidth() || bounds.height() >= bitmap.getHeight()) { + return true; + } + + // If the required area has reduced in size considerably, trigger a reallocation, since + // we might be paying a large unnecessary penalty to work with a bitmap that big. + if (bounds.width() < bitmap.getWidth() * 0.75f || bounds.height() < bitmap.getHeight() * 0.75f) { + return true; + } + + return false; + } + + public Canvas start(Canvas parentCanvas, RectF bounds, ComposeOp op) { + if (this.parentCanvas != null) { + throw new RuntimeException("Cannot nest start() calls on a single OffscreenBitmap - call finish() first"); + } + + // Determine the scaling applied by the parentCanvas' pre-existing transform matrix. This is an optimization + // to avoid creating bitmaps (or render nodes) with unreasonable sizes that will get scaled down when drawn + // onto parentCanvas anyhow. + if (preExistingTransform == null) preExistingTransform = new float[9]; + parentCanvas.getMatrix().getValues(preExistingTransform); + + float pixelScaleX = preExistingTransform[Matrix.MSCALE_X]; + float pixelScaleY = preExistingTransform[Matrix.MSCALE_Y]; + + RectF scaledBounds = new RectF( + bounds.left * pixelScaleX, + bounds.top * pixelScaleY, + bounds.right * pixelScaleX, + bounds.bottom * pixelScaleY + ); + + this.parentCanvas = parentCanvas; + this.op = op; + this.currentStrategy = chooseRenderStrategy(parentCanvas, bounds, op); + this.targetRect = new Rect((int)bounds.left, (int)bounds.top, (int)bounds.right, (int)bounds.bottom); + + if (composePaint == null) composePaint = new LPaint(); + composePaint.reset(); + + Canvas childCanvas; + switch (currentStrategy) { + case DIRECT: + childCanvas = parentCanvas; + childCanvas.save(); + break; + + case SAVE_LAYER: + // Paint to use for composition + composePaint.setAlpha(op.alpha); + composePaint.setColorFilter(op.colorFilter); + if (op.hasBlendMode()) { + PaintCompat.setBlendMode(composePaint, op.blendMode); + } + + // This adds an entry in the parentCanvas stack, will be popped and composited + // when restore() is called + Utils.saveLayerCompat(parentCanvas, bounds, composePaint); + childCanvas = parentCanvas; + break; + + case BITMAP: + if (needNewBitmap(bitmap, scaledBounds)) { + if (bitmap != null) { + deallocateBitmap(bitmap); + } + bitmap = allocateBitmap(scaledBounds, Bitmap.Config.ARGB_8888); + bitmapCanvas = new Canvas(bitmap); + } else { + bitmapCanvas.setMatrix(new Matrix()); + bitmapCanvas.drawRect(-1, -1, scaledBounds.width() + 1, scaledBounds.height() + 1, this.clearPaint); + } + + PaintCompat.setBlendMode(composePaint, op.blendMode); + composePaint.setColorFilter(op.colorFilter); + composePaint.setAlpha(op.alpha); + + childCanvas = bitmapCanvas; + childCanvas.scale(pixelScaleX, pixelScaleY); // Replicate scaling applied by parentCanvas + childCanvas.translate(-bounds.left, -bounds.top); // So that the image begins at the top-left of the bitmap + break; + + case RENDER_NODE: + if (renderNode == null) renderNode = new RenderNode("OffscreenLayer.main"); + if (op.hasShadow() && shadowRenderNode == null) { + shadowRenderNode = new RenderNode("OffscreenLayer.shadow"); + lastRenderNodeShadow = null; + } + + // The render node needs some data in advance + if (op.hasBlendMode() || op.hasColorFilter()) { + if (composePaint == null) composePaint = new LPaint(); + composePaint.reset(); + PaintCompat.setBlendMode(composePaint, op.blendMode); + composePaint.setColorFilter(op.colorFilter); + renderNode.setUseCompositingLayer(true, composePaint); + + if (op.hasShadow()) { + shadowRenderNode.setUseCompositingLayer(true, composePaint); + } + } + renderNode.setAlpha(op.alpha / 255.f); + if (op.hasShadow()) { + // lottie-web composes the shadow onto the canvas first, and then the + // contents separately - mirror this behavior + shadowRenderNode.setAlpha(op.alpha / 255.f); + } + renderNode.setHasOverlappingRendering(true); + renderNode.setPosition((int)scaledBounds.left, (int)scaledBounds.top, (int)scaledBounds.right, (int)scaledBounds.bottom); + + renderNodeCanvas = renderNode.beginRecording((int)scaledBounds.width(), (int)scaledBounds.height()); + + childCanvas = renderNodeCanvas; + childCanvas.setMatrix(new Matrix()); + childCanvas.scale(pixelScaleX, pixelScaleY); // Replicate scaling applied by parentCanvas + childCanvas.translate(-bounds.left, -bounds.top); // So that the image begins at the top-left of the bitmap + break; + + default: + throw new RuntimeException("Invalid render strategy for OffscreenLayer"); + } + + return childCanvas; + } + + public void finish() { + if (parentCanvas == null) { + throw new RuntimeException("OffscreenBitmap: finish() call without matching start()"); + } + + switch (currentStrategy) { + case DIRECT: + parentCanvas.restore(); + break; + + case SAVE_LAYER: + parentCanvas.restore(); + break; + + case BITMAP: + if (op.hasShadow()) { + // Composing the shadow first and then the content like this will be incorrect in the + // presence of op.blendMode. However, that is not used at the moment, so this + // optimization is safe. (Otherwise, we'd have to have another bitmap here for the + // intermediate result.) + renderBitmapShadow(parentCanvas, op.shadow); + } + + parentCanvas.drawBitmap(bitmap, new Rect(0, 0, (int)(targetRect.width() * preExistingTransform[Matrix.MSCALE_X]), (int)(targetRect.height() * preExistingTransform[Matrix.MSCALE_Y])), this.targetRect, composePaint); + break; + + case RENDER_NODE: + parentCanvas.save(); + parentCanvas.scale(1.0f / preExistingTransform[Matrix.MSCALE_X], 1.0f / preExistingTransform[Matrix.MSCALE_Y]); + renderNode.endRecording(); + if (op.hasShadow()) { + // Composing the shadow first and then the content like this will be incorrect in the + // presence of op.blendMode. However, that is not used at the moment, so this + // optimization is safe. (Otherwise, we'd have to have another render node here for the + // intermediate result.) + renderHardwareShadow(parentCanvas, op.shadow); + } + parentCanvas.drawRenderNode(renderNode); + parentCanvas.restore(); + break; + } + + parentCanvas = null; + } + + private RectF calculateRectIncludingShadow(Rect rect, DropShadow shadow) { + RectF newRect = new RectF(rect); + newRect.offsetTo(rect.left + shadow.getDx(), rect.top + shadow.getDy()); + newRect.inset(-shadow.getRadius(), -shadow.getRadius()); + newRect.union(new RectF(rect)); + return newRect; + } + + /** Renders a shadow (only the shadow) of this.bitmap to the provided canvas. */ + private void renderBitmapShadow(Canvas targetCanvas, DropShadow shadow) { + // This is an expanded rect that encompasses the full extent of the shadow. + RectF rectIncludingShadow = calculateRectIncludingShadow(targetRect, shadow); + Rect intRectIncludingShadow = new Rect( + (int)Math.floor(rectIncludingShadow.left), + (int)Math.floor(rectIncludingShadow.top), + (int)Math.ceil(rectIncludingShadow.right), + (int)Math.ceil(rectIncludingShadow.bottom) + ); + float pixelScaleX = preExistingTransform[Matrix.MSCALE_X]; + float pixelScaleY = preExistingTransform[Matrix.MSCALE_Y]; + RectF scaledRectIncludingShadow = new RectF( + rectIncludingShadow.left * pixelScaleX, + rectIncludingShadow.top * pixelScaleY, + rectIncludingShadow.right * pixelScaleX, + rectIncludingShadow.bottom * pixelScaleY + ); + + Rect shadowBitmapSrcRect = new Rect(0, 0, (int)scaledRectIncludingShadow.width(), (int)scaledRectIncludingShadow.height());; + if (needNewBitmap(shadowBitmap, scaledRectIncludingShadow)) { + if (shadowBitmap != null) { + deallocateBitmap(shadowBitmap); + deallocateBitmap(shadowMaskBitmap); + } + + shadowBitmap = allocateBitmap(scaledRectIncludingShadow, Bitmap.Config.ARGB_8888); + shadowMaskBitmap = allocateBitmap(scaledRectIncludingShadow, Bitmap.Config.ALPHA_8); + shadowBitmapCanvas = new Canvas(shadowBitmap); + shadowMaskBitmapCanvas = new Canvas(shadowMaskBitmap); + } else { + shadowBitmapCanvas.drawRect(shadowBitmapSrcRect, clearPaint); + shadowMaskBitmapCanvas.drawRect(shadowBitmapSrcRect, clearPaint); + } + + if (shadowPaint == null) { + shadowPaint = new LPaint(Paint.ANTI_ALIAS_FLAG); + } + + // This is the offset of targetRect inside rectIncludingShadow + float offsetX = targetRect.left - rectIncludingShadow.left; + float offsetY = targetRect.top - rectIncludingShadow.top; + + // Draw the image onto the mask layer first. Since the mask layer is ALPHA_8, this discards color information. + // Align it so that when drawn in the end, it originates at targetRect.x, targetRect.y + // the int casts are very important here - they save us from some slow path for non-integer coords + shadowMaskBitmapCanvas.drawBitmap(bitmap, (int)(offsetX * pixelScaleX), (int)(offsetY * pixelScaleY), null); + + // Prepare the shadow paint. This is the paint that will perform a blur and a tint of the mask + if (shadowBlurFilter == null || lastShadowBlurRadius != shadow.getRadius()) { + float scaledRadius = shadow.getRadius() * (pixelScaleX + pixelScaleY) / 2.0f; + if (scaledRadius > 0) { + shadowBlurFilter = new BlurMaskFilter(scaledRadius, BlurMaskFilter.Blur.NORMAL); + } else { + shadowBlurFilter = null; + } + + lastShadowBlurRadius = shadow.getRadius(); + } + shadowPaint.setColor(shadow.getColor()); + if (shadow.getRadius() > 0.0f) { + shadowPaint.setMaskFilter(shadowBlurFilter); + } else { + shadowPaint.setMaskFilter(null); + } + shadowPaint.setFilterBitmap(true); + + // Draw the mask onto our shadowBitmap with the shadowPaint. This bitmap now contains the final + // look of the shadow, correctly positioned inside a rectIncludingShadow-sized area + // the int casts are very important here - they save us from some slow path for non-integer coords + shadowBitmapCanvas.drawBitmap(shadowMaskBitmap, (int)(shadow.getDx() * pixelScaleX), (int)(shadow.getDy() * pixelScaleY), shadowPaint); + + // Now blit the result onto the final canvas. It might be tempting to skip shadowBitmap and draw the mask + // directly onto the canvas with shadowPaint, but this breaks the blur, since Paint.setMaskFilter() is not + // supported on hardware canvases. + targetCanvas.drawBitmap(shadowBitmap, shadowBitmapSrcRect, intRectIncludingShadow, composePaint); + } + + /** Renders a shadow (only the shadow) of this.renderNode to the provided canvas. */ + private void renderHardwareShadow(Canvas targetCanvas, DropShadow shadow) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + throw new RuntimeException("RenderEffect is not supported on API level <31"); + } + + // The render node canvas is in the pre-pixelscale space, so we have to scale the shadow parameters + // by the pixel scale + float pixelScaleX = preExistingTransform[Matrix.MSCALE_X]; + float pixelScaleY = preExistingTransform[Matrix.MSCALE_Y]; + + if (lastRenderNodeShadow == null || !shadow.sameAs(lastRenderNodeShadow)) { + RenderEffect effect = RenderEffect.createColorFilterEffect(new PorterDuffColorFilter(shadow.getColor(), PorterDuff.Mode.SRC_IN)); + if (shadow.getRadius() > 0.0f) { + float scaledRadius = shadow.getRadius() * (pixelScaleX + pixelScaleY) / 2.0f; + effect = RenderEffect.createBlurEffect(scaledRadius, scaledRadius, effect, Shader.TileMode.CLAMP); + } + shadowRenderNode.setRenderEffect(effect); + lastRenderNodeShadow = shadow; + } + + RectF rectIncludingShadow = calculateRectIncludingShadow(targetRect, shadow); + RectF scaledRectIncludingShadow = new RectF( + rectIncludingShadow.left * pixelScaleX, + rectIncludingShadow.top * pixelScaleY, + rectIncludingShadow.right * pixelScaleX, + rectIncludingShadow.bottom * pixelScaleY + ); + + shadowRenderNode.setPosition(0, 0, (int)scaledRectIncludingShadow.width(), (int)scaledRectIncludingShadow.height()); + Canvas shadowCanvas = shadowRenderNode.beginRecording((int)scaledRectIncludingShadow.width(), (int)scaledRectIncludingShadow.height()); + // Offset so that the image starts at the top left, and then offset by shadow displacement + shadowCanvas.translate(-scaledRectIncludingShadow.left + shadow.getDx() * pixelScaleX, -scaledRectIncludingShadow.top + shadow.getDy() * pixelScaleY); + shadowCanvas.drawRenderNode(renderNode); + shadowRenderNode.endRecording(); + + targetCanvas.save(); + targetCanvas.translate(scaledRectIncludingShadow.left, scaledRectIncludingShadow.top); + targetCanvas.drawRenderNode(shadowRenderNode); + targetCanvas.restore(); + } +} diff --git a/lottie/src/main/res/values/attrs.xml b/lottie/src/main/res/values/attrs.xml index d9299f36ff..be1fdda307 100644 --- a/lottie/src/main/res/values/attrs.xml +++ b/lottie/src/main/res/values/attrs.xml @@ -17,6 +17,8 @@ + +