← Todos los articulos

Motor de enfoque de tvOS: patrones de SwiftUI para el Siri Remote

Apple TV es la única plataforma de Apple sin una superficie táctil. El usuario navega mediante deslizamientos direccionales y pulsaciones de botón en el Siri Remote, y cada interacción pasa por el motor de enfoque: un sistema que decide qué elemento recibe el enfoque a continuación según la geometría, la jerarquía y la estructura de enfoque declarada por el desarrollador1. SwiftUI en tvOS expone un vocabulario enfocado (perdona el juego de palabras) para trabajar con el motor: .focusable, @FocusState, .focused, .focusSection, .prefersDefaultFocus y .focusEffectDisabled. Las apps que adoptan este vocabulario se sienten nativas; las que lo combaten producen la experiencia de un control remoto que se niega a navegar a donde el usuario espera.

Esta publicación recorre el motor de enfoque API con los patrones que se publican. El marco es “lo que el motor asume y cómo SwiftUI te permite cooperar”, porque el diseño de enfoque que funciona en iOS mediante toque-y-desplazamiento a menudo falla en tvOS, y la publicación Apple Platform Matrix del clúster sostuvo que tvOS solo gana su lugar con una UI consciente del enfoque.

TL;DR

  • El motor de enfoque resuelve el enfoque por geometría: elige la vista enfocable más cercana en la dirección del deslizamiento1. Las apps cooperan declarando vistas enfocables, secciones de enfoque y objetivos de enfoque predeterminado.
  • @FocusState (con .focused(_:equals:)) es la primitiva de SwiftUI para el control programático del enfoque. El mismo property wrapper funciona en iOS, macOS, watchOS y tvOS, pero tvOS es donde se gana su sustento2.
  • .focusSection() agrupa varias vistas enfocables en un único objetivo de enfoque para la navegación entre secciones, y luego deja que el motor elija dentro de la sección3. Úsalo para filas de botones, cuadrículas de tarjetas, secciones de la barra lateral.
  • .prefersDefaultFocus(_:in:) declara qué vista recibe el enfoque cuando el usuario entra en un contexto (una pantalla, un popover, una pestaña). Combínalo con @Namespace para acotar el predeterminado4.
  • El efecto de enfoque del sistema (el resaltado que crece alrededor de la vista enfocada) es automático. Desactívalo con .focusEffectDisabled() solo cuando implementes un visual de enfoque personalizado; de lo contrario, el efecto nativo de la plataforma es el correcto.

Cómo decide el motor de enfoque

El motor de enfoque procesa la entrada de deslizamiento del Siri Remote y resuelve “¿adónde va el enfoque a continuación?” mediante una búsqueda jerárquica1:

  1. Lee la dirección del deslizamiento (arriba, abajo, izquierda, derecha).
  2. Dentro del contexto de enfoque actual, encuentra las vistas enfocables cuyos marcos están en esa dirección con respecto a la vista enfocada actualmente.
  3. Elige la geométricamente más cercana a lo largo del eje del deslizamiento (con un pequeño sesgo hacia mantenerse alineada con el centro de la vista actual).
  4. Si no hay ninguna vista enfocable en esa dirección, el deslizamiento se consume sin mover el enfoque.

La implicación: el diseño visual de las vistas enfocables importa tanto como su jerarquía lógica. Dos botones desplazados en diagonal producen una navegación ambigua; dos botones alineados verticalmente producen un arriba/abajo predecible. El patrón recomendado por la HIG para cuadrículas y listas es alineación primero, decoración después.

Las apps participan en el motor a través de los modificadores de enfoque de SwiftUI. El comportamiento por defecto es que las vistas con intención interactiva explícita (Button, NavigationLink, TextField) son enfocables; las vistas estáticas (Text, Image, vistas contenedoras como VStack) no lo son.

Hacer enfocables las vistas personalizadas

El modificador .focusable() marca una vista como objetivo de enfoque5. El parámetro booleano opcional condiciona la posibilidad de enfoque:

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

La vista se convierte en un objetivo de enfoque sobre el que el motor puede aterrizar. El patrón es adecuado para tarjetas clicables, botones personalizados y cualquier vista compuesta que deba aceptar la atención del usuario. Sin .focusable(), el conjunto de Image + Text sería omitido por el motor.

