아티클

이전 화면 복귀 시 접근성 초점 불일치 해결하기 2부 - iOS SwiftUI

엔비전스 접근성 2026-04-20 15:52:38

안녕하세요. 엔비전스입니다.

지난 1부에서는 iOS UIKit 환경에서 화면 전환 시 보이스오버(VoiceOver) 초점이 유실되는 문제를 다루었습니다. .screenChanged와 .layoutChanged 알림의 차이, 아키텍처별 적용 전략, 고유 ID 기반의 초점 추적, 그리고 DispatchWorkItem을 활용한 안전한 비동기 초점 복원까지 살펴보았습니다.

이번 2부에서는 같은 문제를 iOS SwiftUI 환경에서 해결하는 방법을 다룹니다. 선언형 UI 프레임워크인 SwiftUI에서는 UIKit과 달리 뷰의 생명주기를 직접 관리하지 않으며, 접근성 초점 제어 방식 또한 근본적으로 다릅니다. UIKit에서 사용하던 UIAccessibility.post(notification:argument:) 방식의 명령형(Imperative) 접근 대신, SwiftUI는 상태(State) 바인딩 기반의 선언형(Declarative) 접근성 초점 관리를 제공합니다.

SwiftUI 환경에서도 1부와 동일하게 두 가지 아키텍처를 기준으로 설명하겠습니다. NavigationStack을 사용하여 각 화면이 독립된 뷰로 분리되는 구조와, 단일 뷰 내에서 ZStack과 상태 전환으로 화면을 교체하는 구조입니다.

SwiftUI에서 사용하는 접근성 API 알아보기

본격적인 구현에 앞서, SwiftUI 환경에서 접근성 초점을 제어할 때 사용하는 두 가지 핵심 API를 먼저 정리하겠습니다.

1. AccessibilityNotification (iOS 17+)

UIKit에서는 UIAccessibility.post(notification:argument:)를 사용하여 보이스오버에 화면 변경을 알렸습니다. SwiftUI에서도 이 UIKit API를 직접 호출할 수 있지만, iOS 17부터는 Swift Accessibility 프레임워크에서 제공하는 AccessibilityNotification을 사용하는 것이 권장됩니다.

AccessibilityNotification은 네 가지 타입을 제공합니다.

  • ScreenChanged: 화면 전체가 변경되었음을 알립니다 (UIKit의 .screenChanged에 대응).
  • LayoutChanged: 화면 내 일부 레이아웃이 변경되었음을 알립니다 (UIKit의 .layoutChanged에 대응).
  • Announcement: 보이스오버에 텍스트를 읽어주도록 요청합니다 (UIKit의 .announcement에 대응).
  • PageScrolled: 스크롤 동작을 알립니다 (UIKit의 .pageScrolled에 대응).

사용법은 UIKit보다 직관적입니다.

// UIKit 방식
UIAccessibility.post(notification: .screenChanged, argument: nil)

// SwiftUI 방식 (iOS 17+)
import Accessibility
AccessibilityNotification.ScreenChanged(nil).post()

AccessibilityNotification.ScreenChanged의 생성자에는 초점을 이동시킬 대상을 전달할 수 있습니다. nil을 전달하면 보이스오버가 화면의 첫 번째 접근성 요소로 초점을 이동시킵니다.

다만, SwiftUI에서는 UIKit처럼 특정 UIView 인스턴스를 직접 참조하기 어렵습니다. 따라서 AccessibilityNotification만으로는 "이 게시글 셀로 초점을 보내라"는 식의 정밀한 초점 제어가 어려우며, 이를 위해서는 다음에 소개할 @AccessibilityFocusState를 함께 활용해야 합니다.

2. @AccessibilityFocusState (iOS 15+)

@AccessibilityFocusState는 iOS 15에서 도입된 SwiftUI 전용 프로퍼티 래퍼(Property Wrapper)로, 보이스오버 초점을 선언형으로 제어할 수 있게 해줍니다. 이 API에 대해서는 이전 아티클에서 한 번 다룬 바 있으므로, 여기서는 본 주제에 필요한 핵심 사용법만 간단히 정리하겠습니다.

