Constrain unbounded dimensions when exactly one dimension is unbounded (#2437)
Fixes #2264
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 8530301..3b8d58d 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
@@ -5,7 +5,6 @@
import androidx.annotation.FloatRange
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -19,12 +18,10 @@
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.ScaleFactor
import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.dp
import com.airbnb.lottie.AsyncUpdates
import com.airbnb.lottie.LottieComposition
import com.airbnb.lottie.LottieDrawable
import com.airbnb.lottie.RenderMode
-import com.airbnb.lottie.utils.Utils
import kotlin.math.roundToInt
/**
@@ -95,13 +92,13 @@
if (composition == null || composition.duration == 0f) return Box(modifier)
- val dpScale = Utils.dpScale()
+ val bounds = composition.bounds
Canvas(
modifier = modifier
- .size((composition.bounds.width() / dpScale).dp, (composition.bounds.height() / dpScale).dp)
+ .lottieSize(bounds.width(), bounds.height())
) {
drawIntoCanvas { canvas ->
- val compositionSize = Size(composition.bounds.width().toFloat(), composition.bounds.height().toFloat())
+ val compositionSize = Size(bounds.width().toFloat(), bounds.height().toFloat())
val intSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
val scale = contentScale.computeScaleFactor(compositionSize, size)
@@ -125,7 +122,7 @@
drawable.maintainOriginalImageBounds = maintainOriginalImageBounds
drawable.clipToCompositionBounds = clipToCompositionBounds
drawable.progress = progress()
- drawable.setBounds(0, 0, composition.bounds.width(), composition.bounds.height())
+ drawable.setBounds(0, 0, bounds.width(), bounds.height())
drawable.draw(canvas.nativeCanvas, matrix)
}
}
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimationSizeNode.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimationSizeNode.kt
new file mode 100644
index 0000000..820a2c0
--- /dev/null
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimationSizeNode.kt
@@ -0,0 +1,107 @@
+package com.airbnb.lottie.compose
+
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.ScaleFactor
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.constrain
+
+/**
+ * Custom layout modifier that Lottie uses instead of the normal size modifier.
+ *
+ * This modifier will:
+ * * Attempt to size the composable to width/height (which is set to the composition bounds)
+ * * Constrain the size to the incoming constraints
+ *
+ * However, if the incoming constraints are unbounded in exactly one dimension, it will constrain that
+ * dimension to maintain the correct aspect ratio of the composition.
+ */
+@Stable
+internal fun Modifier.lottieSize(
+ width: Int,
+ height: Int,
+) = this.then(LottieAnimationSizeElement(width, height))
+
+internal data class LottieAnimationSizeElement(
+ val width: Int,
+ val height: Int,
+) : ModifierNodeElement<LottieAnimationSizeNode>() {
+ override fun create(): LottieAnimationSizeNode {
+ return LottieAnimationSizeNode(width, height)
+ }
+
+ override fun update(node: LottieAnimationSizeNode) {
+ node.width = width
+ node.height = height
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ name = "Lottie Size"
+ properties["width"] = width
+ properties["height"] = height
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is LottieAnimationSizeElement) return false
+
+ if (width != other.width) return false
+ if (height != other.height) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = width.hashCode()
+ result = 31 * result + height.hashCode()
+ return result
+ }
+}
+
+internal class LottieAnimationSizeNode(
+ var width: Int,
+ var height: Int,
+) : Modifier.Node(), LayoutModifierNode {
+ override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
+ val constrainedSize = constraints.constrain(IntSize(width, height))
+ val wrappedConstraints = when {
+ // We are constrained in the width dimension but not the height dimension.
+ constraints.maxHeight == Constraints.Infinity && constraints.maxWidth != Constraints.Infinity -> Constraints(
+ minWidth = constrainedSize.width,
+ maxWidth = constrainedSize.width,
+ minHeight = constrainedSize.width * height / width,
+ maxHeight = constrainedSize.width * height / width,
+ )
+ // We are constrained in the height dimension but not the width dimension.
+ constraints.maxWidth == Constraints.Infinity && constraints.maxHeight != Constraints.Infinity -> Constraints(
+ minWidth = constrainedSize.height * width / height,
+ maxWidth = constrainedSize.height * width / height,
+ minHeight = constrainedSize.height,
+ maxHeight = constrainedSize.height,
+ )
+ // We are constrained in both or neither dimension. Use the constrained size.
+ else -> Constraints(
+ minWidth = constrainedSize.width,
+ maxWidth = constrainedSize.width,
+ minHeight = constrainedSize.height,
+ maxHeight = constrainedSize.height,
+ )
+ }
+
+ val placeable = measurable.measure(wrappedConstraints)
+ return layout(placeable.width, placeable.height) {
+ placeable.placeRelative(0, 0)
+ }
+ }
+}
+
+private operator fun Size.times(scale: ScaleFactor): IntSize {
+ return IntSize((width * scale.scaleX).toInt(), (height * scale.scaleY).toInt())
+}
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/ComposeScaleTypesTestCase.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/ComposeScaleTypesTestCase.kt
index 54f993d..e5149c6 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/ComposeScaleTypesTestCase.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/ComposeScaleTypesTestCase.kt
@@ -1,11 +1,29 @@
package com.airbnb.lottie.snapshots.tests
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Text
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.LottieConstants
+import com.airbnb.lottie.compose.animateLottieCompositionAsState
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.airbnb.lottie.snapshots.R
import com.airbnb.lottie.snapshots.SnapshotTestCase
import com.airbnb.lottie.snapshots.SnapshotTestCaseContext
import com.airbnb.lottie.snapshots.loadCompositionFromAssetsSync
@@ -28,7 +46,7 @@
{ 1f },
renderMode = renderMode,
modifier = Modifier
- .size(720.dp, 1280.dp)
+ .size(720.dp, 1280.dp),
)
}
@@ -39,7 +57,7 @@
renderMode = renderMode,
modifier = Modifier
.size(300.dp, 300.dp)
- .scale(2f)
+ .scale(2f),
)
}
@@ -50,7 +68,7 @@
renderMode = renderMode,
modifier = Modifier
.size(300.dp, 300.dp)
- .scale(4f)
+ .scale(4f),
)
}
@@ -61,7 +79,7 @@
contentScale = ContentScale.Crop,
renderMode = renderMode,
modifier = Modifier
- .size(300.dp, 300.dp)
+ .size(300.dp, 300.dp),
)
}
@@ -72,7 +90,7 @@
contentScale = ContentScale.Inside,
renderMode = renderMode,
modifier = Modifier
- .size(300.dp, 300.dp)
+ .size(300.dp, 300.dp),
)
}
@@ -83,7 +101,7 @@
contentScale = ContentScale.FillBounds,
renderMode = renderMode,
modifier = Modifier
- .size(300.dp, 300.dp)
+ .size(300.dp, 300.dp),
)
}
@@ -95,7 +113,7 @@
renderMode = renderMode,
modifier = Modifier
.size(300.dp, 300.dp)
- .scale(2f)
+ .scale(2f),
)
}
@@ -107,7 +125,7 @@
renderMode = renderMode,
modifier = Modifier
.size(300.dp, 300.dp)
- .scale(2f)
+ .scale(2f),
)
}
@@ -118,7 +136,7 @@
contentScale = ContentScale.Inside,
renderMode = renderMode,
modifier = Modifier
- .size(600.dp, 600.dp)
+ .size(600.dp, 600.dp),
)
}
@@ -129,7 +147,7 @@
contentScale = ContentScale.FillBounds,
renderMode = renderMode,
modifier = Modifier
- .size(600.dp, 600.dp)
+ .size(600.dp, 600.dp),
)
}
@@ -140,7 +158,7 @@
contentScale = ContentScale.Fit,
renderMode = renderMode,
modifier = Modifier
- .size(600.dp, 600.dp)
+ .size(600.dp, 600.dp),
)
}
@@ -151,8 +169,47 @@
contentScale = ContentScale.FillBounds,
renderMode = renderMode,
modifier = Modifier
- .size(300.dp, 600.dp)
+ .size(300.dp, 600.dp),
)
}
+
+ val largeSquareComposition = loadCompositionFromAssetsSync("Tests/LargeSquare.json")
+ snapshotComposable("Compose constrained size", "Column", renderHardwareAndSoftware = true) { renderMode ->
+ Column(
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier
+ .width(128.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ LottieAnimation(
+ composition = largeSquareComposition,
+ progress = { 1f },
+ renderMode = renderMode,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Other content",
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ snapshotComposable("Compose constrained size", "Row", renderHardwareAndSoftware = true) { renderMode ->
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier
+ .height(128.dp)
+ .horizontalScroll(rememberScrollState()),
+ ) {
+ LottieAnimation(
+ composition = largeSquareComposition,
+ progress = { 1f },
+ renderMode = renderMode,
+ modifier = Modifier.fillMaxHeight(),
+ )
+ Text("Other content")
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/snapshot-tests/src/main/assets/Tests/LargeSquare.json b/snapshot-tests/src/main/assets/Tests/LargeSquare.json
new file mode 100644
index 0000000..0c38da8
--- /dev/null
+++ b/snapshot-tests/src/main/assets/Tests/LargeSquare.json
@@ -0,0 +1,222 @@
+{
+ "v": "5.9.1",
+ "fr": 25,
+ "ip": 0,
+ "op": 75,
+ "w": 1200,
+ "h": 1200,
+ "nm": "square",
+ "ddd": 0,
+ "assets": [],
+ "layers": [
+ {
+ "ddd": 0,
+ "ind": 15,
+ "ty": 4,
+ "nm": "Fond Silhouettes",
+ "sr": 1,
+ "ks": {
+ "o": {
+ "a": 0,
+ "k": 100,
+ "ix": 11
+ },
+ "r": {
+ "a": 0,
+ "k": 0,
+ "ix": 10
+ },
+ "p": {
+ "a": 0,
+ "k": [
+ 600,
+ 600,
+ 0
+ ],
+ "ix": 2,
+ "l": 2
+ },
+ "a": {
+ "a": 0,
+ "k": [
+ 600,
+ 600,
+ 0
+ ],
+ "ix": 1,
+ "l": 2
+ },
+ "s": {
+ "a": 0,
+ "k": [
+ 100,
+ 100,
+ 100
+ ],
+ "ix": 6,
+ "l": 2
+ }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ind": 0,
+ "ty": "sh",
+ "ix": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "i": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 0
+ ]
+ ],
+ "o": [
+ [
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 0
+ ],
+ [
+ 0,
+ 0
+ ]
+ ],
+ "v": [
+ [
+ -600,
+ 600
+ ],
+ [
+ 600,
+ 600
+ ],
+ [
+ 600,
+ -600
+ ],
+ [
+ -600,
+ -600
+ ]
+ ],
+ "c": true
+ },
+ "ix": 2
+ },
+ "nm": "Tracé 1",
+ "mn": "ADBE Vector Shape - Group",
+ "hd": false
+ },
+ {
+ "ty": "fl",
+ "c": {
+ "a": 0,
+ "k": [
+ 0.561497886508,
+ 0.699996708889,
+ 0.56712066052,
+ 1
+ ],
+ "ix": 4
+ },
+ "o": {
+ "a": 0,
+ "k": 100,
+ "ix": 5
+ },
+ "r": 1,
+ "bm": 0,
+ "nm": "Fond 1",
+ "mn": "ADBE Vector Graphic - Fill",
+ "hd": false
+ },
+ {
+ "ty": "tr",
+ "p": {
+ "a": 0,
+ "k": [
+ 600,
+ 600
+ ],
+ "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": "Transformer "
+ }
+ ],
+ "nm": "Groupe 1",
+ "np": 2,
+ "cix": 2,
+ "bm": 0,
+ "ix": 1,
+ "mn": "ADBE Vector Group",
+ "hd": false
+ }
+ ],
+ "ip": 0,
+ "op": 76,
+ "st": 0,
+ "bm": 0
+ }
+ ],
+ "markers": []
+}