Saltar al contenido principal

Crear un efecto de desplazamiento parallax

Cómo implementar un efecto de paralaje en desplazamiento.

Cuando te desplazas por una lista de tarjetas (que contienen imágenes, por ejemplo) en una aplicación, es posible que notes que esas imágenes parecen desplazarse más lentamente que el resto de la pantalla. Casi parece como si las tarjetas de la lista estuvieran en primer plano, pero las imágenes en sí estuvieran situadas al fondo en la distancia. Este efecto se conoce como paralaje.

En esta receta, crearás el efecto de paralaje construyendo una lista de tarjetas (con esquinas redondeadas que contienen algo de texto). Cada tarjeta también contiene una imagen. A medida que las tarjetas se deslizan hacia arriba en la pantalla, las imágenes dentro de cada tarjeta se deslizan hacia abajo.

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

Desplazamiento de paralaje

Crear una lista para contener los elementos de paralaje

#

Para mostrar una lista de imágenes con desplazamiento de paralaje, primero debes mostrar una lista.

Crea un nuevo Widget Stateless llamado ParallaxRecipe. Dentro de ParallaxRecipe, construye un árbol de Widgets con un SingleChildScrollView y una Column, lo que forma una lista.

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

  @override
  Widget build(BuildContext context) {
    return const SingleChildScrollView(child: Column(children: []));
  }
}

Mostrar elementos con texto y una imagen estática

#

Cada elemento de la lista muestra una imagen de fondo con forma de rectángulo redondeado, que representa uno de los siete lugares del mundo. Superpuesto sobre esa imagen de fondo está el nombre de la ubicación y su país, colocados en la parte inferior izquierda. Entre la imagen de fondo y el texto hay un gradiente oscuro, lo que mejora la legibilidad del texto sobre el fondo.

Implementa un Widget Stateless llamado LocationListItem que consista en los elementos visuales mencionados anteriormente. Por ahora, utiliza un Widget Image estático para el fondo. Más adelante, reemplazarás ese Widget por una versión con paralaje.

dart
@immutable
class LocationListItem extends StatelessWidget {
  const LocationListItem({
    super.key,
    required this.imageUrl,
    required this.name,
    required this.country,
  });

  final String imageUrl;
  final String name;
  final String country;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
      child: AspectRatio(
        aspectRatio: 16 / 9,
        child: ClipRRect(
          borderRadius: BorderRadius.circular(16),
          child: Stack(
            children: [
              _buildParallaxBackground(context),
              _buildGradient(),
              _buildTitleAndSubtitle(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildParallaxBackground(BuildContext context) {
    return Positioned.fill(child: Image.network(imageUrl, fit: BoxFit.cover));
  }

  Widget _buildGradient() {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Colors.black.withValues(alpha: 0.7)],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.6, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle() {
    return Positioned(
      left: 20,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            name,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          Text(
            country,
            style: const TextStyle(color: Colors.white, fontSize: 14),
          ),
        ],
      ),
    );
  }
}

A continuación, añade los elementos de la lista a tu Widget ParallaxRecipe.

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

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          for (final location in locations)
            LocationListItem(
              imageUrl: location.imageUrl,
              name: location.name,
              country: location.place,
            ),
        ],
      ),
    );
  }
}

Ahora tienes una lista típica y desplazable de tarjetas que muestra siete ubicaciones únicas en el mundo. En el siguiente paso, añadirás un efecto de paralaje a la imagen de fondo.

Implementar el efecto de paralaje

#

El efecto de desplazamiento de paralaje se logra empujando ligeramente la imagen de fondo en la dirección opuesta al resto de la lista. A medida que los elementos de la lista se deslizan hacia arriba en la pantalla, cada imagen de fondo se desliza ligeramente hacia abajo. Por el contrario, a medida que los elementos de la lista se deslizan hacia abajo en la pantalla, cada imagen de fondo se desliza ligeramente hacia arriba. Visualmente, esto resulta en paralaje.

