Saltar al contenido principal

Entendiendo el sistema de foco de teclado de Flutter

Cómo usar el sistema de foco en tu app de Flutter.

Este artículo explica cómo controlar a dónde se dirige la entrada del teclado. Si estás implementando una aplicación que usa un teclado físico, como la mayoría de las aplicaciones de escritorio y web, esta página es para ti. Si tu app no se usará con un teclado físico, puedes omitir esto.

Resumen

#

Flutter viene con un sistema de foco que dirige la entrada del teclado a una parte particular de una aplicación. Para hacer esto, los usuarios "enfocan" la entrada en esa parte de la aplicación al tocar o hacer clic en el elemento de UI deseado. Una vez que eso sucede, el texto ingresado con el teclado fluye hacia esa parte de la aplicación hasta que el foco se mueve a otra parte de la aplicación. El foco también se puede mover presionando un atajo de teclado particular, que normalmente está vinculado a Tab, por lo que a veces se le llama "recorrido de tabulación".

Esta página explora las API utilizadas para realizar estas operaciones en una aplicación de Flutter, y cómo funciona el sistema de foco. Hemos notado que existe cierta confusión entre los desarrolladores sobre cómo definir y usar objetos FocusNode. Si eso describe tu experiencia, salta directamente a las mejores prácticas para crear objetos FocusNode.

Casos de uso del foco

#

Algunos ejemplos de situaciones en las que podrías necesitar saber cómo usar el sistema de foco:

Glosario

#

A continuación se presentan los términos, tal como los usa Flutter, para los elementos del sistema de foco. Las diversas clases que implementan algunos de estos conceptos se presentan a continuación.

  • Árbol de foco (Focus tree) - Un árbol de nodos de foco que normalmente refleja de forma dispersa el árbol de widgets, representando todos los widgets que pueden recibir el foco.
  • Nodo de foco (Focus node) - Un solo nodo en un árbol de foco. Este nodo puede recibir el foco, y se dice que "tiene el foco" cuando forma parte de la cadena de foco. Participa en el manejo de eventos de teclado solo cuando tiene el foco.
  • Foco primario (Primary focus) - El nodo de foco más lejano de la raíz del árbol de foco que tiene el foco. Este es el nodo de foco donde los eventos de teclado comienzan a propagarse hacia el nodo de foco primario y sus ancestros.
  • Cadena de foco (Focus chain) - Una lista ordenada de nodos de foco que comienza en el nodo de foco primario y sigue las ramas del árbol de foco hasta la raíz del árbol de foco.
  • Alcance de foco (Focus scope) - Un nodo de foco especial cuyo trabajo es contener un grupo de otros nodos de foco y permitir que solo esos nodos reciban el foco. Contiene información sobre qué nodos fueron enfocados previamente en su subárbol.
  • Recorrido de foco (Focus traversal) - El proceso de moverse de un nodo enfocable a otro en un orden predecible. Esto se ve típicamente en las aplicaciones cuando el usuario presiona Tab para moverse al siguiente control o campo enfocable.

FocusNode y FocusScopeNode

#

Los objetos FocusNode y FocusScopeNode implementan la mecánica del sistema de foco. Son objetos de larga duración (más duraderos que los widgets, similares a los RenderObject) que mantienen el State y los atributos del foco para que sean persistentes entre las construcciones del árbol de widgets. Juntos, forman la estructura de datos del árbol de foco.

Originalmente se pretendía que fueran objetos orientados al desarrollador utilizados para controlar algunos aspectos del sistema de foco, pero con el tiempo han evolucionado para implementar principalmente detalles del sistema de foco. Para evitar romper aplicaciones existentes, todavía contienen interfaces públicas para sus atributos. Pero, en general, para lo que son más útiles es para actuar como un manejador relativamente opaco, pasado a un widget descendiente con el fin de llamar a requestFocus() en un widget ancestro, lo que solicita que un widget descendiente obtenga el foco. La configuración de los demás atributos se gestiona mejor mediante un widget Focus o FocusScope, a menos que no los estés usando o estés implementando tu propia versión de ellos.

Mejores prácticas para crear objetos FocusNode

#

