PUBLIC OBJECT

Testing safe area insets on iOS

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 a UIViewController. There isn’t a direct way to manipulate its safeAreaInsets.
  • That ViewController must also be in a visible UIWindow. The UIViewController 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!