Saltar al contenido principal

Usando Actions y Shortcuts

Cómo usar Actions y Shortcuts en tu app de Flutter.

Esta página describe cómo vincular eventos físicos del teclado a acciones en la interfaz de usuario. Por ejemplo, si quieres definir atajos de teclado en tu aplicación, esta página es para ti.

Resumen

#

Para que una aplicación GUI haga algo, tiene que tener acciones: los usuarios quieren decirle a la aplicación que haga algo. Las acciones suelen ser funciones simples que realizan directamente la acción (como establecer un valor o guardar un archivo). En una aplicación más grande, sin embargo, las cosas son más complejas: el código para invocar la acción, y el código para la acción misma podrían necesitar estar en diferentes lugares. Los accesos directos (vinculaciones de teclas) pueden necesitar definirse a un nivel que no sepa nada sobre las acciones que invocan.

Ahí es donde entra el sistema de acciones y atajos de Flutter. Permite a los desarrolladores definir acciones que cumplan con los intents vinculados a ellas. En este contexto, un intent es una acción genérica que el usuario desea realizar, y una instancia de la clase Intent representa estos intents del usuario en Flutter. Un Intent puede ser de propósito general, cumplido por diferentes acciones en diferentes contextos. Un Action puede ser un simple callback (como en el caso de CallbackAction) o algo más complejo que se integre con arquitecturas completas de deshacer/rehacer (por ejemplo) u otra lógica.

Diagrama de uso de Shortcuts

Los Shortcuts son vinculaciones de teclas que se activan al presionar una tecla o combinación de teclas. Las combinaciones de teclas residen en una tabla con su intent vinculado. Cuando el widget Shortcuts los invoca, envía su intent correspondiente al subsistema de acciones para su cumplimiento.

Para ilustrar los conceptos en acciones y atajos, este artículo crea una app sencilla que permite a un usuario seleccionar y copiar texto en un campo de texto usando tanto botones como atajos.

¿Por qué separar Actions de Intents?

#

Quizás te preguntes: ¿por qué no mapear directamente una combinación de teclas a una acción? ¿Por qué tener intents? Esto se debe a que es útil tener una separación de incumbencias entre dónde están las definiciones de mapeo de teclas (a menudo a un nivel alto), y dónde están las definiciones de acciones (a menudo a un nivel bajo), y porque es importante poder tener una sola combinación de teclas mapeada a una operación pretendida en una app, y hacer que se adapte automáticamente a cualquiera que sea la acción que cumple esa operación pretendida para el contexto enfocado.

Por ejemplo, Flutter tiene un widget ActivateIntent que mapea cada tipo de control a su versión correspondiente de un ActivateAction (y que ejecuta el código que activa el control). Este código a menudo necesita un acceso bastante privado para hacer su trabajo. Si la capa adicional de indirección que proporcionan los Intents no existiera, sería necesario elevar la definición de las acciones hacia donde la instancia definitoria del widget Shortcuts pudiera verlas, haciendo que los atajos tengan más conocimiento del necesario sobre qué acción invocar, y tengan acceso o proporcionen un State que no necesariamente tendrían o necesitarían de otro modo. Esto permite que tu código separe las dos preocupaciones para ser más independiente.

Los intents configuran una acción para que la misma acción pueda servir para múltiples usos. Un ejemplo de esto es DirectionalFocusIntent, que toma una dirección para mover el foco, lo que permite que el DirectionalFocusAction sepa hacia qué dirección mover el foco. Solo ten cuidado: no pases State en el Intent que se aplique a todas las invocaciones de un Action: ese tipo de State debe pasarse al constructor del propio Action, para evitar que el Intent necesite saber demasiado.

¿Por qué no usar callbacks?

#

También podrías preguntarte: ¿por qué no usar simplemente un callback en lugar de un objeto Action? La razón principal es que es útil que las acciones decidan si están habilitadas implementando isEnabled. Además, a menudo es de gran ayuda si las vinculaciones de teclas, y la implementación de esas vinculaciones, están en diferentes lugares.

Si todo lo que necesitas son callbacks sin la flexibilidad de Actions y Shortcuts, puedes usar el widget CallbackShortcuts:

dart
@override
Widget build(BuildContext context) {
  return CallbackShortcuts(
    bindings: <ShortcutActivator, VoidCallback>{
      const SingleActivator(LogicalKeyboardKey.arrowUp): () {
        setState(() => count = count + 1);
      },
      const SingleActivator(LogicalKeyboardKey.arrowDown): () {
        setState(() => count = count - 1);
      },
    },
    child: Focus(
      autofocus: true,
      child: Column(
        children: <Widget>[
          const Text('Press the up arrow key to add to the counter'),
          const Text('Press the down arrow key to subtract from the counter'),
          Text('count: $count'),
        ],
      ),
    ),
  );
}

Shortcuts

#

Como verás a continuación, las acciones son útiles por sí solas, pero el caso de uso más común implica vincularlas a un atajo de teclado. Para esto sirve el widget Shortcuts.