기본적인 사용 패턴은 다음과 같습니다.

// 특정 값과 매칭하여 초점을 제어하는 패턴
@AccessibilityFocusState var focusedPostId: Int?

ForEach(posts) { post in
    PostRow(post: post)
        .accessibilityFocused($focusedPostId, equals: post.id)
}

// 프로그래밍적으로 초점 이동
focusedPostId = 42  // ID가 42인 게시글로 보이스오버 초점 이동

@AccessibilityFocusState에 값을 설정하면, 해당 값과 equals 파라미터가 일치하는 뷰로 보이스오버 초점이 자동으로 이동합니다. 이것이 SwiftUI에서 특정 요소에 정밀하게 초점을 보낼 수 있는 유일한 공식 방법입니다.

아키텍처별 초점 복원 전략

1부에서 다룬 UIKit과 마찬가지로, SwiftUI에서도 화면 전환 아키텍처에 따라 접근성 초점 복원 전략이 달라집니다.

1. NavigationStack 기반 (독립 화면 구조)

NavigationStack을 사용하여 게시글 목록과 상세를 각각 별도의 뷰로 분리한 구조입니다. UIKit의 UINavigationController에 대응하며, 뷰가 push/pop되면서 화면이 전환됩니다.

이 구조에서는 NavigationStack이 pop될 때 시스템이 자체적으로 화면 전환 이벤트를 발생시킵니다. 따라서 1부의 UIKit UINavigationController 환경과 동일한 원칙이 적용됩니다. 즉, 개발자가 추가로 ScreenChanged를 호출하면 화면 전환 사운드가 중복될 수 있으므로, @AccessibilityFocusState만으로 조용히 초점을 원하는 게시글로 이동시키는 것이 자연스럽습니다.

구현의 핵심 흐름은 다음과 같습니다.

1단계: 게시글 진입 시 접근성 컨텍스트 저장

사용자가 게시글을 탭하여 상세 화면으로 진입할 때, 선택한 게시글의 ID와 이웃 게시글 ID를 저장합니다. 이 정보는 복귀 시 초점을 복원하거나, 게시글이 삭제된 경우 대체 초점 대상을 결정하는 데 사용됩니다.

Button {
    viewModel.saveAccessibilityContext(postId: post.id)
    path.append(AppRoute.separateDetail(post.id))
} label: {
    // 게시글 행 내용
}

ViewModel에서의 컨텍스트 저장 로직은 1부에서 다룬 것과 동일합니다.

func saveAccessibilityContext(postId: Int) {
    lastSelectedPostId = postId
    wasPostDeleted = false
    if let index = posts.firstIndex(where: { $0.id == postId }) {
        neighborPostIds = (
            previous: index > 0 ? posts[index - 1].id : nil,
            next: index < posts.count - 1 ? posts[index + 1].id : nil
        )
    }
}

2단계: 목록 뷰에 @AccessibilityFocusState 바인딩

목록의 각 게시글 행과 작성 버튼에 접근성 초점 상태를 바인딩합니다.

@AccessibilityFocusState private var focusedPostId: Int?
@AccessibilityFocusState private var isCreateButtonFocused: Bool

// 게시글 행에 바인딩
ForEach(posts) { post in
    PostRow(post: post)
        .accessibilityFocused($focusedPostId, equals: post.id)
}

// 작성 버튼에 바인딩 (삭제 후 게시글이 없을 때의 폴백용)
Button("게시글 작성") { /* ... */ }
    .accessibilityFocused($isCreateButtonFocused)

3단계: 복귀 감지 및 초점 복원

UIKit에서는 viewDidAppear에서 복귀를 감지했지만, SwiftUI에서는 NavigationPath의 변화를 관찰하여 pop(복귀)을 감지합니다.

@State private var previousPathCount: Int = 0

