← 모든 글

tvOS Focus Engine: Siri Remote를 위한 SwiftUI 패턴

Apple TV는 터치 표면이 없는 유일한 Apple 플랫폼입니다. 사용자는 Siri Remote의 방향 스와이프와 버튼 누름으로 탐색하며, 모든 상호작용은 포커스 엔진을 거칩니다. 포커스 엔진은 기하학적 구조, 계층 구조, 그리고 개발자가 선언한 포커스 구조를 기반으로 다음에 어떤 요소가 포커스를 받을지 결정하는 시스템입니다1. tvOS의 SwiftUI는 이 엔진과 작업하기 위한 (말장난을 용서해 주세요) 포커스된 어휘를 노출합니다: .focusable, @FocusState, .focused, .focusSection, .prefersDefaultFocus, 그리고 .focusEffectDisabled. 이 어휘를 채택한 앱은 네이티브하게 느껴집니다. 이에 맞서는 앱은 사용자가 기대하는 곳으로 탐색하기를 거부하는 리모컨의 경험을 만듭니다.

이 글은 포커스 엔진의 API 표면을 실제로 출시되는 패턴과 함께 살펴봅니다. 프레임은 “엔진이 무엇을 가정하며 SwiftUI가 어떻게 협력하게 해주는가”입니다. iOS의 탭-앤-스크롤에서 작동하는 포커스 디자인은 tvOS에서 종종 실패하기 때문이며, 클러스터의 Apple Platform Matrix 글은 tvOS가 포커스 인식 UI로만 그 자리를 얻는다고 주장했습니다.

TL;DR

  • 포커스 엔진은 기하학적 구조로 포커스를 해결합니다. 스와이프 방향에서 가장 가까운 포커스 가능한 뷰를 선택합니다1. 앱은 포커스 가능한 뷰, 포커스 섹션, 기본 포커스 대상을 선언함으로써 협력합니다.
  • @FocusState(.focused(_:equals:)와 함께)는 프로그래밍 방식의 포커스 제어를 위한 SwiftUI 프리미티브입니다. 동일한 프로퍼티 래퍼가 iOS, macOS, watchOS, tvOS에서 작동하지만, 그 진가를 발휘하는 곳은 tvOS입니다2.
  • .focusSection()은 여러 포커스 가능한 뷰를 단일 포커스 대상으로 그룹화하여 섹션 간 탐색에 사용한 다음, 엔진이 섹션 내에서 선택하도록 합니다3. 버튼 행, 카드 그리드, 사이드바 섹션에 사용하세요.
  • .prefersDefaultFocus(_:in:)은 사용자가 컨텍스트(화면, 팝오버, 탭)에 진입할 때 어떤 뷰가 포커스를 받을지 선언합니다. 기본값의 범위를 지정하려면 @Namespace와 함께 사용하세요4.
  • 시스템 포커스 효과(포커스된 뷰 주위에서 커지는 하이라이트)는 자동입니다. 사용자 정의 포커스 비주얼을 구현할 때만 .focusEffectDisabled()로 비활성화하세요. 그렇지 않으면 플랫폼 네이티브 효과가 올바른 선택입니다.

포커스 엔진은 어떻게 결정하는가

포커스 엔진은 Siri Remote의 스와이프 입력을 처리하고 계층적 검색을 통해 “다음에 포커스가 어디로 가는가?”를 해결합니다1:

  1. 스와이프 방향(위, 아래, 왼쪽, 오른쪽)을 읽습니다.
  2. 현재 포커스 컨텍스트 내에서 현재 포커스된 뷰를 기준으로 그 방향에 프레임이 있는 포커스 가능한 뷰를 찾습니다.
  3. 스와이프 축을 따라 기하학적으로 가장 가까운 것을 선택합니다(현재 뷰의 중심과 정렬을 유지하려는 작은 편향과 함께).
  4. 해당 방향에 포커스 가능한 뷰가 없으면, 스와이프는 포커스를 이동시키지 않고 소비됩니다.

함의는 다음과 같습니다. 포커스 가능한 뷰의 시각적 레이아웃은 논리적 계층 구조만큼 중요합니다. 대각선으로 어긋난 두 버튼은 모호한 탐색을 만들어내고, 수직으로 정렬된 두 버튼은 예측 가능한 위/아래를 만들어냅니다. HIG가 그리드와 리스트에 권장하는 패턴은 정렬이 먼저, 장식이 두 번째입니다.

