From 954f7d5d3ed2e3f9d543e2daf720836e2dc11ece Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 14 Jul 2021 22:32:08 -0700 Subject: [PATCH] [Compose] Add support for ContentScale and Alignment (#1844) LottieAnimation's behavior now perfectly matches the behavior of ContentScale and Alignment in Image. --- .../airbnb/lottie/compose/LottieAnimation.kt | 65 ++++--- .../com/airbnb/lottie/LottieDrawable.java | 8 + .../lottie/sample/compose/ComposeActivity.kt | 2 + .../com/airbnb/lottie/sample/compose/Route.kt | 2 + .../examples/ContentScaleExamplesPage.kt | 183 ++++++++++++++++++ .../sample/compose/examples/ExamplesPage.kt | 6 + sample-compose/src/main/res/raw/gradient.json | 1 + 7 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ContentScaleExamplesPage.kt create mode 100644 sample-compose/src/main/res/raw/gradient.json 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 a78ca2d880..7f8b83a9f4 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 @@ -1,21 +1,26 @@ package com.airbnb.lottie.compose +import android.graphics.Matrix import androidx.annotation.FloatRange import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.ScaleFactor +import androidx.compose.ui.unit.IntSize import com.airbnb.lottie.LottieComposition import com.airbnb.lottie.LottieDrawable +import kotlin.math.roundToInt /** * This is the base LottieAnimation composable. It takes a composition and renders it at a specific progress. @@ -47,6 +52,9 @@ import com.airbnb.lottie.LottieDrawable * doesn't render correctly, please file an issue. * @param dynamicProperties Allows you to change the properties of an animation dynamically. To use them, use * [rememberLottieDynamicProperties]. Refer to its docs for more info. + * @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. */ @Composable fun LottieAnimation( @@ -57,32 +65,41 @@ fun LottieAnimation( applyOpacityToLayers: Boolean = false, enableMergePaths: Boolean = false, dynamicProperties: LottieDynamicProperties? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, ) { val drawable = remember { LottieDrawable() } + val matrix = remember { Matrix() } var setDynamicProperties: LottieDynamicProperties? by remember { mutableStateOf(null) } if (composition == null || composition.duration == 0f) return Box(modifier) Canvas( modifier = modifier - .maintainAspectRatio(composition) + .fillMaxSize() ) { drawIntoCanvas { canvas -> - withTransform({ - scale(size.width / composition.bounds.width().toFloat(), size.height / composition.bounds.height().toFloat(), Offset.Zero) - }) { - drawable.composition = composition - if (dynamicProperties !== setDynamicProperties) { - setDynamicProperties?.removeFrom(drawable) - dynamicProperties?.addTo(drawable) - setDynamicProperties = dynamicProperties - } - drawable.setOutlineMasksAndMattes(outlineMasksAndMattes) - drawable.isApplyingOpacityToLayersEnabled = applyOpacityToLayers - drawable.enableMergePathsForKitKatAndAbove(enableMergePaths) - drawable.progress = progress - drawable.draw(canvas.nativeCanvas) + val compositionSize = Size(composition.bounds.width().toFloat(), composition.bounds.height().toFloat()) + val intSize = IntSize(size.width.roundToInt(), size.height.roundToInt()) + + val scale = contentScale.computeScaleFactor(compositionSize, size) + val translation = alignment.align(compositionSize * scale, intSize, layoutDirection) + matrix.reset() + matrix.preTranslate(translation.x.toFloat(), translation.y.toFloat()) + matrix.preScale(scale.scaleX, scale.scaleY) + + + drawable.composition = composition + if (dynamicProperties !== setDynamicProperties) { + setDynamicProperties?.removeFrom(drawable) + dynamicProperties?.addTo(drawable) + setDynamicProperties = dynamicProperties } + drawable.setOutlineMasksAndMattes(outlineMasksAndMattes) + drawable.isApplyingOpacityToLayersEnabled = applyOpacityToLayers + drawable.enableMergePathsForKitKatAndAbove(enableMergePaths) + drawable.progress = progress + drawable.draw(canvas.nativeCanvas, matrix) } } } @@ -107,6 +124,8 @@ fun LottieAnimation( applyOpacityToLayers: Boolean = false, enableMergePaths: Boolean = false, dynamicProperties: LottieDynamicProperties? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, ) { val progress by animateLottieCompositionAsState( composition, @@ -124,11 +143,11 @@ fun LottieAnimation( applyOpacityToLayers, enableMergePaths, dynamicProperties, + alignment, + contentScale, ) } -private fun Modifier.maintainAspectRatio(composition: LottieComposition?): Modifier { - composition ?: return this - // TODO: use ContentScale and a transform here - return this.then(aspectRatio(composition.bounds.width() / composition.bounds.height().toFloat())) -} +private operator fun Size.times(scale: ScaleFactor): IntSize { + return IntSize((width * scale.scaleX).toInt(), (height * scale.scaleY).toInt()) +} \ No newline at end of file diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java index 7631b62f59..b235b390e7 100644 --- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java +++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java @@ -1200,6 +1200,14 @@ private float getMaxScale(@NonNull Canvas canvas) { return Math.min(maxScaleX, maxScaleY); } + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public void draw(Canvas canvas, Matrix matrix) { + if (compositionLayer == null) { + return; + } + compositionLayer.draw(canvas, matrix, alpha); + } + private void drawWithNewAspectRatio(Canvas canvas) { if (compositionLayer == null) { return; diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt index af9dd05147..1fa3253068 100644 --- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt +++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt @@ -26,6 +26,7 @@ import androidx.navigation.compose.rememberNavController import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.sample.compose.examples.AnimatableExamplesPage import com.airbnb.lottie.sample.compose.examples.BasicUsageExamplesPage +import com.airbnb.lottie.sample.compose.examples.ContentScaleExamplesPage import com.airbnb.lottie.sample.compose.examples.DynamicPropertiesExamplesPage import com.airbnb.lottie.sample.compose.examples.ExamplesPage import com.airbnb.lottie.sample.compose.examples.ImagesExamplesPage @@ -102,6 +103,7 @@ class ComposeActivity : AppCompatActivity() { composable(Route.DynamicPropertiesExamples.route) { DynamicPropertiesExamplesPage() } composable(Route.ImagesExamples.route) { ImagesExamplesPage() } composable(Route.TextExamples.route) { TextExamplesPage() } + composable(Route.ContentScaleExamples.route) { ContentScaleExamplesPage() } composable( Route.Player.fullRoute, arguments = Route.Player.args diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt index 9c8ee0ecaa..1be37a28ad 100644 --- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt +++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt @@ -32,6 +32,8 @@ sealed class Route(val route: String, val args: List = emptyLi object DynamicPropertiesExamples : Route("dynamic properties examples") + object ContentScaleExamples : Route("ContentScale examples") + object Player : Route( "player", listOf( diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ContentScaleExamplesPage.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ContentScaleExamplesPage.kt new file mode 100644 index 0000000000..dc1a748010 --- /dev/null +++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ContentScaleExamplesPage.kt @@ -0,0 +1,183 @@ +package com.airbnb.lottie.sample.compose.examples + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieComposition +import com.airbnb.lottie.sample.compose.R + +@Composable +fun ContentScaleExamplesPage() { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.gradient)) + var alignment by remember { mutableStateOf(Alignment.Center) } + var contentScale by remember { mutableStateOf(ContentScale.Fit) } + + UsageExamplePageScaffold { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + OptionsRow( + alignment, + contentScale, + onAlignmentChanged = { alignment = it }, + onContentScaleChanged = { contentScale = it }, + ) + Box( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .border(2.dp, Color.Green) + .padding(2.dp) + ) { + LottieAnimation( + composition, + progress = 0f, + alignment = alignment, + contentScale = contentScale, + ) + } + } + } +} +@Composable +private fun OptionsRow( + alignment: Alignment, + contentScale: ContentScale, + onContentScaleChanged: (ContentScale) -> Unit, + onAlignmentChanged: (Alignment) -> Unit, +) { + var scaleExpanded by remember { mutableStateOf(false) } + var alignmentExpanded by remember { mutableStateOf(false) } + + val onContentScaleChangedState by rememberUpdatedState(onContentScaleChanged) + val onAlignmentChangedState by rememberUpdatedState(onAlignmentChanged) + + Row( + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + ) { + Row( + modifier = Modifier + .clickable { + scaleExpanded = true + alignmentExpanded = false + } + .padding(horizontal = 4.dp, vertical = 16.dp) + ){ + Text( + ContentScales[contentScale] ?: "Unknown Content Scale", + textAlign = TextAlign.Center, + modifier = Modifier + .defaultMinSize(minWidth = 128.dp) + ) + Icon( + imageVector = if (scaleExpanded) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown, + contentDescription = null, + ) + DropdownMenu( + scaleExpanded, + onDismissRequest = { scaleExpanded = false }, + ) { + ContentScales.forEach { (cs, label) -> + DropdownMenuItem( + onClick = { onContentScaleChangedState(cs) }, + ) { + Text(label) + } + } + } + } + Row( + modifier = Modifier + .clickable { + alignmentExpanded = true + scaleExpanded = false + } + .padding(horizontal = 4.dp, vertical = 16.dp) + ) { + Text( + Alignments[alignment] ?: "Unknown Alignment", + textAlign = TextAlign.Center, + modifier = Modifier + .defaultMinSize(minWidth = 128.dp) + ) + Icon( + imageVector = if (alignmentExpanded) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown, + contentDescription = null, + ) + DropdownMenu( + alignmentExpanded, + onDismissRequest = { alignmentExpanded = false } + ) { + Alignments.forEach { (a, label) -> + DropdownMenuItem( + onClick = { onAlignmentChangedState(a) }, + ) { + Text(label) + } + } + } + } + } +} + +private val Alignments = mapOf( + Alignment.TopStart to "TopStart", + Alignment.TopCenter to "TopCenter", + Alignment.TopEnd to "TopEnd", + Alignment.CenterStart to "CenterStart", + Alignment.Center to "Center", + Alignment.CenterEnd to "CenterEnd", + Alignment.BottomStart to "BottomStart", + Alignment.BottomCenter to "BottomCenter", + Alignment.BottomEnd to "BottomEnd", +) + +private val ContentScales = mapOf( + ContentScale.Fit to "Fit", + ContentScale.Crop to "Crop", + ContentScale.Inside to "Inside", + ContentScale.None to "None", + ContentScale.FillBounds to "FillBounds", + ContentScale.FillHeight to "FillHeight", + ContentScale.FillWidth to "FillWidth", +) diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt index b4c4402dc1..e2a80e8ba6 100644 --- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt +++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt @@ -70,5 +70,11 @@ fun ExamplesPage(navController: NavController) { modifier = Modifier .clickable { navController.navigate(Route.TextExamples) } ) + ListItem( + text = { Text("ContentScale and Alignment") }, + secondaryText = { Text("Changing an animation's ContentScale and Alignment") }, + modifier = Modifier + .clickable { navController.navigate(Route.ContentScaleExamples) } + ) } } \ No newline at end of file diff --git a/sample-compose/src/main/res/raw/gradient.json b/sample-compose/src/main/res/raw/gradient.json new file mode 100644 index 0000000000..3a38ccd6b2 --- /dev/null +++ b/sample-compose/src/main/res/raw/gradient.json @@ -0,0 +1 @@ +{"v":"5.7.7","fr":29.9700012207031,"ip":0,"op":900.000036657751,"w":500,"h":200,"nm":"Gradient","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[500,200],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":0,"k":[0.526341557503,0.526341557503,0.526341557503,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[250,100],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,0,0,0.5,0.5,0,0.5,1,0,0,1],"ix":9}},"s":{"a":0,"k":[0,0],"ix":5},"e":{"a":0,"k":[500,0],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0}],"markers":[]} \ No newline at end of file