VoiceOver focus does not reset to top when navigating between screens in SwiftUI

3 weeks ago 36
ARTICLE AD BOX

I’m developing an iOS application using SwiftUI and I’m having persistent issues with VoiceOver accessibility focus when navigating between screens.

When navigating from one screen to another, VoiceOver focus does not reset to the top of the new screen. Instead, it remains on a previously focused element or jumps to an unexpected position (to battery, for example).

From a UX and accessibility perspective, I expect VoiceOver to focus on the first meaningful element at the top of the screen whenever a new screen appears.

I’ve already tried all commonly suggested and currently recommended approaches (as of 2025), including:

UIAccessibility.post(notification: .screenChanged, argument: ...) UIAccessibility.post(notification: .layoutChanged, argument: ...) Wrapping accessibility posts inside DispatchQueue.main.async in ViewModels, Screens or Sections. Using @AccessibilityFocusState private var isFocused: Bool and then Text("Screen title").accessibilityFocused($isTitleFocused) and .onAppear { isTitleFocused = true } Using accessibility priority

Despite all of this, the behavior remains inconsistent or simply does not work as expected.


Expected behavior:

When navigating to a new screen, VoiceOver focus should reset to the top-most accessible element of that screen.

Actual behavior:

VoiceOver focus remains on a previous element or lands in the middle/bottom of the screen.


Here are some examples of how I have applied the different solutions in my files:

ViewModel:

/// ViewModel for the WomenCarePoint Home screen final class WomenCarePointHomeViewModel: @unchecked Sendable, WomenCarePointHomeViewModelContract, WomenCarePointHomeListSectionViewModelContract, WomenCarePointHomeFooterSectionViewModelContract, WomenCarePointHomeHeaderSectionViewModelContract, CrossErrorSectionViewModelContract { // MARK: - UseCase ... // MARK: - Published Properties ... // MARK: - Publishers ... // MARK: - Inputs /// Called when the view appears public func notifyAppearance() { loadData() DispatchQueue.main.async { // UIAccessibility.post(notification: .screenChanged, argument: nil) <- TRY HERE } } /// Navigate to the detail screen of a care point /// - Parameter centerId: identifier of the care point public func navigateToCarePointDetail(centerId: String) { navigationBuilder.navigateToCarePointDetail(centerId: centerId) } } // MARK: - Private Methods private extension WomenCarePointHomeViewModel { /// Loads care points information using the use case func loadData() { Task { @MainActor [weak self] in guard let self else { return } self.isLoading = true // UIAccessibility.post(notification: .screenChanged, argument: nil) <- TRY HERE do { let info = try await self.getWomenCarePointUseCase.run() self.womenCarePointInformationPublished.data = info.data self.isLoading = false // UIAccessibility.post(notification: .screenChanged, argument: nil) <- TRY HERE } catch { self.isLoading = false self.isError = true } } } }

Screen:

struct WomenCarePointHomeScreen<Top: View, Content: View, Bottom: View, Overlay: View, Error: View>: View { ... // @AccessibilityFocusState private var isFocused: Bool <- TRY HERE public var body: some View { ZStack { BackgroundView() VStack { top // .accessibilityFocused($isTitleFocused) <- TRY HERE ScrollView { content .background( GeometryReader { proxy in Color.clear .preference( key: BottomSentinelMaxYPreferenceKey.self, value: proxy.frame(in: .named("WomenCarePointScroll")).maxY ) } ) } .coordinateSpace(name: "WomenCarePointScroll") .background( GeometryReader { geo in Color.clear .onAppear { scrollViewHeight = geo.size.height } .onChange(of: geo.size.height) { _, new in scrollViewHeight = new } } ) .onPreferenceChange(BottomSentinelMaxYPreferenceKey.self) { maxY in let margin = bottomDetectionMargin let reached = maxY <= (scrollViewHeight + margin) if reached != didReachBottom { didReachBottom = reached } } ZStack(alignment: .top) { // Footer shadow LinearGradient( gradient: Gradient(colors: [ Color.black.opacity(didReachBottom ? 0 : 0.1), .clear ]), startPoint: .bottom, endPoint: .top ) .frame(height: 12) .offset(y: -18) bottom .ignoresSafeArea(.all) } } .frame(maxWidth: .infinity) .background(.white) .hiddenOrRemoved(showError, remove: true) if showError { error } if showLoader { overlay } } .onAppear { viewModel.notifyAppearance() // isTitleFocused = true <- TRY HERE // UIAccessibility.post(notification: .screenChanged, argument: nil) <- TRY HERE } .onReceive(viewModel.errorPublisher) { showError = $0 } .onReceive(viewModel.loaderPublisher) { showLoader = $0 } } }

Section:

struct WomenCarePointHomeHeaderSectionView: View { // MARK: Modular Variable ... // @AccessibilityFocusState private var isFocused: Bool <- TRY HERE // MARK: Life cycle ... var body: some View { contentView .onAppear { // isTitleFocused = true <- TRY HERE // UIAccessibility.post(notification: .screenChanged, argument: nil) <- TRY HERE } } } // MARK: - Private UI private extension WomenCarePointHomeHeaderSectionView { @ViewBuilder var contentView: some View { VStack(alignment: .leading) { HStack(spacing: 0) { Image("icon_app") .resizable() .scaledToFit() .frame(width: 65, height: 65) .accessibilityHidden(true) VStack(alignment: .leading) { Text("Mujer Madrid") .font(.title) .foregroundStyle(.black) .bold() .accessibilityAddTraits(.isHeader) // .accessibilityFocused($isTitleFocused) <- TRY HERE Text("Información y acceso rápido a centros de atención a mujeres") .font(.subheadline) .foregroundStyle(.black) .fixedSize(horizontal: false, vertical: true) .multilineTextAlignment(.leading) .lineLimit(nil) } } .padding(.horizontal, 7) toogleButton } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, verticalSizeClass == .compact ? 12 : 0) } @ViewBuilder var toogleButton: some View { Toggle(isOn: $isOn) { Text("Mostrar como lista") .font(.subheadline) .foregroundStyle(.black) } .padding(.horizontal, 16) .colorScheme(.light) .onChange(of: isOn) { viewModel.didTapToggle() } } }
iOS version: 26.0 Xcode version: 26.1.1 SwiftUI Custom Navigation router
Is there any reliable and deterministic way to ensure that VoiceOver focus resets to the top of the screen when navigating between SwiftUI screens? Is this a known limitation or bug in SwiftUI accessibility/navigation handling, or am I missing a key concept in how VoiceOver focus should be managed across screen transitions?
Read Entire Article