Repository: cashapp/redwood
Pull request #391 (2022-09-27 01:12:19)

Split Measurable from Node
The state related to measurements should be its own type.


Patch:
diff --git a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Direction.kt b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Direction.kt
index 0c64cb6c2..23f0231cf 100644
--- a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Direction.kt
+++ b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Direction.kt
@@ -48,8 +48,8 @@ internal sealed interface Direction {
     override fun mainPaddingEnd(padding: Spacing) = padding.end
     override fun crossPaddingStart(padding: Spacing) = padding.top
     override fun crossPaddingEnd(padding: Spacing) = padding.bottom
-    override fun mainSize(node: Node) = node.width
-    override fun crossSize(node: Node) = node.height
+    override fun mainSize(node: Node) = node.measurable.width
+    override fun crossSize(node: Node) = node.measurable.height
     override fun mainMeasuredSize(node: Node) = node.measuredWidth
     override fun crossMeasuredSize(node: Node) = node.measuredHeight
     override fun mainMarginStart(node: Node) = node.margin.start
@@ -63,8 +63,8 @@ internal sealed interface Direction {
     override fun mainPaddingEnd(padding: Spacing) = padding.bottom
     override fun crossPaddingStart(padding: Spacing) = padding.start
     override fun crossPaddingEnd(padding: Spacing) = padding.end
-    override fun mainSize(node: Node) = node.height
-    override fun crossSize(node: Node) = node.width
+    override fun mainSize(node: Node) = node.measurable.height
+    override fun crossSize(node: Node) = node.measurable.width
     override fun mainMeasuredSize(node: Node) = node.measuredHeight
     override fun crossMeasuredSize(node: Node) = node.measuredWidth
     override fun mainMarginStart(node: Node) = node.margin.top
diff --git a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt
index 8cf11e2c2..2328ef73f 100644
--- a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt
+++ b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt
@@ -17,9 +17,9 @@
 
 package app.cash.redwood
 
+import app.cash.redwood.Measurable.Companion.MatchParent
 import app.cash.redwood.Node.Companion.DefaultFlexBasisPercent
 import app.cash.redwood.Node.Companion.DefaultFlexGrow
-import app.cash.redwood.Node.Companion.MatchParent
 import app.cash.redwood.Node.Companion.UndefinedFlexShrink
 import kotlin.math.roundToInt
 
@@ -273,8 +273,10 @@ public class FlexboxEngine {
           addFlexLine(flexLines, flexLine, if (i > 0) i - 1 else 0, sumCrossSize)
           sumCrossSize += flexLine.crossSize
         }
+        val measurable = child.measurable
         if (direction == Direction.Horizontal) {
-          if (child.height == MatchParent) {
+          val height = measurable.height
+          if (height == MatchParent) {
             // This case takes care of the corner case where the cross size of the
             // child is affected by the just added flex line.
             // E.g. when the child's layout_height is set to match_parent, the height
@@ -285,13 +287,14 @@ public class FlexboxEngine {
             childCrossMeasureSpec = MeasureSpec.getChildMeasureSpec(
               spec = crossMeasureSpec,
               padding = padding.top + padding.bottom + child.margin.top + child.margin.bottom + sumCrossSize,
-              childDimension = child.height,
+              childDimension = height,
             )
             child.applyMeasure(childMainMeasureSpec, childCrossMeasureSpec)
             measureWithConstraints(child, i)
           }
         } else {
-          if (child.width == MatchParent) {
+          val width = measurable.width
+          if (width == MatchParent) {
             // This case takes care of the corner case where the cross size of the
             // child is affected by the just added flex line.
             // E.g. when the child's layout_width is set to match_parent, the width
@@ -302,7 +305,7 @@ public class FlexboxEngine {
             childCrossMeasureSpec = MeasureSpec.getChildMeasureSpec(
               spec = crossMeasureSpec,
               padding = padding.start + padding.end + child.margin.start + child.margin.end + sumCrossSize,
-              childDimension = child.width,
+              childDimension = width,
             )
             child.applyMeasure(childCrossMeasureSpec, childMainMeasureSpec)
             measureWithConstraints(child, i)
@@ -439,19 +442,28 @@ public class FlexboxEngine {
     var needsMeasure = false
     var childWidth = node.measuredWidth
     var childHeight = node.measuredHeight
-    if (childWidth < node.minWidth) {
+    val measurable = node.measurable
+    val minWidth = measurable.minWidth
+    if (childWidth < minWidth) {
       needsMeasure = true
-      childWidth = node.minWidth
-    } else if (childWidth > node.maxWidth) {
-      needsMeasure = true
-      childWidth = node.maxWidth
+      childWidth = minWidth
+    } else {
+      val maxWidth = measurable.maxWidth
+      if (childWidth > maxWidth) {
+        needsMeasure = true
+        childWidth = maxWidth
+      }
     }
-    if (childHeight < node.minHeight) {
-      needsMeasure = true
-      childHeight = node.minHeight
-    } else if (childHeight > node.maxHeight) {
+    val minHeight = measurable.minHeight
+    if (childHeight < minHeight) {
       needsMeasure = true
-      childHeight = node.maxHeight
+      childHeight = minHeight
+    } else {
+      val maxHeight = measurable.maxHeight
+      if (childHeight > maxHeight) {
+        needsMeasure = true
+        childHeight = maxHeight
+      }
     }
     if (needsMeasure) {
       val widthSpec = MeasureSpec.from(childWidth, MeasureSpecMode.Exactly)
@@ -592,6 +604,7 @@ public class FlexboxEngine {
       if (child == null || !child.visible) {
         continue
       }
+      val measurable = child.measurable
       if (flexDirection == FlexDirection.Row || flexDirection == FlexDirection.RowReverse) {
         // The direction of the main axis is horizontal
         var childMeasuredWidth = child.measuredWidth
@@ -615,7 +628,8 @@ public class FlexboxEngine {
             accumulatedRoundError = 0f
           }
           var newWidth = rawCalculatedWidth.roundToInt()
-          if (newWidth > child.maxWidth) {
+          val maxWidth = measurable.maxWidth
+          if (newWidth > maxWidth) {
             // This means the child can't expand beyond the value of the maxWidth
             // attribute.
             // To adjust the flex line length to the size of maxMainSize, remaining
@@ -623,7 +637,7 @@ public class FlexboxEngine {
             // (children nodes). In that case, invoke this method again with the same
             // fromIndex.
             needsReexpand = true
-            newWidth = child.maxWidth
+            newWidth = maxWidth
             childrenFrozen!![index] = true
             flexLine.totalFlexGrow -= child.flexGrow
           } else {
@@ -675,7 +689,8 @@ public class FlexboxEngine {
             accumulatedRoundError = 0f
           }
           var newHeight = rawCalculatedHeight.roundToInt()
-          if (newHeight > child.maxHeight) {
+          val maxHeight = measurable.maxHeight
+          if (newHeight > maxHeight) {
             // This means the child can't expand beyond the value of the maxHeight
             // attribute.
             // To adjust the flex line length to the size of maxMainSize, remaining
@@ -683,7 +698,7 @@ public class FlexboxEngine {
             // (children nodes). In that case, invoke this method again with the same
             // fromIndex.
             needsReexpand = true
-            newHeight = child.maxHeight
+            newHeight = maxHeight
             childrenFrozen!![index] = true
             flexLine.totalFlexGrow -= child.flexGrow
           } else {
@@ -775,6 +790,7 @@ public class FlexboxEngine {
       if (child == null || !child.visible) {
         continue
       }
+      val measurable = child.measurable
       if (flexDirection == FlexDirection.Row || flexDirection == FlexDirection.RowReverse) {
         // The direction of main axis is horizontal
         var childMeasuredWidth = child.measuredWidth
@@ -798,7 +814,8 @@ public class FlexboxEngine {
             accumulatedRoundError = 0f
           }
           var newWidth = rawCalculatedWidth.roundToInt()
-          if (newWidth < child.minWidth) {
+          val minWidth = measurable.minWidth
+          if (newWidth < minWidth) {
             // This means the child doesn't have enough space to distribute the negative
             // free space. To adjust the flex line length down to the maxMainSize,
             // remaining
@@ -806,7 +823,7 @@ public class FlexboxEngine {
             // (children nodes). In that case, invoke this method again with the same
             // fromIndex.
             needsReshrink = true
-            newWidth = child.minWidth
+            newWidth = minWidth
             childrenFrozen!![index] = true
             flexLine.totalFlexShrink -= child.flexShrink
           } else {
@@ -861,10 +878,11 @@ public class FlexboxEngine {
             accumulatedRoundError = 0f
           }
           var newHeight = rawCalculatedHeight.roundToInt()
-          if (newHeight < child.minHeight) {
+          val minHeight = measurable.minHeight
+          if (newHeight < minHeight) {
             // Need to invoke this method again like the case flex direction is vertical
             needsReshrink = true
-            newHeight = child.minHeight
+            newHeight = minHeight
             childrenFrozen!![index] = true
             flexLine.totalFlexShrink -= child.flexShrink
           } else {
@@ -915,16 +933,20 @@ public class FlexboxEngine {
     flexItem: Node,
     padding: Int,
   ): MeasureSpec {
-    var childWidthMeasureSpec = MeasureSpec.getChildMeasureSpec(
+    val measurable = flexItem.measurable
+    val childWidthMeasureSpec = MeasureSpec.getChildMeasureSpec(
       spec = widthMeasureSpec,
       padding = this.padding.start + this.padding.end + flexItem.margin.start + flexItem.margin.end + padding,
-      childDimension = flexItem.width,
+      childDimension = measurable.width,
     )
     val childWidth = childWidthMeasureSpec.size
-    if (childWidth > flexItem.maxWidth) {
-      childWidthMeasureSpec = MeasureSpec.from(flexItem.maxWidth, childWidthMeasureSpec.mode)
-    } else if (childWidth < flexItem.minWidth) {
-      childWidthMeasureSpec = MeasureSpec.from(flexItem.minWidth, childWidthMeasureSpec.mode)
+    val maxWidth = measurable.maxWidth
+    if (childWidth > maxWidth) {
+      return MeasureSpec.from(maxWidth, childWidthMeasureSpec.mode)
+    }
+    val minWidth = measurable.minWidth
+    if (childWidth < minWidth) {
+      return MeasureSpec.from(minWidth, childWidthMeasureSpec.mode)
     }
     return childWidthMeasureSpec
   }
@@ -934,16 +956,20 @@ public class FlexboxEngine {
     flexItem: Node,
     padding: Int,
   ): MeasureSpec {
-    var childHeightMeasureSpec = MeasureSpec.getChildMeasureSpec(
+    val measurable = flexItem.measurable
+    val childHeightMeasureSpec = MeasureSpec.getChildMeasureSpec(
       spec = heightMeasureSpec,
       padding = this.padding.top + this.padding.bottom + flexItem.margin.top + flexItem.margin.bottom + padding,
-      childDimension = flexItem.height,
+      childDimension = measurable.height,
     )
     val childHeight = childHeightMeasureSpec.size
-    if (childHeight > flexItem.maxHeight) {
-      childHeightMeasureSpec = MeasureSpec.from(flexItem.maxHeight, childHeightMeasureSpec.mode)
-    } else if (childHeight < flexItem.minHeight) {
-      childHeightMeasureSpec = MeasureSpec.from(flexItem.minHeight, childHeightMeasureSpec.mode)
+    val maxHeight = measurable.maxHeight
+    if (childHeight > maxHeight) {
+      return MeasureSpec.from(maxHeight, childHeightMeasureSpec.mode)
+    }
+    val minHeight = measurable.minHeight
+    if (childHeight < minHeight) {
+      return MeasureSpec.from(minHeight, childHeightMeasureSpec.mode)
     }
     return childHeightMeasureSpec
   }
@@ -1199,7 +1225,9 @@ public class FlexboxEngine {
    * Expand the node vertically to the size of the [crossSize] (considering [node]'s margins).
    */
   private fun stretchViewVertically(node: Node, crossSize: Int, index: Int) {
-    val newHeight = (crossSize - node.margin.top - node.margin.bottom).coerceIn(node.minHeight, node.maxHeight)
+    val measurable = node.measurable
+    val newHeight = (crossSize - node.margin.top - node.margin.bottom)
+      .coerceIn(measurable.minHeight, measurable.maxHeight)
     val measuredWidth = if (measuredSizeCache != null) {
       // Retrieve the measured height from the cache because there
       // are some cases that the view is re-created from the last measure, thus
@@ -1220,7 +1248,9 @@ public class FlexboxEngine {
    * Expand the node horizontally to the size of the crossSize (considering [node]'s margins).
    */
   private fun stretchViewHorizontally(node: Node, crossSize: Int, index: Int) {
-    val newWidth = (crossSize - node.margin.start - node.margin.end).coerceIn(node.minWidth, node.maxWidth)
+    val measurable = node.measurable
+    val newWidth = (crossSize - node.margin.start - node.margin.end)
+      .coerceIn(measurable.minWidth, measurable.maxWidth)
     val measuredHeight = if (measuredSizeCache != null) {
       // Retrieve the measured height from the cache because there
       // are some cases that the view is re-created from the last measure, thus
@@ -1855,10 +1885,11 @@ public class FlexboxEngine {
   }
 
   /**
-   * Call [Node.measure] and update [Node.measuredWidth] and [Node.measuredHeight] with the result.
+   * Call [Measurable.measure] and update [Node.measuredWidth] and [Node.measuredHeight] with the
+   * result.
    */
   private fun Node.applyMeasure(widthSpec: MeasureSpec, heightSpec: MeasureSpec) {
-    val size = measure(widthSpec, heightSpec)
+    val size = measurable.measure(widthSpec, heightSpec)
     this.measuredWidth = size.width
     this.measuredHeight = size.height
   }
diff --git a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Measurable.kt b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Measurable.kt
new file mode 100644
index 000000000..983f27961
--- /dev/null
+++ b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Measurable.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood
+
+public open class Measurable {
+  /**
+   * The requested width of the item.
+   *
+   * The attribute is about how wide the view wants to be. Can be one of the
+   * constants [MatchParent] or [WrapContent], or an exact size.
+   */
+  public open val width: Int = WrapContent
+
+  /**
+   * The requested height of the item.
+   *
+   * The attribute is about how wide the view wants to be. Can be one of the
+   * constants [MatchParent] or [WrapContent], or an exact size.
+   */
+  public open val height: Int = WrapContent
+
+  /**
+   * The minimum width attribute of the item.
+   *
+   * The attribute determines the minimum width the child can shrink to.
+   */
+  public open val minWidth: Int = 0
+
+  /**
+   * The minimum height attribute of the item.
+   *
+   * The attribute determines the minimum height the child can shrink to.
+   */
+  public open val minHeight: Int = 0
+
+  /**
+   * The maximum width attribute of the item.
+   *
+   * The attribute determines the maximum width the child can expand to.
+   */
+  public open val maxWidth: Int = Int.MAX_VALUE
+
+  /**
+   * The maximum height attribute of the item.
+   */
+  public open val maxHeight: Int = Int.MAX_VALUE
+
+  public open fun measure(widthSpec: MeasureSpec, heightSpec: MeasureSpec): Size {
+    return Size(
+      width = MeasureSpec.resolveSize(width, widthSpec),
+      height = MeasureSpec.resolveSize(height, heightSpec),
+    )
+  }
+
+  public companion object {
+    /**
+     * A special constant for [width] or [height] that means that the item wants to be as big as its
+     * parent.
+     */
+    public const val MatchParent: Int = -1
+
+    /**
+     * A special constant for [width] or [height] that the item wants to be just large enough to fit
+     * its own internal content, taking its own padding into account.
+     */
+    public const val WrapContent: Int = -2
+  }
+}
diff --git a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt
index 80eed45b3..db718675d 100644
--- a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt
+++ b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt
@@ -20,48 +20,6 @@ package app.cash.redwood
  */
 public class Node(
 
-  /**
-   * The requested width of the node.
-   *
-   * The attribute is about how wide the view wants to be. Can be one of the
-   * constants [MatchParent] or [WrapContent], or an exact size.
-   */
-  public val width: Int = WrapContent,
-
-  /**
-   * The requested height of the node.
-   *
-   * The attribute is about how wide the view wants to be. Can be one of the
-   * constants [MatchParent] or [WrapContent], or an exact size.
-   */
-  public val height: Int = WrapContent,
-
-  /**
-   * The minimum width attribute of the node.
-   *
-   * The attribute determines the minimum width the child can shrink to.
-   */
-  public val minWidth: Int = 0,
-
-  /**
-   * The minimum height attribute of the node.
-   *
-   * The attribute determines the minimum height the child can shrink to.
-   */
-  public val minHeight: Int = 0,
-
-  /**
-   * The maximum width attribute of the node.
-   *
-   * The attribute determines the maximum width the child can expand to.
-   */
-  public val maxWidth: Int = Int.MAX_VALUE,
-
-  /**
-   * The maximum height attribute of the node.
-   */
-  public val maxHeight: Int = Int.MAX_VALUE,
-
   /**
    * True if this item is visible and should be laid out.
    */
@@ -142,50 +100,32 @@ public class Node(
    * The margin of the node.
    */
   public val margin: Spacing = Spacing.Zero,
-) {
 
   /**
-   * The measured width after invoking [measure].
+   * A callback to to measure this node according to the `widthSpec` and `heightSpec` constraints.
+   */
+  public var measurable: Measurable = Measurable()
+) {
+  /**
+   * The measured width after invoking [Measurable.measure].
    *
-   * TODO: Remove this mutable attribute and use the returned [Size] from [measure].
+   * TODO: Remove this mutable attribute and use the returned [Size] from [Measurable.measure].
    */
   public var measuredWidth: Int = -1
 
   /**
-   * The measured height after invoking [measure].
+   * The measured height after invoking [Measurable.measure].
    *
-   * TODO: Remove this mutable attribute and use the returned [Size] from [measure].
+   * TODO: Remove this mutable attribute and use the returned [Size] from [Measurable.measure].
    */
   public var measuredHeight: Int = -1
 
-  /**
-   * A callback to to measure this node according to the `widthSpec` and `heightSpec` constraints.
-   */
-  public var measure: (widthSpec: MeasureSpec, heightSpec: MeasureSpec) -> Size = { widthSpec, heightSpec ->
-    Size(
-      width = MeasureSpec.resolveSize(width, widthSpec),
-      height = MeasureSpec.resolveSize(height, heightSpec),
-    )
-  }
-
   /**
    * A callback to place the node inside the given `left`, `top`, `right`, and `bottom` coordinates.
    */
   public var layout: (left: Int, top: Int, right: Int, bottom: Int) -> Unit = { _, _, _, _ -> }
 
   public companion object {
-    /**
-     * A special constant for [width] or [height] that means that the node wants to be as
-     * big as its parent.
-     */
-    public const val MatchParent: Int = -1
-
-    /**
-     * A special constant for [width] or [height] that the node wants to be just large enough
-     * to fit its own internal content, taking its own padding into account.
-     */
-    public const val WrapContent: Int = -2
-
     /** The default value for the order attribute */
     public const val DefaultOrder: Int = 1
 
diff --git a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/utils.kt b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/utils.kt
index f8e956018..6296eb4ac 100644
--- a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/utils.kt
+++ b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/utils.kt
@@ -15,8 +15,8 @@
  */
 package app.cash.redwood
 
-import app.cash.redwood.Node.Companion.MatchParent
-import app.cash.redwood.Node.Companion.WrapContent
+import app.cash.redwood.Measurable.Companion.MatchParent
+import app.cash.redwood.Measurable.Companion.WrapContent
 
 internal fun packLong(lower: Int, higher: Int): Long {
   return (higher.toLong() shl 32) or (lower.toLong() and 0xFFFFFFFFL)


Test patch:
diff --git a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxEngineTest.kt b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxEngineTest.kt
index f2a4811d6..abe65910f 100644
--- a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxEngineTest.kt
+++ b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxEngineTest.kt
@@ -31,10 +31,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testCalculateHorizontalFlexLines() {
-    val node1 = Node(100, 100)
-    val node2 = Node(200, 100)
-    val node3 = Node(300, 100)
-    val node4 = Node(400, 100)
+    val node1 = Node(measurable = BoxMeasurable(100, 100))
+    val node2 = Node(measurable = BoxMeasurable(200, 100))
+    val node3 = Node(measurable = BoxMeasurable(300, 100))
+    val node4 = Node(measurable = BoxMeasurable(400, 100))
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -73,10 +73,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testCalculateVerticalFlexLines() {
-    val node1 = Node(100, 100)
-    val node2 = Node(100, 200)
-    val node3 = Node(100, 300)
-    val node4 = Node(100, 400)
+    val node1 = Node(measurable = BoxMeasurable(100, 100))
+    val node2 = Node(measurable = BoxMeasurable(100, 200))
+    val node3 = Node(measurable = BoxMeasurable(100, 300))
+    val node4 = Node(measurable = BoxMeasurable(100, 400))
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -116,10 +116,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineMainSize_direction_row_flexGrowSet() {
-    val node1 = Node(100, 100)
-    val node2 = Node(200, 100, flexGrow = 1.0f)
-    val node3 = Node(300, 100)
-    val node4 = Node(400, 100, flexGrow = 2.0f)
+    val node1 = Node(measurable = BoxMeasurable(100, 100))
+    val node2 = Node(measurable = BoxMeasurable(200, 100), flexGrow = 1.0f)
+    val node3 = Node(measurable = BoxMeasurable(300, 100))
+    val node4 = Node(measurable = BoxMeasurable(400, 100), flexGrow = 2.0f)
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -145,10 +145,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineMainSize_direction_column_flexGrowSet() {
-    val node1 = Node(100, 100)
-    val node2 = Node(100, 200, flexGrow = 1.0f)
-    val node3 = Node(100, 300)
-    val node4 = Node(100, 400, flexGrow = 2.0f)
+    val node1 = Node(measurable = BoxMeasurable(100, 100))
+    val node2 = Node(measurable = BoxMeasurable(100, 200), flexGrow = 1.0f)
+    val node3 = Node(measurable = BoxMeasurable(100, 300))
+    val node4 = Node(measurable = BoxMeasurable(100, 400), flexGrow = 2.0f)
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -174,10 +174,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineMainSize_direction_row_flexShrinkSet() {
-    val node1 = Node(200, 100)
-    val node2 = Node(200, 100)
-    val node3 = Node(200, 100)
-    val node4 = Node(200, 100)
+    val node1 = Node(measurable = BoxMeasurable(200, 100))
+    val node2 = Node(measurable = BoxMeasurable(200, 100))
+    val node3 = Node(measurable = BoxMeasurable(200, 100))
+    val node4 = Node(measurable = BoxMeasurable(200, 100))
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -203,10 +203,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineMainSize_direction_column_flexShrinkSet() {
-    val node1 = Node(100, 200)
-    val node2 = Node(100, 200)
-    val node3 = Node(100, 200)
-    val node4 = Node(100, 200)
+    val node1 = Node(measurable = BoxMeasurable(100, 200))
+    val node2 = Node(measurable = BoxMeasurable(100, 200))
+    val node3 = Node(measurable = BoxMeasurable(100, 200))
+    val node4 = Node(measurable = BoxMeasurable(100, 200))
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -232,8 +232,8 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineMainSize_directionRow_fixedSizeViewAndShrinkable_doNotExceedMaxMainSize() {
-    val node1 = Node(100, 100, flexShrink = 0f)
-    val node2 = Node(2000, 2000) // simulate a very long text view
+    val node1 = Node(measurable = BoxMeasurable(100, 100), flexShrink = 0f)
+    val node2 = Node(measurable = BoxMeasurable(2000, 2000)) // simulate a very long text view
     engine.nodes += node1
     engine.nodes += node2
     engine.flexWrap = FlexWrap.NoWrap
@@ -250,9 +250,9 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineMainSize_directionRow_twoFixedSizeViewsAndShrinkable_doNotExceedMaxMainSize() {
-    val node1 = Node(100, 100, flexShrink = 0f)
-    val node2 = Node(2000, 2000) // simulate a very long text view
-    val node3 = Node(100, 100, flexShrink = 0f)
+    val node1 = Node(measurable = BoxMeasurable(100, 100), flexShrink = 0f)
+    val node2 = Node(measurable = BoxMeasurable(2000, 2000)) // simulate a very long text view
+    val node3 = Node(measurable = BoxMeasurable(100, 100), flexShrink = 0f)
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -271,10 +271,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineCrossSize_direction_row_alignContent_stretch() {
-    val node1 = Node(100, 100)
-    val node2 = Node(200, 100)
-    val node3 = Node(300, 100)
-    val node4 = Node(400, 100)
+    val node1 = Node(measurable = BoxMeasurable(100, 100))
+    val node2 = Node(measurable = BoxMeasurable(200, 100))
+    val node3 = Node(measurable = BoxMeasurable(300, 100))
+    val node4 = Node(measurable = BoxMeasurable(400, 100))
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -300,10 +300,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testDetermineCrossSize_direction_column_alignContent_stretch() {
-    val node1 = Node(100, 100)
-    val node2 = Node(100, 200)
-    val node3 = Node(100, 300)
-    val node4 = Node(100, 400)
+    val node1 = Node(measurable = BoxMeasurable(100, 100))
+    val node2 = Node(measurable = BoxMeasurable(100, 200))
+    val node3 = Node(measurable = BoxMeasurable(100, 300))
+    val node4 = Node(measurable = BoxMeasurable(100, 400))
     engine.nodes += node1
     engine.nodes += node2
     engine.nodes += node3
@@ -365,10 +365,10 @@ class FlexboxEngineTest {
 
   @Test
   fun testFlexLine_anyItemsHaveFlexGrow() {
-    val node1 = Node(100, 100, flexGrow = 1.0f)
-    val node2 = Node(100, 200)
-    val node3 = Node(100, 300)
-    val node4 = Node(100, 400, flexGrow = 2.0f)
+    val node1 = Node(measurable = BoxMeasurable(100, 100), flexGrow = 1.0f)
+    val node2 = Node(measurable = BoxMeasurable(100, 200))
+    val node3 = Node(measurable = BoxMeasurable(100, 300))
+    val node4 = Node(measurable = BoxMeasurable(100, 400), flexGrow = 2.0f)
     engine.apply {
       nodes += node1
       nodes += node2
@@ -386,4 +386,9 @@ class FlexboxEngineTest {
     assertFalse(engine.flexLines[1].anyItemsHaveFlexGrow)
     assertTrue(engine.flexLines[2].anyItemsHaveFlexGrow)
   }
+
+  class BoxMeasurable(
+    override val width: Int,
+    override val height: Int,
+  ) : Measurable()
 }
diff --git a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxTest.kt b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxTest.kt
index cc750a24a..52a1347e3 100644
--- a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxTest.kt
+++ b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxTest.kt
@@ -115,7 +115,7 @@ class FlexboxTest {
     val widgets = imdbTop4.map { StringWidget(it) }
     val engine = FlexboxEngine().apply {
       flexDirection = Row
-      nodes += widgets.map { it.toNode(flexBasisPercent = 0f) }
+      nodes += widgets.map { it.toNode(Node(flexBasisPercent = 0f)) }
       justifyContent = JustifyContent.Center
     }
 
@@ -155,4 +155,11 @@ class FlexboxTest {
 
     return canvas.toString()
   }
+
+  private fun StringWidget.toNode(node: Node = Node()): Node {
+    return node.apply {
+      this.measurable = this@toNode
+      this.layout = this@toNode::layout
+    }
+  }
 }
diff --git a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringWidget.kt b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringWidget.kt
index 29fe6f99f..2265bae68 100644
--- a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringWidget.kt
+++ b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringWidget.kt
@@ -21,7 +21,7 @@ package app.cash.redwood
  */
 class StringWidget(
   private val text: String,
-) {
+) : Measurable() {
   private val words = text.split(" ")
 
   private var left = -1
@@ -29,21 +29,12 @@ class StringWidget(
   private var right = -1
   private var bottom = -1
 
-  // TODO: fix the Node API to not conflate layout parameters with measurements.
-  fun toNode(
-    flexBasisPercent: Float = Node.DefaultFlexBasisPercent,
-  ): Node {
-    return Node(
-      minWidth = words.maxOf { it.length } + 2,
-      minHeight = 2,
-      flexBasisPercent = flexBasisPercent,
-    ).apply {
-      measure = this@StringWidget::measure
-      layout = this@StringWidget::layout
-    }
-  }
+  override val minWidth
+    get() = words.maxOf { it.length } + 2
+  override val minHeight
+    get() = 2
 
-  private fun measure(widthSpec: MeasureSpec, heightSpec: MeasureSpec): Size {
+  override fun measure(widthSpec: MeasureSpec, heightSpec: MeasureSpec): Size {
     val widthSpecMode = widthSpec.mode
     val width = widthSpec.size
     val heightSpecMode = heightSpec.mode
@@ -73,7 +64,7 @@ class StringWidget(
     return Size(measuredWidth, measuredHeight)
   }
 
-  private fun layout(left: Int, top: Int, right: Int, bottom: Int) {
+  internal fun layout(left: Int, top: Int, right: Int, bottom: Int) {
     this.left = left
     this.top = top
     this.right = right