Se inserta en la jerarquía de widgets para definir combinaciones de teclas que representan la intención del usuario cuando se presiona esa combinación de teclas. Para convertir ese propósito pretendido para la combinación de teclas en una acción concreta, el widget Actions se utiliza para mapear el Intent a un Action. Por ejemplo, puedes definir un SelectAllIntent y vincularlo a tu propio SelectAllAction o a tu CanvasSelectAllAction, y a partir de esa única vinculación de tecla, el sistema invoca cualquiera de los dos, dependiendo de qué parte de tu aplicación tenga el foco. Veamos cómo funciona la parte de vinculación de teclas:

dart
@override
Widget build(BuildContext context) {
  return Shortcuts(
    shortcuts: <LogicalKeySet, Intent>{
      LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
          const SelectAllIntent(),
    },
    child: Actions(
      dispatcher: LoggingActionDispatcher(),
      actions: <Type, Action<Intent>>{
        SelectAllIntent: SelectAllAction(model),
      },
      child: Builder(
        builder: (context) => TextButton(
          onPressed: Actions.handler<SelectAllIntent>(
            context,
            const SelectAllIntent(),
          ),
          child: const Text('SELECT ALL'),
        ),
      ),
    ),
  );
}

El mapa proporcionado a un widget Shortcuts mapea un LogicalKeySet (o un ShortcutActivator, consulta la nota a continuación) a una instancia de Intent. El conjunto de teclas lógicas define un conjunto de una o más teclas, y el intent indica el propósito pretendido de la pulsación de tecla. El widget Shortcuts busca pulsaciones de teclas en el mapa para encontrar una instancia de Intent, la cual le entrega al método invoke() de la acción.

El ShortcutManager

#

El gestor de atajos (shortcut manager), un objeto de vida más larga que el widget Shortcuts, transmite los eventos de teclado cuando los recibe. Contiene la lógica para decidir cómo manejar las teclas, la lógica para subir por el árbol y encontrar otros mapeos de atajos, y mantiene un mapa de combinaciones de teclas a intents.

Si bien el comportamiento predeterminado del ShortcutManager suele ser el deseado, el widget Shortcuts acepta un ShortcutManager del cual puedes heredar para personalizar su funcionalidad.

Por ejemplo, si quisieras registrar cada tecla que manejó un widget Shortcuts, podrías crear un LoggingShortcutManager:

dart
class LoggingShortcutManager extends ShortcutManager {
  @override
  KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
    final KeyEventResult result = super.handleKeypress(context, event);
    if (result == KeyEventResult.handled) {
      print('Handled shortcut $event in $context');
    }
    return result;
  }
}

Ahora, cada vez que el widget Shortcuts maneja un atajo, imprime el evento de tecla y el contexto relevante.

Actions

#

Las Actions permiten la definición de operaciones que la aplicación puede realizar al invocarlas con un Intent. Las acciones se pueden habilitar o deshabilitar, y reciben la instancia de intent que las invocó como argumento para permitir su configuración por parte del intent.

Definiendo acciones

#

Las acciones, en su forma más simple, son solo subclases de Action<Intent> con un método invoke(). Aquí hay una acción simple que simplemente invoca una función en el modelo proporcionado:

dart
class SelectAllAction extends Action<SelectAllIntent> {
  SelectAllAction(this.model);

  final Model model;

  @override
  void invoke(covariant SelectAllIntent intent) => model.selectAll();
}

O, si da demasiada pereza crear una nueva clase, usa un CallbackAction:

dart
CallbackAction(onInvoke: (intent) => model.selectAll());

Una vez que tienes una acción, la agregas a tu aplicación usando el widget Actions, el cual toma un mapa de tipos de Intent a Actions:

dart
@override
Widget build(BuildContext context) {
  return Actions(
    actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
    child: child,
  );
}

El widget Shortcuts utiliza el contexto del widget Focus y Actions.invoke para encontrar qué acción invocar. Si el widget Shortcuts no encuentra un tipo de intent coincidente en el primer widget Actions encontrado, considera el siguiente widget Actions ancestro, y así sucesivamente, hasta llegar a la raíz del árbol de widgets, o encontrar un tipo de intent coincidente e invocar la acción correspondiente.

Invocando Actions

#

El sistema de acciones tiene varias formas de invocar acciones. Con diferencia, la forma más común es mediante el uso de un widget Shortcuts cubierto en la sección anterior, pero existen otras formas de interrogar al subsistema de acciones e invocar una acción. Es posible invocar acciones que no están vinculadas a teclas.

Por ejemplo, para encontrar una acción asociada con un intent, puedes usar:

dart
Action<SelectAllIntent>? selectAll = Actions.maybeFind<SelectAllIntent>(
  context,
);

Esto devuelve un Action asociado con el tipo SelectAllIntent si hay uno disponible en el context dado. Si no hay uno disponible, devuelve null. Si un Action asociado siempre debería estar disponible, entonces usa find en lugar de maybeFind, el cual lanza una excepción cuando no encuentra un tipo de Intent coincidente.

