Repository: cashapp/redwood
Pull request #934 (2023-04-19 09:14:16)

Add Dp and Density classes.
Introduces `Dp` and `Density` classes to Redwood with similar designs to Compose UI.

This cleans up a lot of the guest -> host size value conversion logic that was happening in the `redwood-layout` classes and makes it reusable and type-safe. It also changes our size values to be represented as doubles, which helps us avoid losing precision (by rounding to ints) when sending size info host -> guest (like with `HostConfiguration.safeAreaInsets`).

It also reverts `Margin.left/right` back to `Margin.start/end`. Though, I think there's probably still fixes that need to be done to properly support RTL.


Patch:
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index b54cbf8b6..284ef27eb 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -23,7 +23,7 @@ jobs:
         env:
           ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}
           ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}
-          ORG_GRADLE_PROJECT_signingKey: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY }}
+          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY }}
         run: ./gradlew publish dokkaHtmlMultiModule browserProductionWebpack --parallel
 
       - name: Extract release notes
diff --git a/build-support/src/main/resources/app/cash/redwood/buildsupport/flexboxHelpers.kt b/build-support/src/main/resources/app/cash/redwood/buildsupport/flexboxHelpers.kt
index f7cf2425d..317eacfd0 100644
--- a/build-support/src/main/resources/app/cash/redwood/buildsupport/flexboxHelpers.kt
+++ b/build-support/src/main/resources/app/cash/redwood/buildsupport/flexboxHelpers.kt
@@ -33,6 +33,7 @@ import app.cash.redwood.layout.Margin as MarginModifier
 import app.cash.redwood.layout.Shrink as ShrinkModifier
 import app.cash.redwood.layout.VerticalAlignment as VerticalAlignmentModifier
 import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Margin
 
@@ -62,16 +63,18 @@ internal fun CrossAxisAlignment.toAlignSelf() = when (this) {
   else -> throw AssertionError()
 }
 
-internal fun Margin.toSpacing(density: Double) = Spacing(
-  left = density * left.toDouble(),
-  right = density * right.toDouble(),
-  top = density * top.toDouble(),
-  bottom = density * bottom.toDouble(),
-)
+internal fun Margin.toSpacing(density: Density) = with(density) {
+  Spacing(
+    left = start.toPx(),
+    right = end.toPx(),
+    top = top.toPx(),
+    bottom = bottom.toPx(),
+  )
+}
 
 internal fun newFlexItem(
   direction: FlexDirection,
-  density: Double,
+  density: Density,
   layoutModifiers: LayoutModifier,
   measurable: Measurable,
 ): FlexItem {
diff --git a/build.gradle b/build.gradle
index 2117c3c60..23b789f5b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -211,14 +211,6 @@ allprojects {
     }
   }
 
