Skip to content

Commit a4eaaf5

Browse files
Added UI loading animation recipe. (flutter#63)
1 parent d36d66e commit a4eaaf5

1 file changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/scheduler.dart';
7+
8+
void main() {
9+
runApp(
10+
const MaterialApp(
11+
home: ExampleUiLoadingAnimation(),
12+
debugShowCheckedModeBanner: false,
13+
),
14+
);
15+
}
16+
17+
class ExampleUiLoadingAnimation extends StatefulWidget {
18+
const ExampleUiLoadingAnimation({
19+
Key? key,
20+
}) : super(key: key);
21+
22+
@override
23+
_ExampleUiLoadingAnimationState createState() =>
24+
_ExampleUiLoadingAnimationState();
25+
}
26+
27+
class _ExampleUiLoadingAnimationState extends State<ExampleUiLoadingAnimation> {
28+
static const _shimmerGradient = LinearGradient(
29+
colors: [
30+
Color(0xFFEBEBF4),
31+
Color(0xFFF4F4F4),
32+
Color(0xFFEBEBF4),
33+
],
34+
stops: [
35+
0.1,
36+
0.3,
37+
0.4,
38+
],
39+
begin: Alignment(-1.0, -0.3),
40+
end: Alignment(1.0, 0.3),
41+
tileMode: TileMode.clamp,
42+
);
43+
44+
bool _isLoading = true;
45+
46+
void _toggleLoading() {
47+
setState(() {
48+
_isLoading = !_isLoading;
49+
});
50+
}
51+
52+
@override
53+
Widget build(BuildContext context) {
54+
return Scaffold(
55+
body: Shimmer(
56+
linearGradient: _shimmerGradient,
57+
child: ListView(
58+
physics: _isLoading ? const NeverScrollableScrollPhysics() : null,
59+
children: [
60+
const SizedBox(height: 16),
61+
_buildTopRowList(),
62+
const SizedBox(height: 16),
63+
_buildListItem(),
64+
_buildListItem(),
65+
_buildListItem(),
66+
],
67+
),
68+
),
69+
floatingActionButton: FloatingActionButton(
70+
onPressed: _toggleLoading,
71+
child: Icon(
72+
_isLoading ? Icons.hourglass_full : Icons.hourglass_bottom,
73+
),
74+
),
75+
);
76+
}
77+
78+
Widget _buildTopRowList() {
79+
return SizedBox(
80+
height: 72,
81+
child: ListView(
82+
physics: _isLoading ? const NeverScrollableScrollPhysics() : null,
83+
scrollDirection: Axis.horizontal,
84+
shrinkWrap: true,
85+
children: [
86+
const SizedBox(width: 16),
87+
_buildTopRowItem(),
88+
_buildTopRowItem(),
89+
_buildTopRowItem(),
90+
_buildTopRowItem(),
91+
_buildTopRowItem(),
92+
_buildTopRowItem(),
93+
],
94+
),
95+
);
96+
}
97+
98+
Widget _buildTopRowItem() {
99+
return ShimmerLoading(
100+
isLoading: _isLoading,
101+
child: CircleListItem(),
102+
);
103+
}
104+
105+
Widget _buildListItem() {
106+
return ShimmerLoading(
107+
isLoading: _isLoading,
108+
child: CardListItem(
109+
isLoading: _isLoading,
110+
),
111+
);
112+
}
113+
}
114+
115+
class Shimmer extends StatefulWidget {
116+
static _ShimmerState? of(BuildContext context) {
117+
return context.findAncestorStateOfType<_ShimmerState>();
118+
}
119+
120+
const Shimmer({
121+
Key? key,
122+
required this.linearGradient,
123+
this.child,
124+
}) : super(key: key);
125+
126+
final LinearGradient linearGradient;
127+
final Widget? child;
128+
129+
@override
130+
_ShimmerState createState() => _ShimmerState();
131+
}
132+
133+
class _ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {
134+
late AnimationController _shimmerController;
135+
136+
@override
137+
void initState() {
138+
super.initState();
139+
140+
_shimmerController = AnimationController.unbounded(vsync: this)
141+
..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));
142+
}
143+
144+
@override
145+
void dispose() {
146+
_shimmerController.dispose();
147+
super.dispose();
148+
}
149+
150+
Gradient get gradient => LinearGradient(
151+
colors: widget.linearGradient.colors,
152+
stops: widget.linearGradient.stops,
153+
begin: widget.linearGradient.begin,
154+
end: widget.linearGradient.end,
155+
transform:
156+
_SlidingGradientTransform(slidePercent: _shimmerController.value),
157+
);
158+
159+
@override
160+
Widget build(BuildContext context) {
161+
return widget.child ?? const SizedBox();
162+
}
163+
}
164+
165+
class _SlidingGradientTransform extends GradientTransform {
166+
const _SlidingGradientTransform({
167+
required this.slidePercent,
168+
});
169+
170+
final double slidePercent;
171+
172+
@override
173+
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
174+
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
175+
}
176+
}
177+
178+
class ShimmerLoading extends StatefulWidget {
179+
const ShimmerLoading({
180+
Key? key,
181+
required this.isLoading,
182+
required this.child,
183+
}) : super(key: key);
184+
185+
final bool isLoading;
186+
final Widget child;
187+
188+
@override
189+
_ShimmerLoadingState createState() => _ShimmerLoadingState();
190+
}
191+
192+
class _ShimmerLoadingState extends State<ShimmerLoading>
193+
with SingleTickerProviderStateMixin {
194+
late Ticker _ticker;
195+
196+
@override
197+
void initState() {
198+
super.initState();
199+
200+
_ticker = createTicker((elapsed) {
201+
setState(() {});
202+
});
203+
204+
if (widget.isLoading) {
205+
_ticker.start();
206+
}
207+
}
208+
209+
@override
210+
void didUpdateWidget(ShimmerLoading oldWidget) {
211+
super.didUpdateWidget(oldWidget);
212+
213+
if (widget.isLoading != oldWidget.isLoading) {
214+
if (widget.isLoading) {
215+
_ticker.start();
216+
} else {
217+
_ticker.stop();
218+
}
219+
}
220+
}
221+
222+
@override
223+
void dispose() {
224+
_ticker.dispose();
225+
super.dispose();
226+
}
227+
228+
@override
229+
Widget build(BuildContext context) {
230+
if (!widget.isLoading) {
231+
return widget.child;
232+
}
233+
234+
// Collect ancestor shimmer info.
235+
final shimmer = Shimmer.of(context)!;
236+
final shimmerBox = shimmer.context.findRenderObject()! as RenderBox;
237+
if (!shimmerBox.hasSize) {
238+
return const SizedBox();
239+
}
240+
final shimmerWidth = shimmerBox.size.width;
241+
final shimmerHeight = shimmerBox.size.height;
242+
final gradient = shimmer.gradient;
243+
244+
// Determine our position within the ancestor Shimmer.
245+
final renderBox = context.findRenderObject() as RenderBox?;
246+
if (renderBox == null) {
247+
return const SizedBox();
248+
}
249+
final offsetWithinShimmer =
250+
renderBox.localToGlobal(Offset.zero, ancestor: shimmerBox);
251+
252+
return ShaderMask(
253+
blendMode: BlendMode.srcATop,
254+
shaderCallback: (bounds) {
255+
return gradient.createShader(
256+
Rect.fromLTWH(
257+
-offsetWithinShimmer.dx,
258+
-offsetWithinShimmer.dy,
259+
shimmerWidth,
260+
shimmerHeight,
261+
),
262+
);
263+
},
264+
child: widget.child,
265+
);
266+
}
267+
}
268+
269+
//----------- List Items ---------
270+
class CircleListItem extends StatelessWidget {
271+
@override
272+
Widget build(BuildContext context) {
273+
return Padding(
274+
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
275+
child: Container(
276+
width: 54,
277+
height: 54,
278+
decoration: const BoxDecoration(
279+
color: Colors.black,
280+
shape: BoxShape.circle,
281+
),
282+
child: ClipOval(
283+
child: Image.network(
284+
'https://flutter'
285+
'.dev/docs/cookbook/img-files/effects/split-check/Avatar1.jpg',
286+
fit: BoxFit.cover,
287+
),
288+
),
289+
),
290+
);
291+
}
292+
}
293+
294+
class CardListItem extends StatelessWidget {
295+
const CardListItem({
296+
Key? key,
297+
required this.isLoading,
298+
}) : super(key: key);
299+
300+
final bool isLoading;
301+
302+
@override
303+
Widget build(BuildContext context) {
304+
return Padding(
305+
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
306+
child: Column(
307+
crossAxisAlignment: CrossAxisAlignment.start,
308+
children: [
309+
_buildImage(),
310+
const SizedBox(height: 16),
311+
_buildText(),
312+
],
313+
),
314+
);
315+
}
316+
317+
Widget _buildImage() {
318+
return AspectRatio(
319+
aspectRatio: 16 / 9,
320+
child: Container(
321+
width: double.infinity,
322+
decoration: BoxDecoration(
323+
color: Colors.black,
324+
borderRadius: BorderRadius.circular(16),
325+
),
326+
child: ClipRRect(
327+
borderRadius: BorderRadius.circular(16),
328+
child: Image.network(
329+
'https://flutter'
330+
'.dev/docs/cookbook/img-files/effects/split-check/Food1.jpg',
331+
fit: BoxFit.cover,
332+
),
333+
),
334+
),
335+
);
336+
}
337+
338+
Widget _buildText() {
339+
if (isLoading) {
340+
return Column(
341+
crossAxisAlignment: CrossAxisAlignment.start,
342+
children: [
343+
Container(
344+
width: double.infinity,
345+
height: 24,
346+
decoration: BoxDecoration(
347+
color: Colors.black,
348+
borderRadius: BorderRadius.circular(16),
349+
),
350+
),
351+
const SizedBox(height: 16),
352+
Container(
353+
width: 250,
354+
height: 24,
355+
decoration: BoxDecoration(
356+
color: Colors.black,
357+
borderRadius: BorderRadius.circular(16),
358+
),
359+
),
360+
],
361+
);
362+
} else {
363+
return Padding(
364+
padding: const EdgeInsets.symmetric(horizontal: 8.0),
365+
child: Text(
366+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do '
367+
'eiusmod tempor incididunt ut labore et dolore magna aliqua.',
368+
),
369+
);
370+
}
371+
}
372+
}

0 commit comments

Comments
 (0)