Algunas cosas que se deben y no se deben hacer con respecto al uso de estos objetos incluyen:

  • No asignes un nuevo FocusNode para cada construcción. Esto puede causar fugas de memoria y, ocasionalmente, causa una pérdida de foco cuando el widget se reconstruye mientras el nodo tiene el foco.
  • Crea objetos FocusNode y FocusScopeNode en un stateful widget. FocusNode y FocusScopeNode deben liberarse (disposed) cuando hayas terminado de usarlos, por lo que solo deben crearse dentro del objeto State de un stateful widget, donde puedes sobrescribir dispose para liberarlos.
  • No uses el mismo FocusNode para múltiples widgets. Si lo haces, los widgets competirán por gestionar los atributos del nodo y es probable que no obtengas lo que esperas.
  • Establece la debugLabel de un widget de nodo de foco para ayudar a diagnosticar problemas de foco.
  • No establezcas el callback onKeyEvent en un FocusNode o FocusScopeNode si están siendo gestionados por un widget Focus o FocusScope. Si deseas un manejador onKeyEvent, agrega un nuevo widget Focus alrededor del subárbol de widgets que te gustaría escuchar, y establece el atributo onKeyEvent del widget a tu manejador. Establece canRequestFocus: false en el widget si tampoco quieres que pueda tomar el foco primario. Esto se debe a que el atributo onKeyEvent en el widget Focus se puede establecer en otra cosa en una construcción posterior, y si eso sucede, sobrescribe el manejador onKeyEvent que estableciste en el nodo.
  • Llama a requestFocus() en un nodo para solicitar que reciba el foco primario, especialmente desde un ancestro que ha pasado un nodo del que es propietario a un descendiente donde deseas enfocar.
  • Usa focusNode.requestFocus(). No es necesario llamar a FocusScope.of(context).requestFocus(focusNode). El método focusNode.requestFocus() es equivalente y más eficiente.

Desenfocar (Unfocusing)

#

Existe una API para decirle a un nodo que "renuncie al foco", llamada FocusNode.unfocus(). Si bien elimina el foco del nodo, es importante darse cuenta de que en realidad no existe tal cosa como "desenfocar" todos los nodos. Si un nodo se desenfoca, debe pasar el foco a otra parte, ya que siempre hay un foco primario. El nodo que recibe el foco cuando un nodo llama a unfocus() es el FocusScopeNode más cercano o un nodo enfocado previamente en ese alcance, dependiendo del argumento disposition otorgado a unfocus(). Si deseas más control sobre a dónde va el foco cuando lo quitas de un nodo, enfoca explícitamente otro nodo en lugar de llamar a unfocus(), o usa el mecanismo de recorrido de foco para encontrar otro nodo con los métodos focusInDirection, nextFocus, o previousFocus en FocusNode.

Al llamar a unfocus(), el argumento disposition permite dos modos para desenfocar: UnfocusDisposition.scope y UnfocusDisposition.previouslyFocusedChild. El predeterminado es scope, el cual otorga el foco al alcance de foco (focus scope) padre más cercano. Esto significa que si el foco se mueve posteriormente al siguiente nodo con FocusNode.nextFocus, comienza con el "primer" elemento enfocable del alcance.

La disposición previouslyFocusedChild buscará en el alcance para encontrar el hijo previamente enfocado y solicitará el foco en él. Si no hay un hijo previamente enfocado, es equivalente a scope.

Widget Focus

#

El widget Focus posee y gestiona un nodo de foco, y es el elemento principal del sistema de foco. Gestiona la vinculación y desvinculación del nodo de foco que posee del árbol de foco, gestiona los atributos y callbacks del nodo de foco, y tiene funciones estáticas para permitir el descubrimiento de nodos de foco vinculados al árbol de widgets.

En su forma más simple, envolver el widget Focus alrededor de un subárbol de widgets permite que ese subárbol de widgets obtenga el foco como parte del proceso de recorrido del foco, o cada vez que se llama a requestFocus en el FocusNode pasado a este. Cuando se combina con un detector de gestos que llama a requestFocus, puede recibir el foco cuando se toca o se hace clic en él.

Podrías pasar un objeto FocusNode al widget Focus para gestionarlo, pero si no lo haces, este crea el suyo propio. La razón principal para crear tu propio FocusNode es poder llamar a requestFocus() en el nodo para controlar el foco desde un widget padre. La mayor parte de la demás funcionalidad de un FocusNode se accede mejor cambiando los atributos del propio widget Focus.

El widget Focus se utiliza en la mayoría de los controles propios de Flutter para implementar su funcionalidad de foco.

Aquí hay un ejemplo que muestra cómo usar el widget Focus para hacer enfocable un control personalizado. Crea un contenedor con texto que reacciona al recibir el foco.

dart
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  static const String _title = 'Focus Sample';

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[MyCustomWidget(), MyCustomWidget()],
        ),
      ),
    );
  }
}

class MyCustomWidget extends StatefulWidget {
  const MyCustomWidget({super.key});

  @override
  State<MyCustomWidget> createState() => _MyCustomWidgetState();
}

