创建一个平行错位滚动的效果
创建一个视差滚动效果
实用教程chevron_rightEffectschevron_right创建一个视差滚动效果
当你在应用程序中滚动一个包含图片的卡片列表时,你可能会注意到那些图片的滚动速度比屏幕的其余部分要慢一些。看起来似乎列表中的卡片位于前面,而图片本身则像是远远地在背景中。这种效果被称为视差。
在这个教程中,你通过构建一个卡片列表来创建视差效果(这些卡片具有圆角并包含一些文本)。每个卡片还包含一张图片。当卡片向上滑动时,卡片中的图片会向下滑动。
下面的动画展示了应用程序的行为:

创建一个列表来保存视差项
要实现一个带有视差滚动效果的图片列表,首先必须显示一个列表。
创建一个新的 stateless widget,名为 ParallaxRecipe。在 ParallaxRecipe 中,构建一个包含 SingleChildScrollView 和 Column 的 widget 树,这样就形成了一个列表。
dart
class ParallaxRecipe extends StatelessWidget {
const ParallaxRecipe({super.key});
@override
Widget build(BuildContext context) {
return const SingleChildScrollView(
child: Column(
children: [],
),
);
}
}
content_copy
显示带有文本和静态图片的列表项
每个列表项显示一个圆角矩形背景图片,代表世界上的七个地点之一。在该背景图片的左下角叠加了地点名称及其所在国家。在背景图片和文字之间有一个深色渐变层,以提高文字在背景上的可读性。
实现一个名为 LocationListItem 的 stateless widget,该 widget 包含之前提到的视觉效果。现在,使用一个静态的 Image widget 作为背景。稍后,你将用视差版本的 widget 替换这个静态图片 widget。
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.withOpacity(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,
),
),
],
),
);
}
}
content_copy
然后,将列表项添加到你的 ParallaxRecipe widget 中。
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,
),
],
),
);
}
}
content_copy
现在,你有了一个常见的可滚动卡片列表,展示了世界上七个独特的地点。在下一步中,你将为背景图片添加视差效果。
实现视差滚动效果
视差滚动效果通过轻微地将背景图片推向与列表其余部分相反的方向来实现。当列表项向上滑动时,每个背景图片会轻微向下滑动。相反,当列表项向下滑动时,每个背景图片会轻微向上滑动。从视觉上看,这就产生了视差效果。
视差效果依赖于列表项在其祖先 Scrollable 中的当前位置。随着列表项的滚动位置变化,列表项的背景图片位置也必须随之变化。这是一个有趣的问题,因为在 Flutter 的布局阶段完成之前,无法获取列表项在 Scrollable 中的位置。这意味着背景图片的位置必须在绘制阶段确定,而绘制阶段在布局阶段之后进行。幸运的是,Flutter 提供了一个名为 Flow 的 widget,专门设计用于在 widget 被绘制之前立即控制子 widget 的变换。换句话说,你可以拦截绘制阶段并控制子 widget 的位置,以便按照你的需求重新定位。
info提示
要了解更多信息,请观看这段关于 Flow widget (Flutter widget of the week) 的简短视频:
Flow | Flutter widget of the week
info提示
如果你需要控制子 widget 绘制内容,而不是子 widget 的绘制位置时,可以考虑使用 CustomPaint widget。
如果你需要控制布局、绘制和点击测试时,可以考虑自定义一个 RenderBox。
用 Flow widget 包裹你的背景 Image widget。
dart
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
],
);
}
content_copy
引入一个新的 FlowDelegate,名为 ParallaxFlowDelegate。
dart
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(),
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
],
);
}
content_copy
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;
}
}
content_copy
FlowDelegate 控制其子 widget 的大小和绘制位置。在本教程中,你的 Flow widget 只有一个子 widget:背景图片。该图片的宽度必须与 Flow widget 的宽度完全一致。
为你的背景图片子 widget 返回严格的宽度约束。
dart
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(
width: constraints.maxWidth,
);
}
content_copy
现在你的背景图片大小已经合适,但你仍然需要根据每个背景图片的滚动位置计算其垂直位置,然后进行绘制。
计算背景图片所需位置的三个关键要素是:
- 祖先
Scrollable的边界 - 单个列表项的边界
- 图片缩放后的尺寸(为了适应列表项)
要获取 Scrollable 的边界,可以将 ScrollableState 传递给你的 FlowDelegate。
要获取单个列表项的边界,可以将列表项的 BuildContext 传递给你的 FlowDelegate。
要获取背景图片最终的尺寸,可以为 Image widget 分配一个 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,
),
],
);
}
}
content_copy
dart
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
});
final ScrollableState scrollable;
final BuildContext listItemContext;
final GlobalKey backgroundImageKey;
}
content_copy
在拥有实现视差滚动所需的所有信息之后,实现 shouldRepaint() 方法。
dart
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
content_copy
现在,实施视差效果的布局计算。
首先,计算列表项在其祖先 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);
}
content_copy
使用列表项的像素位置来计算它距离 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);
// ···
}
content_copy
使用滚动百分比来计算 Alignment。在 0% 时,你需要 Alignment(0.0, -1.0),在 100% 时,你需要 Alignment(0.0, 1.0)。这些坐标分别对应于顶部对齐和底部对齐。
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);
}
content_copy
使用 verticalAlignment,结合列表项的大小和背景图片的大小,生成一个 Rect,以确定背景图片的位置。
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);
}
content_copy
使用 childRect,根据所需的平移变换绘制背景图片。正是这种随时间推移的变换效果产生了视差效果。
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,
);
}
content_copy
你还需要一个最后的细节来实现视差效果。 ParallaxFlowDelegate 在参数发生变化时会重新绘制,但它不会在每次滚动位置变化时都重新绘制。
将 ScrollableState 的 ScrollPosition 传递给 FlowDelegate 的父类,以便在 ScrollPosition 每次变化时, FlowDelegate 都会重新绘制。
dart
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
}) : super(repaint: scrollable.position);
}
content_copy
恭喜!现在你拥有了一个带有视差滚动背景图片的卡片列表。
交互示例
运行应用程序:
- 向上和向下滚动,以观察视差效果。
浙公网安备 33010602011771号