[iOS] UE 5.8: safe-area insets stay at zero for the whole session in portrait apps (HUD under the notch after an App Store / TestFlight update)

Summary

On iOS, FIOSApplication keeps the window’s safe-area insets in the file-static CachedInsets (ApplicationCore/Private/IOS/IOSApplication.cpp). It starts at zero, and in portrait FDisplayMetrics::RebuildDisplayMetrics uses it directly (the SafeZone.* override cvars only apply in landscape). It is only ever filled in by:

  • FIOSApplication::OrientationChanged → CacheDisplayMetrics, called from -[FIOSView layoutSubviews] only when ![IOSAppDelegate GetDelegate].bIsSuspended, or from -[IOSAppDelegate didRotate:] only when the interface orientation differs from self.InterfaceOrientation (or is forced, which only happens with GIOSDelayRotationUntilPresent);
  • FIOSApplication::UpdateSafeZoneAfterRotation, called only after a rotation transition.

If the view’s layout passes at launch all run while the app still counts as suspended, CachedInsets is never filled in. A portrait-only app never rotates, and resuming from the background calls didRotate:nil, which does nothing when the orientation hasn’t changed. So the insets stay {0,0,0,0} for the whole session and everything inside UMG SafeZone widgets draws under the notch / Dynamic Island.

In our game this happens reliably on the first launches after players update through the App Store / TestFlight from our previous (5.7-based) release to our 5.8.3-based release. A fresh install of the same build is fine.

What Type of Bug are you experiencing?

Platform Mobile

Steps to Reproduce

Project: portrait-only (bSupportsPortraitOrientation=True, no landscape), UIRequiresFullScreen, default non-scene lifecycle (bUseSceneBasedLifecycle=False), UI wrapped in UMG SafeZone widgets.

  1. Install a previous release of the app (ours was built with 5.7) from TestFlight on a notched iPhone and launch it once.
  2. Update to a 5.8.3 build of the app through TestFlight (don’t uninstall), and launch it.
  3. Look at UI inside a SafeZone widget at the top of the screen.

Also reproduces when a development-signed 5.8.3 build is installed over the step-1 install through Xcode’s Devices and Simulators window (which keeps the app’s data). Deleting the app and installing the same 5.8.3 build fresh does not reproduce it.

Expected Result

SafeZone content is inset below the notch, as on a fresh install.

Observed Result

SafeZone padding is zero for the whole session; the top of the UI sits under the notch. Backgrounding and returning to the app doesn’t fix it.

Affects Versions

5.8

Platform(s)

iOS

Additional Notes

Why only after an update: we compared the app’s data container in the broken and working states and found nothing layout-related (only the game’s own config, SDK state and the default UISceneSession record). The same build works on a fresh install and breaks over the old install’s data, so we believe it comes down to iOS launch timing / the suspended state at first layout rather than anything the old build saved. We haven’t pinned down exactly what iOS does differently on that launch.

Workaround (game side), verified on device and through TestFlight: after the engine is up, and on every return to the foreground, re-read the insets on the UI thread. UpdateSafeZoneAfterRotation re-reads safeAreaInsets, rebuilds the display metrics and broadcasts OnSafeFrameChangedEvent, so UMG SafeZone widgets update:

#if PLATFORM_IOS
#include "IOS/IOSApplication.h"
#include "Misc/CoreDelegates.h"

static void RefreshIOSSafeZone()
{
    dispatch_async(dispatch_get_main_queue(), ^{ FIOSApplication::UpdateSafeZoneAfterRotation(); });
    // again shortly after, in case the window hadn't finished laying out yet
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(),
                   ^{ FIOSApplication::UpdateSafeZoneAfterRotation(); });
}

// e.g. in a UGameInstanceSubsystem::Initialize:
RefreshIOSSafeZone();
FCoreDelegates::ApplicationHasEnteredForegroundDelegate.AddStatic(&RefreshIOSSafeZone);
#endif

Suggested engine fix: don’t rely on rotation or an unsuspended layoutSubviews for the first insets. For example, override -[FIOSView safeAreaInsetsDidChange] (or the view controller’s viewSafeAreaInsetsDidChange) to call FIOSApplication::UpdateSafeZoneAfterRotation(). That’s UIKit’s own notification for exactly this, and it fires whenever the insets change, including at launch. And/or refresh the insets when the app leaves the suspended state, not only when the orientation changes.

Code references (5.8.3):

  • Engine/Source/Runtime/ApplicationCore/Private/IOS/IOSApplication.cpp: UIEdgeInsets CachedInsets;, FDisplayMetrics::RebuildDisplayMetrics, CacheDisplayMetrics, OrientationChanged, UpdateSafeZoneAfterRotation.
  • Engine/Source/Runtime/ApplicationCore/Private/IOS/IOSView.cpp: -layoutSubviews (the bIsSuspended check before OrientationChanged), -viewWillTransitionToSize:withTransitionCoordinator:.
  • Engine/Source/Runtime/ApplicationCore/Private/IOS/IOSAppDelegate.cpp: -didRotate: (only calls OrientationChanged on an orientation change or a forced update), and the resume path calling [self didRotate:nil].