Saltar al contenido principal

Crear un FAB expandible

Cómo implementar un FAB que se expande a múltiples botones al tocarlo.

Un Botón de Acción Flotante (FAB, por sus siglas en inglés) es un botón redondo que flota cerca de la parte inferior derecha de un área de contenido. Este botón representa la acción principal para el contenido correspondiente, pero a veces no hay una acción principal. En su lugar, hay algunas acciones críticas que el usuario podría realizar. En este caso, podrías crear un FAB expandible como el que se muestra en la siguiente figura. Cuando se presiona, este FAB expandible genera múltiples otros botones de acción. Cada botón corresponde a una de esas acciones críticas.

La siguiente animación muestra el comportamiento de la app:

Expandir y colapsar el FAB

Crear un widget ExpandableFab

#

Comienza creando un nuevo widget stateful llamado ExpandableFab. Este widget muestra el FAB principal y coordina la expansión y el colapso de los otros botones de acción. El widget toma parámetros para determinar si el ExpandedFab comienza o no en la posición expandida, cuál es la distancia máxima de cada botón de acción, y una lista de hijos. Usarás la lista más adelante para proporcionar los otros botones de acción.

dart
@immutable
class ExpandableFab extends StatefulWidget {
  const ExpandableFab({
    super.key,
    this.initialOpen,
    required this.distance,
    required this.children,
  });

  final bool? initialOpen;
  final double distance;
  final List<Widget> children;

  @override
  State<ExpandableFab> createState() => _ExpandableFabState();
}

class _ExpandableFabState extends State<ExpandableFab> {
  @override
  Widget build(BuildContext context) {
    return const SizedBox();
  }
}

Desvanecimiento cruzado (cross-fade) del FAB

#

El ExpandableFab muestra un botón azul de edición cuando está colapsado y un botón blanco de cierre cuando está expandido. Al expandirse y colapsarse, estos dos botones se escalan y se desvanecen entre sí.

Implementar el desvanecimiento cruzado de expansión y colapso entre los dos FABs diferentes.

dart
class _ExpandableFabState extends State<ExpandableFab> {
  bool _open = false;

  @override
  void initState() {
    super.initState();
    _open = widget.initialOpen ?? false;
  }

  void _toggle() {
    setState(() {
      _open = !_open;
    });
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox.expand(
      child: Stack(
        alignment: Alignment.bottomRight,
        clipBehavior: Clip.none,
        children: [_buildTapToCloseFab(), _buildTapToOpenFab()],
      ),
    );
  }