-  plugins.withId('signing') {
-    signing {
-      def signingKey = findProperty('signingKey')
-      def signingPassword = ''
-      useInMemoryPgpKeys(signingKey, signingPassword)
-    }
-  }
-
   apply plugin: 'com.diffplug.spotless'
   spotless {
     kotlin {
diff --git a/redwood-layout-api-density/build.gradle b/redwood-layout-api-density/build.gradle
new file mode 100644
index 000000000..6f2cb03cc
--- /dev/null
+++ b/redwood-layout-api-density/build.gradle
@@ -0,0 +1,30 @@
+import app.cash.redwood.buildsupport.KmpTargets
+
+apply plugin: 'org.jetbrains.kotlin.multiplatform'
+apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
+apply plugin: 'com.android.library'
+apply plugin: 'com.vanniktech.maven.publish'
+apply plugin: 'org.jetbrains.dokka' // Must be applied here for publish plugin.
+apply plugin: 'app.cash.redwood.build.compose'
+
+kotlin {
+  KmpTargets.addAllTargets(project)
+
+  sourceSets {
+    commonMain {
+      dependencies {
+        api libs.jetbrains.compose.runtime
+        api libs.kotlinx.serialization.core
+      }
+    }
+    commonTest {
+      dependencies {
+        implementation libs.kotlin.test
+      }
+    }
+  }
+}
+
+android {
+  namespace 'app.cash.redwood.layout.api.density'
+}
diff --git a/redwood-layout-api-density/gradle.properties b/redwood-layout-api-density/gradle.properties
new file mode 100644
index 000000000..d25f298ec
--- /dev/null
+++ b/redwood-layout-api-density/gradle.properties
@@ -0,0 +1,17 @@
+#
+# 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.
+#
+POM_ARTIFACT_ID=redwood-layout-api-density
+POM_NAME=Redwood Layout API Density
diff --git a/redwood-layout-api-density/src/androidMain/kotlin/app/cash/redwood/layout/api/density.kt b/redwood-layout-api-density/src/androidMain/kotlin/app/cash/redwood/layout/api/density.kt
new file mode 100644
index 000000000..6f1871bf4
--- /dev/null
+++ b/redwood-layout-api-density/src/androidMain/kotlin/app/cash/redwood/layout/api/density.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.api
+
+import android.content.res.Resources
+
+// Android uses 2.75 as a density scale for most recent Pixel devices and iOS
+// uses 3. This aligns the two so the generic values used by Redwood layout are
+// visually similar on both platforms.
+internal actual const val DensityMultiplier = 1.1
+
+/**
+ * Create a [Density] from the device's [Resources].
+ */
+public fun Density(resources: Resources): Density {
+  return Density(resources.displayMetrics.density.toDouble())
+}
diff --git a/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Density.kt b/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Density.kt
new file mode 100644
index 000000000..a1f37de66
--- /dev/null
+++ b/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Density.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.api
+
+import androidx.compose.runtime.Immutable
+import kotlin.jvm.JvmInline
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents a device's display density.
+ *
+ * @param rawDensity The raw, platform-specific density which hasn't been
+ *  normalized with respect to other platforms.
+ */
+@[Immutable JvmInline Serializable]
+public value class Density(
+  public val rawDensity: Double,
+) {
+  init {
+    require(rawDensity > 0) {
+      "rawDensity must be positive: $rawDensity"
+    }
+  }
+
+  /** Convert a [Dp] into a pixel value. */
+  public fun Dp.toPx(): Double {
+    return value * DensityMultiplier * rawDensity
+  }
+
+  /** Convert a pixel value into a [Dp]. */
+  public fun Int.toDp(): Dp {
+    return Dp(this / DensityMultiplier / rawDensity)
+  }
+
+  /** Convert a pixel value into a [Dp]. */
+  public fun Float.toDp(): Dp {
+    return Dp(this / DensityMultiplier / rawDensity)
+  }
+
+  /** Convert a pixel value into a [Dp]. */
+  public fun Double.toDp(): Dp {
+    return Dp(this / DensityMultiplier / rawDensity)
+  }
+
+  /** Empty companion object used for extensions. */
+  public companion object
+}
+
+/**
+ * A multiplier that's used to normalize the density values of each
+ * target platform so [Dp] values look similar on each device.
+ */
+internal expect val DensityMultiplier: Double
diff --git a/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Dp.kt b/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Dp.kt
new file mode 100644
index 000000000..20d9f97c1
--- /dev/null
+++ b/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Dp.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.api
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import kotlin.jvm.JvmInline
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents a density-independent pixel value. Values will be scaled and
+ * converted into standard pixel values according to the device's density.
+ */
+@[Immutable JvmInline Serializable]
+public value class Dp(
+  public val value: Double,
+) {
+  init {
+    require(value >= 0) {
+      "value must be non-negative: $value"
+    }
+  }
+
+  override fun toString(): String = "$value.dp"
+
+  /** Empty companion object used for extensions. */
+  public companion object
+}
+
+/** Create a [Dp] from an [Int]. */
+@Stable
+public inline val Int.dp: Dp get() = Dp(toDouble())
+
+/** Create a [Dp] from a [Float]. */
+@Stable
+public inline val Float.dp: Dp get() = Dp(toDouble())
+
+/** Create a [Dp] from a [Double]. */
+@Stable
+public inline val Double.dp: Dp get() = Dp(toDouble())
+
+/**
+ * Convert device-agnostic density-independent pixel value into a
+ * device-specific density-independent pixel value.
+ */
+public fun Dp.toPlatformDp(): Double {
+  return value / DensityMultiplier
+}
+
+/**
+ * Convert device-specific density-independent pixel value into a
+ * device-agnostic density-independent pixel value.
+ */
+public fun Dp.Companion.fromPlatformDp(value: Double): Dp {
+  return Dp(value * DensityMultiplier)
+}
diff --git a/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Margin.kt b/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Margin.kt
new file mode 100644
index 000000000..f86fcc29c
--- /dev/null
+++ b/redwood-layout-api-density/src/commonMain/kotlin/app/cash/redwood/layout/api/Margin.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.api
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import kotlinx.serialization.Serializable
+
+@[Immutable Serializable]
+public data class Margin(
+  val start: Dp = 0.dp,
+  val end: Dp = 0.dp,
+  val top: Dp = 0.dp,
+  val bottom: Dp = 0.dp,
+) {
+
+  override fun toString(): String = when {
+    start != end || top != bottom -> {
+      "Margin(start=$start, end=$end, top=$top, bottom=$bottom)"
+    }
+    start != top -> {
+      "Margin(horizontal=$start, vertical=$top)"
+    }
+    else -> "Margin(all=$start)"
+  }
+
+  public companion object {
+    public val Zero: Margin = Margin()
+  }
+}
+
+@Stable
+public fun Margin(
+  horizontal: Dp = 0.dp,
+  vertical: Dp = 0.dp,
+): Margin = Margin(horizontal, horizontal, vertical, vertical)
+
+@Stable
+public fun Margin(
+  all: Dp = 0.dp,
+): Margin = Margin(all, all, all, all)
diff --git a/redwood-layout-api-density/src/iosMain/kotlin/app/cash/redwood/layout/api/density.kt b/redwood-layout-api-density/src/iosMain/kotlin/app/cash/redwood/layout/api/density.kt
new file mode 100644
index 000000000..14971d006
--- /dev/null
+++ b/redwood-layout-api-density/src/iosMain/kotlin/app/cash/redwood/layout/api/density.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.api
+
+/** iOS's coordinate system accounts for density automatically. */
+public val Density.Companion.Default: Density get() = DefaultDensity
+
+private val DefaultDensity = Density(1.0)
+
+internal actual const val DensityMultiplier = 1.0
diff --git a/redwood-layout-api-density/src/jsMain/kotlin/app/cash/redwood/layout/api/density.kt b/redwood-layout-api-density/src/jsMain/kotlin/app/cash/redwood/layout/api/density.kt
new file mode 100644
index 000000000..568bac732
--- /dev/null
+++ b/redwood-layout-api-density/src/jsMain/kotlin/app/cash/redwood/layout/api/density.kt
@@ -0,0 +1,18 @@
+/*
+ * 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.api
+
+internal actual const val DensityMultiplier = 1.0
diff --git a/redwood-layout-api-density/src/jvmMain/kotlin/app/cash/redwood/layout/api/density.kt b/redwood-layout-api-density/src/jvmMain/kotlin/app/cash/redwood/layout/api/density.kt
new file mode 100644
index 000000000..568bac732
--- /dev/null
+++ b/redwood-layout-api-density/src/jvmMain/kotlin/app/cash/redwood/layout/api/density.kt
@@ -0,0 +1,18 @@
+/*
+ * 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.api
+
+internal actual const val DensityMultiplier = 1.0
diff --git a/redwood-layout-api-density/src/macosMain/kotlin/app/cash/redwood/layout/api/density.kt b/redwood-layout-api-density/src/macosMain/kotlin/app/cash/redwood/layout/api/density.kt
new file mode 100644
index 000000000..2aa67f831
--- /dev/null
+++ b/redwood-layout-api-density/src/macosMain/kotlin/app/cash/redwood/layout/api/density.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.api
+
+/** MacOS's coordinate system accounts for density automatically. */
+public val Density.Companion.Default: Density get() = DefaultDensity
+
+private val DefaultDensity = Density(1.0)
+
+internal actual const val DensityMultiplier = 1.0
diff --git a/redwood-layout-api/build.gradle b/redwood-layout-api/build.gradle
index 9906338d5..1a2fef000 100644
--- a/redwood-layout-api/build.gradle
+++ b/redwood-layout-api/build.gradle
@@ -12,6 +12,7 @@ kotlin {
   sourceSets {
     commonMain {
       dependencies {
+        api projects.redwoodLayoutApiDensity
         api libs.jetbrains.compose.runtime
         api libs.kotlinx.serialization.core
       }
diff --git a/redwood-layout-api/src/commonMain/kotlin/app/cash/redwood/layout/api/properties.kt b/redwood-layout-api/src/commonMain/kotlin/app/cash/redwood/layout/api/properties.kt
index 25d368ee9..d6687b683 100644
--- a/redwood-layout-api/src/commonMain/kotlin/app/cash/redwood/layout/api/properties.kt
+++ b/redwood-layout-api/src/commonMain/kotlin/app/cash/redwood/layout/api/properties.kt
@@ -18,7 +18,6 @@
 package app.cash.redwood.layout.api
 
 import androidx.compose.runtime.Immutable
-import androidx.compose.runtime.Stable
 import kotlin.jvm.JvmInline
 import kotlinx.serialization.Serializable
 
@@ -26,8 +25,7 @@ import kotlinx.serialization.Serializable
 //  Blocked by https://issuetracker.google.com/issues/251430194.
 
 /** Controls how the container should determine its width/height. */
-@JvmInline
-@Serializable
+@[Immutable JvmInline Serializable]
 public value class Constraint internal constructor(internal val ordinal: Int) {
 
   override fun toString(): String = when (ordinal) {
@@ -43,8 +41,7 @@ public value class Constraint internal constructor(internal val ordinal: Int) {
 }
 
 /** Equivalent to `justify-content`. */
-@JvmInline
-@Serializable
+@[Immutable JvmInline Serializable]
 public value class MainAxisAlignment internal constructor(internal val ordinal: Int) {
 
   override fun toString(): String = when (ordinal) {
@@ -68,8 +65,7 @@ public value class MainAxisAlignment internal constructor(internal val ordinal:
 }
 
 /** Equivalent to `align-items`. */
-@JvmInline
-@Serializable
+@[Immutable JvmInline Serializable]
 public value class CrossAxisAlignment internal constructor(internal val ordinal: Int) {
 
   override fun toString(): String = when (ordinal) {
@@ -89,8 +85,7 @@ public value class CrossAxisAlignment internal constructor(internal val ordinal:
 }
 
 /** Equivalent to `overflow-x`/`overflow-y`. */
-@JvmInline
-@Serializable
+@[Immutable JvmInline Serializable]
 public value class Overflow internal constructor(internal val ordinal: Int) {
 
   override fun toString(): String = when (ordinal) {
@@ -104,59 +99,3 @@ public value class Overflow internal constructor(internal val ordinal: Int) {
     public val Scroll: Overflow = Overflow(1)
   }
 }
-
-@Immutable
-@Serializable
-public data class Margin(
-  val left: Int = 0,
-  val right: Int = 0,
-  val top: Int = 0,
-  val bottom: Int = 0,
-) {
-  init {
-    require(left >= 0 && right >= 0 && top >= 0 && bottom >= 0) {
-      "Invalid Margin: [$left, $right, $top, $bottom]"
-    }
-  }
-
-  override fun toString(): String {
-    return if (left == right && top == bottom) {
-      if (left == top) {
-        "Margin(all=$left)"
-      } else {
-        "Margin(horizontal=$left, vertical=$top)"
-      }
-    } else {
-      "Margin(left=$left, right=$right, top=$top, bottom=$bottom)"
-    }
-  }
-
-  public companion object {
-    public val Zero: Margin = Margin()
-  }
-}
-
-@Stable
-public fun Margin(
-  horizontal: Int = 0,
-  vertical: Int = 0,
-): Margin = Margin(horizontal, horizontal, vertical, vertical)
-
-@Stable
-public fun Margin(
-  all: Int = 0,
-): Margin = Margin(all, all, all, all)
-
-// Temporary constructor before migrating everything to doubles.
-@Stable
-public fun Margin(
-  left: Double = 0.0,
-  right: Double = 0.0,
-  top: Double = 0.0,
-  bottom: Double = 0.0,
-): Margin = Margin(
-  left = left.toInt(),
-  right = right.toInt(),
-  top = top.toInt(),
-  bottom = bottom.toInt(),
-)
diff --git a/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainer.kt b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainer.kt
index fea2c3d1f..66ef20946 100644
--- a/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainer.kt
+++ b/redwood-layout-composeui/src/commonMain/kotlin/app/cash/redwood/layout/composeui/ComposeUiFlexContainer.kt
@@ -37,6 +37,7 @@ import app.cash.redwood.flexbox.JustifyContent
 import app.cash.redwood.flexbox.isHorizontal
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.layout.api.Overflow
@@ -60,7 +61,7 @@ internal class ComposeUiFlexContainer(
   private var overflow by mutableStateOf(Overflow.Clip)
   private var margin by mutableStateOf(Margin.Zero)
 
-  private var density = -1.0
+  private var density = Density(1.0)
   private var marginUpdated = false
 
   var modifier: Modifier by mutableStateOf(Modifier)
@@ -121,7 +122,7 @@ internal class ComposeUiFlexContainer(
         recomposeTick
 
         // Read the density for use in 'measure'.
-        updateDensity(DensityMultiplier * LocalDensity.current.density)
+        updateDensity(Density(LocalDensity.current.density.toDouble()))
 
         children.render()
       },
@@ -138,7 +139,7 @@ internal class ComposeUiFlexContainer(
     )
   }
 
-  private fun updateDensity(density: Double) {
+  private fun updateDensity(density: Density) {
     if (density != this.density || marginUpdated) {
       this.density = density
       this.marginUpdated = false
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
index bbbae922b..7c07e287c 100644
--- 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
@@ -23,25 +23,25 @@ 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.api.Dp
+import app.cash.redwood.layout.api.dp
 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)
+  private var width by mutableStateOf(0.dp)
+  private var height by mutableStateOf(0.dp)
 
   override val value = @Composable {
-    Spacer(Modifier.defaultMinSize(unitsToDp(width), unitsToDp(height)))
+    Spacer(Modifier.defaultMinSize(width.toDp(), height.toDp()))
   }
 
   override var layoutModifiers: LayoutModifier = LayoutModifier
 
-  override fun width(width: Int) {
-    require(width >= 0) { "width must be >= 0: $width" }
+  override fun width(width: Dp) {
     this.width = width
   }
 
-  override fun height(height: Int) {
-    require(height >= 0) { "height must be >= 0: $height" }
+  override fun height(height: Dp) {
     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 1229eff1b..66228ea02 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
@@ -19,19 +19,15 @@ 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
 import app.cash.redwood.flexbox.Size
+import app.cash.redwood.layout.api.Dp as RedwoodDp
+import app.cash.redwood.layout.api.toPlatformDp
 
-// Android uses 2.75 as a density scale for most recent Pixel devices and iOS
-// uses 3. This aligns the two so the generic values used by Redwood layout are
-// visually similar on both platforms.
-internal const val DensityMultiplier = 1.1
-
-internal fun unitsToDp(units: Int): Dp {
-  return (units / DensityMultiplier).dp
+internal fun RedwoodDp.toDp(): Dp {
+  return Dp(toPlatformDp().toFloat())
 }
 
 internal fun Constraints.toMeasureSpecs(): Pair<MeasureSpec, MeasureSpec> {
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 1aab2e86e..7b75cfb72 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
@@ -18,6 +18,7 @@ package app.cash.redwood.layout.dom
 import app.cash.redwood.LayoutModifier
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.Dp
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.layout.api.Overflow
@@ -75,10 +76,10 @@ private class HTMLFlexContainer(
 
   override fun margin(margin: Margin) {
     value.style.apply {
-      marginLeft = unitsToPx(margin.left)
-      marginRight = unitsToPx(margin.right)
-      marginTop = unitsToPx(margin.top)
-      marginBottom = unitsToPx(margin.bottom)
+      marginLeft = margin.start.toPxString()
+      marginRight = margin.end.toPxString()
+      marginTop = margin.top.toPxString()
+      marginBottom = margin.bottom.toPxString()
     }
   }
 
@@ -108,12 +109,12 @@ private class HTMLFlexContainer(
 private class HTMLSpacer(
   override val value: HTMLDivElement,
 ) : Spacer<HTMLElement> {
-  override fun width(width: Int) {
-    value.style.width = unitsToPx(width)
+  override fun width(width: Dp) {
+    value.style.width = width.toPxString()
   }
 
-  override fun height(height: Int) {
-    value.style.height = unitsToPx(height)
+  override fun height(height: Dp) {
+    value.style.height = height.toPxString()
   }
 
   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
index a3650247f..6ca2450a8 100644
--- 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
@@ -17,15 +17,14 @@ package app.cash.redwood.layout.dom
 
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.Density
+import app.cash.redwood.layout.api.Dp
 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 Dp.toPxString(): String = with(Density(1.0)) {
+  "${toPx().roundToInt()}px"
 }
 
 internal fun Constraint.toCss() = when (this) {
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 f5db0efa9..4f8d09519 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
@@ -17,6 +17,7 @@ package app.cash.redwood.layout
 
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.Dp
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.layout.api.Overflow
@@ -78,9 +79,9 @@ public object ColumnScope
 @Widget(3)
 public data class Spacer(
   @Property(1)
-  @Default("0")
-  val width: Int,
+  @Default("Dp(0.0)")
+  val width: Dp,
   @Property(2)
-  @Default("0")
-  val height: Int,
+  @Default("Dp(0.0)")
+  val height: Dp,
 )
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 e85bf3af7..cab15e9b9 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
@@ -23,6 +23,8 @@ import app.cash.redwood.flexbox.JustifyContent
 import app.cash.redwood.flexbox.Size
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.Default
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.layout.api.Overflow
@@ -69,7 +71,7 @@ internal class UIViewFlexContainer(
   }
 
   override fun margin(margin: Margin) {
-    container.margin = margin.toSpacing(DensityMultiplier)
+    container.margin = margin.toSpacing(Density.Default)
     invalidate()
   }
 
@@ -161,7 +163,7 @@ internal class UIViewFlexContainer(
       children.widgets.forEach { widget ->
         container.items += newFlexItem(
           direction = direction,
-          density = DensityMultiplier,
+          density = Density.Default,
           layoutModifiers = widget.layoutModifiers,
           measurable = UIViewMeasurable(widget.value),
         )
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
index 357388351..e50c7a33a 100644
--- 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
@@ -16,6 +16,9 @@
 package app.cash.redwood.layout.uiview
 
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.api.Default
+import app.cash.redwood.layout.api.Density
+import app.cash.redwood.layout.api.Dp
 import app.cash.redwood.layout.widget.Spacer
 import kotlinx.cinterop.cValue
 import platform.CoreGraphics.CGRectZero
@@ -32,15 +35,13 @@ internal class UIViewSpacer : Spacer<UIView> {
 
   override var layoutModifiers: LayoutModifier = LayoutModifier
 
-  override fun width(width: Int) {
-    require(width >= 0) { "width must be >= 0: $width" }
-    this.width = width.toDouble()
+  override fun width(width: Dp) {
+    this.width = with(Density.Default) { width.toPx() }
     invalidate()
   }
 
-  override fun height(height: Int) {
-    require(height >= 0) { "height must be >= 0: $height" }
-    this.height = height.toDouble()
+  override fun height(height: Dp) {
+    this.height = with(Density.Default) { height.toPx() }
     invalidate()
   }
 
diff --git a/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/utils.kt b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/utils.kt
index 521c60360..6698a07c3 100644
--- a/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/utils.kt
+++ b/redwood-layout-uiview/src/commonMain/kotlin/app/cash/redwood/layout/uiview/utils.kt
@@ -26,9 +26,6 @@ import platform.CoreGraphics.CGSizeMake
 import platform.UIKit.UIView
 import platform.UIKit.UIViewNoIntrinsicMetric
 
-// The cross platform density multiples use iOS as an anchor.
-internal const val DensityMultiplier = 1.0
-
 internal fun CGSize.toSize() = Size(width, height)
 
 internal fun CGSize.toUnsafeSize() = UnsafeSize(width, height)
diff --git a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt
index c0399dcce..5885c132a 100644
--- a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt
+++ b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt
@@ -31,6 +31,7 @@ import app.cash.redwood.flexbox.MeasureSpec as RedwoodMeasureSpec
 import app.cash.redwood.flexbox.isHorizontal
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.MainAxisAlignment
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.layout.api.Overflow
@@ -46,7 +47,7 @@ internal class ViewFlexContainer(
     flexDirection = direction
     roundToInt = true
   }
-  private val density = DensityMultiplier * context.resources.displayMetrics.density
+  private val density = Density(context.resources)
 
   private val hostView = HostView(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
index c8bec808c..0c046dfa5 100644
--- 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
@@ -19,34 +19,30 @@ import android.content.Context
 import android.view.View
 import android.widget.Space
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.api.Density
+import app.cash.redwood.layout.api.Dp
 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
+  private val density = Density(context.resources)
 
   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)
+  override fun width(width: Dp) {
+    value.minimumWidth = with(density) { width.toPx() }.roundToInt()
     invalidate()
   }
 
-  override fun height(height: Int) {
-    require(height >= 0) { "height must be >= 0: $height" }
-    value.minimumHeight = unitsToPx(height)
+  override fun height(height: Dp) {
+    value.minimumHeight = with(density) { height.toPx() }.roundToInt()
     invalidate()
   }
 
-  private fun unitsToPx(units: Int): Int {
-    return (density * units).roundToInt()
-  }
-
   private fun invalidate() {
     value.invalidate()
     value.requestLayout()
diff --git a/redwood-treehouse-composeui-insets/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt b/redwood-treehouse-composeui-insets/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
index 2d2c6fa39..d093d17c0 100644
--- a/redwood-treehouse-composeui-insets/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
+++ b/redwood-treehouse-composeui-insets/src/androidMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
@@ -24,7 +24,9 @@ import androidx.compose.foundation.layout.safeDrawing
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.unit.LayoutDirection
+import app.cash.redwood.layout.api.Dp
 import app.cash.redwood.layout.api.Margin
+import app.cash.redwood.layout.api.fromPlatformDp
 
 @Composable
 public actual fun safeAreaInsets(): Margin {
@@ -34,8 +36,8 @@ public actual fun safeAreaInsets(): Margin {
 
 @Composable
 private fun PaddingValues.toMargin(layoutDirection: LayoutDirection) = Margin(
-  left = calculateLeftPadding(layoutDirection).value.toDouble(),
-  right = calculateRightPadding(layoutDirection).value.toDouble(),
-  top = calculateTopPadding().value.toDouble(),
-  bottom = calculateBottomPadding().value.toDouble(),
+  start = Dp.fromPlatformDp(calculateLeftPadding(layoutDirection).value.toDouble()),
+  end = Dp.fromPlatformDp(calculateRightPadding(layoutDirection).value.toDouble()),
+  top = Dp.fromPlatformDp(calculateTopPadding().value.toDouble()),
+  bottom = Dp.fromPlatformDp(calculateBottomPadding().value.toDouble()),
 )
diff --git a/redwood-treehouse-composeui-insets/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt b/redwood-treehouse-composeui-insets/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
index a436b5b53..afa5e01ed 100644
--- a/redwood-treehouse-composeui-insets/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
+++ b/redwood-treehouse-composeui-insets/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
@@ -21,10 +21,3 @@ import app.cash.redwood.layout.api.Margin
 /** Return the device's insets in screen density-independent pixels. */
 @Composable
 public expect fun safeAreaInsets(): Margin
-
-internal operator fun Margin.div(density: Double) = Margin(
-  left = left / density,
-  right = right / density,
-  top = top / density,
-  bottom = bottom / density,
-)
diff --git a/redwood-treehouse-composeui-insets/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt b/redwood-treehouse-composeui-insets/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
index d695c89b0..49aff5f10 100644
--- a/redwood-treehouse-composeui-insets/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
+++ b/redwood-treehouse-composeui-insets/src/iosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
@@ -15,12 +15,17 @@
  */
 package app.cash.redwood.treehouse.composeui
 
+import app.cash.redwood.layout.api.Default
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.Margin
 import kotlinx.cinterop.useContents
 import platform.UIKit.UIApplication
-import platform.UIKit.safeAreaInsets
 
 public actual fun safeAreaInsets(): Margin {
   val keyWindow = UIApplication.sharedApplication.keyWindow ?: return Margin.Zero
-  return keyWindow.safeAreaInsets.useContents { Margin(left, right, top, bottom) }
+  return keyWindow.safeAreaInsets.useContents {
+    with(Density.Default) {
+      Margin(left.toDp(), right.toDp(), top.toDp(), bottom.toDp())
+    }
+  }
 }
diff --git a/redwood-treehouse-composeui-insets/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt b/redwood-treehouse-composeui-insets/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
index c4569c8a7..c1ce61b52 100644
--- a/redwood-treehouse-composeui-insets/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
+++ b/redwood-treehouse-composeui-insets/src/jvmMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
@@ -19,6 +19,7 @@ package app.cash.redwood.treehouse.composeui
 
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.platform.LocalDensity
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.Margin
 import java.awt.GraphicsEnvironment
 import java.awt.Insets
@@ -28,13 +29,10 @@ import java.awt.Toolkit
 public actual fun safeAreaInsets(): Margin {
   val configuration = GraphicsEnvironment.getLocalGraphicsEnvironment()
     .defaultScreenDevice.defaultConfiguration
-  val rawSpacing = Toolkit.getDefaultToolkit().getScreenInsets(configuration).toMargin()
-  return rawSpacing / LocalDensity.current.density.toDouble()
+  val density = Density(LocalDensity.current.density.toDouble())
+  return Toolkit.getDefaultToolkit().getScreenInsets(configuration).toMargin(density)
 }
 
-private fun Insets.toMargin() = Margin(
-  left = left.toDouble(),
-  right = right.toDouble(),
-  top = top.toDouble(),
-  bottom = bottom.toDouble(),
-)
+private fun Insets.toMargin(density: Density) = with(density) {
+  Margin(left.toDp(), right.toDp(), top.toDp(), bottom.toDp())
+}
diff --git a/redwood-treehouse-composeui-insets/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt b/redwood-treehouse-composeui-insets/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
index d7b2db511..ab5827e53 100644
--- a/redwood-treehouse-composeui-insets/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
+++ b/redwood-treehouse-composeui-insets/src/macosMain/kotlin/app/cash/redwood/treehouse/composeui/insets.kt
@@ -15,11 +15,17 @@
  */
 package app.cash.redwood.treehouse.composeui
 
+import app.cash.redwood.layout.api.Default
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.Margin
 import kotlinx.cinterop.useContents
 import platform.AppKit.NSScreen
 
 public actual fun safeAreaInsets(): Margin {
   val mainScreen = NSScreen.mainScreen ?: return Margin.Zero
-  return mainScreen.safeAreaInsets.useContents { Margin(left, right, top, bottom) }
+  return mainScreen.safeAreaInsets.useContents {
+    with(Density.Default) {
+      Margin(left.toDp(), right.toDp(), top.toDp(), bottom.toDp())
+    }
+  }
 }
diff --git a/redwood-treehouse-configuration/build.gradle b/redwood-treehouse-configuration/build.gradle
index 430bdacc3..4dc093b80 100644
--- a/redwood-treehouse-configuration/build.gradle
+++ b/redwood-treehouse-configuration/build.gradle
@@ -11,7 +11,7 @@ kotlin {
   sourceSets {
     commonMain {
       dependencies {
-        api projects.redwoodLayoutApi
+        api projects.redwoodLayoutApiDensity
         api libs.kotlinx.serialization.core
       }
     }
diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
index 97d4b2201..251a74f67 100644
--- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
+++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
@@ -24,6 +24,7 @@ import android.view.View
 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
 import android.widget.FrameLayout
 import androidx.core.graphics.Insets
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener
 import app.cash.redwood.treehouse.TreehouseView.WidgetSystem
 import app.cash.redwood.widget.ViewGroupChildren
@@ -96,7 +97,7 @@ public class TreehouseWidgetView(
   ): HostConfiguration {
     return HostConfiguration(
       darkMode = (config.uiMode and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES,
-      safeAreaInsets = insets.toMargin(resources.displayMetrics.density.toDouble()),
+      safeAreaInsets = insets.toMargin(Density(resources)),
     )
   }
 }
diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/utils.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/utils.kt
index a326b7f24..c22f11e23 100644
--- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/utils.kt
+++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/utils.kt
@@ -19,14 +19,12 @@ import android.view.View
 import androidx.core.graphics.Insets
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.Margin
 
-internal fun Insets.toMargin(density: Double) = Margin(
-  left = left / density,
-  right = right / density,
-  top = top / density,
-  bottom = bottom / density,
-)
+internal fun Insets.toMargin(density: Density) = with(density) {
+  Margin(left.toDp(), right.toDp(), top.toDp(), bottom.toDp())
+}
 
 // Root insets will be null if the view is not attached.
 internal val View.rootWindowInsetsCompat: WindowInsetsCompat?
diff --git a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
index 30b8401e9..0e956b815 100644
--- a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
+++ b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
@@ -15,6 +15,8 @@
  */
 package app.cash.redwood.treehouse
 
+import app.cash.redwood.layout.api.Default
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener
 import app.cash.redwood.treehouse.TreehouseView.WidgetSystem
@@ -82,7 +84,11 @@ private fun computeHostConfiguration(
 
 private fun computeSafeAreaInsets(): Margin {
   val keyWindow = UIApplication.sharedApplication.keyWindow ?: return Margin.Zero
-  return keyWindow.safeAreaInsets.useContents { Margin(left, right, top, bottom) }
+  return keyWindow.safeAreaInsets.useContents {
+    with(Density.Default) {
+      Margin(left.toDp(), right.toDp(), top.toDp(), bottom.toDp())
+    }
+  }
 }
 
 private class RootUiView(
diff --git a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt
index ac11ee17c..3915c8ea2 100644
--- a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt
+++ b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt
@@ -27,6 +27,7 @@ import app.cash.redwood.LayoutModifier
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
 import app.cash.redwood.layout.api.Margin
+import app.cash.redwood.layout.api.dp
 import app.cash.redwood.layout.compose.Column
 import app.cash.redwood.layout.compose.Row
 import com.example.redwood.emojisearch.compose.Image
@@ -95,7 +96,7 @@ fun EmojiSearch(
   Column(
     width = Constraint.Fill,
     horizontalAlignment = CrossAxisAlignment.Stretch,
-    margin = Margin(horizontal = 24),
+    margin = Margin(horizontal = 24.dp),
   ) {
     TextInput(
       state = searchTerm,
@@ -109,7 +110,7 @@ fun EmojiSearch(
       ) {
         Image(
           url = image.url,
-          layoutModifier = LayoutModifier.margin(Margin(8)),
+          layoutModifier = LayoutModifier.margin(Margin(8.dp)),
         )
         Text(text = image.label)
       }
diff --git a/settings.gradle b/settings.gradle
index e59e94bd3..c32c316b8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -6,6 +6,7 @@ include ':redwood-compose-testing'
 include ':redwood-flexbox'
 include ':redwood-gradle-plugin'
 include ':redwood-layout-api'
+include ':redwood-layout-api-density'
 include ':redwood-layout-compose'
 include ':redwood-layout-composeui'
 include ':redwood-layout-dom'


Test patch:
diff --git a/redwood-layout-api-density/src/commonTest/kotlin/app/cash/redwood/layout/api/DensityTest.kt b/redwood-layout-api-density/src/commonTest/kotlin/app/cash/redwood/layout/api/DensityTest.kt
new file mode 100644
index 000000000..c510108b5
--- /dev/null
+++ b/redwood-layout-api-density/src/commonTest/kotlin/app/cash/redwood/layout/api/DensityTest.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.api
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class DensityTest {
+
+  @Test fun dpToPxConversionIsSymmetric() {
+    with(Density(2.0)) {
+      var dp = 4.dp
+      assertEquals(dp, dp.toPx().toDp())
+
+      dp = 20.dp
+      assertEquals(dp, dp.toPx().toDp())
+    }
+  }
+
+  @Test fun pxToDpConversionIsSymmetric() {
+    with(Density(2.0)) {
+      var px = 4.0
+      assertEquals(px, px.toDp().toPx())
+
+      px = 20.0
+      assertEquals(px, px.toDp().toPx())
+    }
+  }
+}
diff --git a/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacerTest.kt b/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacerTest.kt
index d71df0964..73ee25781 100644
--- a/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacerTest.kt
+++ b/redwood-layout-composeui/src/androidUnitTest/kotlin/app/cash/redwood/layout/composeui/ComposeUiSpacerTest.kt
@@ -23,6 +23,7 @@ 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.api.dp
 import app.cash.redwood.layout.widget.Spacer
 import app.cash.redwood.widget.Widget
 import com.android.ide.common.rendering.api.SessionParams
@@ -47,8 +48,8 @@ class ComposeUiSpacerTest : AbstractSpacerTest<@Composable () -> Unit>() {
     layoutModifier: LayoutModifier,
   ): Spacer<@Composable () -> Unit> = ComposeUiSpacer().apply {
     layoutModifiers = layoutModifier
-    width(width)
-    height(height)
+    width(width.dp)
+    height(height.dp)
   }
 
   override fun wrap(widget: Widget<@Composable () -> Unit>, horizontal: Boolean) = @Composable {
diff --git a/redwood-layout-shared-test/src/main/kotlin/app/cash/redwood/layout/AbstractFlexContainerTest.kt b/redwood-layout-shared-test/src/main/kotlin/app/cash/redwood/layout/AbstractFlexContainerTest.kt
index 1839ead64..cb5729639 100644
--- a/redwood-layout-shared-test/src/main/kotlin/app/cash/redwood/layout/AbstractFlexContainerTest.kt
+++ b/redwood-layout-shared-test/src/main/kotlin/app/cash/redwood/layout/AbstractFlexContainerTest.kt
@@ -22,6 +22,7 @@ import app.cash.redwood.flexbox.JustifyContent
 import app.cash.redwood.layout.api.Constraint
 import app.cash.redwood.layout.api.CrossAxisAlignment
 import app.cash.redwood.layout.api.Margin
+import app.cash.redwood.layout.api.dp
 import app.cash.redwood.widget.Widget
 import org.junit.Test
 
@@ -64,7 +65,7 @@ abstract class AbstractFlexContainerTest<T : Any> {
 
   @Test fun columnWithMarginAndDifferentAlignments() {
     val container = flexContainer(FlexDirection.Column)
-    container.margin(Margin(horizontal = 10, vertical = 20))
+    container.margin(Margin(horizontal = 10.dp, vertical = 20.dp))
     movies.forEachIndexed { index, movie ->
       val modifier = when (index % 4) {
         0 -> CrossAxisAlignmentImpl(CrossAxisAlignment.Start)
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
index 61628ae3d..79db8eb75 100644
--- 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
@@ -24,6 +24,7 @@ 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.api.dp
 import app.cash.redwood.layout.widget.Spacer
 import app.cash.redwood.widget.Widget
 import com.android.ide.common.rendering.api.SessionParams
@@ -48,8 +49,8 @@ class ViewSpacerTest : AbstractSpacerTest<View>() {
     layoutModifier: LayoutModifier,
   ): Spacer<View> = ViewSpacer(paparazzi.context).apply {
     layoutModifiers = layoutModifier
-    width(width)
-    height(height)
+    width(width.dp)
+    height(height.dp)
   }
 
   override fun wrap(widget: Widget<View>, horizontal: Boolean): View {
diff --git a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt
index 0abc25eb1..d4d54db4b 100644
--- a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt
+++ b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt
@@ -25,6 +25,7 @@ import androidx.core.graphics.Insets
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.treehouse.TreehouseView.WidgetSystem
 import app.cash.redwood.widget.ViewGroupChildren
@@ -125,13 +126,14 @@ class TreehouseWidgetViewTest {
         .setInsets(WindowInsetsCompat.Type.systemBars(), insets)
         .build()
       ViewCompat.dispatchApplyWindowInsets(layout, windowInsets)
-      val density = context.resources.displayMetrics.density.toDouble()
-      val expectedInsets = Margin(
-        left = density * insets.left,
-        right = density * insets.right,
-        top = density * insets.top,
-        bottom = density * insets.bottom,
-      )
+      val expectedInsets = with(Density(context.resources)) {
+        Margin(
+          start = insets.left.toDp(),
+          end = insets.right.toDp(),
+          top = insets.top.toDp(),
+          bottom = insets.bottom.toDp(),
+        )
+      }
       assertEquals(HostConfiguration(safeAreaInsets = expectedInsets), awaitItem())
     }
   }
diff --git a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt
index 8b4c03cda..a91e82fbd 100644
--- a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt
+++ b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt
@@ -16,6 +16,8 @@
 package app.cash.redwood.treehouse
 
 import app.cash.redwood.LayoutModifier
+import app.cash.redwood.layout.api.Default
+import app.cash.redwood.layout.api.Density
 import app.cash.redwood.layout.api.Margin
 import app.cash.redwood.treehouse.TreehouseView.WidgetSystem
 import app.cash.redwood.widget.UIViewChildren
@@ -33,11 +35,6 @@ import platform.Foundation.runUntilDate
 import platform.UIKit.UIUserInterfaceStyle.UIUserInterfaceStyleDark
 import platform.UIKit.UIView
 import platform.UIKit.UIWindow
-import platform.UIKit.addSubview
-import platform.UIKit.overrideUserInterfaceStyle
-import platform.UIKit.removeFromSuperview
-import platform.UIKit.safeAreaInsets
-import platform.UIKit.subviews
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class TreehouseUIKitViewTest {
@@ -122,7 +119,9 @@ class TreehouseUIKitViewTest {
 
     // We can't override this value so test that they match.
     val expectedInsets = parent.safeAreaInsets.useContents {
-      Margin(left, right, top, bottom)
+      with(Density.Default) {
+        Margin(left.toDp(), right.toDp(), top.toDp(), bottom.toDp())
+      }
     }
 
     val layout = TreehouseUIKitView(throwingWidgetSystem)