tvOS Focus Engine: wzorce SwiftUI dla pilota Siri Remote
Apple TV jest jedyną platformą Apple bez powierzchni dotykowej. Użytkownik nawiguje za pomocą gestów kierunkowych i naciśnięć przycisków na pilocie Siri Remote, a każda interakcja przechodzi przez focus engine: system, który decyduje, który element otrzyma fokus jako następny, na podstawie geometrii, hierarchii i zadeklarowanej przez programistę struktury fokusu1. SwiftUI w tvOS udostępnia skoncentrowane (proszę wybaczyć kalambur) słownictwo do pracy z tym silnikiem: .focusable, @FocusState, .focused, .focusSection, .prefersDefaultFocus oraz .focusEffectDisabled. Aplikacje, które przyjmują to słownictwo, sprawiają wrażenie natywnych; aplikacje, które z nim walczą, tworzą wrażenie pilota, który odmawia nawigacji tam, gdzie użytkownik się tego spodziewa.
Niniejszy artykuł omawia powierzchnię focus engine API wraz z wzorcami, które sprawdzają się w produkcji. Ramą jest „co silnik zakłada i jak SwiftUI pozwala z nim współpracować”, ponieważ projektowanie fokusu, które działa w iOS poprzez tap-and-scroll, często zawodzi w tvOS, a wpis Apple Platform Matrix z tej serii argumentował, że tvOS zasługuje na swoje miejsce tylko z UI świadomym fokusu.
TL;DR
- Focus engine rozwiązuje fokus geometrycznie: wybiera najbliższy widok przyjmujący fokus w kierunku gestu1. Aplikacje współpracują, deklarując widoki przyjmujące fokus, sekcje fokusu i cele domyślnego fokusu.
@FocusState(z.focused(_:equals:)) jest prymitywem SwiftUI do programowej kontroli fokusu. Ten sam property wrapper działa w iOS, macOS, watchOS i tvOS, ale to w tvOS naprawdę zarabia na swoje miejsce2..focusSection()grupuje wiele widoków przyjmujących fokus w pojedynczy cel fokusu dla nawigacji między sekcjami, a następnie pozwala silnikowi wybrać element wewnątrz sekcji3. Można jej używać dla rzędów przycisków, siatek kart, sekcji paska bocznego..prefersDefaultFocus(_:in:)deklaruje, który widok otrzyma fokus, gdy użytkownik wejdzie do kontekstu (ekranu, popoveru, taba). Należy łączyć z@Namespace, aby zakresować domyślny4.- Systemowy efekt fokusu (podświetlenie powiększające się wokół widoku z fokusem) jest automatyczny. Można go wyłączyć za pomocą
.focusEffectDisabled()tylko podczas implementacji niestandardowej wizualizacji fokusu; w przeciwnym razie efekt natywny dla platformy jest tym właściwym.
Jak focus engine podejmuje decyzje
Focus engine przetwarza wejście gestów z pilota Siri Remote i rozwiązuje pytanie „dokąd trafia fokus?” poprzez hierarchiczne wyszukiwanie1:
- Odczyt kierunku gestu (góra, dół, lewo, prawo).
- W obrębie bieżącego kontekstu fokusu, odnalezienie widoków przyjmujących fokus, których ramki znajdują się w tym kierunku względem aktualnie sfokusowanego widoku.
- Wybór tego, który jest geometrycznie najbliższy wzdłuż osi gestu (z niewielką tendencją do utrzymywania wyrównania ze środkiem aktualnego widoku).
- Jeśli żaden widok przyjmujący fokus nie znajduje się w tym kierunku, gest jest konsumowany bez przesuwania fokusu.
Implikacja: wizualny układ widoków przyjmujących fokus ma takie samo znaczenie jak ich logiczna hierarchia. Dwa przyciski przesunięte po przekątnej tworzą niejednoznaczną nawigację; dwa przyciski wyrównane pionowo tworzą przewidywalną nawigację góra/dół. Wzorzec rekomendowany przez HIG dla siatek i list to wyrównanie w pierwszej kolejności, dekoracja w drugiej.
Aplikacje uczestniczą w pracy silnika poprzez modyfikatory fokusu w SwiftUI. Domyślnym zachowaniem jest to, że widoki z jawnym interaktywnym zamiarem (Button, NavigationLink, TextField) przyjmują fokus; widoki statyczne (Text, Image, widoki kontenerowe takie jak VStack) nie.
Czynienie własnych widoków zdolnymi do przyjęcia fokusu
Modyfikator .focusable() oznacza widok jako cel fokusu5. Opcjonalny parametr boolowski warunkuje zdolność do przyjmowania fokusu:
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)
}
}
Widok staje się celem fokusu, na którym silnik może wylądować. Wzorzec sprawdza się w przypadku klikalnych kart, niestandardowych przycisków oraz dowolnego widoku złożonego, który powinien przyjmować uwagę użytkownika. Bez .focusable() skupienie Image + Text byłoby pomijane przez silnik.
@FocusState i .focused(_:equals:) do kontroli programowej
Gdy aplikacja musi kierować fokusem (po przejściu nawigacyjnym, po wysłaniu zapytania wyszukiwania, po zamknięciu modala), @FocusState jest prymitywem SwiftUI2:
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
}
}
}
Wartość enuma @FocusState śledzi, które pole ma fokus; przypisanie nowej wartości programowo przenosi fokus na odpowiadający widok. Konwencją jest enum Hashable; wiele pól z tą samą wartością przypadku byłoby niejednoznacznych.
Dla pojedynczego widoku przyjmującego fokus prostszą formą jest @FocusState var isFocused: Bool plus .focused($isFocused). Wariant boolowski sprawdza się, gdy pytanie brzmi „czy ten widok ma fokus?”; wariant z enumem sprawdza się dla pytania „który widok z tego zestawu?”.
.focusSection() do grupowania
Bez .focusSection() każdy widok przyjmujący fokus uczestniczy w geometrycznym wyszukiwaniu silnika na tym samym poziomie. Z nim kontener staje się grupą fokusu: nawigacja do/z sekcji jest jedną decyzją, nawigacja w obrębie sekcji jest osobną3. Warto zauważyć, że .focusSection() jest dostępny tylko w tvOS i macOS; nie ma efektu w iOS, iPadOS, watchOS ani visionOS.
HStack {
VStack {
Button("Settings") { ... }
Button("Profile") { ... }
Button("Logout") { ... }
}
.focusSection()
VStack {
ContentList(items: items)
}
.focusSection()
}
Dwa VStack-i stają się nawigowalnymi jednostkami. Użytkownik wykonuje gest w prawo z paska bocznego, aby wylądować w obszarze treści; raz tam, silnik obsługuje nawigację wewnątrz obszaru. Bez .focusSection() gesty z przycisku paska bocznego mogłyby wylądować na dowolnym elemencie treści, który przypadkiem jest geometrycznie najbliższy, tworząc UX odbierane jako losowe.
Właściwy wzorzec: każdy region UI z wewnętrzną strukturą fokusu (paski boczne, siatki kart, paski tabów, kontrolki paginacji) otrzymuje modyfikator .focusSection() na swoim kontenerze. Silnik nawiguje wówczas między sekcjami na poziomie makro i wewnątrz sekcji na poziomie mikro.
.prefersDefaultFocus(_:in:) dla początkowego fokusu
Gdy ekran się pojawia lub popover się otwiera, coś musi otrzymać początkowy fokus. Bez wyraźnej wskazówki silnik wybiera pierwszy widok przyjmujący fokus w układzie, co często jest niewłaściwe (przycisk Wstecz zamiast głównej akcji, niejasna komórka listy zamiast przycisku odtwarzania)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 plus .focusScope() definiują granicę fokusu, a .prefersDefaultFocus(in:) deklaruje preferowany początkowy fokus w obrębie tego zakresu. Gdy ekran się pojawia, fokus ląduje na Play.
Wzorzec ten jest właściwy dla każdego widoku, do którego użytkownik wchodzi z oczywistym oczekiwaniem „co zrobić jako pierwsze”: Play na stronie szczegółów filmu, Sign In na ekranie logowania, Get Started na ekranie onboardingu.
Niestandardowe efekty fokusu (i kiedy wyłączyć domyślny)
Systemowy efekt fokusu to miękka poświata, która powiększa się wokół widoku z fokusem. Skaluje widok nieznacznie, dodaje subtelny cień i animuje się ze standardowym czasem platformy. Dla większości aplikacji domyślny jest właściwy; pasuje do każdej innej aplikacji tvOS i pozwala użytkownikom uczyć się słownictwa platformy.
Dla aplikacji, które potrzebują niestandardowej wizualizacji fokusu (poświata specyficzna dla marki, efekt świadomy treści, pierścień fokusu, który koliduje z domyślnym), .focusEffectDisabled() rezygnuje z systemowego traktowania6:
Button {
play(movie)
} label: {
PosterImage(movie: movie)
.overlay(focusBorder)
.scaleEffect(isFocused ? 1.05 : 1.0)
}
.focusEffectDisabled()
.focused($isFocused)
Niestandardowy widok jest odpowiedzialny za wizualne wskazanie fokusu; system już nie ingeruje. Kompromis: każda wizualizacja fokusu musi zostać zaprojektowana i zaimplementowana przez aplikację, zamiast być odziedziczona. Dla większości aplikacji systemowy efekt jest właściwym wyborem.
Częste niepowodzenia fokusu w tvOS
Trzy wzorce, które tworzą słabe UX w tvOS:
Przyciski, które nie przyjmują fokusu. Niestandardowy przycisk wyrenderowany jako HStack { Image; Text } bez .focusable() jest niewidoczny dla silnika. Gesty pilota Siri Remote go pomijają. Rozwiązanie: opakować interaktywną zawartość w Button (który zapewnia uczestnictwo w fokusie domyślnie) lub jawnie zastosować .focusable().
Pułapki fokusu. Widok, który przyjmuje fokus, ale nie zapewnia ścieżki wyjścia (brak rodzeństwa lewo/prawo/góra/dół, które przyjmuje fokus, brak ucieczki przez przycisk Menu) pozostawia użytkownika uwięzionego. Rozwiązanie: każdy kontekst fokusu powinien mieć udokumentowaną ścieżkę wyjścia. Wzorzec .focusSection() pomaga, ponieważ daje silnikowi jednostkę, do której można uciec.
Domyślny fokus na niewłaściwym elemencie. Ekran szczegółów filmu, który otwiera się z fokusem na Wstecz zamiast Play, jest tarciem, które użytkownik płaci przy każdej wizycie. Rozwiązanie: zadeklarować .prefersDefaultFocus(in:) na głównej akcji.
Niestandardowe efekty fokusu, które nie są dostępne. Pierścień fokusu, który jest tylko 1-punktową kolorową obwódką o niskim kontraście, nie spełnia wymogów dostępności. Systemowy efekt fokusu ma wysoki kontrast i jest przetestowany pod kątem ruchu; niestandardowe zamienniki wymagają tej samej staranności. Wpis Accessibility as platform z tej serii omawia szerszą zasadę.
Kiedy tvOS zasługuje na swoje miejsce
Wpis Apple Platform Matrix z tej serii argumentował, że tvOS jest platformą o najmniejszej bazie zainstalowanej w stosunku do iOS, a aplikacje potrzebują prawdziwego przypadku użycia „lean back” lub „couch-mode”, aby uzasadnić inwestycję inżynieryjną. Focus engine jest częścią tej inwestycji: aplikacja tvOS, która nie szanuje słownictwa fokusu, sprawia wrażenie aplikacji iPad rozciągniętej na telewizorze. Inwestycja jest realna, ponieważ powierzchnia API jest realna; praca inżynieryjna jest znacząca, ponieważ silnik faktycznie decyduje, dokąd trafia fokus.
Aplikacje, które zasługują na swoje miejsce w tvOS, mają tendencję do dzielenia trzech właściwości: 1. Treści konsumowane z odległości oglądania telewizora. Streaming, pokazy slajdów ze zdjęciami, gry sterowane kontrolerem. 2. Rzadki model interakcji. Kilka głównych akcji na ekranie, nawigowanych za pomocą wejścia kierunkowego. 3. Przypadek użycia „lean-back”. Użytkownik jest na kanapie, możliwie wykonując multitasking z innym urządzeniem, możliwie oglądając połowicznie.
Dla aplikacji w tych kategoriach inwestycja w focus engine jest właściwa. Dla aplikacji, które nie pasują (narzędzia produktywności, drobnoziarniste aplikacje kreatywne, cokolwiek silnie obciążonego wprowadzaniem tekstu), właściwą decyzją jest pominięcie tvOS, jak rekomenduje wpis o matrix.
Co ten wzorzec oznacza dla aplikacji tvOS
Trzy wnioski.
-
Należy wbudować zamiar fokusu w układ, a nie w naprawę post hoc. Gdzie użytkownik zacznie? Dokąd może stamtąd pójść? Jaka jest główna akcja? Projektowanie ekranu w tvOS zaczyna się od przepływu fokusu, a nie od wizualnej kompozycji. Wizualna strona idzie za tym.
-
Należy używać
.focusSection()agresywnie dla każdego regionu z wewnętrzną strukturą. Domyślna nawigacja geometryczna jest często niewłaściwa dla siatek, pasków bocznych, pasków tabów. Modyfikator sekcji jest mały, a różnica duża. -
Należy zachować systemowy efekt fokusu, chyba że jest realny powód, aby go zastąpić. Niestandardowe wizualizacje fokusu to realna praca inżynieryjna plus praca nad dostępnością plus testowanie we wszystkich motywach. Systemowy efekt jest właściwym domyślnym; po
.focusEffectDisabled()warto sięgać tylko wtedy, gdy projekt naprawdę potrzebuje niestandardowego traktowania.
Pełna seria Apple Ecosystem: typowane App Intents; serwery MCP; pytanie o routing; Foundation Models; rozróżnienie runtime vs tooling LLM; trzy powierzchnie; wzorzec single source of truth; Two MCP Servers; hooki dla rozwoju Apple; Live Activities; kontrakt runtime watchOS; wnętrzności SwiftUI; model mentalny przestrzeni RealityKit; dyscyplina schematu SwiftData; wzorce Liquid Glass; shipping multiplatformowy; matrix platform; Vision framework; Symbol Effects; Core ML inference; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility as platform; typografia SF Pro; wzorce przestrzenne visionOS; Speech framework; migracje SwiftData; o czym odmawiam pisać. Hub znajduje się w Apple Ecosystem Series. Dla szerszego kontekstu iOS-z-agentami-AI, warto zapoznać się z iOS Agent Development guide.
FAQ
Czy .focusable() działa w iOS?
Tak, ale jego zachowanie w docelowych iOS dotyczy interakcji z klawiaturą i wskaźnikiem (klawiatura Bluetooth, wskaźnik iPadOS, iPad Magic Keyboard), a nie nawigacji opartej na focus engine, jakiej używa tvOS. Ten sam kod może być używany cross-platform; interakcja widoczna dla użytkownika się różni. W tvOS .focusable() jest główną ścieżką. W iOS jest uzupełniającą afordancją dla dostępności.
Jaka jest różnica między .focusable() a Button?
Button to konstrukcja wyższego poziomu, która zawiera zdolność do przyjmowania fokusu, obsługę akcji, systemowy styl przycisku oraz cechy dostępności. .focusable() jest niskopoziomowym znacznikiem, który po prostu czyni widok celem fokusu. Należy używać Button, gdy widok jest logicznie przyciskiem; należy używać .focusable(), gdy buduje się niestandardowy widok interaktywny (kartę plakatu, kafelek w siatce), który nie pasuje do mentalnego modelu przycisku.
Czy mogę mieć wiele deklaracji .prefersDefaultFocus?
Tak, zakresowanych przez @Namespace. Każdy zakres fokusu może mieć swój własny preferowany domyślny. Wzorzec sprawdza się dla zagnieżdżonych kontekstów (popover w obrębie ekranu, tab w obrębie paska bocznego): każdy zakres wybiera swój własny początkowy fokus.
Jak obsłużyć fokus na liście z wieloma elementami?
Listy w SwiftUI są domyślnie zdolne do przyjmowania fokusu; silnik obsługuje nawigację góra/dół przez komórki automatycznie. Dla niestandardowych układów typu lista należy opakować każdą komórkę w Button lub zastosować .focusable(), a następnie umieścić całą listę wewnątrz .focusSection(), aby silnik traktował listę jako jednostkę względem innych regionów UI.
Co robi przycisk Menu w modelu fokusu?
Przycisk Menu pilota Siri Remote jest akcją zamknięcia/cofania w całym tvOS. Zdejmuje stos nawigacji, wychodzi z modali, wraca do nadrzędnego kontekstu. SwiftUI obsługuje go automatycznie poprzez NavigationStack i standardowe zamykanie modali; aplikacje zazwyczaj go nie przechwytują. Dla niestandardowej logiki zamykania modyfikator widoku onExitCommand przechwytuje naciśnięcie.
Jak to się ma do innych wpisów platformowych z tej serii?
Focus engine tvOS jest specyficzną dla platformy powierzchnią nawigacji, równoległą do gaze-and-pinch w visionOS (omówioną w visionOS spatial patterns) oraz tap-and-scroll w iOS. Każda platforma ma swoją własną metaforę wejścia; wpis Apple Platform Matrix z tej serii argumentuje, że włączenie platformy wymaga uhonorowania tej metafory, a focus engine jest tym, czego tvOS wymaga.
References
-
Apple Developer: App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote. Model focus engine i geometryczne reguły rozwiązywania. ↩↩↩
-
Apple Developer Documentation:
@FocusState. Property wrapper do śledzenia i programowego kierowania fokusem na platformach SwiftUI. ↩↩ -
Apple Developer Documentation:
focusSection(). Modyfikator widoku, który grupuje potomków zdolnych do przyjmowania fokusu w pojedynczy cel fokusu dla nawigacji między sekcjami. ↩↩ -
Apple Developer Documentation:
prefersDefaultFocus(_:in:)orazfocusScope(_:). Deklaracja domyślnego fokusu połączona z granicami fokusu zakresowanymi przez namespace. ↩↩ -
Apple Developer Documentation:
focusable(_:). Modyfikator widoku oznaczający widok jako cel fokusu z opcjonalnym warunkowym booleanem. ↩ -
Apple Developer Documentation:
focusEffectDisabled(_:). Rezygnacja z systemowego efektu fokusu (Bool domyślnietrue); należy łączyć z niestandardowymi wizualizacjami fokusu, gdy potrzeba. ↩