From d08cbe346d88901668ef266b3490b7f70f3acbcd Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Wed, 20 Nov 2024 17:16:20 -0500 Subject: [PATCH] Host-side insets for iOS UIViews (#2462) * Host-side insets for iOS UIViews * Fix visibility * Update redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt Co-authored-by: Jake Wharton * Use setHidden(false) Otherwise it crashes in CI. --------- Co-authored-by: Jesse Wilson Co-authored-by: Jake Wharton --- .../redwood/treehouse/TreehouseLayoutTest.kt | 7 +- .../app/cash/redwood/widget/RedwoodUIView.kt | 29 +++++-- .../cash/redwood/widget/RedwoodUIViewTest.kt | 79 +++++++++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 redwood-widget/src/iosTest/kotlin/app/cash/redwood/widget/RedwoodUIViewTest.kt diff --git a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt index 22e2185764..e0c8cd32e9 100644 --- a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt +++ b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt @@ -54,7 +54,7 @@ class TreehouseLayoutTest { val rootView = layout.value as ViewGroup val view = View(activity) - layout.children.insert(0, viewWidget(view)) + layout.children.insert(0, ViewWidget(view)) assertThat(rootView.childCount).isEqualTo(1) assertThat(rootView.getChildAt(0)).isSameInstanceAs(view) } @@ -134,8 +134,9 @@ class TreehouseLayoutTest { } } - private fun viewWidget(view: View) = object : Widget { - override val value: View get() = view + class ViewWidget( + override val value: View, + ) : Widget { override var modifier: Modifier = Modifier } diff --git a/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt index 26814ca2a0..71e1e51461 100644 --- a/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt +++ b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectZero import platform.UIKit.UIApplication +import platform.UIKit.UIEdgeInsets import platform.UIKit.UILayoutConstraintAxisVertical import platform.UIKit.UIStackView import platform.UIKit.UIStackViewAlignmentFill @@ -54,11 +55,14 @@ public open class RedwoodUIView : RedwoodView { override val children: Widget.Children get() = _children + private val density: Density get() = Density.Default + private val mutableUiConfiguration = MutableStateFlow( computeUiConfiguration( + density = density, traitCollection = valueRootView.traitCollection, - viewInsets = Margin.Zero, + viewInsets = valueRootView.safeAreaInsets.toMargin(), layoutDirection = valueRootView.effectiveUserInterfaceLayoutDirection, bounds = valueRootView.bounds, ), @@ -79,10 +83,10 @@ public open class RedwoodUIView : RedwoodView { get() = null private fun updateUiConfiguration() { - val old = mutableUiConfiguration.value mutableUiConfiguration.value = computeUiConfiguration( + density = density, traitCollection = valueRootView.traitCollection, - viewInsets = old.viewInsets, + viewInsets = valueRootView.safeAreaInsets.toMargin(), layoutDirection = valueRootView.effectiveUserInterfaceLayoutDirection, bounds = valueRootView.bounds, ) @@ -104,6 +108,12 @@ public open class RedwoodUIView : RedwoodView { this.axis = UILayoutConstraintAxisVertical this.alignment = UIStackViewAlignmentFill // Fill horizontal. this.distribution = UIStackViewDistributionFillEqually // Fill vertical. + this.setInsetsLayoutMarginsFromSafeArea(false) // Consume insets internally. + } + + override fun safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + updateUiConfiguration() } override fun layoutSubviews() { @@ -123,9 +133,18 @@ public open class RedwoodUIView : RedwoodView { updateUiConfiguration() } } + + private fun CValue.toMargin(): Margin { + return with(density) { + useContents { + Margin(left.toDp(), right.toDp(), top.toDp(), bottom.toDp()) + } + } + } } internal fun computeUiConfiguration( + density: Density, traitCollection: UITraitCollection, viewInsets: Margin, layoutDirection: UIUserInterfaceLayoutDirection, @@ -136,11 +155,11 @@ internal fun computeUiConfiguration( safeAreaInsets = computeSafeAreaInsets(), viewInsets = viewInsets, viewportSize = bounds.useContents { - with(Density.Default) { + with(density) { Size(size.width.toDp(), size.height.toDp()) } }, - density = Density.Default.rawDensity, + density = density.rawDensity, layoutDirection = when (layoutDirection) { UIUserInterfaceLayoutDirectionRightToLeft -> LayoutDirection.Rtl UIUserInterfaceLayoutDirectionLeftToRight -> LayoutDirection.Ltr diff --git a/redwood-widget/src/iosTest/kotlin/app/cash/redwood/widget/RedwoodUIViewTest.kt b/redwood-widget/src/iosTest/kotlin/app/cash/redwood/widget/RedwoodUIViewTest.kt new file mode 100644 index 0000000000..43ba851aae --- /dev/null +++ b/redwood-widget/src/iosTest/kotlin/app/cash/redwood/widget/RedwoodUIViewTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024 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.widget + +import app.cash.redwood.Modifier +import app.cash.redwood.ui.Margin +import app.cash.redwood.ui.dp +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import kotlin.test.Test +import platform.CoreGraphics.CGRectMake +import platform.UIKit.UIEdgeInsetsMake +import platform.UIKit.UILabel +import platform.UIKit.UIView +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.additionalSafeAreaInsets + +class RedwoodUIViewTest { + @Test + fun widgetsAddChildViews() { + val redwoodUIView = RedwoodUIView() + + val label = UILabel() + redwoodUIView.children.insert(0, UIViewWidget(label)) + + assertThat(redwoodUIView.value.subviews).containsExactly(label) + } + + /** + * Confirm we accept and propagates insets through [RedwoodUIView.uiConfiguration]. + * + * Testing insets is tricky. We need both a [UIWindow] and a [UIViewController] to apply insets to + * a subject view. + */ + @Test + fun viewInsets() { + val redwoodUIView = RedwoodUIView() + val viewController = object : UIViewController(null, null) { + override fun loadView() { + view = redwoodUIView.value + } + } + + val window = UIWindow( + CGRectMake(0.0, 0.0, 390.0, 844.0), // iPhone 14. + ) + window.setHidden(false) // Necessary to propagate additionalSafeAreaInsets. + window.rootViewController = viewController + + assertThat(redwoodUIView.uiConfiguration.value.viewInsets) + .isEqualTo(Margin.Zero) + + viewController.additionalSafeAreaInsets = UIEdgeInsetsMake(10.0, 20.0, 30.0, 40.0) + + assertThat(redwoodUIView.uiConfiguration.value.viewInsets) + .isEqualTo(Margin(top = 10.0.dp, start = 20.0.dp, bottom = 30.0.dp, end = 40.0.dp)) + } + + class UIViewWidget( + override val value: UIView, + ) : Widget { + override var modifier: Modifier = Modifier + } +}