앱은 SwiftUI의 포커스 모디파이어를 통해 엔진에 참여합니다. 기본 동작은 명시적인 상호작용 의도가 있는 뷰(Button, NavigationLink, TextField)는 포커스 가능하고, 정적 뷰(Text, Image, VStack 같은 컨테이너 뷰)는 그렇지 않다는 것입니다.

사용자 정의 뷰를 포커스 가능하게 만들기

.focusable() 모디파이어는 뷰를 포커스 대상으로 표시합니다5. 선택적 Boolean 매개변수가 포커스 가능성을 조건화합니다:

struct PosterCard: View {
    let movie: Movie
    @FocusState private var isFocused: Bool

    var body: some View {
        VStack {
            Image(movie.posterName)
                .resizable()
                .aspectRatio(2/3, contentMode: .fit)
            Text(movie.title)
                .font(.headline)
        }
        .focusable(true)
        .focused($isFocused)
        .scaleEffect(isFocused ? 1.1 : 1.0)
        .animation(.spring(), value: isFocused)
    }
}

뷰는 엔진이 도달할 수 있는 포커스 대상이 됩니다. 이 패턴은 클릭 가능한 카드, 사용자 정의 버튼, 그리고 사용자의 주의를 받아야 하는 모든 합성 뷰에 적합합니다. .focusable()이 없으면 Image + Text 클러스터는 엔진에 의해 건너뛰어집니다.

프로그래밍 제어를 위한 @FocusState.focused(_:equals:)

앱이 포커스를 지시해야 할 때(탐색 전환 후, 검색 제출 후, 모달 해제 후), @FocusState는 SwiftUI 프리미티브입니다2:

struct LoginView: View {
    enum Field { case username, password, submit }
    @FocusState private var focusedField: Field?
    @State private var username = ""
    @State private var password = ""

    var body: some View {
        VStack {
            TextField("Username", text: $username)
                .focused($focusedField, equals: .username)

            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)

            Button("Sign In") { /* ... */ }
                .focused($focusedField, equals: .submit)
        }
        .onAppear {
            focusedField = .username
        }
    }
}

@FocusState enum 값은 어떤 필드가 포커스되어 있는지 추적합니다. 새 값을 프로그래밍 방식으로 할당하면 해당 뷰로 포커스가 이동합니다. Hashable enum 케이스가 관례입니다. 동일한 케이스 값을 가진 여러 필드는 모호할 것입니다.

단일 포커스 가능한 뷰의 경우, @FocusState var isFocused: Bool.focused($isFocused)가 더 간단한 형태입니다. Boolean 변형은 “이 뷰가 포커스되어 있는가?”라는 질문에 적합하고, enum 변형은 “이 집합에서 어떤 뷰인가?”에 적합합니다.

그룹화를 위한 .focusSection()

.focusSection()이 없으면 모든 포커스 가능한 뷰는 동일한 수준에서 엔진의 기하학적 검색에 참여합니다. 이를 사용하면 컨테이너가 포커스 그룹이 됩니다. 섹션으로/에서의 탐색은 하나의 결정이고, 섹션 내에서의 탐색은 또 다른 결정입니다3. .focusSection()은 tvOS와 macOS 전용이며, iOS, iPadOS, watchOS, visionOS에서는 효과가 없다는 점에 유의하세요.

HStack {
    VStack {
        Button("Settings") { ... }
        Button("Profile") { ... }
        Button("Logout") { ... }
    }
    .focusSection()

    VStack {
        ContentList(items: items)
    }
    .focusSection()
}

VStack은 단위로 탐색 가능해집니다. 사용자는 사이드바에서 오른쪽으로 스와이프하여 콘텐츠 영역에 도달합니다. 일단 도착하면 엔진이 영역 내 탐색을 처리합니다. .focusSection()이 없으면 사이드바 버튼에서의 스와이프가 우연히 기하학적으로 가장 가까운 임의의 콘텐츠 항목에 도달하여 무작위로 느껴지는 UX를 만들 수 있습니다.

올바른 패턴은 다음과 같습니다. 내부 포커스 구조가 있는 모든 UI 영역(사이드바, 카드 그리드, 탭 바, 페이지네이션 컨트롤)은 컨테이너에 .focusSection() 모디파이어를 받습니다. 그러면 엔진은 매크로 수준에서 섹션 간을 탐색하고 마이크로 수준에서 섹션 내를 탐색합니다.

초기 포커스를 위한 .prefersDefaultFocus(_:in:)

