iOS: custom background support for BIOS, Help, and Settings tabs

The Games library already rendered a user-chosen background behind its
list; the other menu tabs did not, and the background helpers that drew it
lived privately inside GameListView. This promotes those helpers to shared
code and lets the remaining menu tabs opt in.

  - New MenuBackgroundSupport.swift holds MenuBackgroundLayer (the edge-to-
    edge wallpaper view) and the menuBackgroundListRow modifier (the rounded
    material row backing used so list rows stay readable over a wallpaper).
    GameListView now uses these instead of its private copies.
  - BIOSListView, HelpView, and SettingsRootView draw the background inside
    their own NavigationStack when their per-tab toggle is on, matching how
    GameListView already worked. RootView (MenuTabView) no longer wraps those
    tabs in a background ZStack; a tab that owns its wallpaper must not also
    be wrapped in SafeAreaProtectedMenuTabContent, or the safe-area padding
    would clip the wallpaper.
  - Three per-tab toggles (backgroundEnabledInBIOS/Help/Settings, persisted
    in UserDefaults) are surfaced in Appearance settings under "Show
    Background In". Games keeps the background unconditionally; the others
    default off so existing users see no change until they opt in.

SafeAreaProtectedMenuTabContent is also tightened for iOS 26+: SwiftUI now
reports the correct landscape safe-area inset for a TabView page, so the
manual notch-clearing pad is only applied when SwiftUI itself reports no
horizontal inset. Applying it unconditionally doubled the inset on iOS 26+
and made the menu tabs feel cramped in landscape.
This commit is contained in:
J1coding
2026-07-24 12:15:28 +02:00
committed by Jeen
parent 80feae5f31
commit 29190ff8eb
8 changed files with 264 additions and 126 deletions
@@ -1551,6 +1551,24 @@ final class SettingsStore {
UserDefaults.standard.set(backgroundDim, forKey: "ARMSX2iOSBackgroundDim")
}
}
// Per-tab custom-background toggles. The Games library always honours a set
// background; BIOS/Help/Settings are opt-in so a wallpaper does not suddenly
// appear behind a list the user never asked to decorate.
var backgroundEnabledInBIOS: Bool = true {
didSet { UserDefaults.standard.set(backgroundEnabledInBIOS, forKey: "ARMSX2iOSBackgroundEnabledInBIOS") }
}
var backgroundEnabledInHelp: Bool = false {
didSet { UserDefaults.standard.set(backgroundEnabledInHelp, forKey: "ARMSX2iOSBackgroundEnabledInHelp") }
}
var backgroundEnabledInSettings: Bool = false {
didSet { UserDefaults.standard.set(backgroundEnabledInSettings, forKey: "ARMSX2iOSBackgroundEnabledInSettings") }
}
var hasCustomBackground: Bool {
dynamicBackgroundsEnabled
|| backgroundPrimaryAsset != nil
|| backgroundLandscapeAsset != nil
}
// aspectRatioName / aspectRatioValue see SettingsStore+Graphics.swift.
// loadedFastBoot / loadedJITScriptProtocol see SettingsStore+Speedhacks.swift.
@@ -1754,6 +1772,9 @@ final class SettingsStore {
backgroundLandscapeFitMode = BackgroundFitMode(rawValue: UserDefaults.standard.string(forKey: "ARMSX2iOSBackgroundLandscapeFitMode") ?? "") ?? .fill
backgroundVideoMuted = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundVideoMuted") as? Bool ?? true
backgroundDim = Self.clampedBackgroundDim(UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundDim") as? Double ?? 0.0)
backgroundEnabledInBIOS = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInBIOS") as? Bool ?? true
backgroundEnabledInHelp = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInHelp") as? Bool ?? false
backgroundEnabledInSettings = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInSettings") as? Bool ?? false
normalizeDEV9Settings()
VPadSkinLibraryStore.shared.adoptLegacySelection(virtualPadSkin)
ARMSX2Bridge.setINIString("EmuCore/GS", key: "AspectRatio", value: Self.aspectRatioName(for: aspectRatio))
@@ -1956,6 +1977,9 @@ final class SettingsStore {
backgroundLandscapeFitMode = BackgroundFitMode(rawValue: UserDefaults.standard.string(forKey: "ARMSX2iOSBackgroundLandscapeFitMode") ?? "") ?? .fill
backgroundVideoMuted = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundVideoMuted") as? Bool ?? true
backgroundDim = Self.clampedBackgroundDim(UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundDim") as? Double ?? 0.0)
backgroundEnabledInBIOS = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInBIOS") as? Bool ?? true
backgroundEnabledInHelp = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInHelp") as? Bool ?? false
backgroundEnabledInSettings = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInSettings") as? Bool ?? false
normalizeDEV9Settings()
VPadSkinLibraryStore.shared.adoptLegacySelection(virtualPadSkin)
}
@@ -14,25 +14,38 @@ struct BIOSListView: View {
@State private var showBIOSReplacementAlert = false
@State private var pendingBIOSImportURLs: [URL] = []
@State private var existingBIOSImportFileNames: [String] = []
@Environment(\.menuTabIsActive) private var menuTabIsActive
private var backgroundActive: Bool {
settings.hasCustomBackground && settings.backgroundEnabledInBIOS && menuTabIsActive
}
var body: some View {
NavigationStack {
Group {
if bioses.isEmpty {
emptyState
} else {
List {
ForEach(bioses, id: \.self) { bios in
biosRow(bios)
}
}
#if targetEnvironment(macCatalyst)
.listStyle(.inset)
#endif
ZStack {
if backgroundActive {
MenuBackgroundLayer()
}
Group {
if bioses.isEmpty {
emptyState
} else {
List {
ForEach(bioses, id: \.self) { bios in
biosRow(bios)
.menuBackgroundListRow(backgroundActive)
}
}
.scrollContentBackground(backgroundActive ? .hidden : .automatic)
#if targetEnvironment(macCatalyst)
.listStyle(.inset)
#endif
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle(settings.localized("BIOS"))
.toolbarBackground(backgroundActive ? .hidden : .automatic, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
@@ -263,22 +276,34 @@ struct BIOSListView: View {
return "\(fileMessage)\nNo bootable PS2 BIOS was found. Import a valid PS2 BIOS dump before starting games."
}
@ViewBuilder
private func regionBadge(for bios: ARMSX2BIOSInfo) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemGroupedBackground))
if backgroundActive {
badgeContent(for: bios)
.frame(width: 44, height: 44)
.glassSurface(cornerRadius: 12)
.accessibilityLabel(bios.valid ? "\(bios.regionName) BIOS" : settings.localized("Not a boot BIOS"))
} else {
badgeContent(for: bios)
.frame(width: 44, height: 44)
.background(
Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.accessibilityLabel(bios.valid ? "\(bios.regionName) BIOS" : settings.localized("Not a boot BIOS"))
}
}
if let flag = flagEmoji(for: bios.countryCode) {
Text(flag)
.font(.title2)
} else {
Image(systemName: "globe")
.font(.title3)
.foregroundStyle(.secondary)
}
@ViewBuilder
private func badgeContent(for bios: ARMSX2BIOSInfo) -> some View {
if let flag = flagEmoji(for: bios.countryCode) {
Text(flag)
.font(.title2)
} else {
Image(systemName: "globe")
.font(.title3)
.foregroundStyle(.secondary)
}
.accessibilityLabel(bios.valid ? "\(bios.regionName) BIOS" : settings.localized("Not a boot BIOS"))
}
private func flagEmoji(for countryCode: String) -> String? {
@@ -0,0 +1,41 @@
// MenuBackgroundSupport.swift Shared menu-tab background helpers
// SPDX-License-Identifier: GPL-3.0+
import SwiftUI
struct MenuBackgroundLayer: View {
var body: some View {
GeometryReader { geometry in
BackgroundContainerView(size: geometry.size)
}
.ignoresSafeArea()
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}
struct MenuBackgroundListRowModifier: ViewModifier {
let isEnabled: Bool
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder
func body(content: Content) -> some View {
if isEnabled {
content
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(reduceTransparency ? AnyShapeStyle(.background) : AnyShapeStyle(.regularMaterial), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} else {
content
}
}
}
extension View {
func menuBackgroundListRow(_ isEnabled: Bool) -> some View {
modifier(MenuBackgroundListRowModifier(isEnabled: isEnabled))
}
}
@@ -244,21 +244,11 @@ struct GameListView: View {
}
}
@ViewBuilder
private var libraryBackgroundLayer: some View {
GeometryReader { geometry in
BackgroundContainerView(size: geometry.size)
}
.ignoresSafeArea()
.accessibilityHidden(true)
.allowsHitTesting(false)
}
var body: some View {
NavigationStack {
ZStack {
if hasCustomBackground && shouldRenderLibraryBackground {
libraryBackgroundLayer
MenuBackgroundLayer()
}
GeometryReader { geo in
@@ -594,7 +584,7 @@ struct GameListView: View {
}
ForEach(games) { game in
gameRow(game)
.libraryBackgroundListRow(hasCustomBackground)
.menuBackgroundListRow(hasCustomBackground)
}
}
.scrollContentBackground(hasCustomBackground ? .hidden : .automatic)
@@ -726,7 +716,7 @@ struct GameListView: View {
.padding(.vertical, 6)
}
.tint(.primary)
.libraryBackgroundListRow(hasCustomBackground)
.menuBackgroundListRow(hasCustomBackground)
// Stop button separate row with confirmation alert
Button(role: .destructive) {
@@ -739,7 +729,7 @@ struct GameListView: View {
Spacer()
}
}
.libraryBackgroundListRow(hasCustomBackground)
.menuBackgroundListRow(hasCustomBackground)
}
.alert(settings.localized("Stop Emulation?"), isPresented: $showStopAlert) {
Button(settings.localized("Cancel"), role: .cancel) { }
@@ -1514,32 +1504,6 @@ struct GameListView: View {
}
private struct LibraryBackgroundListRowModifier: ViewModifier {
let isEnabled: Bool
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder
func body(content: Content) -> some View {
if isEnabled {
content
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(reduceTransparency ? AnyShapeStyle(.background) : AnyShapeStyle(.regularMaterial), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} else {
content
}
}
}
private extension View {
func libraryBackgroundListRow(_ isEnabled: Bool) -> some View {
modifier(LibraryBackgroundListRowModifier(isEnabled: isEnabled))
}
}
private struct GameInfoPanel: View {
@Environment(\.dismiss) private var dismiss
@State private var settings = SettingsStore.shared
@@ -102,10 +102,15 @@ private let helpData: [HelpSection] = [
struct HelpView: View {
@State private var settings = SettingsStore.shared
@State private var copyStatusMessage: String?
@Environment(\.menuTabIsActive) private var menuTabIsActive
#if targetEnvironment(macCatalyst)
@State private var selectedTopic: HelpTopic? = .item(section: 0, item: 0)
#endif
private var backgroundActive: Bool {
settings.hasCustomBackground && settings.backgroundEnabledInHelp && menuTabIsActive
}
var body: some View {
#if targetEnvironment(macCatalyst)
NavigationSplitView {
@@ -137,52 +142,61 @@ struct HelpView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.navigationSplitViewStyle(.balanced)
.containerBackground(backgroundActive ? Color.clear : Color(uiColor: .systemGroupedBackground), for: .navigation)
#else
NavigationStack {
List {
ForEach(helpData) { section in
Section {
ForEach(section.items) { item in
DisclosureGroup {
Text(settings.localized(item.answer))
.font(.body)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} label: {
Text(settings.localized(item.question))
.font(.body)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
ZStack {
if backgroundActive {
MenuBackgroundLayer()
}
List {
ForEach(helpData) { section in
Section {
ForEach(section.items) { item in
DisclosureGroup {
Text(settings.localized(item.answer))
.font(.body)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} label: {
Text(settings.localized(item.question))
.font(.body)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
}
.menuBackgroundListRow(backgroundActive)
}
} header: {
Label(settings.localized(section.title), systemImage: section.icon)
}
}
Section {
HStack {
Text(settings.localized("Version"))
Spacer()
Text(ARMSX2Bridge.buildVersion())
.foregroundStyle(.secondary)
.font(.caption)
}
Button {
copyTroubleshootingInfo()
} label: {
Label(settings.localized("Copy Troubleshooting Info"), systemImage: "doc.on.doc")
}
if let copyStatusMessage {
Text(settings.localized(copyStatusMessage))
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Label(settings.localized(section.title), systemImage: section.icon)
Label(settings.localized("About"), systemImage: "info.circle")
}
}
Section {
HStack {
Text(settings.localized("Version"))
Spacer()
Text(ARMSX2Bridge.buildVersion())
.foregroundStyle(.secondary)
.font(.caption)
}
Button {
copyTroubleshootingInfo()
} label: {
Label(settings.localized("Copy Troubleshooting Info"), systemImage: "doc.on.doc")
}
if let copyStatusMessage {
Text(settings.localized(copyStatusMessage))
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Label(settings.localized("About"), systemImage: "info.circle")
}
.scrollContentBackground(backgroundActive ? .hidden : .automatic)
}
.navigationTitle(settings.localized("Help"))
.toolbarBackground(backgroundActive ? .hidden : .automatic, for: .navigationBar)
}
#endif
}
@@ -65,6 +65,10 @@ struct MenuTabView: View {
@State private var settings = SettingsStore.shared
@State private var selectedTab = 0
private var biosBackgroundActive: Bool { settings.hasCustomBackground && settings.backgroundEnabledInBIOS }
private var helpBackgroundActive: Bool { settings.hasCustomBackground && settings.backgroundEnabledInHelp }
private var settingsBackgroundActive: Bool { settings.hasCustomBackground && settings.backgroundEnabledInSettings }
var body: some View {
#if targetEnvironment(macCatalyst)
VStack(spacing: 0) {
@@ -101,8 +105,15 @@ struct MenuTabView: View {
}
.tag(0)
SafeAreaProtectedMenuTabContent {
BIOSListView()
// When a tab's background is active it owns its edge-to-edge MenuBackgroundLayer
// inside its own NavigationStack (matching GameListView), so it must NOT be wrapped
// in SafeAreaProtectedMenuTabContent the padding would clip the wallpaper.
Group {
if biosBackgroundActive {
BIOSListView()
} else {
SafeAreaProtectedMenuTabContent { BIOSListView() }
}
}
.environment(\.menuTabIsActive, selectedTab == 1)
.tabItem {
@@ -110,8 +121,12 @@ struct MenuTabView: View {
}
.tag(1)
SafeAreaProtectedMenuTabContent {
HelpView()
Group {
if helpBackgroundActive {
HelpView()
} else {
SafeAreaProtectedMenuTabContent { HelpView() }
}
}
.environment(\.menuTabIsActive, selectedTab == 2)
.tabItem {
@@ -119,9 +134,17 @@ struct MenuTabView: View {
}
.tag(2)
SafeAreaProtectedMenuTabContent {
NavigationStack {
SettingsRootView()
Group {
if settingsBackgroundActive {
NavigationStack {
SettingsRootView()
}
} else {
SafeAreaProtectedMenuTabContent {
NavigationStack {
SettingsRootView()
}
}
}
}
.environment(\.menuTabIsActive, selectedTab == 3)
@@ -161,17 +184,24 @@ private struct SafeAreaProtectedMenuTabContent<Content: View>: View {
}
var body: some View {
// SwiftUI's GeometryReader safe-area insets are unreliable inside a TabView
// page (the left/right notch/Dynamic Island insets read as zero in landscape),
// so read the real key-window insets instead and use the geometry size only as
// a reliable signal to recompute them on rotation. This keeps normal app tab
// content clear of the notch without touching gameplay or overlay surfaces.
// Pre-iOS 26, SwiftUI reports no horizontal safe-area inset for a TabView page in
// landscape, so a bare list slides under the notch and we pad it manually from the
// key-window insets. On iOS 26+ SwiftUI gets it right, and padding again would
// double-inset the column. Tabs that draw their own edge-to-edge background skip
// this wrapper entirely (see MenuTabView).
GeometryReader { geometry in
let insets = NormalTabContentMargin.effectiveHorizontalInsets(
raw: safeAreaInsets,
isLandscape: geometry.size.width > geometry.size.height,
idiom: UIDevice.current.userInterfaceIdiom
)
let isLandscapePhone = geometry.size.width > geometry.size.height
&& UIDevice.current.userInterfaceIdiom == .phone
let systemProvidesInset = geometry.safeAreaInsets.leading > 0
|| geometry.safeAreaInsets.trailing > 0
let insets: (left: CGFloat, right: CGFloat) = {
guard isLandscapePhone, !systemProvidesInset else { return (0, 0) }
return NormalTabContentMargin.effectiveHorizontalInsets(
raw: safeAreaInsets,
isLandscape: true,
idiom: .phone
)
}()
content
.padding(.leading, layoutDirection == .rightToLeft ? insets.right : insets.left)
.padding(.trailing, layoutDirection == .rightToLeft ? insets.left : insets.right)
@@ -209,11 +239,10 @@ private enum KeyWindowSafeArea {
/// Adds a small readable horizontal margin for normal app tabs in iPhone landscape.
///
/// The raw key-window safe-area insets only clear the hardware cutout (notch / Dynamic
/// Island / sensor housing) by the minimum amount, which still leaves tab content cramped
/// against the cutout in landscape. This applies a small minimum content margin on each
/// side so Games, BIOS, Help, and Settings sit in a balanced, readable column. Portrait and
/// iPad keep the raw insets unchanged, and gameplay surfaces never use this helper.
/// Only used on the legacy path of `SafeAreaProtectedMenuTabContent` (pre-iOS 26, where
/// SwiftUI reports no horizontal safe-area inset in a TabView page). The raw key-window
/// insets only just clear the notch, so this pads each side a bit more. Portrait, iPad,
/// and gameplay surfaces are unaffected.
private enum NormalTabContentMargin {
/// Minimum horizontal content margin for normal app tabs on an iPhone in landscape.
static let minimumLandscapeMargin: CGFloat = 20
@@ -45,7 +45,7 @@ struct AppearanceSettingsView: View {
) { showLandscapePicker = true }
.modifier(BackgroundSourcePicker(isPresented: $showLandscapePicker, role: .landscape, existingAsset: { settings.backgroundLandscapeAsset }) { updateLandscape($0) })
} header: {
Text(settings.localized("Library Background"))
Text(settings.localized("Background"))
} footer: {
Text(settings.localized("Each orientation keeps its own background. Setting one never overwrites the other."))
}
@@ -102,6 +102,22 @@ struct AppearanceSettingsView: View {
}
.padding(.vertical, 4)
}
Section {
Toggle(isOn: $settings.backgroundEnabledInBIOS) {
Label(settings.localized("BIOS"), systemImage: "cpu")
}
Toggle(isOn: $settings.backgroundEnabledInHelp) {
Label(settings.localized("Help"), systemImage: "questionmark.circle")
}
Toggle(isOn: $settings.backgroundEnabledInSettings) {
Label(settings.localized("Settings"), systemImage: "gearshape")
}
} header: {
Text(settings.localized("Show Background In"))
} footer: {
Text(settings.localized("The background also shows behind the Games library. Each tab can be toggled independently. Dim or mute from the settings above."))
}
}
.navigationTitle(settings.localized("Appearance"))
.sheet(item: $presentedEditor, onDismiss: paletteEditorDidDismiss) { _ in
@@ -11,6 +11,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
case appearance
case emulator
case graphics
case framePacing
case audio
case network
case memoryCards
@@ -35,6 +36,8 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
return "Emulator"
case .graphics:
return "Graphics"
case .framePacing:
return "Frame Pacing"
case .audio:
return "Audio"
case .network:
@@ -70,6 +73,8 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
return "cpu"
case .graphics:
return "paintbrush"
case .framePacing:
return "speedometer"
case .audio:
return "speaker.wave.2"
case .network:
@@ -102,10 +107,15 @@ struct SettingsRootView: View {
@State private var noJITFallbackActive = ARMSX2Bridge.isNoJITFallbackActive()
@State private var stikDebugOpenFailed = false
@State private var stikDebugOpenInProgress = false
@Environment(\.menuTabIsActive) private var menuTabIsActive
#if targetEnvironment(macCatalyst)
@State private var selectedPane: SettingsPane? = .emulator
#endif
private var backgroundActive: Bool {
settings.hasCustomBackground && settings.backgroundEnabledInSettings && menuTabIsActive
}
var body: some View {
#if targetEnvironment(macCatalyst)
NavigationSplitView {
@@ -120,8 +130,13 @@ struct SettingsRootView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.navigationSplitViewStyle(.balanced)
.containerBackground(backgroundActive ? Color.clear : Color(uiColor: .systemGroupedBackground), for: .navigation)
#else
List {
ZStack {
if backgroundActive {
MenuBackgroundLayer()
}
List {
Section(settings.localized("Interface")) {
NavigationLink {
LanguageSettingsView()
@@ -146,6 +161,11 @@ struct SettingsRootView: View {
} label: {
Label(settings.localized("Graphics"), systemImage: "paintbrush")
}
NavigationLink {
FramePacingSettingsView()
} label: {
Label(settings.localized("Frame Pacing"), systemImage: "speedometer")
}
NavigationLink {
AudioSettingsView()
} label: {
@@ -243,7 +263,10 @@ struct SettingsRootView: View {
}
}
}
.scrollContentBackground(backgroundActive ? .hidden : .automatic)
}
.navigationTitle(settings.localized("Settings"))
.toolbarBackground(backgroundActive ? .hidden : .automatic, for: .navigationBar)
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .top) {
Color.clear.frame(height: 6)
@@ -343,6 +366,8 @@ struct SettingsRootView: View {
EmulatorSettingsView()
case .graphics:
GraphicsSettingsView()
case .framePacing:
FramePacingSettingsView()
case .audio:
AudioSettingsView()
case .network: