建立滾動視差效果
當你在應用程式中滾動一個卡片清單(例如包含圖片的卡片)時,你可能會注意到那些圖片看起來比螢幕上的其他內容滾動得更慢。這種效果幾乎讓人感覺清單中的卡片位於前景,而圖片本身則像是遠遠地位於背景。這種效果被稱為視差(parallax)。
在本教學中,你將透過建立一個卡片清單(每個卡片有圓角並包含一些文字)來製作視差效果。每個卡片同時也包含一張圖片。當卡片往螢幕上方滑動時,卡片內的圖片則會往下滑動。
下方動畫展示了應用程式的行為:

建立一個用於存放視差項目的清單
#若要顯示一個具有視差滾動效果的圖片清單,你必須先建立一個清單。
建立一個名為 ParallaxRecipe 的無狀態元件(StatelessWidget)。在 ParallaxRecipe 中,建立一個包含 SingleChildScrollView 和 Column 的元件樹,這樣就形成了一個清單。
class ParallaxRecipe extends StatelessWidget {
const ParallaxRecipe({super.key});
@override
Widget build(BuildContext context) {
return const SingleChildScrollView(child: Column(children: []));
}
}顯示帶有文字和靜態圖片的項目
#每個清單項目會顯示一個圓角矩形的背景圖片,代表世界上七個地點之一。
在該背景圖片的上方,堆疊著該地點的名稱及其國家,並定位在左下角。
背景圖片與文字之間有一層深色漸層,這有助於提升文字在背景上的可讀性。
請實作一個名為 LocationListItem 的無狀態元件(StatelessWidget),其內容包含上述所提到的視覺元素。
目前,請先使用靜態的 Image 元件作為背景。
稍後,你將會以視差(parallax)版本取代該元件。
@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 元件(Widget)。
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,
),
],
),
);
}
}你現在已經擁有一個典型、可滾動的卡片清單, 展示了世界上七個獨特的地點。 在下一步,你將為背景圖片加入視差(parallax)效果。
實作視差效果
#視差滾動效果是透過將背景圖片 稍微往與清單其餘部分相反的方向推動來實現的。 當清單項目往螢幕上方滑動時,每個背景圖片會稍微往下滑動。 反之,當清單項目往螢幕下方滑動時, 每個背景圖片會稍微往上滑動。 從視覺上來看,這就產生了視差效果。
視差效果取決於清單項目在其父層 Scrollable 中的當前位置。 隨著清單項目的滾動位置改變,該項目的背景圖片位置也必須跟著改變。 這是一個有趣的問題。清單項目在 Scrollable 中的位置,直到 Flutter 的版面配置階段結束後才會得知。 這表示背景圖片的位置必須在繪製(paint)階段決定,而這個階段是在版面配置階段之後。 幸運的是,Flutter 提供了一個名為 Flow 的元件(Widget), 它專門設計用來讓你在子元件繪製前立即控制其變換(transform)。 換句話說,你可以攔截繪製階段,並自行掌控子元件的位置調整。
將你的背景 Image 元件(Widget)包裹在 Flow 元件(Widget)中。
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 控制其子元件(children)的尺寸,以及這些子元件的繪製位置。在這個例子中,你的 Flow 元件(Widget)只有一個子元件:背景圖片。該圖片的寬度必須與 Flow 元件完全相同。
請為你的背景圖片子元件回傳緊縮(tight)的寬度約束條件。
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(width: constraints.maxWidth);
}你的背景圖片現在已經有正確的尺寸, 但你仍然需要根據每張背景圖片的滾動位置來計算其垂直位置,然後將其繪製出來。
要計算背景圖片的理想位置,有三個關鍵資訊你需要取得:
- 祖先
Scrollable的邊界(bounds) - 個別清單項目的邊界
- 圖片縮放至適合清單項目後的尺寸
若要查詢 Scrollable 的邊界, 你需要將 ScrollableState 傳遞給 FlowDelegate。
若要查詢個別清單項目的邊界, 請將你的清單項目的 BuildContext 傳遞給 FlowDelegate。
若要查詢背景圖片的最終尺寸, 請為你的 Image 元件(Widget)指定一個 GlobalKey, 然後將該 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;
}現在已經擁有實作視差滾動(parallax scrolling)所需的所有資訊,請實作 shouldRepaint() 方法。
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}現在,來實作視差效果(parallax effect)的版面配置計算。
首先,計算一個清單項目在其祖先 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,以所需的平移轉換來繪製背景圖片。 隨著時間變化的這個轉換,就是產生視差(parallax)效果的關鍵。
@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,
);
}你還需要最後一個細節來實現視差(parallax)效果。 當輸入(inputs)改變時,ParallaxFlowDelegate 會重新繪製(repaint), 但當滾動位置改變時,ParallaxFlowDelegate 並不會每次都重新繪製。
將 ScrollableState 的 ScrollPosition 傳遞給 FlowDelegate 的父類別(superclass),這樣 FlowDelegate 就能在每次 ScrollPosition 改變時都重新繪製。
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
}) : super(repaint: scrollable.position);
}恭喜你! 你現在已經擁有一個帶有視差(parallax)效果、 可滾動背景圖片的卡片清單。
互動範例
#執行應用程式:
- 上下滾動以觀察視差效果。
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.dev/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'),
];