I’m adding edge-to-edge UI support in Redwood. My code asks the host platform how much of the screen is consumed by system bars and notches and things, and it returns us a measurement like ‘40 pixels at the top + 10 pixels at the bottom’.
The iOS code to handle this is simple:
class RedwoodUIView : UIStackView(cValue { CGRectZero }) {
init {
this.setInsetsLayoutMarginsFromSafeArea(false) // Handle insets manually.
}
override fun safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
handleNewInsets(safeAreaInsets)
}
private fun handleNewInsets(safeAreaInsets: CValue<UIEdgeInsets>) {
...
}
}
But writing the test was difficult. It took me two days to figure this out.
@Test
fun testSafeArea() {
val redwoodUIView = RedwoodUIView()
val viewController = object : UIViewController(null, null) {
override fun loadView() {
view = redwoodUIView
}
}
val window = UIWindow(
CGRectMake(0.0, 0.0, 390.0, 844.0), // iPhone 14.
)
window.setHidden(false) // Necessary to propagate additionalSafeAreaInsets.
window.rootViewController = viewController
viewController.additionalSafeAreaInsets =
UIEdgeInsetsMake(10.0, 20.0, 30.0, 40.0)
}
To populate safeAreaInsets
on a UIView
:
- The
UIView
must be in aUIViewController
. There isn’t a direct way to manipulate itssafeAreaInsets
. - That
ViewController
must also be in a visibleUIWindow
. TheUIViewController
won’t propagate insets unless it’s in a view hierarchy with a visible window.
I spent so long trying and failing to get it working with a standalone UIViewController
. That was so frustrating! Eventually I found this StackOverflow sample that happened to have a UIWindow
, and I tried that and it worked.
func test_presentationOfViewController() {
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
window.rootViewController = sut
window.makeKeyAndVisible()
...
}
Well, it kind of worked. It worked locally. But when I pushed my test to run on GitHub actions, it failed hard:
:sample:iosSimulatorArm64Test: Test running process exited unexpectedly.
Current test: testSafeArea
Process output:
Child process terminated with signal 5: Trace/BPT trap
What the heck is a Trace/BPT trap
?! I couldn’t find more details on what broke.
With more grinding and experimentation I found that my tests pass on CI once I replaced window.makeKeyAndVisible()
with window.setHidden(false)
.
The whole exercise reminds me that I value testability, and that the iOS platform engineers... don’t.
UPDATE, A FEW HOURS LATER
Tyler Bell, a colleague who knows iOS better than I do, read this post and shared a simpler solution. Override the superview’s safeAreaInsets()
function and request a layout:
class InsetsContainer : UIView(cValue { CGRectZero }) {
var subviewSafeAreaInsets = cValue { UIEdgeInsetsZero }
set(value) {
field = value
setNeedsLayout()
layoutIfNeeded()
}
override fun safeAreaInsets() = subviewSafeAreaInsets
}
@Test
fun testSafeArea() {
val insetsContainer = InsetsContainer()
val redwoodUIView = RedwoodUIView()
insetsContainer.addSubview(redwoodUIView)
insetsContainer.subviewSafeAreaInsets =
UIEdgeInsetsMake(10.0, 20.0, 30.0, 40.0)
}
Yay!