@FocusState y .focused(_:equals:) para el control programático

Cuando la app necesita dirigir el enfoque (después de una transición de navegación, después de enviar una búsqueda, después de descartar un modal), @FocusState es la primitiva de 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
        }
    }
}

El valor del enum @FocusState rastrea qué campo está enfocado; al asignar un nuevo valor de forma programática, el enfoque se mueve a la vista correspondiente. La convención es el caso de enum Hashable; varios campos con el mismo valor de caso serían ambiguos.

Para una sola vista enfocable, @FocusState var isFocused: Bool más .focused($isFocused) es la forma más simple. La variante booleana es adecuada cuando la pregunta es “¿está enfocada esta vista?”; la variante de enum es adecuada para “¿qué vista de este conjunto?”.

.focusSection() para agrupar

Sin .focusSection(), todas las vistas enfocables participan en la búsqueda geométrica del motor en el mismo nivel. Con él, un contenedor se convierte en un grupo de enfoque: la navegación hacia/desde la sección es una decisión, la navegación dentro de la sección es otra3. Ten en cuenta que .focusSection() es exclusivo de tvOS y macOS; no tiene efecto en iOS, iPadOS, watchOS o visionOS.

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

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

Los dos VStack se vuelven navegables como unidades. El usuario desliza a la derecha desde la barra lateral para aterrizar en el área de contenido; una vez allí, el motor maneja la navegación dentro del área. Sin .focusSection(), los deslizamientos desde un botón de la barra lateral podrían aterrizar en un elemento de contenido arbitrario que casualmente sea geométricamente el más cercano, produciendo una UX que se siente aleatoria.

El patrón correcto: cada región de UI con estructura de enfoque interna (barras laterales, cuadrículas de tarjetas, barras de pestañas, controles de paginación) recibe un modificador .focusSection() en su contenedor. Entonces el motor navega entre secciones a nivel macro y dentro de las secciones a nivel micro.

.prefersDefaultFocus(_:in:) para el enfoque inicial

Cuando aparece una pantalla o se abre un popover, algo necesita el enfoque inicial. Sin una guía explícita, el motor elige la primera vista enfocable en el diseño, lo que a menudo es incorrecto (el botón Atrás en lugar de la acción principal, una celda de lista oscura en lugar del botón de reproducción)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)
    }
}

El @Namespace más .focusScope() define el límite de enfoque, y .prefersDefaultFocus(in:) declara el enfoque inicial preferido dentro de ese ámbito. Cuando aparece la pantalla, el enfoque aterriza en Reproducir.

El patrón es el correcto para cualquier vista en la que el usuario entra con una expectativa obvia de “qué hacer primero”: Reproducir en una página de detalle de película, Iniciar sesión en una pantalla de login, Comenzar en una pantalla de incorporación.

Efectos de enfoque personalizados (y cuándo desactivar el predeterminado)

El efecto de enfoque del sistema es el resplandor de bordes suaves que crece alrededor de una vista enfocada. Escala la vista ligeramente, agrega una sombra sutil y se anima con la temporización estándar de la plataforma. Para la mayoría de las apps, el predeterminado es correcto; coincide con todas las demás apps de tvOS y permite a los usuarios aprender el vocabulario de la plataforma.

Para las apps que necesitan un visual de enfoque personalizado (un resplandor específico de la marca, un efecto consciente del contenido, un anillo de enfoque que entra en conflicto con el predeterminado), .focusEffectDisabled() opta por no usar el tratamiento del sistema6:

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

La vista personalizada es responsable de indicar visualmente el enfoque; el sistema ya no interfiere. La contrapartida: cada visual de enfoque debe ser diseñado e implementado por la app en lugar de heredarse. Para la mayoría de las apps, el efecto del sistema es la elección correcta.

Fallos comunes de enfoque en tvOS

Tres patrones que producen una mala UX en tvOS:

Botones que no aceptan enfoque. Un botón personalizado renderizado como HStack { Image; Text } sin .focusable() es invisible para el motor. Los deslizamientos del Siri Remote lo omiten. Solución: envuelve el contenido interactivo en Button (que proporciona participación en el enfoque por defecto) o aplica .focusable() explícitamente.

