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": []
+}