Saltar al contenido principal

Crear un efecto de carga shimmer

Cómo implementar un efecto de carga shimmer.

Los tiempos de carga son inevitables en el desarrollo de aplicaciones. Desde la perspectiva de la experiencia de usuario (UX), lo más importante es mostrar a tus usuarios que se está realizando la carga. Un enfoque popular para comunicar a los usuarios que los datos se están cargando es mostrar un color de cromo con una animación de shimmer sobre las formas que se aproximan al tipo de contenido que se está cargando.

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

Gif que muestra la UI cargando

Esta receta comienza con los Widgets de contenido definidos y posicionados. También hay un Floating Action Button (FAB) en la esquina inferior derecha que alterna entre un modo de carga y un modo cargado para que puedas validar fácilmente tu implementación.

Dibujar las formas de shimmer

#

Las formas que brillan con el shimmer en este efecto son independientes del contenido real que finalmente se carga.

Por lo tanto, el objetivo es mostrar formas que representen el contenido final con la mayor precisión posible.

Mostrar formas precisas es fácil en situaciones donde el contenido tiene un límite claro. Por ejemplo, en esta receta, hay algunas imágenes circulares y algunas imágenes de rectángulos redondeados. Puedes dibujar formas que coincidan exactamente con los contornos de esas imágenes.

Por otro lado, considera el texto que aparece debajo de las imágenes de rectángulos redondeados. No sabrás cuántas líneas de texto existen hasta que se cargue el texto. Por lo tanto, no tiene sentido intentar dibujar un rectángulo para cada línea de texto. En su lugar, mientras se cargan los datos, dibujas un par de rectángulos redondeados muy delgados que representan el texto que aparecerá. El tamaño y la forma no coinciden del todo, pero eso está bien.

Comienza con los elementos de lista circulares en la parte superior de la pantalla. Asegúrate de que cada Widget CircleListItem muestre un círculo con un color mientras se carga la imagen.

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

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
      child: Container(
        width: 54,
        height: 54,
        decoration: const BoxDecoration(
          color: Colors.black,
          shape: BoxShape.circle,
        ),
        child: ClipOval(
          child: Image.network(
            'https://docs.flutter.dev/assets/images/'
            'exercise/split-check/Avatar1.jpg',
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
  }
}

Siempre que tus Widgets muestren algún tipo de forma, puedes aplicar el efecto shimmer de esta receta.

Al igual que con los Widgets CircleListItem, asegúrate de que los Widgets CardListItem muestren un color donde aparecerá la imagen. Además, en el Widget CardListItem, alterna entre la visualización del texto y los rectángulos en función del estado de carga actual.

dart
class CardListItem extends StatelessWidget {
  const CardListItem({super.key, required this.isLoading});

  final bool isLoading;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [_buildImage(), const SizedBox(height: 16), _buildText()],
      ),
    );
  }

  Widget _buildImage() {
    return AspectRatio(
      aspectRatio: 16 / 9,
      child: Container(
        width: double.infinity,
        decoration: BoxDecoration(
          color: Colors.black,
          borderRadius: BorderRadius.circular(16),
        ),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(16),
          child: Image.network(
            'https://docs.flutter.dev/assets/images/'
            'exercise/split-check/Food1.jpg',
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
  }

  Widget _buildText() {
    if (isLoading) {
      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            width: double.infinity,
            height: 24,
            decoration: BoxDecoration(
              color: Colors.black,
              borderRadius: BorderRadius.circular(16),
            ),
          ),
          const SizedBox(height: 16),
          Container(
            width: 250,
            height: 24,
            decoration: BoxDecoration(
              color: Colors.black,
              borderRadius: BorderRadius.circular(16),
            ),
          ),
        ],
      );
    } else {
      return const Padding(
        padding: EdgeInsets.symmetric(horizontal: 8),
        child: Text(
          'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do '
          'eiusmod tempor incididunt ut labore et dolore magna aliqua.',
        ),
      );
    }
  }
}

Tu UI ahora se renderiza de manera diferente según si se está cargando o ya se cargó. Al comentar temporalmente las URLs de las imágenes, puedes ver las dos formas en que se renderiza tu UI.

Gif que muestra la animación de shimmer

El siguiente objetivo es pintar todas las áreas coloreadas con un único gradiente que parezca un shimmer.

Pintar el gradiente de shimmer

#

La clave para el efecto logrado en esta receta es usar un Widget llamado ShaderMask. El Widget ShaderMask, como su nombre indica, aplica un shader a su hijo, pero solo en las áreas donde el hijo ya pintó algo. Por ejemplo, aplicarás un shader solo a las formas negras que configuraste anteriormente.

