iOS: stop the keyboard and rotation breaking the per-game panel

The overlay container publishes how much of the card the keyboard covers
instead of insetting the hosted content itself, and the per-game panel
applies that inset inside its own reader, below the point where it picks one
column or two. The container's two card arms become one. The per-game overlay
no longer carries an id on orientation. The panel's rail and its form share a
single property for which section is open, with the form driving it through a
navigation path. The game screen and emulation only mode take orientation and
the portrait viewport split from the window rather than the safe region, and
the fullscreen sync that rode along on the size preference gets its own
trigger.

An inset applied from outside shrank the box the panel measured itself in, and
the panel picks its layout from that box, so a keyboard flipped it to the
landscape rail: wrong layout, navigation back at General, content clipped, and
a gap where its background stopped short of the card. The id and the two arms
each rebuilt the panel on a flip and took any unsaved edits with them.
This commit is contained in:
J1coding
2026-08-05 17:10:21 +02:00
committed by Jeen
parent dd5b6616eb
commit 324056d2c1
4 changed files with 128 additions and 136 deletions
@@ -40,10 +40,9 @@ struct OverlayMetrics {
/// users while remaining light enough for the paused game to show through glass.
let scrimOpacity: Double
init(size: CGSize, isIPad: Bool, safeArea: EdgeInsets, reduceTransparency: Bool) {
/// `size` is already the safe region, insets removed. Do not take them off again.
init(size: CGSize, isIPad: Bool, reduceTransparency: Bool) {
let isLandscape = size.width > size.height
let horizontalInset = safeArea.leading + safeArea.trailing
let verticalInset = safeArea.top + safeArea.bottom
if isIPad {
// iPad. Landscape gets a wider/taller card than portrait so the deck and Per-Game
@@ -53,8 +52,8 @@ struct OverlayMetrics {
let heightMargin: CGFloat = isLandscape ? 88 : 72
let widthCap: CGFloat = isLandscape ? 980 : 620
let heightCap: CGFloat = isLandscape ? 760 : 640
cardMaxWidth = max(0, min(widthCap, size.width - horizontalInset - widthMargin))
cardMaxHeight = max(0, min(heightCap, size.height - verticalInset - heightMargin))
cardMaxWidth = max(0, min(widthCap, size.width - widthMargin))
cardMaxHeight = max(0, min(heightCap, size.height - heightMargin))
scrimOpacity = reduceTransparency ? OverlayTheme.scrimPadReduceTransparency : OverlayTheme.scrimPad
} else if isLandscape {
// iPhone landscape: a tall floating command panel. Bounds leave a real margin
@@ -62,14 +61,14 @@ struct OverlayMetrics {
// the virtual pad instead of reading as an edge-to-edge slab. Caps keep wide
// phones from stretching past a comfortable reading width/height.
variant = .phoneLandscape
cardMaxWidth = max(0, min(760, size.width - horizontalInset - 40))
cardMaxHeight = max(0, min(468, size.height - verticalInset - 36))
cardMaxWidth = max(0, min(760, size.width - 40))
cardMaxHeight = max(0, min(468, size.height - 36))
scrimOpacity = reduceTransparency ? OverlayTheme.scrimPhoneLandscapeReduceTransparency : OverlayTheme.scrimPhoneLandscape
} else {
// iPhone portrait: the liked compact card, slightly roomier.
variant = .phonePortrait
cardMaxWidth = max(0, min(480, size.width - horizontalInset - 32))
cardMaxHeight = max(0, min(620, size.height - verticalInset - 32))
cardMaxWidth = max(0, min(480, size.width - 32))
cardMaxHeight = max(0, min(620, size.height - 32))
scrimOpacity = reduceTransparency ? OverlayTheme.scrimPhonePortraitReduceTransparency : OverlayTheme.scrimPhonePortrait
}
}
@@ -111,63 +110,54 @@ struct GameOverlayContainer<Content: View>: View {
private var isIPad: Bool { UIDevice.current.userInterfaceIdiom == .pad }
var body: some View {
GeometryReader { geo in
// This geometry belongs to the active gameplay scene, so its insets update
// with rotation, Stage Manager, and system chrome. Using it here avoids a
// competing UIApplication/window lookup and preserves asymmetric notch space.
let safeAreaInsets = geo.safeAreaInsets
let metrics = OverlayMetrics(
size: geo.size,
isIPad: isIPad,
safeArea: safeAreaInsets,
reduceTransparency: reduceTransparency
)
// Outer reader sees the keyboard, inner one does not. Card is sized off the inner
// one so it cannot collapse; the difference is how much the keyboard covers.
GeometryReader { keyboardGeo in
GeometryReader { geo in
// Gameplay scene geometry, so rotation and Stage Manager come for free.
let metrics = OverlayMetrics(
size: geo.size,
isIPad: isIPad,
reduceTransparency: reduceTransparency
)
let keyboardOverlap = max(0, geo.size.height - keyboardGeo.size.height)
// The card is centred, so the keyboard covers less of it than of the region.
let cardMargin = max(0, (geo.size.height - metrics.cardMaxHeight) / 2)
ZStack {
backdrop(metrics: metrics)
ZStack {
backdrop(metrics: metrics)
if frameMode == .landscapeDeck && metrics.variant == .phoneLandscape {
let deckGutter: CGFloat = 8
content(metrics)
.padding(.leading, max(safeAreaInsets.leading, deckGutter))
.padding(.trailing, max(safeAreaInsets.trailing, deckGutter))
.padding(.top, max(safeAreaInsets.top, deckGutter))
.padding(.bottom, max(safeAreaInsets.bottom, deckGutter))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(reduceMotion ? .opacity : .scale(scale: 0.97).combined(with: .opacity))
} else if frameMode == .landscapePanel && metrics.variant == .phoneLandscape {
// Keep the landscape panel floating and bounded. Safe-area padding is
// outside the clipped card, so an asymmetric notch shifts the panel
// into the usable region instead of becoming empty space inside it.
content(metrics)
.frame(maxWidth: metrics.cardMaxWidth, maxHeight: metrics.cardMaxHeight)
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 12)
.padding(safeAreaInsets)
.transition(reduceMotion ? .opacity : .scale(scale: 0.97).combined(with: .opacity))
} else {
content(metrics)
.frame(maxWidth: metrics.cardMaxWidth, maxHeight: metrics.cardMaxHeight)
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 12)
.padding(safeAreaInsets)
.transition(reduceMotion ? .opacity : .scale(scale: 0.96).combined(with: .opacity))
// Told, not inset here: an inset shrinks the box the panel measures itself in.
let hosted = content(metrics)
.environment(\.overlayKeyboardOverlap, max(0, keyboardOverlap - cardMargin))
if frameMode == .landscapeDeck && metrics.variant == .phoneLandscape {
let deckGutter: CGFloat = 8
hosted
.padding(deckGutter)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(reduceMotion ? .opacity : .scale(scale: 0.97).combined(with: .opacity))
} else {
// One arm, so a flip cannot swap it and take the panel's state along.
let popScale: CGFloat = frameMode == .landscapePanel && metrics.variant == .phoneLandscape ? 0.97 : 0.96
hosted
.frame(maxWidth: metrics.cardMaxWidth, maxHeight: metrics.cardMaxHeight)
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 12)
.transition(reduceMotion ? .opacity : .scale(scale: popScale).combined(with: .opacity))
}
}
}
.ignoresSafeArea(.keyboard)
}
.transition(.opacity)
}
/// Plain opacity scrim, not a Material, which washes out to flat grey over a paused
/// Metal frame. Near-black and light enough that the game still reads through. Eats
/// taps, so gameplay gets none while an overlay is up.
@ViewBuilder
private func backdrop(metrics: OverlayMetrics) -> some View {
// Deterministic dim, not a SwiftUI Material: a Material collapses to a flat grey
// wash over a paused Metal frame (worst on iPad and under Reduce Transparency).
// A plain opacity scrim is stable on every device and still lets the paused game
// read through. Full-bleed; intercepts taps so gameplay never receives input while
// the overlay is up.
// Tinted near-black (NOT pure Color.black) at a controlled, lighter opacity. Decoupled
// from the opaque panel: the dim only signals "paused", so gameplay stays visible around
// the card. Still a plain opacity scrim (no Material) for stability over a Metal frame.
let scrim = OverlayTheme.scrimBase
.opacity(metrics.scrimOpacity)
.ignoresSafeArea()
@@ -128,12 +128,18 @@ struct EmulationOnlyGameView: View {
private var retainedGameplayView: some View {
GeometryReader { geometry in
let isLandscape = geometry.size.width > geometry.size.height
// Same screen-not-safe-region measurement as the full game screen, same reason.
let screen = CGSize(
width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing,
height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom
)
let isLandscape = screen.width > screen.height
Group {
if appState.emulationOnlyPresentation.showsVirtualControls && !isLandscape {
VStack(spacing: 0) {
let gameHeight = min(geometry.size.width * 3 / 4, geometry.size.height * 0.6)
let deckHeight = screen.height - geometry.safeAreaInsets.top
let gameHeight = min(geometry.size.width * 3 / 4, deckHeight * 0.6)
accessibleMetalSurface
.frame(height: gameHeight)
.clipped()
@@ -145,7 +151,7 @@ struct EmulationOnlyGameView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.ignoresSafeArea(.container, edges: .bottom)
.ignoresSafeArea([.container, .keyboard], edges: .bottom)
// Same top safe-area strip as the full game screen, same reason.
.background(Color.black.ignoresSafeArea())
} else {
@@ -256,9 +262,7 @@ struct GameScreenView: View {
// from SDL/core. Started when the menu is hidden during gameplay, stopped on restore.
@State private var menuRestorePollTimer: Timer?
@State private var lastControllerInputActive = false
// Orientation, read from the body GeometryReader. The overlay containers
// (pause menu, per-game settings) aren't re-measured on rotation, so we
// key them on this to force a fresh layout on a flip.
// Only the pause menu is keyed on this. The per-game panel holds unsaved edits.
@State private var screenIsLandscape = true
@State private var emulationOnlyTransitionTask: Task<Void, Never>?
@State private var emulationOnlyActivationInFlight = false
@@ -337,7 +341,12 @@ struct GameScreenView: View {
var body: some View {
GeometryReader { geo in
let isLandscape = geo.size.width > geo.size.height
// The window. A keyboard shrinks the safe region until iPad portrait reads wide.
let screen = CGSize(
width: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing,
height: geo.size.height + geo.safeAreaInsets.top + geo.safeAreaInsets.bottom
)
let isLandscape = screen.width > screen.height
Group {
if isLandscape {
@@ -370,7 +379,9 @@ struct GameScreenView: View {
// Game respects the top safe area so OSD stays below the Dynamic Island.
// Controller ignores the bottom safe area so buttons remain usable near the home indicator.
VStack(spacing: 0) {
let gameHeight = min(geo.size.width * 3 / 4, geo.size.height * 0.6)
// The deck ignores the bottom inset, so it runs to the foot of the window.
let deckHeight = screen.height - geo.safeAreaInsets.top
let gameHeight = min(geo.size.width * 3 / 4, deckHeight * 0.6)
MetalGameView()
.frame(height: gameHeight)
.clipped()
@@ -409,24 +420,24 @@ struct GameScreenView: View {
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
}
}
.ignoresSafeArea(.container, edges: .bottom)
// `.keyboard` too: gameplay must not move when an overlay raises one.
.ignoresSafeArea([.container, .keyboard], edges: .bottom)
// The game stays out of the top safe area on purpose, so something has
// to fill it. Black rather than leaving it to whatever is behind: the
// root controller is only black because a boot notification made it so.
.background(Color.black.ignoresSafeArea())
}
}
.preference(key: GameScreenSizePreferenceKey.self, value: geo.size)
.preference(key: GameScreenSizePreferenceKey.self, value: screen)
// Off the safe region, not the preference: the status bar moves one, not the other.
.onChange(of: geo.size) { _, _ in syncFullscreenStateFromWindow() }
}
.onPreferenceChange(GameScreenSizePreferenceKey.self) { size in
// The body GeometryReader is re-measured on rotation; the overlay
// subtrees aren't, so track orientation here and .id() the overlay
// containers off it to rebuild them with the new size.
// The window, so only a real rotation reaches this.
let landscape = size.width > size.height
if screenIsLandscape != landscape {
screenIsLandscape = landscape
}
syncFullscreenStateFromWindow()
}
.sheet(isPresented: childPresentedBinding(.saveStates)) {
SaveStatesPanel { message, isImportant in
@@ -472,14 +483,11 @@ struct GameScreenView: View {
}
.overlay {
if case .pausedPresenting(.perGame) = overlayRoute {
// Presented through the same overlay shell as the pause menu so it stays
// integrated with gameplay (no system sheet chrome / status bar / Dynamic
// Island leak). The panel dismisses via Save/Cancel, so the backdrop does
// not tap-to-dismiss.
// Same shell as the pause menu, so no sheet chrome leaks over gameplay, and
// no `.id` unlike below: a rebuild would drop unsaved edits.
GameOverlayContainer(frameMode: .landscapePanel) { _ in
runtimePerGameSettingsContent
}
.id(screenIsLandscape)
}
}
.overlay {
@@ -115,11 +115,22 @@ private struct OverlayCompactKey: EnvironmentKey {
static let defaultValue: Bool = false
}
private struct OverlayKeyboardOverlapKey: EnvironmentKey {
static let defaultValue: CGFloat = 0
}
extension EnvironmentValues {
var overlayCompact: Bool {
get { self[OverlayCompactKey.self] }
set { self[OverlayCompactKey.self] = newValue }
}
/// How much of the overlay card the keyboard covers. The panel insets itself by this,
/// since an inset applied out here would shrink the box it measures itself in.
var overlayKeyboardOverlap: CGFloat {
get { self[OverlayKeyboardOverlapKey.self] }
set { self[OverlayKeyboardOverlapKey.self] = newValue }
}
}
/// Clear glass overlay shell content. Host this INSIDE `GameOverlayContainer` (which supplies the
@@ -12,11 +12,14 @@ struct PerGameSettingsPanel: View {
@State private var layoutPresets = PadLayoutPresetStore.shared
@State private var skinLibrary = VPadSkinLibraryStore.shared
private enum PerGameSettingsCategory: CaseIterable, Identifiable {
private enum PerGameSettingsCategory: CaseIterable, Identifiable, Hashable {
case general, graphics, framePacing, audio, cpu, pad, fixes, cheats, retroAchievements
var id: Self { self }
/// Everything the root form links to. General is the root, so it is not a link.
static var linked: [PerGameSettingsCategory] { allCases.filter { $0 != .general } }
var titleKey: String {
switch self {
case .general: return "General"
@@ -59,6 +62,9 @@ struct PerGameSettingsPanel: View {
let onDone: (() -> Void)?
let savesToRunningGame: Bool
/// Zero in the library, where this is a sheet and the system does its own avoidance.
@Environment(\.overlayKeyboardOverlap) private var keyboardOverlap
@State private var enabled: Bool
@State private var upscaleMultiplier: Float
@State private var aspectRatio: String
@@ -163,7 +169,9 @@ struct PerGameSettingsPanel: View {
@State private var showDiscardConfirmation = false
@State private var showFramePacingResetConfirmation = false
@State private var savedFingerprint: String = ""
@State private var landscapeCategory: PerGameSettingsCategory = .general
/// Which section is open, shared by both layouts so rotating keeps your place. The
/// rail sets it directly, the portrait form through its path, where `.general` is root.
@State private var openCategory: PerGameSettingsCategory = .general
@State private var raEnabledOverride: Int
@State private var raHardcoreOverride: Int
@@ -397,18 +405,17 @@ struct PerGameSettingsPanel: View {
var body: some View {
GeometryReader { geo in
// Use the landscape workbench (category rail + detail pane) whenever the
// overlay card is wider than it is tall. This covers both iPhone landscape
// (short, wide card) and iPad landscape (large, wide card). The previous
// `height < 500` guard kept iPad landscape on the portrait root form; that
// guard is removed so iPads get the same rail/detail workbench as iPhone
// landscape. Portrait cards (taller than wide) keep the NavigationStack form.
// Rail and detail pane on a wide card, NavigationStack form on a tall one.
let useCompactSettingsLayout = geo.size.width > geo.size.height
VStack(spacing: 0) {
settingsContent(useCompactLayout: useCompactSettingsLayout, availableWidth: geo.size.width)
.frame(maxHeight: .infinity)
saveCancelFooter(compact: useCompactSettingsLayout)
}
// Inside the reader, so a keyboard cannot flip the layout picked above.
.safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: keyboardOverlap)
}
}
.background(OverlayFrostBackground())
.preferredColorScheme(.dark)
@@ -465,13 +472,22 @@ struct PerGameSettingsPanel: View {
}
}
/// The stack holds at most one page, so the path is `openCategory` as a list.
private var navigationPath: Binding<[PerGameSettingsCategory]> {
Binding(
get: { openCategory == .general ? [] : [openCategory] },
set: { openCategory = $0.last ?? .general }
)
}
@ViewBuilder
private func settingsContent(useCompactLayout: Bool, availableWidth: CGFloat = 0) -> some View {
if useCompactLayout {
landscapeSettingsSplit(availableWidth: availableWidth)
} else {
NavigationStack {
NavigationStack(path: navigationPath) {
rootForm
.navigationDestination(for: PerGameSettingsCategory.self, destination: detailContent)
.navigationTitle(settings.localized("Per-Game Settings"))
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(OverlayTheme.shell, for: .navigationBar)
@@ -532,9 +548,9 @@ struct PerGameSettingsPanel: View {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(PerGameSettingsCategory.allCases) { category in
let selected = landscapeCategory == category
let selected = openCategory == category
Button {
landscapeCategory = category
openCategory = category
} label: {
HStack(spacing: 10) {
Image(systemName: category.systemImage)
@@ -564,21 +580,22 @@ struct PerGameSettingsPanel: View {
@ViewBuilder
private var detailPane: some View {
detailContent(for: landscapeCategory)
detailContent(openCategory)
.pickerStyle(.menu)
}
private func detailContent(for category: PerGameSettingsCategory) -> AnyView {
@ViewBuilder
private func detailContent(_ category: PerGameSettingsCategory) -> some View {
switch category {
case .general: return AnyView(generalTab)
case .graphics: return AnyView(graphicsTab)
case .framePacing: return AnyView(framePacingTab)
case .audio: return AnyView(audioTab)
case .cpu: return AnyView(cpuTab)
case .pad: return AnyView(padTab)
case .fixes: return AnyView(fixesTab)
case .cheats: return AnyView(cheatsTab)
case .retroAchievements: return AnyView(retroAchievementsTab)
case .general: generalTab
case .graphics: graphicsTab
case .framePacing: framePacingTab
case .audio: audioTab
case .cpu: cpuTab
case .pad: padTab
case .fixes: fixesTab
case .cheats: cheatsTab
case .retroAchievements: retroAchievementsTab
}
}
@@ -787,45 +804,11 @@ struct PerGameSettingsPanel: View {
@ViewBuilder
private var categoryLinksSection: some View {
Section {
NavigationLink {
graphicsTab
} label: {
Label(settings.localized("Graphics"), systemImage: "paintbrush")
}
NavigationLink {
framePacingTab
} label: {
Label(settings.localized("Frame Pacing"), systemImage: "speedometer")
}
NavigationLink {
audioTab
} label: {
Label(settings.localized("Audio"), systemImage: "speaker.wave.2")
}
NavigationLink {
cpuTab
} label: {
Label(settings.localized("CPU & Speedhacks"), systemImage: "cpu")
}
NavigationLink {
padTab
} label: {
Label(settings.localized("Virtual Pad"), systemImage: "gamecontroller")
}
NavigationLink {
fixesTab
} label: {
Label(settings.localized("Fixes & Compatibility"), systemImage: "wrench.and.screwdriver")
}
NavigationLink {
cheatsTab
} label: {
Label(settings.localized("Cheats & Patches"), systemImage: "rectangle.stack.badge.plus")
}
NavigationLink {
retroAchievementsTab
} label: {
Label(settings.localized("RetroAchievements"), systemImage: "trophy")
// By value, so the path is the panel's own and the rail can agree with it.
ForEach(PerGameSettingsCategory.linked) { category in
NavigationLink(value: category) {
Label(settings.localized(category.titleKey), systemImage: category.systemImage)
}
}
}
}