Skip to main content

创建滚动视差效果

当您在应用中滚动卡片列表(例如包含图像的卡片)时,您可能会注意到这些图像的滚动速度似乎比屏幕上的其他内容慢。它看起来几乎像是列表中的卡片位于前景中,但图像本身则位于遥远的后景中。这种效果称为视差。

在这个食谱中,您将通过构建卡片列表(带有圆角和一些文本)来创建视差效果。每个卡片还包含一个图像。当卡片向上滑动屏幕时,每个卡片内的图像向下滑动。

下面的动画显示了应用程序的行为:

视差滚动

创建列表以容纳视差项目

#

要显示视差滚动图像的列表,您必须首先显示一个列表。

创建一个名为 ParallaxRecipe 的新的无状态小部件。在 ParallaxRecipe 中,使用 SingleChildScrollViewColumn 构建一个包含列表的窗口小部件树。

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

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

显示带有文本和静态图像的项目

#

每个列表项显示一个圆角矩形背景图像,代表世界上的七个位置之一。在该背景图像的顶部叠加的是位置名称及其国家/地区,位于左下方。背景图像和文本之间是一个深色渐变,这提高了文本与背景的反差,使文本更易于阅读。

实现一个名为 LocationListItem 的无状态小部件,该小部件包含前面提到的视觉效果。目前,对背景使用静态 Image 小部件。稍后,您将用视差版本替换该小部件。

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,
            ),
          ),
        ],
      ),
    );
  }
}

接下来,将列表项添加到您的 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,
            ),
        ],
      ),
    );
  }
}

您现在有一个典型的可滚动卡片列表,显示世界上的七个独特位置。在下一步中,您将向背景图像添加视差效果。

实现视差效果

#

视差滚动效果是通过将背景图像稍微推向与列表其余部分相反的方向来实现的。当列表项向上滑动屏幕时,每个背景图像会稍微向下滑动。相反,当列表项向下滑动屏幕时,每个背景图像会稍微向上滑动。从视觉上看,这会产生视差。

视差效果取决于列表项在其祖先 Scrollable 中的当前位置。随着列表项滚动位置的变化,列表项背景图像的位置也必须改变。这是一个有趣的问题。直到 Flutter 的布局阶段完成,列表项在其 Scrollable 中的位置才可用。这意味着必须在绘制阶段确定背景图像的位置,绘制阶段在布局阶段之后。幸运的是,Flutter 提供了一个名为 Flow 的小部件,它专门设计用于让您在绘制小部件之前立即控制子小部件的变换。换句话说,您可以拦截绘制阶段并进行控制,以根据需要重新定位子小部件。

将背景 Image 小部件用 Flow 小部件包装起来。

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

引入一个名为 ParallaxFlowDelegate 的新 FlowDelegate

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;
  }
}

FlowDelegate 控制其子项的大小以及这些子项的绘制位置。在本例中,您的 Flow 小部件只有一个子项:背景图像。该图像必须与 Flow 小部件一样宽。

为背景图像子项返回紧凑的宽度约束。

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

您的背景图像现在大小合适,但您仍然需要根据其滚动位置计算每个背景图像的垂直位置,然后绘制它。

有三个关键信息需要计算背景图像的所需位置:

  • 祖先 Scrollable 的边界
  • 各个列表项的边界
  • 将图像缩小以适合列表项后的图像大小

要查找 Scrollable 的边界,请将 ScrollableState 传递到您的 FlowDelegate 中。

要查找各个列表项的边界,请将列表项的 BuildContext 传递到您的 FlowDelegate 中。

要查找背景图像的最终大小,请为您的 Image 小部件分配一个 GlobalKey,然后将该 GlobalKey 传递到您的 FlowDelegate 中。

使这些信息可用于 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;
}

拥有实现视差滚动所需的所有信息后,实现 shouldRepaint() 方法。

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

现在,实现视差效果的布局计算。

首先,计算列表项在其祖先 Scrollable 中的像素位置。

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);
}

使用列表项的像素位置来计算其在 Scrollable 顶部的百分比。位于可滚动区域顶部的列表项应产生 0%,而位于可滚动区域底部的列表项应产生 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);
  // ···
}

使用滚动百分比计算 Alignment。在 0% 时,您需要 Alignment(0.0, -1.0),在 100% 时,您需要 Alignment(0.0, 1.0)。这些坐标分别对应于顶部和底部对齐。

