← Todos os Posts

Focus Engine do tvOS: padrões SwiftUI para o Siri Remote

A Apple TV é a única plataforma da Apple sem uma superfície de toque. O usuário navega por gestos direcionais e pressionamentos de botões no Siri Remote, e cada interação passa pelo focus engine: um sistema que decide qual elemento recebe o foco em seguida com base na geometria, na hierarquia e na estrutura de foco declarada pelo desenvolvedor1. O SwiftUI no tvOS expõe um vocabulário focado (perdoe o trocadilho) para trabalhar com o engine: .focusable, @FocusState, .focused, .focusSection, .prefersDefaultFocus e .focusEffectDisabled. Apps que adotam esse vocabulário parecem nativos; apps que brigam contra ele produzem a experiência de um controle remoto que se recusa a navegar para onde o usuário espera.

Este post percorre a superfície API do focus engine com os padrões que entregam. O recorte é “o que o engine pressupõe e como o SwiftUI permite que você coopere”, porque o design de foco que funciona no iOS via tap-and-scroll geralmente falha no tvOS, e o post Apple Platform Matrix do cluster argumentou que o tvOS só conquista seu lugar com uma UI consciente do foco.

TL;DR

  • O focus engine resolve o foco pela geometria: ele escolhe a view focável mais próxima na direção do gesto1. Os apps cooperam declarando views focáveis, focus sections e alvos de foco padrão.
  • @FocusState (com .focused(_:equals:)) é a primitiva do SwiftUI para controle programático de foco. O mesmo property wrapper funciona em iOS, macOS, watchOS e tvOS, mas é no tvOS que ele realmente faz a diferença2.
  • .focusSection() agrupa várias views focáveis em um único alvo de foco para a navegação entre seções, e depois deixa o engine escolher dentro da seção3. Use-o para linhas de botões, grades de cards e seções de sidebar.
  • .prefersDefaultFocus(_:in:) declara qual view recebe o foco quando o usuário entra em um contexto (uma tela, um popover, uma aba). Combine com @Namespace para escopar o padrão4.
  • O efeito de foco do sistema (o destaque que cresce ao redor da view focada) é automático. Desabilite-o com .focusEffectDisabled() apenas ao implementar um visual de foco personalizado; caso contrário, o efeito nativo da plataforma é o correto.

Como o focus engine decide

O focus engine processa a entrada de gestos do Siri Remote e resolve “para onde o foco vai a seguir?” por meio de uma busca hierárquica1:

  1. Lê a direção do gesto (cima, baixo, esquerda, direita).
  2. Dentro do contexto de foco atual, encontra views focáveis cujos frames estão naquela direção em relação à view atualmente focada.
  3. Escolhe a geometricamente mais próxima ao longo do eixo do gesto (com um pequeno viés a favor de manter o alinhamento com o centro da view atual).
  4. Se nenhuma view focável estiver naquela direção, o gesto é consumido sem mover o foco.

A implicação: o layout visual das views focáveis importa tanto quanto sua hierarquia lógica. Dois botões deslocados na diagonal produzem navegação ambígua; dois botões alinhados verticalmente produzem cima/baixo previsível. O padrão recomendado pela HIG para grades e listas é alinhamento primeiro, decoração depois.

Os apps participam do engine por meio dos modificadores de foco do SwiftUI. O comportamento padrão é que views com intenção interativa explícita (Button, NavigationLink, TextField) são focáveis; views estáticas (Text, Image, views de container como VStack) não são.

Tornando views customizadas focáveis

O modificador .focusable() marca uma view como alvo de foco5. O parâmetro Boolean opcional condiciona a focabilidade:

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)
    }
}

A view se torna um alvo de foco em que o engine pode pousar. O padrão é correto para cards clicáveis, botões customizados e qualquer view composta que deva aceitar a atenção do usuário. Sem .focusable(), o conjunto de Image + Text seria ignorado pelo engine.

@FocusState e .focused(_:equals:) para controle programático

Quando o app precisa direcionar o foco (após uma transição de navegação, após o envio de uma busca, após dispensar um modal), @FocusState é a primitiva do 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
        }
    }
}

O valor do enum @FocusState rastreia qual campo está focado; atribuir um novo valor programaticamente move o foco para a view correspondente. O case do enum Hashable é a convenção; vários campos com o mesmo valor de case seriam ambíguos.

Para uma única view focável, @FocusState var isFocused: Bool mais .focused($isFocused) é a forma mais simples. A variante Boolean é a certa quando a pergunta é “esta view está focada?”; a variante enum é a certa para “qual view neste conjunto?”.

.focusSection() para agrupamento

Sem .focusSection(), toda view focável participa da busca geométrica do engine no mesmo nível. Com ele, um container se torna um grupo de foco: a navegação para/de dentro da seção é uma decisão, e a navegação dentro da seção é outra3. Note que .focusSection() é exclusivo de tvOS e macOS; não tem efeito no iOS, iPadOS, watchOS ou visionOS.

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

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

