Skip to content

Commit

Permalink
Allow Lottie to render the full animation, even if it extends beyond …
Browse files Browse the repository at this point in the history
…the original composition bounds (#1993)

Since the beginning of time, Lottie has only rendered the bounds of the original composition. This PR adds a new API to enable rendering the full animation, even if it extends beyond the original composition bounds.

This API defaults to off to retain backwards compatibility.

Fixes #1825
  • Loading branch information
gpeal committed Jan 16, 2022
1 parent b348862 commit 700aace
Show file tree
Hide file tree
Showing 9 changed files with 320 additions and 75 deletions.
Expand Up @@ -59,6 +59,7 @@ import kotlin.math.roundToInt
* @param alignment Define where the animation should be placed within this composable if it has a different
* size than this composable.
* @param contentScale Define how the animation should be scaled if it has a different size than this Composable.
* @param clipToComposition Determines whether or not Lottie will clip the animation to the original animation composition bounds.
*/
@Composable
fun LottieAnimation(
Expand All @@ -72,6 +73,7 @@ fun LottieAnimation(
dynamicProperties: LottieDynamicProperties? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
clipToComposition: Boolean = true,
) {
val drawable = remember { LottieDrawable() }
val matrix = remember { Matrix() }
Expand Down Expand Up @@ -106,6 +108,7 @@ fun LottieAnimation(
drawable.isApplyingOpacityToLayersEnabled = applyOpacityToLayers
drawable.enableMergePathsForKitKatAndAbove(enableMergePaths)
drawable.useSoftwareRendering(useSoftwareRendering)
drawable.clipToCompositionBounds = clipToComposition
drawable.progress = progress
drawable.setBounds(0, 0, composition.bounds.width(), composition.bounds.height())
drawable.draw(canvas.nativeCanvas, matrix)
Expand Down Expand Up @@ -136,6 +139,7 @@ fun LottieAnimation(
dynamicProperties: LottieDynamicProperties? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
clipToComposition: Boolean = true,
) {
val progress by animateLottieCompositionAsState(
composition,
Expand All @@ -156,6 +160,7 @@ fun LottieAnimation(
dynamicProperties,
alignment,
contentScale,
clipToComposition,
)
}

Expand Down
24 changes: 24 additions & 0 deletions lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
Expand Up @@ -184,6 +184,10 @@ private void init(@Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
setSpeed(ta.getFloat(R.styleable.LottieAnimationView_lottie_speed, 1f));
}

if (ta.hasValue(R.styleable.LottieAnimationView_lottie_clipToCompositionBounds)) {
setClipToCompositionBounds(ta.getBoolean(R.styleable.LottieAnimationView_lottie_clipToCompositionBounds, true));
}

setImageAssetsFolder(ta.getString(R.styleable.LottieAnimationView_lottie_imageAssetsFolder));
setProgress(ta.getFloat(R.styleable.LottieAnimationView_lottie_progress, 0));
enableMergePathsForKitKatAndAbove(ta.getBoolean(
Expand Down Expand Up @@ -332,6 +336,26 @@ public boolean isMergePathsEnabledForKitKatAndAbove() {
return lottieDrawable.isMergePathsEnabledForKitKatAndAbove();
}

/**
* Sets whether or not Lottie should clip to the original animation composition bounds.
*
* When set to true, the parent view may need to disable clipChildren so Lottie can render outside of the LottieAnimationView bounds.
*
* Defaults to true.
*/
public void setClipToCompositionBounds(boolean clipToCompositionBounds) {
lottieDrawable.setClipToCompositionBounds(clipToCompositionBounds);
}

/**
* Gets whether or not Lottie should clip to the original animation composition bounds.
*
* Defaults to true.
*/
public boolean getClipToCompositionBounds() {
return lottieDrawable.getClipToCompositionBounds();
}

/**
* If set to true, all future compositions that are set will be cached so that they don't need to be parsed
* next time they are loaded. This won't apply to compositions that have already been loaded.
Expand Down
70 changes: 54 additions & 16 deletions lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
Expand Up @@ -112,6 +112,7 @@ public void onAnimationUpdate(ValueAnimator animation) {
@Nullable
TextDelegate textDelegate;
private boolean enableMergePaths;
private boolean clipToCompositionBounds = true;
@Nullable
private CompositionLayer compositionLayer;
private int alpha = 255;
Expand Down Expand Up @@ -208,6 +209,27 @@ public boolean isMergePathsEnabledForKitKatAndAbove() {
return enableMergePaths;
}

/**
* Sets whether or not Lottie should clip to the original animation composition bounds.
*
* Defaults to true.
*/
public void setClipToCompositionBounds(boolean clipToCompositionBounds) {
if (clipToCompositionBounds != this.clipToCompositionBounds) {
this.clipToCompositionBounds = clipToCompositionBounds;
invalidateSelf();
}
}

/**
* Gets whether or not Lottie should clip to the original animation composition bounds.
*
* Defaults to true.
*/
public boolean getClipToCompositionBounds() {
return clipToCompositionBounds;
}

/**
* If you use image assets, you must explicitly specify the folder in assets/ in which they are
* located because bodymovin uses the name filenames across all compositions (img_#).
Expand Down Expand Up @@ -434,7 +456,6 @@ public void draw(@NonNull Canvas canvas) {
} else {
drawInternal(canvas);
}
isDirty = false;

L.endSection("Drawable#draw");
}
Expand All @@ -445,6 +466,7 @@ private void drawInternal(@NonNull Canvas canvas) {
} else {
drawWithOriginalAspectRatio(canvas);
}
isDirty = false;
}

private boolean boundsMatchesCompositionAspectRatio() {
Expand Down Expand Up @@ -860,6 +882,7 @@ public int getRepeatCount() {
}


@SuppressWarnings("unused")
public boolean isLooping() {
return animator.getRepeatCount() == ValueAnimator.INFINITE;
}
Expand Down Expand Up @@ -1221,7 +1244,10 @@ public void draw(Canvas canvas, Matrix matrix) {
}

if (softwareRenderingEnabled) {
renderAndDrawAsBitmap(canvas, compositionLayer, matrix);
canvas.save();
canvas.concat(matrix);
renderAndDrawAsBitmap(canvas, compositionLayer);
canvas.restore();
} else {
compositionLayer.draw(canvas, matrix, alpha);
}
Expand All @@ -1235,7 +1261,7 @@ private void drawWithNewAspectRatio(Canvas canvas) {
}

if (softwareRenderingEnabled) {
renderAndDrawAsBitmap(canvas, compositionLayer, null);
renderAndDrawAsBitmap(canvas, compositionLayer);
} else {
Rect bounds = getBounds();
// In fitXY mode, the scale doesn't take effect.
Expand All @@ -1257,7 +1283,7 @@ private void drawWithOriginalAspectRatio(Canvas canvas) {
}

if (softwareRenderingEnabled) {
renderAndDrawAsBitmap(canvas, compositionLayer, null);
renderAndDrawAsBitmap(canvas, compositionLayer);
} else {
renderingMatrix.reset();
renderingMatrix.preScale(scale, scale);
Expand All @@ -1272,23 +1298,38 @@ private void drawWithOriginalAspectRatio(Canvas canvas) {
* @see LottieDrawable#useSoftwareRendering(boolean)
* @see LottieAnimationView#setRenderMode(RenderMode)
*/
private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compositionLayer, @Nullable Matrix parentMatrix) {
private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compositionLayer) {
ensureSoftwareRenderingObjectsInitialized();

//noinspection deprecation
originalCanvas.getMatrix(softwareRenderingOriginalCanvasMatrix);
softwareRenderingOriginalCanvasMatrix.invert(softwareRenderingOriginalCanvasMatrixInverse);
renderingMatrix.set(softwareRenderingOriginalCanvasMatrix);
if (parentMatrix != null) {
renderingMatrix.postConcat(parentMatrix);
}

// Determine what bounds the animation will render to after taking into account the canvas and parent matrix.
softwareRenderingTransformedBounds.set(0f, 0f, getIntrinsicWidth(), getIntrinsicHeight());
// The bounds are usually intrinsicWidth x intrinsicHeight. If they are different, an external source is scaling this drawable.
// This is how ImageView.ScaleType.FIT_XY works.
Rect bounds = getBounds();
float scaleX = bounds.width() / (float) getIntrinsicWidth();
float scaleY = bounds.height() / (float) getIntrinsicHeight();

if (clipToCompositionBounds) {
// Only render the intrinsic (composition) bounds.
softwareRenderingTransformedBounds.set(0f, 0f, getIntrinsicWidth(), getIntrinsicHeight());
} else {
// Find the full bounds of the animation.
softwareRenderingTransformedBounds.set(0f, 0f, 0f, 0f);
compositionLayer.getBounds(softwareRenderingTransformedBounds, null, false);
}
softwareRenderingTransformedBounds.set(
softwareRenderingTransformedBounds.left * scaleX,
softwareRenderingTransformedBounds.top * scaleY,
softwareRenderingTransformedBounds.right * scaleX,
softwareRenderingTransformedBounds.bottom * scaleY
);

// Transform the animation bounds to the bounds that they will render to on the canvas.
renderingMatrix.mapRect(softwareRenderingTransformedBounds);

// We only need to render the portion of the animation that intersects with the canvas's bounds.
softwareRenderingTransformedBounds.intersect(0f, 0f, originalCanvas.getWidth(), originalCanvas.getHeight());

int renderWidth = (int) Math.ceil(softwareRenderingTransformedBounds.width());
int renderHeight = (int) Math.ceil(softwareRenderingTransformedBounds.height());
Expand All @@ -1303,10 +1344,7 @@ private void renderAndDrawAsBitmap(Canvas originalCanvas, CompositionLayer compo

if (isDirty) {
softwareRenderingBitmap.eraseColor(0);
renderingMatrix.preScale(scale, scale);
// The bounds are usually intrinsicWidth x intrinsicHeight. If they are different, an external source is scaling this drawable.
// This is how ImageView.ScaleType.FIT_XY works.
renderingMatrix.preScale(getBounds().width() / (float) getIntrinsicWidth(), getBounds().height() / (float) getIntrinsicHeight());
renderingMatrix.preScale(scale * scaleX, scale * scaleY);
// We want to render the smallest bitmap possible. If the animation doesn't start at the top left, we translate the canvas and shrink the
// bitmap to avoid allocating and copying the empty space on the left and top. renderWidth and renderHeight take this into account.
renderingMatrix.postTranslate(-softwareRenderingTransformedBounds.left, -softwareRenderingTransformedBounds.top);
Expand Down
Expand Up @@ -112,7 +112,7 @@ public CompositionLayer(LottieDrawable lottieDrawable, Layer layerModel, List<La
int childAlpha = isDrawingWithOffScreen ? 255 : parentAlpha;
for (int i = layers.size() - 1; i >= 0; i--) {
boolean nonEmptyClip = true;
if (!newClipRect.isEmpty()) {
if (lottieDrawable.getClipToCompositionBounds() && !newClipRect.isEmpty()) {
nonEmptyClip = canvas.clipRect(newClipRect);
}
if (nonEmptyClip) {
Expand Down
1 change: 1 addition & 0 deletions lottie/src/main/res/values/attrs.xml
Expand Up @@ -28,5 +28,6 @@
<enum name="hardware" value="1" />
<enum name="software" value="2" />
</attr>
<attr name="lottie_clipToCompositionBounds" format="boolean" />
</declare-styleable>
</resources>
Expand Up @@ -17,6 +17,7 @@ import com.airbnb.lottie.snapshots.tests.ApplyOpacityToLayerTestCase
import com.airbnb.lottie.snapshots.tests.AssetsTestCase
import com.airbnb.lottie.snapshots.tests.ColorStateListColorFilterTestCase
import com.airbnb.lottie.snapshots.tests.ComposeDynamicPropertiesTestCase
import com.airbnb.lottie.snapshots.tests.ComposeScaleTypesTestCase
import com.airbnb.lottie.snapshots.tests.CustomBoundsTestCase
import com.airbnb.lottie.snapshots.tests.DynamicPropertiesTestCase
import com.airbnb.lottie.snapshots.tests.FailureTestCase
Expand Down Expand Up @@ -110,6 +111,7 @@ class LottieSnapshotTest {
FailureTestCase(),
FrameBoundariesTestCase(),
ScaleTypesTestCase(),
ComposeScaleTypesTestCase(),
DynamicPropertiesTestCase(),
MarkersTestCase(),
AssetsTestCase(),
Expand Down
Expand Up @@ -14,20 +14,18 @@ import android.widget.ImageView
import android.widget.LinearLayout
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.doOnAttach
import androidx.core.view.doOnLayout
import androidx.core.view.doOnPreDraw
import com.airbnb.lottie.FontAssetDelegate
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieComposition
import com.airbnb.lottie.LottieCompositionFactory
import com.airbnb.lottie.LottieDrawable
import com.airbnb.lottie.RenderMode
import com.airbnb.lottie.model.LottieCompositionCache
import com.airbnb.lottie.snapshots.utils.BitmapPool
import com.airbnb.lottie.snapshots.utils.HappoSnapshotter
import com.airbnb.lottie.snapshots.utils.ObjectPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -78,6 +76,7 @@ suspend fun SnapshotTestCaseContext.withAnimationView(
snapshotVariant: String = "default",
widthPx: Int = context.resources.displayMetrics.widthPixels,
heightPx: Int = context.resources.displayMetrics.heightPixels,
renderHardwareAndSoftware: Boolean = false,
callback: (LottieAnimationView) -> Unit,
) {
val result = LottieCompositionFactory.fromAssetSync(context, assetName)
Expand All @@ -101,10 +100,25 @@ suspend fun SnapshotTestCaseContext.withAnimationView(
animationViewContainer.layout(0, 0, animationViewContainer.measuredWidth, animationViewContainer.measuredHeight)
val bitmap = bitmapPool.acquire(animationView.width, animationView.height)
val canvas = Canvas(bitmap)
log("Drawing $assetName")
animationView.draw(canvas)
animationViewPool.release(animationView)
snapshotter.record(bitmap, snapshotName, snapshotVariant)
if (renderHardwareAndSoftware) {
log("Drawing $assetName - hardware")
val renderMode = animationView.renderMode
animationView.renderMode = RenderMode.HARDWARE
animationView.draw(canvas)
snapshotter.record(bitmap, snapshotName, "$snapshotVariant - Hardware")

bitmap.eraseColor(0)
animationView.renderMode = RenderMode.SOFTWARE
animationView.draw(canvas)
animationViewPool.release(animationView)
snapshotter.record(bitmap, snapshotName, "$snapshotVariant - Software")
animationView.renderMode = renderMode
} else {
log("Drawing $assetName")
animationView.draw(canvas)
animationViewPool.release(animationView)
snapshotter.record(bitmap, snapshotName, snapshotVariant)
}
bitmapPool.release(bitmap)
}

Expand Down Expand Up @@ -153,23 +167,30 @@ suspend fun SnapshotTestCaseContext.snapshotComposition(
bitmapPool.release(bitmap)
}

fun SnapshotTestCaseContext.loadCompositionFromAssetsSync(fileName: String): LottieComposition {
return LottieCompositionFactory.fromAssetSync(context, fileName).value!!
}

suspend fun SnapshotTestCaseContext.snapshotComposable(
name: String,
variant: String = "default",
content: @Composable () -> Unit,
renderHardwareAndSoftware: Boolean = false,
content: @Composable (RenderMode) -> Unit,
) = withContext(Dispatchers.Default) {
log("Snapshotting $name")
val composeView = ComposeView(context)
composeView.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
val bitmap = withContext(Dispatchers.Main) {
composeView.setContent(content)
var bitmap = withContext(Dispatchers.Main) {
composeView.setContent {
content(RenderMode.SOFTWARE)
}
suspendCancellableCoroutine<Bitmap> { cont ->
composeView.doOnLayout {
log("Drawing $name")
val bitmap = bitmapPool.acquire(composeView.width, composeView.height)
val canvas = Canvas(bitmap)
val b = bitmapPool.acquire(composeView.width, composeView.height)
val canvas = Canvas(b)
composeView.draw(canvas)
cont.resume(bitmap)
cont.resume(b)
}
onActivity { activity ->
activity.binding.content.addView(composeView)
Expand All @@ -179,7 +200,33 @@ suspend fun SnapshotTestCaseContext.snapshotComposable(
onActivity { activity ->
activity.binding.content.removeView(composeView)
}
LottieCompositionCache.getInstance().clear()
snapshotter.record(bitmap, name, variant)
snapshotter.record(bitmap, name, if (renderHardwareAndSoftware) "$variant - Software" else variant)
bitmapPool.release(bitmap)

if (renderHardwareAndSoftware) {
bitmap = withContext(Dispatchers.Main) {
composeView.setContent {
content(RenderMode.HARDWARE)
}
suspendCancellableCoroutine { cont ->
composeView.doOnLayout {
log("Drawing $name")
val b = bitmapPool.acquire(composeView.width, composeView.height)
val canvas = Canvas(b)
composeView.draw(canvas)
cont.resume(b)
}
onActivity { activity ->
activity.binding.content.addView(composeView)
}
}
}
onActivity { activity ->
activity.binding.content.removeView(composeView)
}
snapshotter.record(bitmap, name, "$variant - Hardware")
bitmapPool.release(bitmap)
}

LottieCompositionCache.getInstance().clear()
}

0 comments on commit 700aace

Please sign in to comment.