화면이 나타나거나 팝오버가 열릴 때, 무언가가 초기 포커스를 받아야 합니다. 명시적인 안내 없이 엔진은 레이아웃에서 첫 번째 포커스 가능한 뷰를 선택하는데, 이는 종종 잘못된 선택입니다(기본 액션 대신 뒤로 가기 버튼, 재생 버튼 대신 모호한 리스트 셀)4.

struct MovieDetailView: View {
    let movie: Movie
    @Namespace private var detailNamespace

    var body: some View {
        VStack {
            HStack {
                Button("Back") { ... }
                Spacer()
            }

            PosterImage(movie: movie)

            Button("Play") { ... }
                .prefersDefaultFocus(in: detailNamespace)

            Button("Add to Watchlist") { ... }
        }
        .focusScope(detailNamespace)
    }
}

@Namespace.focusScope()은 포커스 경계를 정의하고, .prefersDefaultFocus(in:)은 그 범위 내에서 선호되는 초기 포커스를 선언합니다. 화면이 나타나면 포커스는 Play에 안착합니다.

이 패턴은 사용자가 명백한 “먼저 무엇을 할지” 기대를 가지고 진입하는 모든 뷰에 적합합니다. 영화 상세 페이지의 Play, 로그인 화면의 Sign In, 온보딩 화면의 Get Started 같은 것들입니다.

사용자 정의 포커스 효과 (그리고 기본값을 비활성화해야 할 때)

시스템 포커스 효과는 포커스된 뷰 주위에서 자라나는 부드러운 가장자리의 빛입니다. 뷰를 약간 확대하고, 미묘한 그림자를 추가하며, 플랫폼의 표준 타이밍으로 애니메이션됩니다. 대부분의 앱에서 기본값이 올바릅니다. 다른 모든 tvOS 앱과 일치하며 사용자가 플랫폼의 어휘를 배울 수 있게 해줍니다.

사용자 정의 포커스 비주얼이 필요한 앱(브랜드별 빛, 콘텐츠 인식 효과, 기본값과 충돌하는 포커스 링)의 경우, .focusEffectDisabled()이 시스템 처리에서 옵트아웃합니다6:

Button {
    play(movie)
} label: {
    PosterImage(movie: movie)
        .overlay(focusBorder)
        .scaleEffect(isFocused ? 1.05 : 1.0)
}
.focusEffectDisabled()
.focused($isFocused)

사용자 정의 뷰는 포커스를 시각적으로 표시할 책임이 있습니다. 시스템은 더 이상 간섭하지 않습니다. 트레이드오프는 다음과 같습니다. 모든 포커스 비주얼은 상속되는 대신 앱에 의해 디자인되고 구현되어야 합니다. 대부분의 앱에서 시스템 효과가 올바른 선택입니다.

일반적인 tvOS 포커스 실패

빈약한 tvOS UX를 만드는 세 가지 패턴:

포커스를 받지 못하는 버튼. .focusable() 없이 HStack { Image; Text }로 렌더링된 사용자 정의 버튼은 엔진에게 보이지 않습니다. Siri Remote의 스와이프는 그것을 건너뜁니다. 수정 방법: 상호작용 콘텐츠를 Button(기본적으로 포커스 참여를 제공)으로 감싸거나 .focusable()을 명시적으로 적용하세요.

포커스 트랩. 포커스를 받지만 빠져나갈 경로가 없는 뷰(포커스 가능한 왼쪽/오른쪽/위/아래 형제 없음, Menu 버튼을 통한 탈출 없음)는 사용자를 갇히게 합니다. 수정 방법: 모든 포커스 컨텍스트는 문서화된 출구 경로가 있어야 합니다. .focusSection() 패턴은 엔진에게 탈출할 단위를 제공하기 때문에 도움이 됩니다.

잘못된 요소에 대한 기본 포커스. Play 대신 Back에 포커스가 맞춰진 채로 열리는 영화 상세 화면은 사용자가 매번 방문할 때마다 지불하는 마찰입니다. 수정 방법: 기본 액션에 .prefersDefaultFocus(in:)을 선언하세요.

접근성이 없는 사용자 정의 포커스 효과. 낮은 대비의 1pt 색상 테두리에 불과한 포커스 링은 접근성에 실패합니다. 시스템 포커스 효과는 고대비이며 모션 테스트를 거쳤습니다. 사용자 정의 대체물은 동일한 주의가 필요합니다. 클러스터의 Accessibility as platform 글이 더 넓은 원칙을 다룹니다.