Os dois VStacks se tornam navegáveis como unidades. O usuário desliza para a direita a partir da sidebar para pousar na área de conteúdo; uma vez lá, o engine cuida da navegação dentro da área. Sem .focusSection(), gestos a partir de um botão da sidebar poderiam pousar em um item de conteúdo arbitrário que por acaso esteja geometricamente mais próximo, produzindo uma UX que parece aleatória.

O padrão certo: toda região da UI com estrutura de foco interna (sidebars, grades de cards, barras de abas, controles de paginação) recebe um modificador .focusSection() em seu container. O engine então navega entre seções no nível macro e dentro das seções no nível micro.

.prefersDefaultFocus(_:in:) para foco inicial

Quando uma tela aparece ou um popover é aberto, algo precisa receber o foco inicial. Sem orientação explícita, o engine escolhe a primeira view focável no layout, o que muitas vezes está errado (o botão de voltar em vez da ação primária, uma célula de lista obscura em vez do botão de play)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)
    }
}

O @Namespace mais .focusScope() define a fronteira do foco, e .prefersDefaultFocus(in:) declara o foco inicial preferido dentro daquele escopo. Quando a tela aparece, o foco pousa no Play.

O padrão é o correto para qualquer view em que o usuário entra com uma expectativa óbvia de “o que fazer primeiro”: Play em uma página de detalhes do filme, Sign In em uma tela de login, Get Started em uma tela de onboarding.

Efeitos de foco customizados (e quando desabilitar o padrão)

O efeito de foco do sistema é o brilho de bordas suaves que cresce ao redor de uma view focada. Ele escala a view ligeiramente, adiciona uma sombra sutil e anima com o timing padrão da plataforma. Para a maioria dos apps, o padrão é o correto; ele combina com qualquer outro app de tvOS e permite que os usuários aprendam o vocabulário da plataforma.

Para apps que precisam de um visual de foco customizado (um brilho específico da marca, um efeito sensível ao conteúdo, um anel de foco que conflita com o padrão), .focusEffectDisabled() opta por sair do tratamento do sistema6:

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

A view customizada é responsável por indicar o foco visualmente; o sistema não interfere mais. O trade-off: cada visual de foco precisa ser projetado e implementado pelo app em vez de herdado. Para a maioria dos apps, o efeito do sistema é a escolha certa.

Falhas comuns de foco no tvOS

Três padrões que produzem uma UX ruim no tvOS:

Botões que não aceitam foco. Um botão customizado renderizado como HStack { Image; Text } sem .focusable() é invisível para o engine. Os gestos do Siri Remote o ignoram. Correção: envolva o conteúdo interativo em Button (que fornece participação no foco por padrão) ou aplique .focusable() explicitamente.

Armadilhas de foco. Uma view que aceita foco mas não fornece um caminho de saída (nenhum vizinho à esquerda/direita/cima/baixo focável, nenhuma saída pelo botão Menu) deixa o usuário preso. Correção: todo contexto de foco deve ter um caminho de saída documentado. O padrão .focusSection() ajuda porque dá ao engine uma unidade para a qual escapar.

Foco padrão no elemento errado. Uma tela de detalhes de filme que abre com foco em Back em vez de Play é um atrito que o usuário paga em cada visita. Correção: declare .prefersDefaultFocus(in:) na ação primária.

Efeitos de foco customizados que não são acessíveis. Um anel de foco que é apenas uma borda colorida de 1pt em baixo contraste falha em acessibilidade. O efeito de foco do sistema é de alto contraste e testado para movimento; substituições customizadas precisam do mesmo cuidado. O post Accessibility as platform do cluster cobre o princípio mais amplo.

Quando o tvOS conquista seu lugar

O post Apple Platform Matrix do cluster argumentou que o tvOS é a plataforma com a menor base instalada em relação ao iOS, e os apps precisam de um caso de uso real “lean back” ou “couch-mode” para justificar o investimento em engenharia. O focus engine faz parte desse investimento: um app de tvOS que não honra o vocabulário de foco parece um app de iPad esticado para uma TV. O investimento é real porque a superfície API é real; o trabalho de engenharia é significativo porque o engine de fato decide para onde o foco vai.

Apps que conquistam seu lugar no tvOS tendem a compartilhar três propriedades: 1. Conteúdo consumido à distância de assistir TV. Streaming, slideshows de fotos, jogos com controle. 2. Modelo de interação esparso. Algumas ações primárias por tela, navegadas com entrada direcional. 3. Caso de uso lean-back. O usuário está no sofá, possivelmente fazendo multitarefa com outro dispositivo, possivelmente assistindo pela metade.

Para apps nessas categorias, o investimento no focus engine é o correto. Para apps que não se encaixam (ferramentas de produtividade, apps criativos de granularidade fina, qualquer coisa pesada em entrada de texto), a decisão certa é pular o tvOS, como o post da matrix recomenda.

O que esse padrão significa para apps tvOS