Para invocar la acción (si existe), llama a:

dart
Object? result;
if (selectAll != null) {
  result = Actions.of(
    context,
  ).invokeAction(selectAll, const SelectAllIntent());
}

Combina eso en una sola llamada con lo siguiente:

dart
Object? result = Actions.maybeInvoke<SelectAllIntent>(
  context,
  const SelectAllIntent(),
);

A veces quieres invocar una acción como resultado de presionar un botón u otro control. Puedes hacer esto con la función Actions.handler. Si el intent tiene un mapeo a una acción habilitada, la función Actions.handler crea un closure manejador. Sin embargo, si no tiene un mapeo, devuelve null. Esto permite que el botón se deshabilite si no hay una acción habilitada que coincida en el contexto.

dart
@override
Widget build(BuildContext context) {
  return Actions(
    actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
    child: Builder(
      builder: (context) => TextButton(
        onPressed: Actions.handler<SelectAllIntent>(
          context,
          SelectAllIntent(controller: controller),
        ),
        child: const Text('SELECT ALL'),
      ),
    ),
  );
}

El widget Actions solo invoca acciones cuando isEnabled(Intent intent) devuelve true, permitiendo que la acción decida si el despachador debe considerarla para su invocación. Si la acción no está habilitada, la acción no se invoca y el framework deja de buscar acciones coincidentes en ese punto.

El ejemplo anterior usa un Builder porque Actions.handler y Actions.invoke (por ejemplo) solo encuentran acciones en el context proporcionado, y si el ejemplo pasa el context dado a la función build, el framework comienza a buscar por encima del widget actual. El uso de un Builder permite que el framework encuentre las acciones definidas en la misma función build.

Puedes invocar una acción sin necesitar un BuildContext, pero dado que el widget Actions requiere un contexto para encontrar una acción habilitada a invocar, necesitas proporcionar uno, ya sea creando tu propia instancia de Action, o encontrando una en un contexto apropiado con Actions.find.

Para invocar la acción, pasa la acción al método invoke en un ActionDispatcher, ya sea uno creado por ti mismo, o uno obtenido de un widget Actions existente usando el método Actions.of(context). Comprueba si la acción está habilitada antes de llamar a invoke. Por supuesto, también puedes llamar a invoke en la propia acción, pasando un Intent, pero entonces te estás excluyendo de cualquier servicio que un despachador de acciones pueda proporcionar (como registro, deshacer/rehacer, y demás).

Despachadores de acciones

#

La mayor parte del tiempo, solo quieres invocar una acción, hacer que realice su trabajo y olvidarte de ella. A veces, sin embargo, es posible que desees registrar las acciones ejecutadas.

Aquí es donde entra el reemplazo del ActionDispatcher predeterminado por un despachador personalizado. Pasas tu ActionDispatcher al widget Actions, y este invoca acciones de cualquier widget Actions por debajo de ese que no defina un despachador propio.

Lo primero que hace Actions al invocar una acción es buscar el ActionDispatcher y pasarle la acción para su invocación. Si no hay ninguno, crea un ActionDispatcher predeterminado que simplemente invoca la acción.

Sin embargo, si deseas un registro de todas las acciones invocadas, puedes crear tu propio LoggingActionDispatcher para hacer el trabajo:

dart
class LoggingActionDispatcher extends ActionDispatcher {
  @override
  Object? invokeAction(
    covariant Action<Intent> action,
    covariant Intent intent, [
    BuildContext? context,
  ]) {
    print('Action invoked: $action($intent) from $context');
    super.invokeAction(action, intent, context);

    return null;
  }

  @override
  (bool, Object?) invokeActionIfEnabled(
    covariant Action<Intent> action,
    covariant Intent intent, [
    BuildContext? context,
  ]) {
    print('Action invoked: $action($intent) from $context');
    return super.invokeActionIfEnabled(action, intent, context);
  }
}

Luego le pasas eso a tu widget Actions de nivel superior:

dart
@override
Widget build(BuildContext context) {
  return Actions(
    dispatcher: LoggingActionDispatcher(),
    actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
    child: Builder(
      builder: (context) => TextButton(
        onPressed: Actions.handler<SelectAllIntent>(
          context,
          const SelectAllIntent(),
        ),
        child: const Text('SELECT ALL'),
      ),
    ),
  );
}

Esto registra cada acción a medida que se ejecuta, así:

flutter: Action invoked: SelectAllAction#906fc(SelectAllIntent#a98e3) from Builder(dependencies: _[ActionsMarker])

Juntando todo

#

La combinación de Actions y Shortcuts es potente: puedes definir intents genéricos que se mapean a acciones específicas a nivel de widget. Aquí hay una app sencilla que ilustra los conceptos descritos anteriormente. La app crea un campo de texto que también tiene botones de "seleccionar todo" y "copiar al portapapeles" junto a él. Los botones invocan acciones para realizar su trabajo. Todas las acciones e atujos invocados quedan registrados.