tvOS가 그 자리를 얻을 때

클러스터의 Apple Platform Matrix 글은 tvOS가 iOS 대비 가장 작은 설치 기반을 가진 플랫폼이며, 앱은 엔지니어링 투자를 정당화하기 위해 실제 “뒤로 기대는” 또는 “소파 모드” 사용 사례가 필요하다고 주장했습니다. 포커스 엔진은 그 투자의 일부입니다. 포커스 어휘를 존중하지 않는 tvOS 앱은 TV 화면에 늘여진 iPad 앱처럼 느껴집니다. API 표면이 실재하기 때문에 투자도 실재합니다. 엔진이 실제로 포커스가 어디로 갈지 결정하기 때문에 엔지니어링 작업은 의미가 있습니다.

tvOS 자리를 얻는 앱은 세 가지 속성을 공유하는 경향이 있습니다: 1. TV 시청 거리에서 소비되는 콘텐츠. 스트리밍, 사진 슬라이드쇼, 컨트롤러 기반 게임. 2. 희소한 상호작용 모델. 화면당 몇 가지 기본 액션, 방향 입력으로 탐색. 3. 뒤로 기대는 사용 사례. 사용자는 소파에 앉아 있고, 다른 기기로 멀티태스킹할 가능성이 있으며, 절반쯤 시청할 가능성이 있습니다.

해당 카테고리의 앱에는 포커스 엔진 투자가 옳습니다. 맞지 않는 앱(생산성 도구, 세밀한 창작 앱, 텍스트 입력이 많은 모든 것)의 경우, 매트릭스 글이 권장하는 대로 tvOS를 건너뛰는 것이 옳은 선택입니다.

이 패턴이 tvOS 앱에 의미하는 것

세 가지 핵심 사항.

  1. 포커스 의도를 사후 수정이 아닌 레이아웃에 구축하세요. 사용자는 어디서 시작할까요? 거기서 어디로 갈 수 있을까요? 기본 액션은 무엇입니까? tvOS에서 화면을 디자인하는 것은 시각적 구성이 아닌 포커스 흐름에서 시작합니다. 시각적 요소가 그 뒤를 따릅니다.

  2. 내부 구조가 있는 모든 영역에 .focusSection()을 적극적으로 사용하세요. 기본 기하학적 탐색은 그리드, 사이드바, 탭 바에 종종 잘못됩니다. 섹션 모디파이어는 작지만 그 차이는 큽니다.

  3. 시스템 포커스 효과를 대체할 진짜 이유가 없다면 그대로 유지하세요. 사용자 정의 포커스 비주얼은 실제 엔지니어링 작업, 접근성 작업, 모든 테마에 걸친 테스트를 의미합니다. 시스템 효과가 올바른 기본값입니다. 디자인이 진정으로 사용자 정의 처리를 필요로 할 때만 .focusEffectDisabled()에 손을 뻗으세요.

전체 Apple Ecosystem 클러스터: 타입 지정된 App Intents; MCP 서버; 라우팅 질문; Foundation Models; 런타임 vs 툴링 LLM 구분; 세 가지 표면; 단일 진실의 출처 패턴; 두 개의 MCP 서버; Apple 개발을 위한 hooks; Live Activities; watchOS 런타임; SwiftUI 내부; RealityKit의 공간 멘탈 모델; SwiftData 스키마 규율; Liquid Glass 패턴; 멀티 플랫폼 출시; 플랫폼 매트릭스; Vision framework; Symbol Effects; Core ML 추론; Writing Tools API; Swift Testing; Privacy Manifest; 플랫폼으로서의 접근성; SF Pro 타이포그래피; visionOS 공간 패턴; Speech framework; SwiftData 마이그레이션; 내가 쓰지 않기로 한 것. 허브는 Apple Ecosystem Series에 있습니다. AI 에이전트가 있는 더 넓은 iOS 컨텍스트는 iOS Agent Development guide를 참조하세요.

FAQ

.focusable()이 iOS에서 작동하나요?

네, 하지만 iOS 타겟에서의 동작은 tvOS가 사용하는 포커스 엔진 기반 탐색이 아니라 키보드 및 포인터 상호작용(블루투스 키보드, iPadOS 포인터, iPad Magic Keyboard)을 대상으로 합니다. 동일한 코드를 크로스 플랫폼으로 사용할 수 있으며, 사용자 대면 상호작용은 다릅니다. tvOS에서 .focusable()은 기본 경로입니다. iOS에서는 접근성을 위한 보조 어포던스입니다.