El efecto de paralaje depende de la posición actual del elemento de la lista dentro de su Scrollable ancestro. A medida que cambia la posición de desplazamiento del elemento de la lista, la posición de la imagen de fondo del elemento de la lista también debe cambiar. Este es un problema interesante de resolver. La posición de un elemento de la lista dentro de Scrollable no está disponible hasta que se completa la fase de diseño (layout) de Flutter. Esto significa que la posición de la imagen de fondo debe determinarse en la fase de pintura (paint), que viene después de la fase de diseño. Afortunadamente, Flutter proporciona un Widget llam Flow, que está diseñado específicamente para darte control sobre la transformación de un Widget hijo inmediatamente antes de que se pinte. En otras palabras, puedes interceptar la fase de pintura y tomar el control para reposicionar tus Widgets hijos como quieras.

Envuelve tu Widget Image de fondo con un Widget Flow.

dart
Widget _buildParallaxBackground(BuildContext context) {
  return Flow(
    children: [Image.network(imageUrl, fit: BoxFit.cover)],
  );
}

Introduce un nuevo FlowDelegate llamado ParallaxFlowDelegate.

dart
Widget _buildParallaxBackground(BuildContext context) {
  return Flow(
    delegate: ParallaxFlowDelegate(),
    children: [Image.network(imageUrl, fit: BoxFit.cover)],
  );
}
dart
class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate();

  @override
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    // TODO: We'll add more to this later.
  }

  @override
  void paintChildren(FlowPaintingContext context) {
    // TODO: We'll add more to this later.
  }

  @override
  bool shouldRepaint(covariant FlowDelegate oldDelegate) {
    // TODO: We'll add more to this later.
    return true;
  }
}

Un FlowDelegate controla cómo se dimensionan sus hijos y dónde se pintan esos hijos. En este caso, tu Widget Flow tiene un solo hijo: la imagen de fondo. Esa imagen debe tener exactamente el mismo ancho que el Widget Flow.

Devuelve restricciones de ancho ajustadas para el hijo de tu imagen de fondo.

dart
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
  return BoxConstraints.tightFor(width: constraints.maxWidth);
}

Tus imágenes de fondo ahora tienen el tamaño adecuado, pero aún necesitas calcular la posición vertical de cada imagen de fondo en función de su posición de desplazamiento, y luego pintarla.

Hay tres piezas de información críticas que necesitas para calcular la posición deseada de una imagen de fondo:

  • Los límites del Scrollable ancestro
  • Los límites del elemento de lista individual
  • El tamaño de la imagen después de reducirla para que quepa en el elemento de la lista

Para buscar los límites del Scrollable, pasas un ScrollableState a tu FlowDelegate.

Para buscar los límites de tu elemento de lista individual, pasa el BuildContext de tu elemento de lista a tu FlowDelegate.

Para buscar el tamaño final de tu imagen de fondo, asigna una GlobalKey a tu Widget Image, y luego pasa esa GlobalKey a tu FlowDelegate.

Haz que esta información esté disponible para ParallaxFlowDelegate.

dart
@immutable
class LocationListItem extends StatelessWidget {
  final GlobalKey _backgroundImageKey = GlobalKey();

  Widget _buildParallaxBackground(BuildContext context) {
    return Flow(
      delegate: ParallaxFlowDelegate(
        scrollable: Scrollable.of(context),
        listItemContext: context,
        backgroundImageKey: _backgroundImageKey,
      ),
      children: [
        Image.network(imageUrl, key: _backgroundImageKey, fit: BoxFit.cover),
      ],
    );
  }
}
dart
class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate({
    required this.scrollable,
    required this.listItemContext,
    required this.backgroundImageKey,
  });

  final ScrollableState scrollable;
  final BuildContext listItemContext;
  final GlobalKey backgroundImageKey;
}

Teniendo toda la información necesaria para implementar el desplazamiento de paralaje, implementa el método shouldRepaint().

dart
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
  return scrollable != oldDelegate.scrollable ||
      listItemContext != oldDelegate.listItemContext ||
      backgroundImageKey != oldDelegate.backgroundImageKey;
}

Ahora, implementa los cálculos de diseño para el efecto de paralaje.

Primero, calcula la posición en píxeles de un elemento de la lista dentro de su Scrollable ancestro.

dart
@override
void paintChildren(FlowPaintingContext context) {
  // Calculate the position of this list item within the viewport.
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
    listItemBox.size.centerLeft(Offset.zero),
    ancestor: scrollableBox,
  );
}

Utiliza la posición en píxeles del elemento de la lista para calcular su porcentaje desde la parte superior del Scrollable. Un elemento de la lista en la parte superior del área desplazable debería producir 0%, y un elemento de la lista en la parte inferior del área desplazable debería producir 100%.