Trampas de enfoque. Una vista que acepta el enfoque pero no proporciona una salida (ningún hermano enfocable a izquierda/derecha/arriba/abajo, ningún escape mediante el botón Menú) deja al usuario atrapado. Solución: cada contexto de enfoque debe tener una ruta de salida documentada. El patrón .focusSection() ayuda porque le da al motor una unidad a la que escapar.

Enfoque predeterminado en el elemento equivocado. Una pantalla de detalle de película que se abre con el enfoque en Atrás en lugar de Reproducir es una fricción que el usuario paga en cada visita. Solución: declara .prefersDefaultFocus(in:) en la acción principal.

Efectos de enfoque personalizados que no son accesibles. Un anillo de enfoque que es solo un borde de color de 1pt con bajo contraste falla en accesibilidad. El efecto de enfoque del sistema es de alto contraste y está probado en movimiento; los reemplazos personalizados necesitan el mismo cuidado. La publicación Accessibility as platform del clúster cubre el principio más amplio.

Cuándo tvOS gana su lugar

La publicación Apple Platform Matrix del clúster sostuvo que tvOS es la plataforma con la base instalada más pequeña en relación con iOS, y las apps necesitan un caso de uso real de “echar atrás” o “modo sofá” para justificar la inversión en ingeniería. El motor de enfoque es parte de esa inversión: una app de tvOS que no honra el vocabulario del enfoque se siente como una app de iPad estirada por un televisor. La inversión es real porque la superficie API es real; el trabajo de ingeniería es significativo porque el motor realmente decide adónde va el enfoque.

Las apps que ganan su lugar en tvOS tienden a compartir tres propiedades: 1. Contenido consumido a distancia de televisión. Streaming, presentaciones de fotos, juegos controlados por mando. 2. Modelo de interacción escaso. Unas pocas acciones primarias por pantalla, navegadas con entrada direccional. 3. Caso de uso de “echar atrás”. El usuario está en un sofá, posiblemente haciendo multitarea con otro dispositivo, posiblemente viendo a medias.

Para las apps en esas categorías, la inversión en el motor de enfoque es la correcta. Para las apps que no encajan (herramientas de productividad, apps creativas de grano fino, cualquier cosa con mucha entrada de texto), la decisión correcta es saltarse tvOS, como recomienda la publicación de la matriz.

Lo que este patrón significa para las apps de tvOS

Tres conclusiones.

  1. Construye la intención de enfoque en el diseño, no como un parche posterior. ¿Dónde comenzará el usuario? ¿Adónde puede ir desde allí? ¿Cuál es la acción principal? Diseñar una pantalla en tvOS comienza con el flujo de enfoque, no con la composición visual. Lo visual sigue.

  2. Usa .focusSection() agresivamente para cualquier región con estructura interna. La navegación geométrica predeterminada suele ser incorrecta para cuadrículas, barras laterales, barras de pestañas. El modificador de sección es pequeño y la diferencia es grande.

  3. Mantén el efecto de enfoque del sistema a menos que tengas una razón real para reemplazarlo. Los visuales de enfoque personalizados son trabajo de ingeniería real más trabajo de accesibilidad más pruebas en todos los temas. El efecto del sistema es el predeterminado correcto; recurre a .focusEffectDisabled() solo cuando el diseño realmente necesite un tratamiento personalizado.

El clúster completo del Ecosistema Apple: App Intents tipados; servidores MCP; la pregunta de enrutamiento; Foundation Models; la distinción runtime vs tooling LLM; tres superficies; el patrón de fuente única de verdad; Dos Servidores MCP; hooks para el desarrollo en Apple; Live Activities; el contrato de runtime de watchOS; internos de SwiftUI; el modelo mental espacial de RealityKit; disciplina de esquema de SwiftData; patrones de Liquid Glass; envío multiplataforma; la matriz de plataformas; framework Vision; Symbol Effects; inferencia con Core ML; Writing Tools API; Swift Testing; Privacy Manifest; Accesibilidad como plataforma; tipografía SF Pro; patrones espaciales de visionOS; framework Speech; migraciones de SwiftData; sobre lo que me niego a escribir. El hub está en la Serie del Ecosistema Apple. Para un contexto más amplio sobre iOS con agentes de IA, consulta la guía de Desarrollo de Agentes iOS.