.onChange(of: path.count) { oldCount, newCount in
    // pop 감지: path가 줄어들면 상세에서 복귀한 것
    guard newCount < oldCount else {
        previousPathCount = newCount
        return
    }
    previousPathCount = newCount

    // 필요 시 새로고침 후 초점 복원
    if needsRefresh {
        Task {
            await viewModel.refreshPosts()
            restoreAccessibilityFocus()
        }
    } else {
        restoreAccessibilityFocus()
    }
}

초점 복원 메서드에서는 1부에서 다룬 것과 동일한 원칙들을 적용합니다. 보이스오버 실행 여부를 확인하고, 비동기 딜레이를 부여하며, Task 취소를 통해 유령 초점을 방지합니다.

@State private var focusTask: Task<Void, Never>?

private func restoreAccessibilityFocus() {
    guard UIAccessibility.isVoiceOverRunning,
          viewModel.lastSelectedPostId != nil else { return }

    focusTask?.cancel()
    focusTask = Task {
        try? await Task.sleep(nanoseconds: 1_000_000_000) // 1초 딜레이
        guard !Task.isCancelled else { return }

        viewModel.resolveAccessibilityFocus()

        if viewModel.focusCreateButton {
            viewModel.focusCreateButton = false
            isCreateButtonFocused = true
        } else if let targetId = viewModel.focusedPostId {
            viewModel.focusedPostId = nil
            focusedPostId = targetId
        }
    }
}

여기서 UIKit의 DispatchWorkItem 대신 Swift Concurrency의 Task를 사용하고 있다는 점에 주목해 주세요. Task.isCancelled를 체크하여 취소된 작업이 실행되지 않도록 하며, 화면을 벗어날 때 onDisappear에서 focusTask?.cancel()을 호출하여 안전하게 정리합니다.

resolveAccessibilityFocus()는 1부에서 설명한 삭제 시 폴백 로직과 동일합니다. 삭제된 경우 이전 이웃 → 다음 이웃 → 작성 버튼 순서로 초점 대상을 결정합니다.

4단계: 상세 화면 진입 시 초점 이동

상세 화면에서도 게시글 제목으로 초점을 자동 이동시켜 사용자에게 명확한 맥락을 제공합니다.

@AccessibilityFocusState private var isTitleFocused: Bool
@State private var focusTask: Task<Void, Never>?

Text(post.title)
    .font(.title2)
    .fontWeight(.bold)
    .accessibilityFocused($isTitleFocused)
    .accessibilityAddTraits(.isHeader)

// 데이터 로드 완료 후 초점 예약
private func scheduleAccessibilityFocus() {
    guard UIAccessibility.isVoiceOverRunning else { return }
    focusTask?.cancel()
    focusTask = Task {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        guard !Task.isCancelled else { return }
        isTitleFocused = true
    }
}

2. 단일 뷰 기반 (ZStack 화면 전환 구조)

ZStack 내에서 opacity와 조건부 렌더링으로 화면을 전환하는 단일 뷰 구조입니다. UIKit의 ContainerViewController에 대응하며, 시스템은 이 내부적인 화면 전환을 인지하지 못합니다.

이 구조에서는 1부의 UIKit ContainerViewController 환경과 마찬가지로, 개발자가 명시적으로 화면 전환을 알려야 합니다. 따라서 상세 화면 진입 시에는 AccessibilityNotification.ScreenChanged를 사용하여 보이스오버에 새로운 화면임을 알리고, 목록으로 복귀할 때는 ScreenChanged와 @AccessibilityFocusState를 함께 사용하여 화면 전환 알림과 정밀한 초점 복원을 모두 수행합니다.

상세 화면 진입 시: ScreenChanged 알림

ZStack 기반 구조에서는 상세 화면이 표시될 때 시스템이 화면 전환을 인지하지 못하므로, 데이터 로드가 완료된 시점에 명시적으로 ScreenChanged를 호출합니다.

.onChange(of: viewModel.detailPost?.id) { _, newValue in
    if newValue != nil {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            AccessibilityNotification.ScreenChanged(nil).post()
        }
    }
}

목록 복귀 시: ScreenChanged + @AccessibilityFocusState 조합

목록으로 복귀할 때는 두 단계로 접근합니다. 먼저 ScreenChanged로 보이스오버에 화면 전환을 알린 뒤, @AccessibilityFocusState로 특정 게시글에 초점을 정밀하게 이동시킵니다.

