Repository: cashapp/redwood
Pull request #842 (2023-03-01 12:18:32)

Add support for Spacer to redwood-layout.
Adds support for a basic `Spacer` class to `redwood-layout`. The motivation for this is to support cases when we want to add space in between two items in a list, but want to avoid using the `padding` modifier as hiding the item it's attached to will also hide the padding. The `Spacer` uses the same units -> DPs conversion as the `padding` modifier.

API:
```
Spacer(height = 20)
```

[More context.](https://github.com/squareup/cash-treehouse/pull/1566)


Patch:
diff --git a/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiRedwoodLayoutWidgetFactory.kt b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiRedwoodLayoutWidgetFactory.kt
index 65709b8de..22ed6f742 100644
--- a/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiRedwoodLayoutWidgetFactory.kt
+++ b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiRedwoodLayoutWidgetFactory.kt
@@ -20,8 +20,10 @@ import app.cash.redwood.flexbox.FlexDirection
 import app.cash.redwood.layout.widget.Column
 import app.cash.redwood.layout.widget.RedwoodLayoutWidgetFactory
 import app.cash.redwood.layout.widget.Row
+import app.cash.redwood.layout.widget.Spacer
 
 public class ComposeUiRedwoodLayoutWidgetFactory : RedwoodLayoutWidgetFactory<@Composable () -> Unit> {
-  public override fun Column(): Column<@Composable () -> Unit> = ComposeUiFlexContainer(FlexDirection.Column)
-  public override fun Row(): Row<@Composable () -> Unit> = ComposeUiFlexContainer(FlexDirection.Row)
+  override fun Column(): Column<@Composable () -> Unit> = ComposeUiFlexContainer(FlexDirection.Column)
+  override fun Row(): Row<@Composable () -> Unit> = ComposeUiFlexContainer(FlexDirection.Row)
+  override fun Spacer(): Spacer<@Composable () -> Unit> = ComposeUiSpacer()
 }
diff --git a/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacer.kt b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacer.kt
new file mode 100644
index 000000000..bbbae922b
--- /dev/null
+++ b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacer.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2023 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.layout.composeui
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.widget.Spacer
+
+internal class ComposeUiSpacer : Spacer<@Composable () -> Unit> {
+  private var width by mutableStateOf(0)
+  private var height by mutableStateOf(0)
+
+  override val value = @Composable {
+    Spacer(Modifier.defaultMinSize(unitsToDp(width), unitsToDp(height)))
+  }
+
+  override var layoutModifiers: LayoutModifier = LayoutModifier
+
+  override fun width(width: Int) {
+    require(width >= 0) { "width must be >= 0: $width" }
+    this.width = width
+  }
+
+  override fun height(height: Int) {
+    require(height >= 0) { "height must be >= 0: $height" }
+    this.height = height
+  }
+}
diff --git a/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/utils.kt b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/utils.kt
index 944cd9411..1229eff1b 100644
--- a/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/utils.kt
+++ b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/utils.kt
@@ -18,6 +18,8 @@ package app.cash.redwood.layout.composeui
 import androidx.compose.ui.layout.Measurable
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
 import app.cash.redwood.flexbox.Measurable as RedwoodMeasurable
 import app.cash.redwood.flexbox.MeasureSpec
 import app.cash.redwood.flexbox.MeasureSpecMode
@@ -28,6 +30,10 @@ import app.cash.redwood.flexbox.Size
 // visually similar on both platforms.
 internal const val DensityMultiplier = 1.1
 
+internal fun unitsToDp(units: Int): Dp {
+  return (units / DensityMultiplier).dp
+}
+
 internal fun Constraints.toMeasureSpecs(): Pair<MeasureSpec, MeasureSpec> {
   val widthSpec = when {
     hasFixedWidth -> MeasureSpec.from(maxWidth.toDouble(), MeasureSpecMode.Exactly)
diff --git a/redwood-layout-dom/src/main/kotlin/app/cash/redwood/layout/dom/HTMLElementRedwoodLayoutWidgetFactory.kt b/redwood-layout-dom/src/main/kotlin/app/cash/redwood/layout/dom/HTMLElementRedwoodLayoutWidgetFactory.kt
index 714bd984f..8a9720cfc 100644
--- a/redwood-layout-dom/src/main/kotlin/app/cash/redwood/layout/dom/HTMLElementRedwoodLayoutWidgetFactory.kt
+++ b/redwood-layout-dom/src/main/kotlin/app/cash/redwood/layout/dom/HTMLElementRedwoodLayoutWidgetFactory.kt
@@ -20,11 +20,11 @@ import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Overflow
-import app.cash.redwood.layout.api.Overflow.Companion
 import app.cash.redwood.layout.api.Padding
 import app.cash.redwood.layout.widget.Column
 import app.cash.redwood.layout.widget.RedwoodLayoutWidgetFactory
 import app.cash.redwood.layout.widget.Row
+import app.cash.redwood.layout.widget.Spacer
 import app.cash.redwood.widget.HTMLElementChildren
 import org.w3c.dom.Document
 import org.w3c.dom.HTMLDivElement
@@ -46,6 +46,11 @@ class HTMLElementRedwoodLayoutWidgetFactory(
       direction = "row",
       overflowSetter = { style.overflowX = it },
     )
+
+  override fun Spacer(): Spacer<HTMLElement> =
+    HTMLSpacer(
+      value = document.createElement("div") as HTMLDivElement,
+    )
 }
 
 private class HTMLFlexContainer(
@@ -70,21 +75,15 @@ private class HTMLFlexContainer(
 
   override fun padding(padding: Padding) {
     value.style.apply {
-      paddingLeft = padding.start.toString()
-      paddingRight = padding.end.toString()
-      paddingTop = padding.top.toString()
-      paddingBottom = padding.bottom.toString()
+      paddingLeft = unitsToPx(padding.start)
+      paddingRight = unitsToPx(padding.end)
+      paddingTop = unitsToPx(padding.top)
+      paddingBottom = unitsToPx(padding.bottom)
     }
   }
 
   override fun overflow(overflow: Overflow) {
-    value.overflowSetter(
-      when (overflow) {
-        Overflow.Clip -> "hidden"
-        Companion.Scroll -> "scroll"
-        else -> throw AssertionError()
-      },
-    )
+    value.overflowSetter(overflow.toCss())
   }
 
   override fun horizontalAlignment(horizontalAlignment: MainAxisAlignment) {
@@ -103,22 +102,18 @@ private class HTMLFlexContainer(
     value.style.alignItems = verticalAlignment.toCss()
   }
 
-  private fun MainAxisAlignment.toCss() = when (this) {
-    MainAxisAlignment.Start -> "start"
-    MainAxisAlignment.Center -> "center"
-    MainAxisAlignment.End -> "end"
-    MainAxisAlignment.SpaceBetween -> "space-between"
-    MainAxisAlignment.SpaceAround -> "space-around"
-    MainAxisAlignment.SpaceEvenly -> "space-evenly"
-    else -> throw AssertionError()
+  override var layoutModifiers: LayoutModifier = LayoutModifier
+}
+
+private class HTMLSpacer(
+  override val value: HTMLDivElement,
+) : Spacer<HTMLElement> {
+  override fun width(width: Int) {
+    value.style.width = unitsToPx(width)
   }
 
-  private fun CrossAxisAlignment.toCss() = when (this) {
-    CrossAxisAlignment.Start -> "start"
-    CrossAxisAlignment.Center -> "center"
-    CrossAxisAlignment.End -> "end"
-    CrossAxisAlignment.Stretch -> "stretch"
-    else -> throw AssertionError()
+  override fun height(height: Int) {
+    value.style.height = unitsToPx(height)
   }
 
   override var layoutModifiers: LayoutModifier = LayoutModifier
diff --git a/redwood-layout-dom/src/main/kotlin/app/cash/redwood/layout/dom/utils.kt b/redwood-layout-dom/src/main/kotlin/app/cash/redwood/layout/dom/utils.kt
new file mode 100644
index 000000000..dd2319a14
--- /dev/null
+++ b/redwood-layout-dom/src/main/kotlin/app/cash/redwood/layout/dom/utils.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2023 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.layout.dom
+
+import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.MainAxisAlignment
+import app.cash.redwood.layout.api.Overflow
+import kotlin.math.roundToInt
+
+// TODO: Figure out a good density multiple to map units into a reasonable size on web.
+internal const val DensityMultiplier = 1.0
+
+internal fun unitsToPx(units: Int): String {
+  return "${(DensityMultiplier * units).roundToInt()}px"
+}
+
+internal fun MainAxisAlignment.toCss() = when (this) {
+  MainAxisAlignment.Start -> "start"
+  MainAxisAlignment.Center -> "center"
+  MainAxisAlignment.End -> "end"
+  MainAxisAlignment.SpaceBetween -> "space-between"
+  MainAxisAlignment.SpaceAround -> "space-around"
+  MainAxisAlignment.SpaceEvenly -> "space-evenly"
+  else -> throw AssertionError()
+}
+
+internal fun CrossAxisAlignment.toCss() = when (this) {
+  CrossAxisAlignment.Start -> "start"
+  CrossAxisAlignment.Center -> "center"
+  CrossAxisAlignment.End -> "end"
+  CrossAxisAlignment.Stretch -> "stretch"
+  else -> throw AssertionError()
+}
+
+internal fun Overflow.toCss() = when (this) {
+  Overflow.Clip -> "hidden"
+  Overflow.Scroll -> "scroll"
+  else -> throw AssertionError()
+}
diff --git a/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/schema.kt b/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/schema.kt
index 679c6fd09..c9cb38d08 100644
--- a/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/schema.kt
+++ b/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/schema.kt
@@ -19,8 +19,9 @@ import app.cash.redwood.schema.Schema
 
 @Schema(
   [
-    Column::class,
     Row::class,
+    Column::class,
+    Spacer::class,
     Grow::class,
     Shrink::class,
     Padding::class,
diff --git a/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/widgets.kt b/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/widgets.kt
index e9097012b..2cec40566 100644
--- a/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/widgets.kt
+++ b/redwood-layout-schema/src/main/kotlin/app/cash/redwood/layout/widgets.kt
@@ -74,3 +74,13 @@ public data class Column(
 )
 
 public object ColumnScope
+
+@Widget(3)
+public data class Spacer(
+  @Property(1)
+  @Default("0")
+  val width: Int,
+  @Property(2)
+  @Default("0")
+  val height: Int,
+)
diff --git a/redwood-layout-uiview/native/uikit/RedwoodViewDelegate.h b/redwood-layout-uiview/native/uikit/RedwoodViewDelegate.h
new file mode 100644
index 000000000..041a8a302
--- /dev/null
+++ b/redwood-layout-uiview/native/uikit/RedwoodViewDelegate.h
@@ -0,0 +1,9 @@
+#import <CoreGraphics/CoreGraphics.h>
+#import <Foundation/Foundation.h>
+#import <UIKit/UIKit.h>
+
+@protocol RedwoodViewDelegate <NSObject>
+
+ @property(nonatomic, readonly) CGSize intrinsicContentSize;
+
+@end
diff --git a/redwood-layout-uiview/native/uikit/uiview.h b/redwood-layout-uiview/native/uikit/uiview.h
index 697bf9699..c119c1b41 100644
--- a/redwood-layout-uiview/native/uikit/uiview.h
+++ b/redwood-layout-uiview/native/uikit/uiview.h
@@ -16,8 +16,13 @@
 #import <CoreGraphics/CoreGraphics.h>
 #import <Foundation/Foundation.h>
 #import <UIKit/UIKit.h>
+#import "RedwoodViewDelegate.h"
 #import "RedwoodScrollViewDelegate.h"
 
-@interface HostView : UIScrollView
+@interface FlexContainerHostView : UIScrollView
 @property id <RedwoodScrollViewDelegate> kotlinDelegate;
 @end
+
+@interface SpacerHostView : UIView
+@property id <RedwoodViewDelegate> kotlinDelegate;
+@end
diff --git a/redwood-layout-uiview/native/uikit/uiview.m b/redwood-layout-uiview/native/uikit/uiview.m
index ae991dab0..a045b5096 100644
--- a/redwood-layout-uiview/native/uikit/uiview.m
+++ b/redwood-layout-uiview/native/uikit/uiview.m
@@ -15,7 +15,7 @@
  */
 #import "uiview.h"
 
-@implementation HostView
+@implementation FlexContainerHostView
 
 - (id)initWithFrame :(CGRect)frame :(id <RedwoodScrollViewDelegate>)kotlinDelegate
 {
@@ -47,3 +47,19 @@
 }
 
 @end
+
+@implementation SpacerHostView
+
+- (id)initWithFrame :(CGRect)frame :(id <RedwoodViewDelegate>)kotlinDelegate
+{
+  self = [super initWithFrame:frame];
+  self.kotlinDelegate = kotlinDelegate;
+    return self;
+}
+
+- (CGSize)intrinsicContentSize
+{
+  return [self.kotlinDelegate intrinsicContentSize];
+}
+
+@end
diff --git a/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewFlexContainer.kt b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewFlexContainer.kt
index 2582fe467..37b8c082b 100644
--- a/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewFlexContainer.kt
+++ b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewFlexContainer.kt
@@ -26,7 +26,7 @@ import app.cash.redwood.layout.api.CrossAxisAlignment
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Overflow
 import app.cash.redwood.layout.api.Padding
-import app.cash.redwood.layout.uiview.cinterop.HostView
+import app.cash.redwood.layout.uiview.cinterop.FlexContainerHostView
 import app.cash.redwood.layout.uiview.cinterop.RedwoodScrollViewDelegateProtocol
 import app.cash.redwood.layout.widget.Column
 import app.cash.redwood.layout.widget.Row
@@ -51,7 +51,7 @@ internal class UIViewFlexContainer(
     roundToInt = false
   }
 
-  override val value = HostView().apply {
+  override val value = FlexContainerHostView().apply {
     kotlinDelegate = UIViewDelegate()
     showsHorizontalScrollIndicator = false
     showsVerticalScrollIndicator = false
@@ -117,7 +117,7 @@ internal class UIViewFlexContainer(
     override fun intrinsicContentSize(): CValue<CGSize> = CGSizeMake(noIntrinsicSize.width, noIntrinsicSize.height)
 
     override fun sizeThatFits(size: CValue<CGSize>): CValue<CGSize> =
-      measure(size.useContents { toUnsafeSize() }).let { us -> CGSizeMake(us.width, us.height) }
+      measure(size.useContents { toUnsafeSize() }).run { CGSizeMake(width, height) }
 
     override fun setNeedsLayout() {
       needsLayout = true
diff --git a/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewRedwoodLayoutWidgetFactory.kt b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewRedwoodLayoutWidgetFactory.kt
index b8a1c50ee..d10de648a 100644
--- a/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewRedwoodLayoutWidgetFactory.kt
+++ b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewRedwoodLayoutWidgetFactory.kt
@@ -19,9 +19,11 @@ import app.cash.redwood.flexbox.FlexDirection
 import app.cash.redwood.layout.widget.Column
 import app.cash.redwood.layout.widget.RedwoodLayoutWidgetFactory
 import app.cash.redwood.layout.widget.Row
+import app.cash.redwood.layout.widget.Spacer
 import platform.UIKit.UIView
 
 public class UIViewRedwoodLayoutWidgetFactory : RedwoodLayoutWidgetFactory<UIView> {
   override fun Column(): Column<UIView> = UIViewFlexContainer(FlexDirection.Column)
   override fun Row(): Row<UIView> = UIViewFlexContainer(FlexDirection.Row)
+  override fun Spacer(): Spacer<UIView> = UIViewSpacer()
 }
diff --git a/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewSpacer.kt b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewSpacer.kt
new file mode 100644
index 000000000..0dbd33e9c
--- /dev/null
+++ b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/UIViewSpacer.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2023 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.layout.uiview
+
+import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.uiview.cinterop.RedwoodViewDelegateProtocol
+import app.cash.redwood.layout.uiview.cinterop.SpacerHostView
+import app.cash.redwood.layout.widget.Spacer
+import platform.CoreGraphics.CGSizeMake
+import platform.UIKit.UIView
+import platform.UIKit.invalidateIntrinsicContentSize
+import platform.UIKit.setNeedsLayout
+import platform.darwin.NSObject
+
+internal class UIViewSpacer : Spacer<UIView> {
+  private var width = 0.0
+  private var height = 0.0
+
+  override val value: UIView = SpacerHostView().apply {
+    kotlinDelegate = UIViewDelegate()
+  }
+
+  override var layoutModifiers: LayoutModifier = LayoutModifier
+
+  override fun width(width: Int) {
+    require(width >= 0) { "width must be >= 0: $width" }
+    this.width = width.toDouble()
+    invalidate()
+  }
+
+  override fun height(height: Int) {
+    require(height >= 0) { "height must be >= 0: $height" }
+    this.height = height.toDouble()
+    invalidate()
+  }
+
+  private fun invalidate() {
+    value.invalidateIntrinsicContentSize()
+    value.setNeedsLayout()
+  }
+
+  private inner class UIViewDelegate : NSObject(), RedwoodViewDelegateProtocol {
+    override fun intrinsicContentSize() = CGSizeMake(width, height)
+  }
+}
diff --git a/redwood-layout-uiview/src/nativeInterop/cinterop/uikit.def b/redwood-layout-uiview/src/nativeInterop/cinterop/uikit.def
index a784ba0fe..92eaa0eb8 100644
--- a/redwood-layout-uiview/src/nativeInterop/cinterop/uikit.def
+++ b/redwood-layout-uiview/src/nativeInterop/cinterop/uikit.def
@@ -1,3 +1,3 @@
 package = app.cash.redwood.layout.uiview.cinterop
 language = Objective-C
-headers = uiview.h RedwoodScrollViewDelegate.h
+headers = uiview.h RedwoodViewDelegate.h RedwoodScrollViewDelegate.h
diff --git a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRedwoodLayoutWidgetFactory.kt b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRedwoodLayoutWidgetFactory.kt
index d6b613367..989b8ea51 100644
--- a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRedwoodLayoutWidgetFactory.kt
+++ b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRedwoodLayoutWidgetFactory.kt
@@ -21,10 +21,12 @@ import app.cash.redwood.flexbox.FlexDirection
 import app.cash.redwood.layout.widget.Column
 import app.cash.redwood.layout.widget.RedwoodLayoutWidgetFactory
 import app.cash.redwood.layout.widget.Row
+import app.cash.redwood.layout.widget.Spacer
 
 public class ViewRedwoodLayoutWidgetFactory(
   private val context: Context,
 ) : RedwoodLayoutWidgetFactory<View> {
-  public override fun Column(): Column<View> = ViewFlexContainer(context, FlexDirection.Column)
-  public override fun Row(): Row<View> = ViewFlexContainer(context, FlexDirection.Row)
+  override fun Column(): Column<View> = ViewFlexContainer(context, FlexDirection.Column)
+  override fun Row(): Row<View> = ViewFlexContainer(context, FlexDirection.Row)
+  override fun Spacer(): Spacer<View> = ViewSpacer(context)
 }
diff --git a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewSpacer.kt b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewSpacer.kt
new file mode 100644
index 000000000..c8bec808c
--- /dev/null
+++ b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewSpacer.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2023 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.layout.view
+
+import android.content.Context
+import android.view.View
+import android.widget.Space
+import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.widget.Spacer
+import kotlin.math.roundToInt
+
+internal class ViewSpacer(
+  context: Context,
+) : Spacer<View> {
+  private val density = DensityMultiplier * context.resources.displayMetrics.density
+
+  override val value = Space(context)
+
+  override var layoutModifiers: LayoutModifier = LayoutModifier
+
+  override fun width(width: Int) {
+    require(width >= 0) { "width must be >= 0: $width" }
+    value.minimumWidth = unitsToPx(width)
+    invalidate()
+  }
+
+  override fun height(height: Int) {
+    require(height >= 0) { "height must be >= 0: $height" }
+    value.minimumHeight = unitsToPx(height)
+    invalidate()
+  }
+
+  private fun unitsToPx(units: Int): Int {
+    return (density * units).roundToInt()
+  }
+
+  private fun invalidate() {
+    value.invalidate()
+    value.requestLayout()
+  }
+}


Test patch:
diff --git a/redwood-layout-composeui/src/androidInstrumentedTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacerTest.kt b/redwood-layout-composeui/src/androidInstrumentedTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacerTest.kt
new file mode 100644
index 000000000..6ee93be57
--- /dev/null
+++ b/redwood-layout-composeui/src/androidInstrumentedTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacerTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2023 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.layout.composeui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Paparazzi
+import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.AbstractSpacerTest
+import app.cash.redwood.layout.widget.Spacer
+import app.cash.redwood.widget.Widget
+import com.android.ide.common.rendering.api.SessionParams
+import org.junit.Rule
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class ComposeUiSpacerTest : AbstractSpacerTest<@Composable () -> Unit>() {
+
+  @get:Rule
+  val paparazzi = Paparazzi(
+    deviceConfig = DeviceConfig.PIXEL_6,
+    theme = "android:Theme.Material.Light.NoActionBar",
+    renderingMode = SessionParams.RenderingMode.SHRINK,
+  )
+
+  override fun widget(
+    width: Int,
+    height: Int,
+    layoutModifier: LayoutModifier,
+  ): Spacer<@Composable () -> Unit> = ComposeUiSpacer().apply {
+    layoutModifiers = layoutModifier
+    width(width)
+    height(height)
+  }
+
+  override fun wrap(widget: Widget<@Composable () -> Unit>, horizontal: Boolean) = @Composable {
+    if (horizontal) {
+      Row {
+        BasicText("Text 1")
+        widget.value()
+        BasicText("Text 2")
+      }
+    } else {
+      Column {
+        BasicText("Text 1")
+        widget.value()
+        BasicText("Text 2")
+      }
+    }
+  }
+
+  override fun verifySnapshot(value: @Composable () -> Unit) {
+    paparazzi.snapshot(composable = value)
+  }
+}
diff --git a/redwood-layout-shared-test/src/main/kotlin/app/cash/redwood/layout/AbstractSpacerTest.kt b/redwood-layout-shared-test/src/main/kotlin/app/cash/redwood/layout/AbstractSpacerTest.kt
new file mode 100644
index 000000000..aff010be1
--- /dev/null
+++ b/redwood-layout-shared-test/src/main/kotlin/app/cash/redwood/layout/AbstractSpacerTest.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2023 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.layout
+
+import app.cash.redwood.LayoutModifier
+import app.cash.redwood.widget.Widget
+import org.junit.Test
+
+abstract class AbstractSpacerTest<T : Any> {
+
+  abstract fun widget(
+    width: Int = 0,
+    height: Int = 0,
+    layoutModifier: LayoutModifier = LayoutModifier,
+  ): Widget<T>
+
+  abstract fun wrap(widget: Widget<T>, horizontal: Boolean): T
+
+  abstract fun verifySnapshot(value: T)
+
+  @Test fun zeroSpacer() {
+    val widget = widget(width = 0, height = 0)
+    verifySnapshot(wrap(widget, horizontal = true))
+  }
+
+  @Test fun widthOnlySpacer() {
+    val widget = widget(width = 100, height = 0)
+    verifySnapshot(wrap(widget, horizontal = true))
+  }
+
+  @Test fun heightOnlySpacer() {
+    val widget = widget(width = 0, height = 100)
+    verifySnapshot(wrap(widget, horizontal = false))
+  }
+
+  @Test fun bothSpacer() {
+    val widget = widget(width = 100, height = 100)
+    verifySnapshot(wrap(widget, horizontal = false))
+  }
+}
diff --git a/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewSpacerTest.kt b/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewSpacerTest.kt
new file mode 100644
index 000000000..61628ae3d
--- /dev/null
+++ b/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewSpacerTest.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2023 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.layout.view
+
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.LinearLayout.HORIZONTAL
+import android.widget.LinearLayout.VERTICAL
+import android.widget.TextView
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Paparazzi
+import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.AbstractSpacerTest
+import app.cash.redwood.layout.widget.Spacer
+import app.cash.redwood.widget.Widget
+import com.android.ide.common.rendering.api.SessionParams
+import org.junit.Rule
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class ViewSpacerTest : AbstractSpacerTest<View>() {
+
+  @get:Rule
+  val paparazzi = Paparazzi(
+    deviceConfig = DeviceConfig.PIXEL_6,
+    theme = "android:Theme.Material.Light.NoActionBar",
+    renderingMode = SessionParams.RenderingMode.SHRINK,
+    showSystemUi = false,
+  )
+
+  override fun widget(
+    width: Int,
+    height: Int,
+    layoutModifier: LayoutModifier,
+  ): Spacer<View> = ViewSpacer(paparazzi.context).apply {
+    layoutModifiers = layoutModifier
+    width(width)
+    height(height)
+  }
+
+  override fun wrap(widget: Widget<View>, horizontal: Boolean): View {
+    return LinearLayout(paparazzi.context).apply {
+      orientation = if (horizontal) HORIZONTAL else VERTICAL
+      addView(TextView(paparazzi.context).apply { text = "Text 1" })
+      addView(widget.value)
+      addView(TextView(paparazzi.context).apply { text = "Text 2" })
+    }
+  }
+
+  override fun verifySnapshot(value: View) {
+    paparazzi.snapshot(value)
+  }
+}
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_bothSpacer.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_bothSpacer.png
new file mode 100644
index 000000000..0ad4aa85a
--- /dev/null
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_bothSpacer.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f6a70cb49698337a6cf5f7aa6a47cdfe5a5a3a237299b44b6d3c133810fbfb80
+size 4204
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_heightOnlySpacer.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_heightOnlySpacer.png
new file mode 100644
index 000000000..2bcf58616
--- /dev/null
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_heightOnlySpacer.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2bdf89dbbc4cc77e0107c42b244e46667ae939c963c1b8f22dadc1d86d6d8b26
+size 3587
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_widthOnlySpacer.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_widthOnlySpacer.png
new file mode 100644
index 000000000..1623714ec
--- /dev/null
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_widthOnlySpacer.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5eab4dedd2f3b8c8498962370a612a2441fa4639051039ca3cdaf70742ee33e4
+size 2051
diff --git a/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_zeroSpacer.png b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_zeroSpacer.png
new file mode 100644
index 000000000..7095267b7
--- /dev/null
+++ b/redwood-layout-view/src/test/snapshots/images/app.cash.redwood.layout.view_ViewSpacerTest_zeroSpacer.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e3db33855398e2e393f9af89af0ee39b69a66b9b0e723b4e3b52e49a68e0f388
+size 1895
diff --git a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/EmptyRedwoodLayoutWidgetFactory.kt b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/EmptyRedwoodLayoutWidgetFactory.kt
index 84e990ea8..61392bbec 100644
--- a/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/EmptyRedwoodLayoutWidgetFactory.kt
+++ b/redwood-protocol-widget/src/commonTest/kotlin/app/cash/redwood/protocol/widget/EmptyRedwoodLayoutWidgetFactory.kt
@@ -20,4 +20,5 @@ import app.cash.redwood.layout.widget.RedwoodLayoutWidgetFactory
 class EmptyRedwoodLayoutWidgetFactory : RedwoodLayoutWidgetFactory<Nothing> {
   override fun Column() = TODO()
   override fun Row() = TODO()
+  override fun Spacer() = TODO()
 }