dart
@override
void paintChildren(FlowPaintingContext context) {
  // Calculate the position of this list item within the viewport.
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
    listItemBox.size.centerLeft(Offset.zero),
    ancestor: scrollableBox,
  );

  // Determine the percent position of this list item within the
  // scrollable area.
  final viewportDimension = scrollable.position.viewportDimension;
  final scrollFraction = (listItemOffset.dy / viewportDimension).clamp(
    0.0,
    1.0,
  );
  // ···
}

Utiliza el porcentaje de desplazamiento para calcular un Alignment. Al 0%, quieres Alignment(0.0, -1.0), y al 100%, quieres Alignment(0.0, 1.0). Estas coordenadas corresponden a la alineación superior e inferior, respectivamente.

dart
@override
void paintChildren(FlowPaintingContext context) {
  // Calculate the position of this list item within the viewport.
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
    listItemBox.size.centerLeft(Offset.zero),
    ancestor: scrollableBox,
  );

  // Determine the percent position of this list item within the
  // scrollable area.
  final viewportDimension = scrollable.position.viewportDimension;
  final scrollFraction = (listItemOffset.dy / viewportDimension).clamp(
    0.0,
    1.0,
  );

  // Calculate the vertical alignment of the background
  // based on the scroll percent.
  final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
}

Utiliza verticalAlignment, junto con el tamaño del elemento de la lista y el tamaño de la imagen de fondo, para producir un Rect que determine dónde debe posicionarse la imagen de fondo.

dart
@override
void paintChildren(FlowPaintingContext context) {
  // Calculate the position of this list item within the viewport.
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
    listItemBox.size.centerLeft(Offset.zero),
    ancestor: scrollableBox,
  );

  // Determine the percent position of this list item within the
  // scrollable area.
  final viewportDimension = scrollable.position.viewportDimension;
  final scrollFraction = (listItemOffset.dy / viewportDimension).clamp(
    0.0,
    1.0,
  );

  // Calculate the vertical alignment of the background
  // based on the scroll percent.
  final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

  // Convert the background alignment into a pixel offset for
  // painting purposes.
  final backgroundSize =
      (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
          .size;
  final listItemSize = context.size;
  final childRect = verticalAlignment.inscribe(
    backgroundSize,
    Offset.zero & listItemSize,
  );
}

Usando childRect, pinta la imagen de fondo con la transformación de traslación deseada. Es esta transformación a lo largo del tiempo lo que te da el efecto de paralaje.

dart
@override
void paintChildren(FlowPaintingContext context) {
  // Calculate the position of this list item within the viewport.
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
    listItemBox.size.centerLeft(Offset.zero),
    ancestor: scrollableBox,
  );

  // Determine the percent position of this list item within the
  // scrollable area.
  final viewportDimension = scrollable.position.viewportDimension;
  final scrollFraction = (listItemOffset.dy / viewportDimension).clamp(
    0.0,
    1.0,
  );

  // Calculate the vertical alignment of the background
  // based on the scroll percent.
  final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

  // Convert the background alignment into a pixel offset for
  // painting purposes.
  final backgroundSize =
      (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
          .size;
  final listItemSize = context.size;
  final childRect = verticalAlignment.inscribe(
    backgroundSize,
    Offset.zero & listItemSize,
  );

  // Paint the background.
  context.paintChild(
    0,
    transform: Transform.translate(
      offset: Offset(0.0, childRect.top),
    ).transform,
  );
}

Necesitas un detalle final para lograr el efecto de paralaje. El ParallaxFlowDelegate se repinta cuando cambian las entradas, pero el ParallaxFlowDelegate no se repinta cada vez que cambia la posición de desplazamiento.

Pasa el ScrollPosition de ScrollableState a la superclase FlowDelegate para que FlowDelegate se repinte cada vez que cambie ScrollPosition.

dart
class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate({
    required this.scrollable,
    required this.listItemContext,
    required this.backgroundImageKey,
  }) : super(repaint: scrollable.position);

¡Felicitaciones! Ahora tienes una lista de tarjetas con imágenes de fondo con desplazamiento de paralaje.

Ejemplo interactivo

#

Ejecutar la app:

  • Desplázate hacia arriba y hacia abajo para observar el efecto de paralaje.