建立滾動視差效果
如何實現滾動視差效果。
當您在應用程式中滾動卡片列表(例如,包含影像)時,您可能會注意到這些影像比螢幕的其餘部分滾動得更慢。它幾乎看起來好像列表中的卡片在前景中,但影像本身卻位於遙遠背景中。這種效果被稱為視差。
在本教程中,您透過構建一個卡片列表(帶有圓角,包含一些文字)來建立視差效果。每張卡片還包含一張影像。當卡片向上滑動螢幕時,每張卡片內的影像會向下滑動。
以下動畫展示了應用程式的行為
建立一個列表來容納視差專案
#要顯示視差滾動影像列表,您首先需要顯示一個列表。
建立一個名為 ParallaxRecipe 的無狀態小部件。在 ParallaxRecipe 內部,構建一個包含 SingleChildScrollView 和 Column 的小部件樹,形成一個列表。
class ParallaxRecipe extends StatelessWidget {
const ParallaxRecipe({super.key});
@override
Widget build(BuildContext context) {
return const SingleChildScrollView(child: Column(children: []));
}
}
顯示帶有文字和靜態影像的專案
#每個列表項顯示一個圓角矩形背景影像,代表世界上的七個地點之一。該背景影像的頂部堆疊著該地點的名稱及其國家/地區,位於左下角。在背景影像和文字之間有一個深色漸變,可以提高文字在背景下的可讀性。
實現一個名為 LocationListItem 的無狀態小部件,該小部件由前面提到的視覺元素組成。現在,對背景使用靜態 Image 小部件。稍後,您將用視差版本替換該小部件。
@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 小部件中。
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 的小部件,該小部件專門設計用於在繪製小部件之前控制子小部件的變換。換句話說,您可以攔截繪製階段並控制以您想要的方式重新定位子小部件。
使用 Flow 小部件包裝您的背景 Image 小部件。
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
children: [Image.network(imageUrl, fit: BoxFit.cover)],
);
}
引入一個新的 FlowDelegate,名為 ParallaxFlowDelegate。
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(),
children: [Image.network(imageUrl, fit: BoxFit.cover)],
);
}
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 小部件一樣寬。
為您的背景影像子項返回緊密的寬度約束。
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(width: constraints.maxWidth);
}
現在,您的背景影像已正確調整大小,但您仍然需要根據其滾動位置計算每個背景影像的垂直位置,然後繪製它。
有三個關鍵資訊需要您計算背景影像的所需位置
- 祖先
Scrollable的邊界 - 單個列表項的邊界
- 影像縮小以適應列表項後的尺寸
要查詢 Scrollable 的邊界,您將 ScrollableState 傳遞到您的 FlowDelegate。
要查詢您的單個列表項的邊界,將您的列表項的 BuildContext 傳遞到您的 FlowDelegate。
要查詢背景影像的最終大小,將 GlobalKey 分配給您的 Image 小部件,然後將該 GlobalKey 傳遞到您的 FlowDelegate。
使這些資訊可供 ParallaxFlowDelegate 使用。
@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),
],
);
}
}
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
});
final ScrollableState scrollable;
final BuildContext listItemContext;
final GlobalKey backgroundImageKey;
}
擁有實現視差滾動所需的所有資訊後,實現 shouldRepaint() 方法。
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
現在,實現視差效果的佈局計算。
首先,計算列表項在其祖先 Scrollable 中的畫素位置。
@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%。
@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)。這些座標分別對應於頂部和底部對齊方式。
@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);
}
使用 verticalAlignment 以及列表項的尺寸和背景影像的尺寸,生成一個 Rect,確定背景影像應放置的位置。
@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,
);
}
使用 childRect,使用所需的平移變換繪製背景影像。正是隨著時間的推移的這種變換產生了視差效果。
@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,
);
}
您需要一個最終細節才能實現視差效果。ParallaxFlowDelegate 在輸入發生變化時重新繪製,但 ParallaxFlowDelegate 並非每次滾動位置發生變化時都重新繪製。
將 ScrollableState 的 ScrollPosition 傳遞給 FlowDelegate 超類,以便在 ScrollPosition 發生變化時 FlowDelegate 重新繪製。
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) {
// 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,
);
}
@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;
// Force the background to take up all available width
// and then scale its height based on the image's aspect ratio.
final background = child!;
final backgroundImageConstraints = BoxConstraints.tightFor(
width: size.width,
);
background.layout(backgroundImageConstraints, parentUsesSize: true);
// Set the background's local offset, which is zero.
(background.parentData as ParallaxParentData).offset = Offset.zero;
}
@override
void paint(PaintingContext context, Offset offset) {
// Get the size of the scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
// Calculate the global position of this list item.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final backgroundOffset = localToGlobal(
size.centerLeft(Offset.zero),
ancestor: scrollableBox,
);
// Determine the percent position of this list item within the
// scrollable area.
final scrollFraction = (backgroundOffset.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 background = child!;
final backgroundSize = background.size;
final listItemSize = size;
final childRect = verticalAlignment.inscribe(
backgroundSize,
Offset.zero & listItemSize,
);
// Paint the background.
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.club.tw/assets/images/exercise/effects/parallax';
const locations = [
Location(
name: 'Mount Rushmore',
place: 'U.S.A',
imageUrl: '$urlPrefix/01-mount-rushmore.jpg',
),
Location(
name: 'Gardens By The Bay',
place: 'Singapore',
imageUrl: '$urlPrefix/02-singapore.jpg',
),
Location(
name: 'Machu Picchu',
place: 'Peru',
imageUrl: '$urlPrefix/03-machu-picchu.jpg',
),
Location(
name: 'Vitznau',
place: 'Switzerland',
imageUrl: '$urlPrefix/04-vitznau.jpg',
),
Location(
name: 'Bali',
place: 'Indonesia',
imageUrl: '$urlPrefix/05-bali.jpg',
),
Location(
name: 'Mexico City',
place: 'Mexico',
imageUrl: '$urlPrefix/06-mexico-city.jpg',
),
Location(name: 'Cairo', place: 'Egypt', imageUrl: '$urlPrefix/07-cairo.jpg'),
];