From 28ebbc9a27e86095ee1d628064af8e0cd1ef6c96 Mon Sep 17 00:00:00 2001 From: dadachi Date: Fri, 16 Oct 2026 10:16:07 +0900 Subject: [PATCH] Show the message bar above the tab bar and label its close button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainView overlaid the message bar on the whole screen, so with the tabs showing it covered the tab bar and blocked tab taps until closed (found on an iPhone in an app built from the same views). With the tabs showing, each tab now shows the bar in its own overlay, above the tab bar; MainView still shows it for the screens without a tab bar (MainContent.hasTabBar). The tab's accessibility label moved before that overlay, since after it it replaced the message's labels. The close button gets an explicit "Close" label (new Strings.close, Japanese 閉じる) and a 32 pt target around the 16 pt symbol. The hosting-window accessibility walk from ValidationMessageTest moved to a shared test helper (AccessibilityElements), which keeps accessibility on until the last parallel caller ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YDK5huvuRECyVrsQEdTN9q --- CHANGELOG.md | 1 + NativeAppTemplate/Constants.swift | 1 + NativeAppTemplate/Localizable.xcstrings | 10 ++ .../UI/App Root/AppTabView.swift | 27 +++++- NativeAppTemplate/UI/App Root/MainView.swift | 16 +++- .../UI/App Root/MainViewModel.swift | 11 +++ .../UI/App Root/SnackbarView.swift | 4 + .../Testing/AccessibilityElements.swift | 91 +++++++++++++++++++ .../UI/App Root/MainContentTest.swift | 15 +++ .../UI/App Root/SnackbarViewTest.swift | 51 +++++++++++ .../UI/Shared/ValidationMessageTest.swift | 61 ++----------- 11 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 NativeAppTemplateTests/Testing/AccessibilityElements.swift create mode 100644 NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9c63a..b2d86bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error messages are localized (English and Japanese). A request that fails without the API's `error_message` shows "Something went wrong while communicating with the server. Please try again later." (`NATIVEAPPTEMPLATE-2001`); developer-only errors (`NATIVEAPPTEMPLATE-1001`, `2002`–`2008`, `4001`–`4004`) show "An unexpected error occurred." The API's `error_message` is shown without the " [Status: nnn]" suffix. Error codes are unchanged; status codes, field names and underlying errors go to the log (`CodedError.debugDescription`) ### Fixed +- The message bar no longer covers the tab bar and blocks tab taps until it is closed: with the tabs showing, it sits above the tab bar. Its close button is a labeled "Close" button (new string, Japanese 閉じる) with a 32 pt target (the symbol stays 16 pt); the tabs' accessibility label no longer replaces the message's labels - VoiceOver read validation messages that weren't shown, e.g. "Name is required." under a filled-in name: a valid form keeps the message's space with a clear color, which still left it an accessibility element. The message is now also hidden from accessibility until it is shown (`validationMessage(isShown:)`, used by every form that hid its message this way: sign-up, profile, password, shop create/basic settings, item tag create/edit) - A failed sign-in's error message stayed on screen after the next attempt signed in; a new attempt now dismisses it - Debug builds lost the local API when launched other than from Xcode, e.g. from the Home Screen or when iOS relaunched the app after its language was changed in Settings: the endpoint came only from the scheme's environment variables, which only Xcode passes. It now comes from `API.xcconfig` (override it in the gitignored `API.local.xcconfig`) and is built into `Info.plist`; `NATIVEAPPTEMPLATE_API_*` environment variables still win. The scheme no longer sets them diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index 204c8ae..008f03a 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -306,6 +306,7 @@ enum Strings { static let ok = String(localized: "OK") static let cancel = String(localized: "Cancel") + static let close = String(localized: "Close") static let save = String(localized: "Save") static let edit = String(localized: "Edit") static let delete = String(localized: "Delete") diff --git a/NativeAppTemplate/Localizable.xcstrings b/NativeAppTemplate/Localizable.xcstrings index cbc8cd5..489ebb2 100644 --- a/NativeAppTemplate/Localizable.xcstrings +++ b/NativeAppTemplate/Localizable.xcstrings @@ -237,6 +237,16 @@ } } }, + "Close" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + } + } + }, "Complete" : { "localizations" : { "ja" : { diff --git a/NativeAppTemplate/UI/App Root/AppTabView.swift b/NativeAppTemplate/UI/App Root/AppTabView.swift index afa5052..2c2381d 100644 --- a/NativeAppTemplate/UI/App Root/AppTabView.swift +++ b/NativeAppTemplate/UI/App Root/AppTabView.swift @@ -84,12 +84,23 @@ struct AppTabView_Previews: PreviewProvider { AppTabView( shopListView: { Text(verbatim: "SHOPS") }, settingsView: { Text(verbatim: "SETTINGS") } - ).environment(TabViewModel()) + ) + .environment(TabViewModel()) + .environment(MessageBus()) } } // MARK: - private +/// The app's message bar, in a tab +private struct TabMessageBarView: View { + @Environment(MessageBus.self) private var messageBus + + var body: some View { + MessageBarView(messageBus: messageBus) + } +} + @MainActor private func tab( content: @escaping () -> some View, navigationPath: Binding?, @@ -99,21 +110,31 @@ struct AppTabView_Previews: PreviewProvider { ) -> some View { if let navigationPath { NavigationStack(path: navigationPath, root: content) + // Before the overlay: applied after it, it would replace the message's labels + .accessibility(label: .init(text)) + // Inside the tab, so the bar sits above the tab bar instead of covering it + .overlay(alignment: .bottom) { + TabMessageBarView() + } .tabItem { Text(text) Image(systemName: imageName) } .tag(tab) .environment(\.mainTab, tab) - .accessibility(label: .init(text)) } else { NavigationStack(root: content) + // Before the overlay: applied after it, it would replace the message's labels + .accessibility(label: .init(text)) + // Inside the tab, so the bar sits above the tab bar instead of covering it + .overlay(alignment: .bottom) { + TabMessageBarView() + } .tabItem { Text(text) Image(systemName: imageName) } .tag(tab) .environment(\.mainTab, tab) - .accessibility(label: .init(text)) } } diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index 74d0dfe..5d817cf 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -18,7 +18,11 @@ struct MainView: View { ZStack { contentView .background(Color.backgroundColor) - .overlay(MessageBarView(messageBus: messageBus), alignment: .bottom) + .overlay(alignment: .bottom) { + if !content.hasTabBar { + MessageBarView(messageBus: messageBus) + } + } .alert(Strings.sessionExpired, isPresented: sessionExpiredAlertBinding) { Button(Strings.ok) {} } message: { @@ -53,11 +57,15 @@ private extension MainView { ) } - @ViewBuilder var contentView: some View { - switch MainViewModel.content( + var content: MainContent { + MainViewModel.content( sessionController: sessionController, isRebuildingRepositories: dataManager.isRebuildingRepositories - ) { + ) + } + + @ViewBuilder var contentView: some View { + switch content { case .onboarding: OnboardingView() case .acceptPrivacy: diff --git a/NativeAppTemplate/UI/App Root/MainViewModel.swift b/NativeAppTemplate/UI/App Root/MainViewModel.swift index b69274f..19f78f0 100644 --- a/NativeAppTemplate/UI/App Root/MainViewModel.swift +++ b/NativeAppTemplate/UI/App Root/MainViewModel.swift @@ -19,6 +19,17 @@ enum MainContent: Equatable { case rebuildingRepositories case needsAppUpdate case tabs + + /// Shown in `AppTabView`. Its tabs show the message bar themselves, above the tab + /// bar, so a message doesn't cover the tabs until it is closed. + var hasTabBar: Bool { + switch self { + case .offline, .rebuildingRepositories, .needsAppUpdate, .tabs: + true + case .onboarding, .acceptPrivacy, .acceptTerms, .permissionsLoading, .permissionsError, .sessionLoading: + false + } + } } @Observable diff --git a/NativeAppTemplate/UI/App Root/SnackbarView.swift b/NativeAppTemplate/UI/App Root/SnackbarView.swift index acc07ff..ae5de49 100644 --- a/NativeAppTemplate/UI/App Root/SnackbarView.swift +++ b/NativeAppTemplate/UI/App Root/SnackbarView.swift @@ -35,8 +35,12 @@ struct SnackbarView: View { Image(systemName: "xmark") .resizable() .frame(width: NativeAppTemplateConstants.Spacing.sm, height: NativeAppTemplateConstants.Spacing.sm) + // The symbol stays small; the area to tap is larger + .frame(width: NativeAppTemplateConstants.Spacing.lg, height: NativeAppTemplateConstants.Spacing.lg) + .contentShape(.rect) } .foregroundStyle(.snackText) + .accessibilityLabel(Strings.close) } .padding(.vertical, NativeAppTemplateConstants.Spacing.sm) .padding(.horizontal, NativeAppTemplateConstants.Spacing.md) diff --git a/NativeAppTemplateTests/Testing/AccessibilityElements.swift b/NativeAppTemplateTests/Testing/AccessibilityElements.swift new file mode 100644 index 0000000..64cdebd --- /dev/null +++ b/NativeAppTemplateTests/Testing/AccessibilityElements.swift @@ -0,0 +1,91 @@ +// +// AccessibilityElements.swift +// NativeAppTemplate +// + +import SwiftUI +import Testing +import UIKit + +/// What VoiceOver, XCUITest and WebDriverAgent can reach in a SwiftUI view +@MainActor +enum AccessibilityElements { + struct Element { + let label: String + let traits: UIAccessibilityTraits + /// In screen coordinates + let frame: CGRect + } + + /// Hosts `view` in a window, lays it out and returns every accessibility element + /// with a label + static func of(_ view: some View) throws -> [Element] { + try beginAccessibility() + defer { try? endAccessibility() } + let controller = UIHostingController(rootView: view) + // Tests run inside the app, so its window scene hosts the test window. + let scene = try #require( + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + ) + let window = UIWindow(windowScene: scene) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + defer { window.isHidden = true } + // Let SwiftUI publish the accessibility tree after the update. + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + + var elements: [Element] = [] + collect(from: controller.view, into: &elements) + return elements + } + + /// Suites run in parallel, and the run loop below lets another test's call start + /// and end in the middle of this one; accessibility stays on until the last ends. + private static var accessibilityUsers = 0 + + private static func beginAccessibility() throws { + if accessibilityUsers == 0 { + try setAccessibilityEnabled(true) + } + accessibilityUsers += 1 + } + + private static func endAccessibility() throws { + accessibilityUsers -= 1 + if accessibilityUsers == 0 { + try setAccessibilityEnabled(false) + } + } + + /// SwiftUI builds its accessibility elements only while an assistive + /// technology is on, so the test turns accessibility on for this process + /// the way XCUITest and VoiceOver do (libAccessibility, part of iOS). + private static func setAccessibilityEnabled(_ enabled: Bool) throws { + let handle = try #require(dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW)) + let symbol = try #require(dlsym(handle, "_AXSApplicationAccessibilitySetEnabled")) + typealias SetEnabled = @convention(c) (Bool) -> Void + unsafeBitCast(symbol, to: SetEnabled.self)(enabled) + } + + private static func collect(from element: NSObject, into elements: inout [Element]) { + if element.isAccessibilityElement, let label = element.accessibilityLabel, !label.isEmpty { + elements.append(Element( + label: label, + traits: element.accessibilityTraits, + frame: element.accessibilityFrame + )) + } + let children: [NSObject] = if let accessibilityElements = element.accessibilityElements as? [NSObject] { + accessibilityElements + } else if let view = element as? UIView { + view.subviews + } else { + [] + } + for child in children { + collect(from: child, into: &elements) + } + } +} diff --git a/NativeAppTemplateTests/UI/App Root/MainContentTest.swift b/NativeAppTemplateTests/UI/App Root/MainContentTest.swift index 2936465..f3fb96a 100644 --- a/NativeAppTemplateTests/UI/App Root/MainContentTest.swift +++ b/NativeAppTemplateTests/UI/App Root/MainContentTest.swift @@ -117,4 +117,19 @@ struct MainContentTest { #expect(content() == .needsAppUpdate) } + + /// MainView shows the message bar over the whole screen only without a tab bar; + /// with one, each tab shows it above the tab bar (it covered the tab bar and + /// blocked tab taps until closed). The cases with a tab bar are the ones + /// MainView.contentView shows in AppTabView. + @Test + func contentInAppTabViewHasATabBar() { + let withTabBar: [MainContent] = [.offline, .rebuildingRepositories, .needsAppUpdate, .tabs] + let withoutTabBar: [MainContent] = [ + .onboarding, .acceptPrivacy, .acceptTerms, .permissionsLoading, .permissionsError, .sessionLoading + ] + + #expect(withTabBar.allSatisfy { $0.hasTabBar }) + #expect(!withoutTabBar.contains { $0.hasTabBar }) + } } diff --git a/NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift b/NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift new file mode 100644 index 0000000..a0f37ec --- /dev/null +++ b/NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift @@ -0,0 +1,51 @@ +// +// SnackbarViewTest.swift +// NativeAppTemplate +// + +@testable import NativeAppTemplate +import SwiftUI +import Testing + +/// The snackbar's close button, as VoiceOver and UI automation reach it. On the +/// same view in another app, a WebDriverAgent dump of a message listed its text but +/// no close button, and the button was a 16 pt square (the symbol's frame, +/// NativeAppTemplateConstants.Spacing.sm). +@MainActor +struct SnackbarViewTest { + private let message = "Something went wrong." + + private func closeButton() throws -> AccessibilityElements.Element? { + try AccessibilityElements.of( + SnackbarView(state: SnackbarState(status: .warning, message: message), visible: .constant(true)) + ).first { $0.traits.contains(.button) } + } + + /// A button named "Close" (Strings.close; the tests run in English) + @Test + func closeButtonIsALabeledButton() throws { + let button = try #require(try closeButton()) + + #expect(button.label == "Close") + } + + /// At least NativeAppTemplateConstants.Spacing.lg (32 pt) square to tap, while the + /// symbol stays 16 pt + @Test + func closeButtonIsLargeEnoughToTap() throws { + let button = try #require(try closeButton()) + + #expect(button.frame.width >= NativeAppTemplateConstants.Spacing.lg) + #expect(button.frame.height >= NativeAppTemplateConstants.Spacing.lg) + } + + /// The message itself is still read + @Test + func messageIsRead() throws { + let labels = try AccessibilityElements.of( + SnackbarView(state: SnackbarState(status: .warning, message: message), visible: .constant(true)) + ).map(\.label) + + #expect(labels.contains(message)) + } +} diff --git a/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift b/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift index 2011945..7dbee67 100644 --- a/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift +++ b/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift @@ -6,7 +6,6 @@ @testable import NativeAppTemplate import SwiftUI import Testing -import UIKit /// A validation message hidden with `.clear` alone was still an accessibility /// element, so VoiceOver read "Name is required." on a valid form. @@ -37,58 +36,14 @@ struct ValidationMessageTest { // MARK: - Helpers - /// Hosts the message next to a visible text in a window, lays it out and - /// returns the labels of every accessibility element VoiceOver could reach. + /// The labels of every accessibility element next to the message and in it private func accessibilityLabels(isShown: Bool) throws -> [String] { - try Self.setAccessibilityEnabled(true) - defer { try? Self.setAccessibilityEnabled(false) } - let view = VStack { - Text(siblingLabel) - Text(messageText) - .validationMessage(isShown: isShown) - } - let controller = UIHostingController(rootView: view) - // Tests run inside the app, so its window scene hosts the test window. - let scene = try #require( - UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first - ) - let window = UIWindow(windowScene: scene) - window.rootViewController = controller - window.makeKeyAndVisible() - controller.view.setNeedsLayout() - controller.view.layoutIfNeeded() - defer { window.isHidden = true } - // Let SwiftUI publish the accessibility tree after the update. - RunLoop.main.run(until: Date().addingTimeInterval(0.1)) - - var labels: [String] = [] - collectLabels(from: controller.view, into: &labels) - return labels - } - - /// SwiftUI builds its accessibility elements only while an assistive - /// technology is on, so the test turns accessibility on for this process - /// the way XCUITest and VoiceOver do (libAccessibility, part of iOS). - private static func setAccessibilityEnabled(_ enabled: Bool) throws { - let handle = try #require(dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW)) - let symbol = try #require(dlsym(handle, "_AXSApplicationAccessibilitySetEnabled")) - typealias SetEnabled = @convention(c) (Bool) -> Void - unsafeBitCast(symbol, to: SetEnabled.self)(enabled) - } - - private func collectLabels(from element: NSObject, into labels: inout [String]) { - if element.isAccessibilityElement, let label = element.accessibilityLabel, !label.isEmpty { - labels.append(label) - } - let children: [NSObject] = if let elements = element.accessibilityElements as? [NSObject] { - elements - } else if let view = element as? UIView { - view.subviews - } else { - [] - } - for child in children { - collectLabels(from: child, into: &labels) - } + try AccessibilityElements.of( + VStack { + Text(siblingLabel) + Text(messageText) + .validationMessage(isShown: isShown) + } + ).map(\.label) } }