Define un gradiente lineal de color cromo que se aplique a las formas de shimmer.

dart
const _shimmerGradient = LinearGradient(
  colors: [Color(0xFFEBEBF4), Color(0xFFF4F4F4), Color(0xFFEBEBF4)],
  stops: [0.1, 0.3, 0.4],
  begin: Alignment(-1.0, -0.3),
  end: Alignment(1.0, 0.3),
  tileMode: TileMode.clamp,
);

Crea un nuevo Widget Stateful llamado ShimmerLoading que envuelva un Widget child dado con un ShaderMask. Configura el Widget ShaderMask para aplicar el gradiente de shimmer como un shader con un blendMode de srcATop. El modo de mezcla (blend mode) srcATop reemplaza cualquier color que tu Widget child haya pintado con el color del shader.

dart
class ShimmerLoading extends StatefulWidget {
  const ShimmerLoading({
    super.key,
    required this.isLoading,
    required this.child,
  });

  final bool isLoading;
  final Widget child;

  @override
  State<ShimmerLoading> createState() => _ShimmerLoadingState();
}

class _ShimmerLoadingState extends State<ShimmerLoading> {
  @override
  Widget build(BuildContext context) {
    if (!widget.isLoading) {
      return widget.child;
    }

    return ShaderMask(
      blendMode: BlendMode.srcATop,
      shaderCallback: (bounds) {
        return _shimmerGradient.createShader(bounds);
      },
      child: widget.child,
    );
  }
}

Envuelve tus Widgets CircleListItem con un Widget ShimmerLoading.

dart
Widget _buildTopRowItem() {
  return ShimmerLoading(isLoading: _isLoading, child: const CircleListItem());
}

Envuelve tus Widgets CardListItem con un Widget ShimmerLoading.

dart
Widget _buildListItem() {
  return ShimmerLoading(
    isLoading: _isLoading,
    child: CardListItem(isLoading: _isLoading),
  );
}

Cuando tus formas se están cargando, ahora muestran el gradiente de shimmer que devuelve el shaderCallback.

Este es un gran paso en la dirección correcta, pero hay un problema con la visualización de este gradiente. Cada Widget CircleListItem y cada Widget CardListItem muestra una nueva versión del gradiente. Para esta receta, toda la pantalla debería verse como una única gran superficie con shimmer. Resolverás este problema en el siguiente paso.

Pintar un gran shimmer

#

Para pintar un gran shimmer a lo largo de la pantalla, cada Widget ShimmerLoading necesita pintar el mismo gradiente de pantalla completa en función de la posición de ese Widget ShimmerLoading en la pantalla.

Para ser más precisos, en lugar de asumir que el shimmer debe ocupar toda la pantalla, debería haber alguna área que comparta el shimmer. Tal vez esa área ocupe toda la pantalla, o tal vez no. La forma de resolver este tipo de problemas en Flutter es definir otro Widget que se sitúe por encima de todos los Widgets ShimmerLoading en el árbol de Widgets, y llamarlo Shimmer. Luego, cada Widget ShimmerLoading obtiene una referencia al ancestro Shimmer y solicita el tamaño y gradiente deseados para mostrar.

Crea un nuevo Widget Stateful llamado Shimmer que reciba un LinearGradient y proporcione a los descendientes acceso a su objeto State.

dart
class Shimmer extends StatefulWidget {
  static ShimmerState? of(BuildContext context) {
    return context.findAncestorStateOfType<ShimmerState>();
  }

  const Shimmer({super.key, required this.linearGradient, this.child});

  final LinearGradient linearGradient;
  final Widget? child;

  @override
  ShimmerState createState() => ShimmerState();
}

class ShimmerState extends State<Shimmer> {
  @override
  Widget build(BuildContext context) {
    return widget.child ?? const SizedBox();
  }
}

Añade métodos a la clase ShimmerState para proporcionar acceso al linearGradient, al tamaño del RenderBox de ShimmerState, y buscar la posición de un descendiente dentro del ShimmerState's RenderBox.

dart
class ShimmerState extends State<Shimmer> {
  Gradient get gradient => LinearGradient(
    colors: widget.linearGradient.colors,
    stops: widget.linearGradient.stops,
    begin: widget.linearGradient.begin,
    end: widget.linearGradient.end,
  );

  bool get isSized =>
      (context.findRenderObject() as RenderBox?)?.hasSize ?? false;

  Size get size => (context.findRenderObject() as RenderBox).size;

  Offset getDescendantOffset({
    required RenderBox descendant,
    Offset offset = Offset.zero,
  }) {
    final shimmerBox = context.findRenderObject() as RenderBox;
    return descendant.localToGlobal(offset, ancestor: shimmerBox);
  }

  @override
  Widget build(BuildContext context) {
    return widget.child ?? const SizedBox();
  }
}

Envuelve todo el contenido de tu pantalla con el Widget Shimmer.

dart
class _ExampleUiLoadingAnimationState extends State<ExampleUiLoadingAnimation> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Shimmer(
        linearGradient: _shimmerGradient,
        child: ListView(
          // ListView Contents
        ),
      ),
    );
  }
}

Usa el Widget Shimmer dentro de tu Widget ShimmerLoading para pintar el gradiente compartido.

dart
class _ShimmerLoadingState extends State<ShimmerLoading> {
  @override
  Widget build(BuildContext context) {
    if (!widget.isLoading) {
      return widget.child;
    }

    // Collect ancestor shimmer information.
    final shimmer = Shimmer.of(context)!;
    if (!shimmer.isSized) {
      // The ancestor Shimmer widget isn't laid
      // out yet. Return an empty box.
      return const SizedBox();
    }
    final shimmerSize = shimmer.size;
    final gradient = shimmer.gradient;
    final offsetWithinShimmer = shimmer.getDescendantOffset(
      descendant: context.findRenderObject() as RenderBox,
    );

    return ShaderMask(
      blendMode: BlendMode.srcATop,
      shaderCallback: (bounds) {
        return gradient.createShader(
          Rect.fromLTWH(
            -offsetWithinShimmer.dx,
            -offsetWithinShimmer.dy,
            shimmerSize.width,
            shimmerSize.height,
          ),
        );
      },
      child: widget.child,
    );
  }
}

Tus Widgets ShimmerLoading ahora muestran un gradiente compartido que ocupa todo el espacio dentro del Widget Shimmer.

Animar el shimmer

#

El gradiente de shimmer necesita moverse para dar la apariencia de un brillo centelleante.

El LinearGradient tiene una propiedad llamada transform que se puede usar para transformar la apariencia del gradiente, por ejemplo, para moverlo horizontalmente. La propiedad transform acepta una instancia de GradientTransform.

Define una clase llamada _SlidingGradientTransform que implemente GradientTransform para lograr la apariencia de un deslizamiento horizontal.

dart
class _SlidingGradientTransform extends GradientTransform {
  const _SlidingGradientTransform({required this.slidePercent});

  final double slidePercent;

  @override
  Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
    return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
  }
}

El porcentaje de deslizamiento del gradiente cambia con el tiempo para crear la apariencia de movimiento. Para cambiar el porcentaje, configura un AnimationController en la clase ShimmerState.

dart
class ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {
  late AnimationController _shimmerController;

  @override
  void initState() {
    super.initState();

    _shimmerController = AnimationController.unbounded(vsync: this)
      ..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));
  }

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

Aplica el _SlidingGradientTransform al gradient usando el value de _shimmerController como el slidePercent.

dart
LinearGradient get gradient => LinearGradient(
  colors: widget.linearGradient.colors,
  stops: widget.linearGradient.stops,
  begin: widget.linearGradient.begin,
  end: widget.linearGradient.end,
  transform: _SlidingGradientTransform(
    slidePercent: _shimmerController.value,
  ),
);

El gradiente ahora se anima, pero tus Widgets ShimmerLoading individuales no se repintan cuando cambia el gradiente. Por lo tanto, parece que no ocurre nada.

Expón el _shimmerController de ShimmerState como un Listenable.

dart
Listenable get shimmerChanges => _shimmerController;

En ShimmerLoading, escucha los cambios en la propiedad shimmerChanges del ancestro ShimmerState, y repinta el gradiente de shimmer.

dart
class _ShimmerLoadingState extends State<ShimmerLoading> {
  Listenable? _shimmerChanges;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_shimmerChanges != null) {
      _shimmerChanges!.removeListener(_onShimmerChange);
    }
    _shimmerChanges = Shimmer.of(context)?.shimmerChanges;
    if (_shimmerChanges != null) {
      _shimmerChanges!.addListener(_onShimmerChange);
    }
  }

  @override
  void dispose() {
    _shimmerChanges?.removeListener(_onShimmerChange);
    super.dispose();
  }

  void _onShimmerChange() {
    if (widget.isLoading) {
      setState(() {
        // Update the shimmer painting.
      });
    }
  }
  }

¡Felicitaciones! Ahora tienes un efecto de shimmer animado a pantalla completa que se activa y desactiva a medida que se carga el contenido.

Ejemplo interactivo

#