dart
@override
void paintChildren(FlowPaintingContext context) {
  // 计算此列表项在视口中的位置。
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
      listItemBox.size.centerLeft(Offset.zero),
      ancestor: scrollableBox);

  // 确定此列表项在可滚动区域中的百分比位置。
  final viewportDimension = scrollable.position.viewportDimension;
  final scrollFraction =
      (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

  // 根据滚动百分比计算背景的垂直对齐方式。
  final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
}

使用 verticalAlignment,以及列表项的大小和背景图像的大小,生成一个 Rect,该 Rect 决定了背景图像的放置位置。

dart
@override
void paintChildren(FlowPaintingContext context) {
  // 计算此列表项在视口中的位置。
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
      listItemBox.size.centerLeft(Offset.zero),
      ancestor: scrollableBox);

  // 确定此列表项在可滚动区域中的百分比位置。
  final viewportDimension = scrollable.position.viewportDimension;
  final scrollFraction =
      (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

  // 根据滚动百分比计算背景的垂直对齐方式。
  final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

  // 将背景对齐方式转换为像素偏移量以用于绘制。
  final backgroundSize =
      (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
          .size;
  final listItemSize = context.size;
  final childRect =
      verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
}

使用 childRect,使用所需的平移变换绘制背景图像。正是这种随时间变化的变换为您提供了视差效果。

dart
@override
void paintChildren(FlowPaintingContext context) {
  // 计算此列表项在视口中的位置。
  final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
  final listItemBox = listItemContext.findRenderObject() as RenderBox;
  final listItemOffset = listItemBox.localToGlobal(
      listItemBox.size.centerLeft(Offset.zero),
      ancestor: scrollableBox);

  // 确定此列表项在可滚动区域中的百分比位置。
  final viewportDimension = scrollable.position.viewportDimension;
  final scrollFraction =
      (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

  // 根据滚动百分比计算背景的垂直对齐方式。
  final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

  // 将背景对齐方式转换为像素偏移量以用于绘制。
  final backgroundSize =
      (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
          .size;
  final listItemSize = context.size;
  final childRect =
      verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);

  // 绘制背景。
  context.paintChild(
    0,
    transform:
        Transform.translate(offset: Offset(0.0, childRect.top)).transform,
  );
}

要实现视差效果,您还需要一个最终细节。当输入发生变化时,ParallaxFlowDelegate 会重新绘制,但并非每次滚动位置发生变化时 ParallaxFlowDelegate 都会重新绘制。

ScrollableStateScrollPosition 传递给 FlowDelegate 超类,以便 FlowDelegate 在每次 ScrollPosition 发生变化时重新绘制。

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

恭喜!您现在拥有一个带有视差效果的卡片列表,背景图像可以滚动。

交互式示例

#

运行应用程序:

  • 上下滚动以观察视差效果。
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: const Scaffold(
        body: Center(
          child: ExampleParallax(),
        ),
      ),
    );
  }
}

class ExampleParallax extends StatelessWidget {
  const ExampleParallax({
    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,
            ),
        ],
      ),
    );
  }
}

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

  final String imageUrl;
  final String name;
  final String country;
  final GlobalKey _backgroundImageKey = GlobalKey();

  @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 Flow(
      delegate: ParallaxFlowDelegate(
        scrollable: Scrollable.of(context),
        listItemContext: context,
        backgroundImageKey: _backgroundImageKey,
      ),
      children: [
        Image.network(
          imageUrl,
          key: _backgroundImageKey,
          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,
            ),
          ),
        ],
      ),
    );
  }
}

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


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

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

  @override
  void paintChildren(FlowPaintingContext context) {
    // 计算此列表项在视口中的位置。
    final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
    final listItemBox = listItemContext.findRenderObject() as RenderBox;
    final listItemOffset = listItemBox.localToGlobal(
        listItemBox.size.centerLeft(Offset.zero),
        ancestor: scrollableBox);

    // 确定此列表项在可滚动区域中的百分比位置。
    final viewportDimension = scrollable.position.viewportDimension;
    final scrollFraction =
        (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

    // 根据滚动百分比计算背景的垂直对齐方式。
    final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

    // 将背景对齐方式转换为像素偏移量以用于绘制。
    final backgroundSize =
        (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
            .size;
    final listItemSize = context.size;
    final childRect =
        verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);

    // 绘制背景。
    context.paintChild(
      0,
      transform:
          Transform.translate(offset: Offset(0.0, childRect.top)).transform,
    );
  }

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

class Parallax extends SingleChildRenderObjectWidget {
  const Parallax({
    super.key,
    required Widget background,
  }) : super(child: background);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderParallax(scrollable: Scrollable.of(context));
  }

  @override
  void updateRenderObject(
      BuildContext context, covariant RenderParallax renderObject) {
    renderObject.scrollable = Scrollable.of(context);
  }
}