class _MyCustomWidgetState extends State<MyCustomWidget> {
  Color _color = Colors.white;
  String _label = 'Unfocused';

  @override
  Widget build(BuildContext context) {
    return Focus(
      onFocusChange: (focused) {
        setState(() {
          _color = focused ? Colors.black26 : Colors.white;
          _label = focused ? 'Focused' : 'Unfocused';
        });
      },
      child: Center(
        child: Container(
          width: 300,
          height: 50,
          alignment: Alignment.center,
          color: _color,
          child: Text(_label),
        ),
      ),
    );
  }
}

Eventos de teclado

#

Si deseas escuchar eventos de teclado en un subárbol, establece el atributo onKeyEvent del widget Focus para que sea un manejador que simplemente escuche la tecla, o maneje la tecla y detenga su propagación a otros widgets.

Los eventos de teclado comienzan en el nodo de foco con foco primario. Si ese nodo no devuelve KeyEventResult.handled desde su manejador onKeyEvent, entonces a su nodo de foco padre se le entrega el evento. Si el padre no lo maneja, pasa a su padre, y así sucesivamente, hasta llegar a la raíz del árbol de foco. Si el evento llega a la raíz del árbol de foco sin ser manejado, entonces se devuelve a la plataforma para dárselo a el siguiente control nativo de la aplicación (en caso de que la UI de Flutter forme parte de una UI de aplicación nativa más grande). Los eventos que se manejan no se propagan a otros widgets de Flutter, y tampoco se propagan a widgets nativos.

Aquí hay un ejemplo de un widget Focus que absorbe cada tecla que su subárbol no maneja, sin poder ser el foco primario:

dart
@override
Widget build(BuildContext context) {
  return Focus(
    onKeyEvent: (node, event) => KeyEventResult.handled,
    canRequestFocus: false,
    child: child,
  );
}

Los eventos de teclado de foco se procesan antes que los eventos de entrada de texto, por lo que manejar un evento de teclado cuando el widget de foco rodea un campo de texto evita que esa tecla se ingrese en el campo de texto.

Aquí hay un ejemplo de un widget que no permitirá que la letra "a" se escriba en el campo de texto:

dart
@override
Widget build(BuildContext context) {
  return Focus(
    onKeyEvent: (node, event) {
      return (event.logicalKey == LogicalKeyboardKey.keyA)
          ? KeyEventResult.handled
          : KeyEventResult.ignored;
    },
    child: const TextField(),
  );
}

Si la intención es la validación de la entrada, la funcionalidad de este ejemplo probablemente se implementaría mejor usando un TextInputFormatter, pero la técnica aún puede ser útil: el widget Shortcuts usa este método para manejar atajos antes de que se conviertan en entrada de texto, por ejemplo.

Controlar qué obtiene el foco

#

Uno de los aspectos principales del foco es controlar qué puede recibir el foco y cómo. Los atributos canRequestFocus, skipTraversal, y descendantsAreFocusable controlan cómo este nodo y sus descendientes participan en el proceso de foco.

Si el atributo skipTraversal es true, entonces este nodo de foco no participa en el recorrido del foco. Sigue siendo enfocable si se llama a requestFocus en su nodo de foco, pero por lo demás se omite cuando el sistema de recorrido de foco está buscando el siguiente elemento en el que enfocarse.

El atributo canRequestFocus, como era de esperar, controla si el nodo de foco que gestiona este widget Focus se puede usar o no para solicitar el foco. Si este atributo es false, entonces llamar a requestFocus en el nodo no tiene efecto. También implica que este nodo se omite para el recorrido del foco, ya que no puede solicitar el foco.

El atributo descendantsAreFocusable controla si los descendientes de este nodo pueden recibir el foco, pero aún permite que este nodo reciba el foco. Este atributo se puede usar para desactivar la capacidad de enfoque para todo un subárbol de widgets. Así es como funciona el widget ExcludeFocus: es solo un widget Focus con este atributo establecido.

Autofocus

#

Establecer el atributo autofocus de un widget Focus le dice al widget que solicite el foco la primera vez que el alcance de foco al que pertenece recibe el foco. Si más de un widget tiene establecido autofocus, entonces es arbitrario cuál recibe el foco, así que intenta establecerlo solo en un widget por alcance de foco.

El atributo autofocus solo surte efecto si no hay ya un foco en el alcance al que pertenece el nodo.

Establecer el atributo autofocus en dos nodos que pertenecen a diferentes alcances de foco está bien definido: cada uno se convierte en el widget enfocado cuando se enfocan sus correspondientes alcances.

Notificaciones de cambio

#