// ViewModel의 navigateBack() 내부
func navigateBack(shouldRefresh: Bool = false) {
    withAnimation(.easeInOut(duration: 0.3)) {
        currentScreen = .list
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
        self?.detailPost = nil
        // 1단계: 화면 전환 사운드와 함께 보이스오버에 알림
        AccessibilityNotification.ScreenChanged(nil).post()
    }
    if shouldRefresh {
        needsForceRefresh = true
    }
}

ScreenChanged가 0.35초 후에 발송된 뒤, 목록 뷰에서는 화면 상태 변화를 감지하여 약 1초 후에 @AccessibilityFocusState로 특정 게시글에 초점을 이동시킵니다. NavigationStack 구조에서는 시스템이 화면 전환을 자체적으로 처리하므로 @AccessibilityFocusState만 사용하면 충분했지만, 단일 뷰 구조에서는 이처럼 ScreenChanged로 먼저 화면 전환을 알리고, 이어서 @AccessibilityFocusState로 정밀한 초점 제어를 수행하는 두 단계 접근이 필요합니다.

// 목록 뷰에서 화면 전환 감지
.onChange(of: viewModel.currentScreen) { oldValue, newValue in
    if newValue == .list && oldValue != .list {
        // 접근성 컨텍스트가 있으면 (상세에서 복귀) 초점 복원
        if viewModel.lastSelectedPostId != nil {
            if needsRefresh {
                Task {
                    await viewModel.refreshPosts()
                    restoreAccessibilityFocus()
                }
            } else {
                restoreAccessibilityFocus()
            }
        }
    }
}

restoreAccessibilityFocus() 메서드의 내부 구현은 NavigationStack 구조와 동일합니다. VoiceOver 실행 여부 확인, Task 기반의 1초 딜레이, 취소 처리, resolveAccessibilityFocus()를 통한 초점 대상 결정까지 같은 패턴을 따릅니다.

UIKit과 SwiftUI의 접근 방식 비교

지금까지의 내용을 UIKit 1부와 비교하여 정리하면 다음과 같습니다.

구분 UIKit SwiftUI
초점 이동 API UIAccessibility.post(notification:argument:) @AccessibilityFocusState + .accessibilityFocused()
화면 전환 알림 UIAccessibility.post(notification: .screenChanged) AccessibilityNotification.ScreenChanged (iOS 17+)
복귀 감지 viewDidAppear 생명주기 .onChange(of: path.count) 또는 .onChange(of: currentScreen)
비동기 딜레이 DispatchWorkItem + asyncAfter Task + Task.sleep (Swift Concurrency)
작업 취소 workItem.cancel() task.cancel() + Task.isCancelled
중복 실행 방지 hasRestoredFocus 플래그 path.count 또는 currentScreen 변화 감지로 자연스럽게 제어
초점 대상 지정 UIView 인스턴스 직접 전달 Post ID 값 바인딩 (equals: post.id)

특히 마지막 항목에 주목할 필요가 있습니다. UIKit에서는 초점을 이동시킬 때 실제 UIView 인스턴스(예: tableView.cellForRow(at:))를 직접 참조해야 했습니다. 반면 SwiftUI에서는 데이터의 고유 ID만으로 초점을 제어할 수 있어, 뷰 인스턴스를 직접 다루지 않는 선언형 패러다임에 자연스럽게 부합합니다.

지금까지 iOS SwiftUI 환경에서 화면 전환 시 보이스오버 초점을 유지하고 복원하는 방법을 살펴보았습니다. UIKit과 동일한 원칙인 아키텍처별 알림 전략, ID 기반 추적, 안전한 비동기 처리를 공유하면서도, SwiftUI만의 선언형 상태 바인딩 방식으로 이를 구현한다는 차이를 확인할 수 있었습니다.

다음 3부에서는 Android View System 환경에서의 화면별 초점 유지 방안을 다루겠습니다. 감사합니다.

댓글 0
댓글을 작성하려면 해주세요.