  Widget _buildTapToCloseFab() {
    return SizedBox(
      width: 56,
      height: 56,
      child: Center(
        child: Material(
          shape: const CircleBorder(),
          clipBehavior: Clip.antiAlias,
          elevation: 4,
          child: InkWell(
            onTap: _toggle,
            child: Padding(
              padding: const EdgeInsets.all(8),
              child: Icon(Icons.close, color: Theme.of(context).primaryColor),
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildTapToOpenFab() {
    return IgnorePointer(
      ignoring: _open,
      child: AnimatedContainer(
        transformAlignment: Alignment.center,
        transform: Matrix4.diagonal3Values(
          _open ? 0.7 : 1.0,
          _open ? 0.7 : 1.0,
          1.0,
        ),
        duration: const Duration(milliseconds: 250),
        curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
        child: AnimatedOpacity(
          opacity: _open ? 0.0 : 1.0,
          curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
          duration: const Duration(milliseconds: 250),
          child: FloatingActionButton(
            onPressed: _toggle,
            child: const Icon(Icons.create),
          ),
        ),
      ),
    );
  }
}

El botón de abrir se sitúa encima del botón de cerrar dentro de un Stack, permitiendo la apariencia visual de un desvanecimiento cruzado a medida que el botón superior aparece y desaparece.

Para lograr la animación de desvanecimiento cruzado, el botón de abrir utiliza un AnimatedContainer con una transformación de escala y un AnimatedOpacity. El botón de abrir se reduce de tamaño y se desvanece cuando el ExpandableFab pasa de colapsado a expandido. Luego, el botón de abrir se amplía y aparece gradualmente cuando el ExpandableFab pasa de expandido a colapsado.

Notarás que el botón de abrir está envuelto con un widget IgnorePointer. Esto se debe a que el botón de abrir siempre existe, incluso cuando es transparente. Sin el IgnorePointer, el botón de abrir siempre recibe el evento de toque, incluso cuando el botón de cerrar está visible.

Crear un widget ActionButton

#

Cada uno de los botones que se expanden desde el ExpandableFab tienen el mismo diseño. Son círculos azules con iconos blancos. Más precisamente, el color de fondo del botón es el color ColorScheme.secondary, y el color del icono es ColorScheme.onSecondary.

Define un nuevo stateless widget llamado ActionButton para mostrar estos botones redondos.

dart
@immutable
class ActionButton extends StatelessWidget {
  const ActionButton({super.key, this.onPressed, required this.icon});

  final VoidCallback? onPressed;
  final Widget icon;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Material(
      shape: const CircleBorder(),
      clipBehavior: Clip.antiAlias,
      color: theme.colorScheme.secondary,
      elevation: 4,
      child: IconButton(
        onPressed: onPressed,
        icon: icon,
        color: theme.colorScheme.onSecondary,
      ),
    );
  }
}

Pasa algunas instancias de este nuevo widget ActionButton a tu ExpandableFab.

dart
floatingActionButton: ExpandableFab(
  distance: 112,
  children: [
    ActionButton(
      onPressed: () => _showAction(context, 0),
      icon: const Icon(Icons.format_size),
    ),
    ActionButton(
      onPressed: () => _showAction(context, 1),
      icon: const Icon(Icons.insert_photo),
    ),
    ActionButton(
      onPressed: () => _showAction(context, 2),
      icon: const Icon(Icons.videocam),
    ),
  ],
),

Expandir y colapsar los botones de acción

#

Los ActionButton hijos deberían salir volando desde debajo del FAB abierto cuando se expande. Luego, los ActionButton hijos deberían volver a meterse debajo del FAB abierto cuando se colapsa. Este movimiento requiere un posicionamiento (x,y) explícito de cada ActionButton y una Animation para coreografiar los cambios en esas posiciones (x,y) a lo largo del tiempo.

Introduce un AnimationController y una Animation para controlar el ritmo al que los diversos ActionButtons se expanden y colapsan.

dart
class _ExpandableFabState extends State<ExpandableFab>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _expandAnimation;
  bool _open = false;

  @override
  void initState() {
    super.initState();
    _open = widget.initialOpen ?? false;
    _controller = AnimationController(
      value: _open ? 1.0 : 0.0,
      duration: const Duration(milliseconds: 250),
      vsync: this,
    );
    _expandAnimation = CurvedAnimation(
      curve: Curves.fastOutSlowIn,
      reverseCurve: Curves.easeOutQuad,
      parent: _controller,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _toggle() {
    setState(() {
      _open = !_open;
      if (_open) {
        _controller.forward();
      } else {
        _controller.reverse();
      }
    });
  }
  }

A continuación, introduce un nuevo stateless widget llamado _ExpandingActionButton, y configura este widget para animar y posicionar un ActionButton individual. El ActionButton se proporciona como un Widget genérico llamado child.

dart
@immutable
class _ExpandingActionButton extends StatelessWidget {
  const _ExpandingActionButton({
    required this.directionInDegrees,
    required this.maxDistance,
    required this.progress,
    required this.child,
  });

  final double directionInDegrees;
  final double maxDistance;
  final Animation<double> progress;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: progress,
      builder: (context, child) {
        final offset = Offset.fromDirection(
          directionInDegrees * (math.pi / 180.0),
          progress.value * maxDistance,
        );
        return Positioned(
          right: 4.0 + offset.dx,
          bottom: 4.0 + offset.dy,
          child: Transform.rotate(
            angle: (1.0 - progress.value) * math.pi / 2,
            child: child!,
          ),
        );
      },
      child: FadeTransition(opacity: progress, child: child),
    );
  }
}

La parte más importante de _ExpandingActionButton es el widget Positioned, que posiciona al child en una coordenada (x,y) específica dentro del Stack circundante. El AnimatedBuilder hace que el widget Positioned se vuelva a construir cada vez que cambia la animación. El widget FadeTransition orquesta la aparición y desaparición de cada ActionButton a medida que se expanden y colapsan, respectivamente.

Finalmente, usa el nuevo widget _ExpandingActionButton dentro del ExpandableFab para completar el ejercicio.

dart
class _ExpandableFabState extends State<ExpandableFab>
    with SingleTickerProviderStateMixin {
  @override
  Widget build(BuildContext context) {
    return SizedBox.expand(
      child: Stack(
        alignment: Alignment.bottomRight,
        clipBehavior: Clip.none,
        children: [
          _buildTapToCloseFab(),
          ..._buildExpandingActionButtons(),
          _buildTapToOpenFab(),
        ],
      ),
    );
  }

  List<Widget> _buildExpandingActionButtons() {
    final children = <Widget>[];
    final count = widget.children.length;
    final step = 90.0 / (count - 1);
    for (
      var i = 0, angleInDegrees = 0.0;
      i < count;
      i++, angleInDegrees += step
    ) {
      children.add(
        _ExpandingActionButton(
          directionInDegrees: angleInDegrees,
          maxDistance: widget.distance,
          progress: _expandAnimation,
          child: widget.children[i],
        ),
      );
    }
    return children;
  }
  }

¡Felicitaciones! Ahora tienes un FAB expandible.

Ejemplo interactivo

#

Ejecutar la app:

  • Haz clic en el FAB en la esquina inferior derecha, representado con un icono de Edición. Este se despliega en 3 botones y es reemplazado por un botón de cerrar, representado por una X.
  • Haz clic en el botón de cerrar para ver cómo los botones expandidos vuelan de regreso al FAB original y la X es reemplazada por el icono de Edición.
  • Expande el FAB de nuevo, y haz clic en cualquiera de los 3 botones satélite para ver un diálogo que representa la acción de ese botón.