El callback Focus.onFocusChanged se puede usar para recibir notificaciones de que el estado de foco para un nodo en particular ha cambiado. Notifica si el nodo se agrega o se elimina de la cadena de foco, lo que significa que recibe notificaciones incluso si no es el foco primario. Si solo quieres saber si has recibido el foco primario, verifica si hasPrimaryFocus es true en el nodo de foco.

Obtener el FocusNode

#

A veces, es útil obtener el nodo de foco de un widget Focus para interrogar sus atributos.

Para acceder al nodo de foco desde un ancestro del widget Focus, crea y pasa un FocusNode como el atributo focusNode del widget Focus. Debido a que necesita ser liberado (disposed), el nodo de foco que pases debe ser propiedad de un stateful widget, así que no crees uno cada vez que se construye.

Si necesitas acceso al nodo de foco desde el descendiente de un widget Focus, puedes llamar a Focus.of(context) para obtener el nodo de foco del widget Focus más cercano al contexto dado. Si necesitas obtener el FocusNode de un widget Focus dentro de la misma función de construcción, usa un Builder para asegurarte de tener el contexto correcto. Esto se muestra en el siguiente ejemplo:

dart
@override
Widget build(BuildContext context) {
  return Focus(
    child: Builder(
      builder: (context) {
        final bool hasPrimary = Focus.of(context).hasPrimaryFocus;
        print('Building with primary focus: $hasPrimary');
        return const SizedBox(width: 100, height: 100);
      },
    ),
  );
}

Temporización (Timing)

#

Uno de los detalles del sistema de foco es que cuando se solicita el foco, este solo surte efecto después de que se completa la fase de construcción actual. Esto significa que los cambios de foco siempre se retrasan un fotograma, porque cambiar el foco puede hacer que partes arbitrarias del árbol de widgets se reconstruyan, incluidos los ancestros del widget que actualmente solicita el foco. Debido a que los descendientes no pueden ensuciar a sus ancestros, tiene que suceder entre fotogramas, para que cualquier cambio necesario pueda suceder en el siguiente fotograma.

Widget FocusScope

#

El widget FocusScope es una versión especial del widget Focus que gestiona un FocusScopeNode en lugar de un FocusNode. El FocusScopeNode es un nodo especial en el árbol de foco que sirve como mecanismo de agrupación para los nodos de foco en un subárbol. El recorrido del foco permanece dentro de un alcance de foco a menos que un nodo fuera del alcance sea enfocado explícitamente.

El alcance de foco también realiza un seguimiento del foco actual y el historial de los nodos enfocados dentro de su subárbol. De esa manera, si un nodo libera el foco o se elimina cuando tenía el foco, el foco se puede devolver al nodo que tenía el foco previamente.

Los alcances de foco también sirven como un lugar al que devolver el foco si ninguno de los descendientes tiene el foco. Esto permite que el código de recorrido de foco tenga un contexto inicial para encontrar el siguiente (o primer) control enfocable al que moverse.

Si enfocas un nodo de alcance de foco, primero intenta enfocar el nodo actual o el más recientemente enfocado en su subárbol, o el nodo en su subárbol que solicitó autofocus (si lo hay). Si no existe tal nodo, recibe el foco él mismo.

Widget FocusableActionDetector

#

El FocusableActionDetector es un widget que combina la funcionalidad de Actions, Shortcuts, MouseRegion y un widget Focus para crear un detector que define acciones y vinculaciones de teclas, y proporciona callbacks para manejar los resaltados de foco y desplazamientos del mouse. Es lo que usan los controles de Flutter para implementar todos estos aspectos de los controles. Simplemente está implementado utilizando los widgets constituyentes, por lo que si no necesitas toda su funcionalidad, puedes usar solo los que necesites, pero es una forma conveniente de incorporar estos comportamientos en tus controles personalizados.

Controlar el recorrido del foco

#

Una vez que una aplicación tiene la capacidad de enfocarse, lo siguiente que muchas apps quieren hacer es permitir que el usuario controle el foco mediante el teclado u otro dispositivo de entrada. El ejemplo más común de esto es el "recorrido por tabulación", donde el usuario presiona Tab para ir al "siguiente" control. Controlar lo que significa "siguiente" es el tema de esta sección. Este tipo de recorrido lo proporciona Flutter de forma predeterminada.

En un layout de cuadrícula simple, es bastante fácil decidir qué control es el siguiente. Si no estás al final de la fila, entonces es el de la derecha (o izquierda para configuraciones regionales de derecha a izquierda). Si estás al final de una fila, es el primer control de la siguiente fila. Desafortunadamente, las aplicaciones rara vez se disponen en cuadrículas, por lo que a menudo se necesita más orientación.