Preguntas frecuentes

¿.focusable() funciona en iOS?

Sí, pero su comportamiento en objetivos de iOS apunta a interacciones con teclado y puntero (teclado Bluetooth, puntero de iPadOS, Magic Keyboard del iPad), no a la navegación impulsada por el motor de enfoque que usa tvOS. El mismo código se puede usar entre plataformas; la interacción con el usuario difiere. En tvOS, .focusable() es la ruta principal. En iOS, es una prestación complementaria para la accesibilidad.

¿Cuál es la diferencia entre .focusable() y Button?

Button es una construcción de nivel superior que incluye la posibilidad de enfoque, el manejo de acciones, el estilo de botón del sistema y los rasgos de accesibilidad. .focusable() es el marcador de bajo nivel que solo convierte una vista en un objetivo de enfoque. Usa Button cuando la vista es lógicamente un botón; usa .focusable() cuando estás construyendo una vista interactiva personalizada (una tarjeta de póster, un mosaico en una cuadrícula) que no encaja con el modelo mental de botón.

¿Puedo tener múltiples declaraciones .prefersDefaultFocus?

Sí, acotadas por @Namespace. Cada ámbito de enfoque puede tener su propio predeterminado preferido. El patrón es adecuado para contextos anidados (un popover dentro de una pantalla, una pestaña dentro de una barra lateral): cada ámbito elige su propio enfoque inicial.

¿Cómo manejo el enfoque en una lista con muchos elementos?

Las listas en SwiftUI son enfocables por defecto; el motor maneja la navegación arriba/abajo a través de las celdas automáticamente. Para diseños personalizados similares a listas, envuelve cada celda en un Button o aplica .focusable(), luego coloca toda la lista dentro de un .focusSection() para que el motor trate la lista como una unidad respecto a otras regiones de UI.

¿Qué hace el botón Menú en el modelo de enfoque?

El botón Menú del Siri Remote es la acción de descartar/atrás en todo tvOS. Saca de la pila de navegación, sale de los modales, regresa al contexto padre. SwiftUI lo maneja automáticamente a través de NavigationStack y el descarte modal estándar; las apps normalmente no lo interceptan. Para una lógica de descarte personalizada, el modificador de vista onExitCommand captura la pulsación.

¿Cómo se relaciona esto con las otras publicaciones de plataforma del clúster?

El motor de enfoque de tvOS es la superficie de navegación específica de la plataforma, paralela al gaze-and-pinch de visionOS (cubierto en patrones espaciales de visionOS) y al toque-y-desplazamiento de iOS. Cada plataforma tiene su propia metáfora de entrada; la publicación Apple Platform Matrix del clúster sostiene que la inclusión de plataforma requiere honrar esa metáfora, y el motor de enfoque es lo que tvOS exige.

Referencias


  1. Apple Developer: App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote. El modelo del motor de enfoque y las reglas de resolución geométrica. 

  2. Apple Developer Documentation: @FocusState. El property wrapper para rastrear y dirigir programáticamente el enfoque entre las plataformas de SwiftUI. 

  3. Apple Developer Documentation: focusSection(). El modificador de vista que agrupa los descendientes enfocables en un único objetivo de enfoque para la navegación entre secciones. 

  4. Apple Developer Documentation: prefersDefaultFocus(_:in:) y focusScope(_:). La declaración de enfoque predeterminado emparejada con límites de enfoque acotados por namespace. 

  5. Apple Developer Documentation: focusable(_:). El modificador de vista que marca una vista como objetivo de enfoque con un booleano condicional opcional. 

  6. Apple Developer Documentation: focusEffectDisabled(_:). La opción de exclusión del efecto de enfoque del sistema (Bool predeterminado true); combínalo con visuales de enfoque personalizados cuando sea necesario. 

Artículos 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 lectura

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 lectura

The Design Engineer's Agent Stack

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

14 min de lectura