肆、Create a scrolling parallax effect

參見:Create a scrolling parallax effect

當我們在滾動時,圖像的滾動速度會比視窗的其餘部分稍慢,而這種速度不一致會產生前景與後景的視差效果,而接下來的範例中,我們會介紹如何用自定義的滾動模式與設置來達成滾動時,讓背景影像的位置會根據滾動位置而變化,從而產生視差效果。

一、元件佈局區塊

  1. 入口點與佈景主題設定:
  • MyApp Class是應用程式的root元件,繼承自 StatelessWidget。

  • 設定了暗黑主題,並將應用程式欄的背景顏色設為 darkBlue。

  1. ExampleParallax class:
  • 是一個 StatelessWidget,用於建立包含捲動清單的單一子視圖。

  • 使用 SingleChildScrollView 建立一個垂直捲動的列,列中包含了多個 LocationListItem 子元件。

  1. LocationListItem class:
  • 表示捲動清單中的每個項目,包括一個背景圖像和相關資訊。

  • 它接受三個必需的參數:imageUrl(圖像連結)、name(名稱)和 country(國家)。

  • 使用 Padding、AspectRatio 和 Stack 組合來建立每個項目的版面。

  • Stack包含了背景圖像、漸層效果和標題資訊。

  1. ParallaxFlowDelegate class:
  • 是一個自訂的 FlowDelegate,用於實現滾動視差效果。

  • 透過監聽滾動事件,它計算每個列表項目的位置,以及應用背景圖像的垂直滾動效果。

paintChildren 方法用於繪製背景圖像,並根據滾動百分比的高度來定位。

  1. Parallax class 和 RenderParallax class:
  • 用於封裝捲動視差效果的自訂 Widget 和 RenderObject。

  • Parallax 會將背景圖像作為子部件傳遞給 RenderParallax。

  • RenderParallax 處理特定的背景影像的繪製邏輯,根據捲動位置計算垂直偏移,以實現滾動視差效果。

二、資料區塊

  1. Location 資料模型:
  • Location class表示一個地點,包括名稱、國家和圖像連結。

  • locations: 一個包含多個 Location 實例的列表,每個實例代表一個不同的地點。

三、使用範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
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.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,
),
),
],
),
);
}
}

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: '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',
),
];
  • 輸出結果

透過滾動到不同位置,我們可以看到圖片呈現的方向皆不太一樣(可以觀察Gardens By The Bay的圖片),大家可以嘗試用dartpad執行,動態滑動過程中的視差效果會更明顯!這樣我們就可以完成一個世界七大奇景圖片的視差滾動效果Demo!