From 1bd408497f2c026e0242e7a5a5977bff2c4b5bd6 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 15:13:07 -0500 Subject: [PATCH] Prevent layout shifts while checking simulators --- src/CMakeLists.txt | 2 + src/MissingXcodeView.swift | 10 +-- src/NoSimulatorsView.swift | 10 +-- src/SimulatorCheckButton.swift | 26 +++++++ src/SimulatorStatusViews.swift | 8 +- src/StableSizeView.swift | 28 +++++++ tests/CMakeLists.txt | 6 ++ tests/StableSizeViewTests.swift | 125 ++++++++++++++++++++++++++++++++ 8 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 src/SimulatorCheckButton.swift create mode 100644 src/StableSizeView.swift create mode 100644 tests/StableSizeViewTests.swift diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2d1e7bd..2a31ed7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -8,6 +8,8 @@ add_executable(SimStore MACOSX_BUNDLE NoSimulatorsView.swift MissingXcodeView.swift SimulatorStatusViews.swift + StableSizeView.swift + SimulatorCheckButton.swift ) include("${PROJECT_SOURCE_DIR}/cmake/Localization.cmake") diff --git a/src/MissingXcodeView.swift b/src/MissingXcodeView.swift index 2130a6b..330ca52 100644 --- a/src/MissingXcodeView.swift +++ b/src/MissingXcodeView.swift @@ -28,14 +28,8 @@ struct MissingXcodeView: View { HStack(spacing: 10) { Button("Open App Store", systemImage: "bag", action: openAppStore) .buttonStyle(.borderedProminent) - Button( - isChecking ? String(localized: "Checking…") : String(localized: "Check Again"), - systemImage: "arrow.clockwise" - ) { - checkAgain() - } - .buttonStyle(.bordered) - .disabled(isChecking) + SimulatorCheckButton(isChecking: isChecking, action: checkAgain) + .buttonStyle(.bordered) } .controlSize(.large) .padding(.top, 25) diff --git a/src/NoSimulatorsView.swift b/src/NoSimulatorsView.swift index d58f25a..245ed52 100644 --- a/src/NoSimulatorsView.swift +++ b/src/NoSimulatorsView.swift @@ -52,14 +52,8 @@ struct NoSimulatorsView: View { openDeviceManager() } .buttonStyle(.borderedProminent) - Button( - isChecking ? String(localized: "Checking…") : String(localized: "Check Again"), - systemImage: "arrow.clockwise" - ) { - checkAgain() - } - .buttonStyle(.bordered) - .disabled(isChecking) + SimulatorCheckButton(isChecking: isChecking, action: checkAgain) + .buttonStyle(.bordered) } .controlSize(.large) .padding(.top, 25) diff --git a/src/SimulatorCheckButton.swift b/src/SimulatorCheckButton.swift new file mode 100644 index 0000000..999bb2b --- /dev/null +++ b/src/SimulatorCheckButton.swift @@ -0,0 +1,26 @@ +import SwiftUI + +@MainActor +struct SimulatorCheckButton: View { + let isChecking: Bool + var isRetry = false + let action: () -> Void + + var body: some View { + Button(action: action) { + Label { + StableSizeView(isChecking, states: [false, true]) { checking in + Text(title(for: checking)) + } + } icon: { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isChecking) + } + + private func title(for checking: Bool) -> LocalizedStringKey { + if checking { return "Checking…" } + return isRetry ? "Try Again" : "Check Again" + } +} diff --git a/src/SimulatorStatusViews.swift b/src/SimulatorStatusViews.swift index eb3c342..857ad34 100644 --- a/src/SimulatorStatusViews.swift +++ b/src/SimulatorStatusViews.swift @@ -55,13 +55,7 @@ struct SimulatorDiscoveryErrorView: View { .frame(maxWidth: 390) HStack(spacing: 10) { Button("Open Xcode", systemImage: "hammer") { openXcode() } - Button( - isChecking ? String(localized: "Checking…") : String(localized: "Try Again"), - systemImage: "arrow.clockwise" - ) { - checkAgain() - } - .disabled(isChecking) + SimulatorCheckButton(isChecking: isChecking, isRetry: true, action: checkAgain) } .controlSize(.large) DisclosureGroup("Details") { diff --git a/src/StableSizeView.swift b/src/StableSizeView.swift new file mode 100644 index 0000000..84da060 --- /dev/null +++ b/src/StableSizeView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +@MainActor +struct StableSizeView: View { + let selection: State + let states: [State] + let content: (State) -> Content + + init( + _ selection: State, states: [State], + @ViewBuilder content: @escaping (State) -> Content + ) { + self.selection = selection + self.states = states + self.content = content + } + + var body: some View { + ZStack { + ForEach(states, id: \.self) { state in + content(state) + .opacity(state == selection ? 1 : 0) + .allowsHitTesting(state == selection) + .accessibilityHidden(state != selection) + } + } + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6b7f92b..1269e4a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,6 +19,12 @@ target_compile_options(SimStoreLocalizationTest PRIVATE -parse-as-library) add_test(NAME localization COMMAND SimStoreLocalizationTest "$") set_tests_properties(localization PROPERTIES TIMEOUT 10) +add_executable(SimStoreStableSizeTest StableSizeViewTests.swift "${PROJECT_SOURCE_DIR}/src/StableSizeView.swift") +add_dependencies(SimStoreStableSizeTest SimStore) +target_compile_options(SimStoreStableSizeTest PRIVATE -parse-as-library) +add_test(NAME stable-button-size COMMAND SimStoreStableSizeTest "$") +set_tests_properties(stable-button-size PROPERTIES TIMEOUT 15 RUN_SERIAL TRUE) + execute_process(COMMAND xcrun --sdk "${CMAKE_OSX_SYSROOT}" --show-sdk-version OUTPUT_VARIABLE SDK_VERSION OUTPUT_STRIP_TRAILING_WHITESPACE diff --git a/tests/StableSizeViewTests.swift b/tests/StableSizeViewTests.swift new file mode 100644 index 0000000..2bdce90 --- /dev/null +++ b/tests/StableSizeViewTests.swift @@ -0,0 +1,125 @@ +import AppKit +import SwiftUI + +private struct ButtonFrames: PreferenceKey { + static let defaultValue: [String: CGRect] = [:] + + static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +@MainActor +private final class Measurements { + var frames: [String: CGRect] = [:] +} + +@main +struct StableSizeViewTests { + @MainActor + static func main() async { + do { + guard CommandLine.arguments.count == 2 else { throw failure("Expected the app bundle path.") } + let resources = URL(fileURLWithPath: CommandLine.arguments[1]) + .appendingPathComponent("Contents/Resources") + let app = NSApplication.shared + app.setActivationPolicy(.regular) + let window = NSWindow( + contentRect: NSRect(x: 200, y: 200, width: 640, height: 180), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + var cases = 0 + for language in ["en", "zh-Hans"] { + guard let bundle = Bundle(url: resources.appendingPathComponent("\(language).lproj")) else { + throw failure("Missing \(language) translations.") + } + let checking = bundle.localizedString(forKey: "Checking…", value: nil, table: nil) + for key in ["Check Again", "Try Again"] { + let idle = bundle.localizedString(forKey: key, value: nil, table: nil) + for controlSize: ControlSize in [.regular, .large] { + for fontSize: CGFloat in [14, 24] { + let labels = [idle, checking] + let before = try await measure( + window, checking: false, labels: labels, controlSize: controlSize, + fontSize: fontSize) + let during = try await measure( + window, checking: true, labels: labels, controlSize: controlSize, fontSize: fontSize + ) + for name in ["check", "neighbor"] { + guard let first = before[name], let second = during[name], + abs(first.minX - second.minX) < 0.5, + abs(first.minY - second.minY) < 0.5, + abs(first.width - second.width) < 0.5, + abs(first.height - second.height) < 0.5 + else { + throw failure( + "\(language), \(key), \(controlSize), \(fontSize): \(name) moved or resized.") + } + } + cases += 1 + } + } + } + } + print( + "PASS: stable button bounds and adjacent position in \(cases) translated control/font combinations." + ) + } catch { + FileHandle.standardError.write( + Data("Stable sizing test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + @MainActor + private static func measure( + _ window: NSWindow, checking: Bool, labels: [String], controlSize: ControlSize, + fontSize: CGFloat + ) async throws -> [String: CGRect] { + let measurements = Measurements() + let content = HStack(spacing: 10) { + Button("Neighbor") {}.buttonStyle(.borderedProminent) + .background(frame("neighbor")) + Button { + } label: { + Label { + StableSizeView(checking, states: [false, true]) { state in + Text(verbatim: labels[state ? 1 : 0]) + } + } icon: { + Image(systemName: "arrow.clockwise") + } + } + .buttonStyle(.bordered) + .disabled(checking) + .background(frame("check")) + } + .controlSize(controlSize) + .font(.system(size: fontSize)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onPreferenceChange(ButtonFrames.self) { frames in + Task { @MainActor in measurements.frames = frames } + } + let host = NSHostingView(rootView: content) + host.frame = NSRect(x: 0, y: 0, width: 640, height: 180) + window.contentView = host + window.orderFront(nil) + for _ in 0..<30 { + host.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(20)) + if measurements.frames.count == 2 { return measurements.frames } + } + throw failure("Native button geometry was not produced.") + } + + private static func frame(_ name: String) -> some View { + GeometryReader { geometry in + Color.clear.preference(key: ButtonFrames.self, value: [name: geometry.frame(in: .global)]) + } + } + + private static func failure(_ message: String) -> NSError { + NSError(domain: "StableSizeViewTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +}