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:
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.
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.
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.
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.
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.
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.
Widget _buildTopRowItem() {
return ShimmerLoading(isLoading: _isLoading, child: const CircleListItem());
}
Envuelve tus Widgets CardListItem con un Widget ShimmerLoading.
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.
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.
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.
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.
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.
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.
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.
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.
Listenable get shimmerChanges => _shimmerController;
En ShimmerLoading, escucha los cambios en la propiedad
shimmerChanges del ancestro ShimmerState,
y repinta el gradiente de shimmer.
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
#A menos que se indique lo contrario, la documentación de este sitio refleja Flutter 3.44.0. Página actualizada por última vez el 2026-05-05. Ver código fuente oreportar un problema.