.focusable()Button의 차이점은 무엇입니까?

Button은 포커스 가능성, 액션 처리, 시스템 버튼 스타일, 접근성 특성을 포함하는 더 높은 수준의 구성입니다. .focusable()은 단지 뷰를 포커스 대상으로 만드는 저수준 마커입니다. 뷰가 논리적으로 버튼인 경우 Button을 사용하세요. 버튼 멘탈 모델에 맞지 않는 사용자 정의 상호작용 뷰(포스터 카드, 그리드의 타일)를 만들 때는 .focusable()을 사용하세요.

여러 개의 .prefersDefaultFocus 선언을 가질 수 있습니까?

네, @Namespace로 범위가 지정됩니다. 각 포커스 범위는 자체의 선호되는 기본값을 가질 수 있습니다. 이 패턴은 중첩된 컨텍스트(화면 내의 팝오버, 사이드바 내의 탭)에 적합합니다. 각 범위는 자체 초기 포커스를 선택합니다.

항목이 많은 리스트에서 포커스를 어떻게 처리합니까?

SwiftUI의 리스트는 기본적으로 포커스 가능합니다. 엔진이 셀을 통한 위/아래 탐색을 자동으로 처리합니다. 사용자 정의 리스트형 레이아웃의 경우, 각 셀을 Button으로 감싸거나 .focusable()을 적용한 다음, 전체 리스트를 .focusSection() 안에 배치하여 엔진이 다른 UI 영역과 비교하여 리스트를 단위로 취급하도록 하세요.

Siri Remote의 Menu 버튼은 tvOS 전반에서 해제/뒤로 가기 액션입니다. 탐색 스택을 팝하고, 모달을 종료하며, 부모 컨텍스트로 돌아갑니다. SwiftUI는 NavigationStack과 표준 모달 해제를 통해 이를 자동으로 처리합니다. 앱은 일반적으로 이를 가로채지 않습니다. 사용자 정의 해제 로직의 경우, onExitCommand 뷰 모디파이어가 누름을 캡처합니다.

이것이 클러스터의 다른 플랫폼 글들과 어떻게 관련됩니까?

tvOS 포커스 엔진은 visionOS의 시선-앤-핀치(visionOS 공간 패턴에서 다룸)와 iOS의 탭-앤-스크롤과 평행한 플랫폼별 탐색 표면입니다. 각 플랫폼에는 자체 입력 메타포가 있습니다. 클러스터의 Apple Platform Matrix 글은 플랫폼 포함이 그 메타포를 존중해야 한다고 주장하며, 포커스 엔진은 tvOS가 요구하는 것입니다.

참고문헌


  1. Apple Developer: App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote. 포커스 엔진 모델과 기하학적 해결 규칙. 

  2. Apple Developer Documentation: @FocusState. SwiftUI 플랫폼 전반에서 포커스를 추적하고 프로그래밍 방식으로 지시하기 위한 프로퍼티 래퍼. 

  3. Apple Developer Documentation: focusSection(). 포커스 가능한 자손을 단일 포커스 대상으로 그룹화하여 섹션 간 탐색에 사용하는 뷰 모디파이어. 

  4. Apple Developer Documentation: prefersDefaultFocus(_:in:)focusScope(_:). 네임스페이스 범위 포커스 경계와 짝을 이루는 기본 포커스 선언. 

  5. Apple Developer Documentation: focusable(_:). 선택적 조건부 Boolean으로 뷰를 포커스 대상으로 표시하는 뷰 모디파이어. 

  6. Apple Developer Documentation: focusEffectDisabled(_:). 시스템 포커스 효과의 옵트아웃(Bool 기본값 true); 필요할 때 사용자 정의 포커스 비주얼과 함께 사용하세요. 

관련 게시물

Accessibility As Platform: Personal Voice, Live Speech, Eye Tracking, Music Haptics

Personal Voice, Live Speech, Eye Tracking, Music Haptics, Vocal Shortcuts: accessibility as platform features, not app r…

14 분 소요

SF Pro: Variable Axes, Optical Sizing, And The Dynamic Type Contract

Apple's system font ships with three variable axes and continuous optical sizing. The vocabulary that makes typography w…

12 분 소요

The Design Engineer's Agent Stack

Design engineers need agent infrastructure that enforces visual consistency, typography discipline, color compliance, an…

14 분 소요