class ParallaxParentData extends ContainerBoxParentData<RenderBox> {}

class RenderParallax extends RenderBox
    with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin {
  RenderParallax({
    required ScrollableState scrollable,
  }) : _scrollable = scrollable;

  ScrollableState _scrollable;

  ScrollableState get scrollable => _scrollable;

  set scrollable(ScrollableState value) {
    if (value != _scrollable) {
      if (attached) {
        _scrollable.position.removeListener(markNeedsLayout);
      }
      _scrollable = value;
      if (attached) {
        _scrollable.position.addListener(markNeedsLayout);
      }
    }
  }

  @override
  void attach(covariant PipelineOwner owner) {
    super.attach(owner);
    _scrollable.position.addListener(markNeedsLayout);
  }

  @override
  void detach() {
    _scrollable.position.removeListener(markNeedsLayout);
    super.detach();
  }

  @override
  void setupParentData(covariant RenderObject child) {
    if (child.parentData is! ParallaxParentData) {
      child.parentData = ParallaxParentData();
    }
  }

  @override
  void performLayout() {
    size = constraints.biggest;

    // 强制背景占据所有可用宽度,然后根据图像的纵横比缩放其高度。
    final background = child!;
    final backgroundImageConstraints =
        BoxConstraints.tightFor(width: size.width);
    background.layout(backgroundImageConstraints, parentUsesSize: true);

    // 设置背景的局部偏移量,为零。
    (background.parentData as ParallaxParentData).offset = Offset.zero;
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    // 获取可滚动区域的大小。
    final viewportDimension = scrollable.position.viewportDimension;

    // 计算此列表项的全局位置。
    final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
    final backgroundOffset =
        localToGlobal(size.centerLeft(Offset.zero), ancestor: scrollableBox);

    // 确定此列表项在可滚动区域中的百分比位置。
    final scrollFraction =
        (backgroundOffset.dy / viewportDimension).clamp(0.0, 1.0);

    // 根据滚动百分比计算背景的垂直对齐方式。
    final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

    // 将背景对齐方式转换为像素偏移量以用于绘制。
    final background = child!;
    final backgroundSize = background.size;
    final listItemSize = size;
    final childRect =
        verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);

    // 绘制背景。
    context.paintChild(
        background,
        (background.parentData as ParallaxParentData).offset +
            offset +
            Offset(0.0, childRect.top));
  }
}

class Location {
  const Location({
    required this.name,
    required this.place,
    required this.imageUrl,
  });

  final String name;
  final String place;
  final String imageUrl;
}

const urlPrefix =
    'https://docs.flutter.dev/cookbook/img-files/effects/parallax';
const locations = [
  Location(
    name: '拉什莫尔山',
    place: '美国',
    imageUrl: '$urlPrefix/01-mount-rushmore.jpg',
  ),
  Location(
    name: '滨海湾花园',
    place: '新加坡',
    imageUrl: '$urlPrefix/02-singapore.jpg',
  ),
  Location(
    name: '马丘比丘',
    place: '秘鲁',
    imageUrl: '$urlPrefix/03-machu-picchu.jpg',
  ),
  Location(
    name: '维茨瑙',
    place: '瑞士',
    imageUrl: '$urlPrefix/04-vitznau.jpg',
  ),
  Location(
    name: '巴厘岛',
    place: '印度尼西亚',
    imageUrl: '$urlPrefix/05-bali.jpg',
  ),
  Location(
    name: '墨西哥城',
    place: '墨西哥',
    imageUrl: '$urlPrefix/06-mexico-city.jpg',
  ),
  Location(
    name: '开罗',
    place: '埃及',
    imageUrl: '$urlPrefix/07-cairo.jpg',
  ),
];