Três conclusões.

  1. Construa intenção de foco no layout, não como uma correção a posteriori. Onde o usuário vai começar? Para onde ele pode ir a partir dali? Qual é a ação primária? Projetar uma tela no tvOS começa com o fluxo de foco, não com a composição visual. O visual segue.

  2. Use .focusSection() agressivamente para qualquer região com estrutura interna. A navegação geométrica padrão muitas vezes é errada para grades, sidebars e barras de abas. O modificador de seção é pequeno e a diferença é grande.

  3. Mantenha o efeito de foco do sistema a menos que tenha um motivo real para substituí-lo. Visuais de foco customizados são trabalho real de engenharia mais trabalho de acessibilidade mais teste em todos os temas. O efeito do sistema é o padrão correto; recorra a .focusEffectDisabled() apenas quando o design genuinamente precisar de um tratamento customizado.

O cluster Apple Ecosystem completo: App Intents tipados; servidores MCP; a questão de roteamento; Foundation Models; a distinção runtime vs tooling LLM; três superfícies; o padrão de fonte única da verdade; dois servidores MCP; hooks para desenvolvimento Apple; Live Activities; o contrato de runtime do watchOS; internals do SwiftUI; o modelo mental espacial do RealityKit; disciplina de schema do SwiftData; padrões Liquid Glass; shipping multiplataforma; a platform matrix; Vision framework; Symbol Effects; inferência Core ML; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility as platform; tipografia SF Pro; padrões espaciais visionOS; Speech framework; migrações SwiftData; sobre o que me recuso a escrever. O hub está em Apple Ecosystem Series. Para um contexto mais amplo de iOS com agentes de IA, veja o guia iOS Agent Development.

FAQ

.focusable() funciona no iOS?

Sim, mas seu comportamento em alvos iOS é voltado para interações de teclado e ponteiro (teclado Bluetooth, ponteiro do iPadOS, Magic Keyboard do iPad), e não para a navegação orientada pelo focus engine que o tvOS usa. O mesmo código pode ser usado em multiplataforma; a interação para o usuário difere. No tvOS, .focusable() é o caminho primário. No iOS, é um recurso suplementar de acessibilidade.

Qual é a diferença entre .focusable() e Button?

Button é uma construção de mais alto nível que inclui focabilidade, tratamento de ação, o estilo de botão do sistema e traits de acessibilidade. .focusable() é o marcador de baixo nível que apenas torna uma view um alvo de foco. Use Button quando a view é logicamente um botão; use .focusable() quando está construindo uma view interativa customizada (um card de pôster, um tile em uma grade) que não se encaixa no modelo mental de botão.

Posso ter várias declarações de .prefersDefaultFocus?

Sim, escopadas por @Namespace. Cada escopo de foco pode ter seu próprio padrão preferido. O padrão é correto para contextos aninhados (um popover dentro de uma tela, uma aba dentro de uma sidebar): cada escopo escolhe seu próprio foco inicial.

Como lido com foco em uma lista com muitos itens?

Listas no SwiftUI são focáveis por padrão; o engine cuida da navegação cima/baixo entre células automaticamente. Para layouts customizados parecidos com lista, envolva cada célula em um Button ou aplique .focusable(), depois coloque a lista inteira dentro de um .focusSection() para que o engine trate a lista como uma unidade em relação a outras regiões da UI.

O que o botão Menu faz no modelo de foco?

O botão Menu do Siri Remote é a ação de dispensar/voltar em todo o tvOS. Ele desempilha a navigation stack, sai de modais, retorna ao contexto pai. O SwiftUI lida com isso automaticamente por meio de NavigationStack e da dispensação padrão de modais; os apps tipicamente não o interceptam. Para lógica de dispensação customizada, o modificador de view onExitCommand captura o pressionamento.

Como isso se relaciona com os outros posts de plataforma do cluster?

O focus engine do tvOS é a superfície de navegação específica da plataforma, paralela ao gaze-and-pinch do visionOS (coberto em padrões espaciais visionOS) e ao tap-and-scroll do iOS. Cada plataforma tem sua própria metáfora de entrada; o post Apple Platform Matrix do cluster argumenta que a inclusão de uma plataforma exige honrar essa metáfora, e o focus engine é o que o tvOS exige.

Referências


  1. Apple Developer: App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote. O modelo do focus engine e as regras de resolução geométrica. 

  2. Apple Developer Documentation: @FocusState. O property wrapper para rastrear e direcionar programaticamente o foco entre as plataformas SwiftUI. 

  3. Apple Developer Documentation: focusSection(). O modificador de view que agrupa descendentes focáveis em um único alvo de foco para a navegação entre seções. 

  4. Apple Developer Documentation: prefersDefaultFocus(_:in:) e focusScope(_:). A declaração de foco padrão combinada com fronteiras de foco escopadas por namespace. 

  5. Apple Developer Documentation: focusable(_:). O modificador de view que marca uma view como alvo de foco com Boolean condicional opcional. 

  6. Apple Developer Documentation: focusEffectDisabled(_:). O opt-out para o efeito de foco do sistema (Bool padrão true); combine com visuais de foco customizados quando necessário. 

Artigos relacionados

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 min de leitura

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 min de leitura

The Design Engineer's Agent Stack

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

14 min de leitura