El algoritmo predeterminado en Flutter (ReadingOrderTraversalPolicy) para el recorrido del foco es bastante bueno: da la respuesta correcta para la mayoría de las aplicaciones. Sin embargo, siempre hay casos patológicos o casos en los que el contexto o el diseño requieren un orden diferente al que llega el algoritmo de ordenamiento predeterminado. Para esos casos, existen otros mecanismos para lograr el orden deseado.

Widget FocusTraversalGroup

#

El widget FocusTraversalGroup debe colocarse en el árbol alrededor de los subárboles de widgets que deben recorrerse por completo antes de pasar a otro widget o grupo de widgets. El solo hecho de agrupar widgets en grupos relacionados suele ser suficiente para resolver muchos problemas de ordenamiento del recorrido por tabulación. Si no es así, al grupo también se le puede asignar una FocusTraversalPolicy para determinar el ordenamiento dentro del grupo.

La ReadingOrderTraversalPolicy predeterminada suele ser suficiente, pero en los casos en que se necesita más control sobre el ordenamiento, se puede usar una OrderedTraversalPolicy. El argumento order del widget FocusTraversalOrder envuelto alrededor de los componentes enfocables determina el orden. El orden puede ser cualquier subclase de FocusOrder, pero se proporcionan NumericFocusOrder y LexicalFocusOrder.

Si ninguna de las políticas de recorrido de foco proporcionadas es suficiente para tu aplicación, también podrías escribir tu propia política y usarla para determinar cualquier ordenamiento personalizado que desees.

Aquí hay un ejemplo de cómo usar el widget FocusTraversalOrder para recorrer una fila de botones en el orden DOS, UNO, TRES usando NumericFocusOrder.

dart
class OrderedButtonRow extends StatelessWidget {
  const OrderedButtonRow({super.key});

  @override
  Widget build(BuildContext context) {
    return FocusTraversalGroup(
      policy: OrderedTraversalPolicy(),
      child: Row(
        children: <Widget>[
          const Spacer(),
          FocusTraversalOrder(
            order: const NumericFocusOrder(2),
            child: TextButton(child: const Text('ONE'), onPressed: () {}),
          ),
          const Spacer(),
          FocusTraversalOrder(
            order: const NumericFocusOrder(1),
            child: TextButton(child: const Text('TWO'), onPressed: () {}),
          ),
          const Spacer(),
          FocusTraversalOrder(
            order: const NumericFocusOrder(3),
            child: TextButton(child: const Text('THREE'), onPressed: () {}),
          ),
          const Spacer(),
        ],
      ),
    );
  }
}

FocusTraversalPolicy

#

La FocusTraversalPolicy es el objeto que determina qué widget es el siguiente, dada una solicitud y el nodo de foco actual. Las solicitudes (funciones miembro) son cosas como findFirstFocus, findLastFocus, next, previous, e inDirection.

FocusTraversalPolicy es la clase base abstracta para políticas concretas, como ReadingOrderTraversalPolicy, OrderedTraversalPolicy y las clases DirectionalFocusTraversalPolicyMixin.

Para usar una FocusTraversalPolicy, le das una a un FocusTraversalGroup, el cual determina el subárbol de widgets en el que la política será efectiva. Rara vez se llama directamente a las funciones miembro de la clase: están destinadas a ser utilizadas por el sistema de foco.

El administrador de foco (focus manager)

#

El FocusManager mantiene el foco primario actual del sistema. Solo tiene unas pocas partes de API que son útiles para los usuarios del sistema de foco. Una es la propiedad FocusManager.instance.primaryFocus, que contiene el nodo de foco actualmente enfocado y también es accesible desde el campo global primaryFocus.

Otras propiedades útiles son FocusManager.instance.highlightMode y FocusManager.instance.highlightStrategy. Estas son utilizadas por widgets que necesitan cambiar entre un modo "táctil" y un modo "tradicional" (mouse y teclado) para sus resaltados de foco. Cuando un usuario usa el toque para navegar, el resaltado de foco suele estar oculto, y cuando cambia a un mouse o teclado, el resaltado de foco debe mostrarse nuevamente para que sepa qué está enfocado. La highlightStrategy le dice al administrador de foco cómo interpretar los cambios en el modo de uso del dispositivo: puede cambiar automáticamente entre los dos en función de los eventos de entrada más recientes, o puede bloquearse en modos táctil o tradicional. Los widgets proporcionados en Flutter ya saben cómo usar esta información, por lo que solo la necesitas si estás escribiendo tus propios controles desde cero. Puedes usar el callback addHighlightModeListener para escuchar los cambios en el modo de resaltado.