1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
---|---|
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'dart:async'; |
6 | import 'dart:math' as math; |
7 | |
8 | import 'package:flutter/material.dart'; |
9 | import 'package:flutter/rendering.dart'; |
10 | import 'package:url_launcher/url_launcher.dart'; |
11 | |
12 | import '../constants.dart'; |
13 | import '../data/demos.dart'; |
14 | import '../data/gallery_options.dart'; |
15 | import '../gallery_localizations.dart'; |
16 | import '../layout/adaptive.dart'; |
17 | import '../studies/crane/colors.dart'; |
18 | import '../studies/crane/routes.dart' as crane_routes; |
19 | import '../studies/fortnightly/routes.dart' as fortnightly_routes; |
20 | import '../studies/rally/colors.dart'; |
21 | import '../studies/rally/routes.dart' as rally_routes; |
22 | import '../studies/reply/routes.dart' as reply_routes; |
23 | import '../studies/shrine/colors.dart'; |
24 | import '../studies/shrine/routes.dart' as shrine_routes; |
25 | import '../studies/starter/routes.dart' as starter_app_routes; |
26 | import 'category_list_item.dart'; |
27 | import 'settings.dart'; |
28 | import 'splash.dart'; |
29 | |
30 | const double _horizontalPadding = 32.0; |
31 | const double _horizontalDesktopPadding = 81.0; |
32 | const double _carouselHeightMin = 240.0; |
33 | const double _carouselItemDesktopMargin = 8.0; |
34 | const double _carouselItemMobileMargin = 4.0; |
35 | const double _carouselItemWidth = 296.0; |
36 | |
37 | class ToggleSplashNotification extends Notification {} |
38 | |
39 | class HomePage extends StatelessWidget { |
40 | const HomePage({super.key}); |
41 | |
42 | @override |
43 | Widget build(BuildContext context) { |
44 | final bool isDesktop = isDisplayDesktop(context); |
45 | final GalleryLocalizations localizations = GalleryLocalizations.of(context)!; |
46 | final Map<String, GalleryDemo> studyDemos = Demos.studies(localizations); |
47 | final List<Widget> carouselCards = <Widget>[ |
48 | _CarouselCard( |
49 | demo: studyDemos['reply'], |
50 | asset: const AssetImage('assets/studies/reply_card.png', package: 'flutter_gallery_assets'), |
51 | assetColor: const Color(0xFF344955), |
52 | assetDark: const AssetImage( |
53 | 'assets/studies/reply_card_dark.png', |
54 | package: 'flutter_gallery_assets', |
55 | ), |
56 | assetDarkColor: const Color(0xFF1D2327), |
57 | textColor: Colors.white, |
58 | studyRoute: reply_routes.homeRoute, |
59 | ), |
60 | _CarouselCard( |
61 | demo: studyDemos['shrine'], |
62 | asset: const AssetImage( |
63 | 'assets/studies/shrine_card.png', |
64 | package: 'flutter_gallery_assets', |
65 | ), |
66 | assetColor: const Color(0xFFFEDBD0), |
67 | assetDark: const AssetImage( |
68 | 'assets/studies/shrine_card_dark.png', |
69 | package: 'flutter_gallery_assets', |
70 | ), |
71 | assetDarkColor: const Color(0xFF543B3C), |
72 | textColor: shrineBrown900, |
73 | studyRoute: shrine_routes.loginRoute, |
74 | ), |
75 | _CarouselCard( |
76 | demo: studyDemos['rally'], |
77 | textColor: RallyColors.accountColors[0], |
78 | asset: const AssetImage('assets/studies/rally_card.png', package: 'flutter_gallery_assets'), |
79 | assetColor: const Color(0xFFD1F2E6), |
80 | assetDark: const AssetImage( |
81 | 'assets/studies/rally_card_dark.png', |
82 | package: 'flutter_gallery_assets', |
83 | ), |
84 | assetDarkColor: const Color(0xFF253538), |
85 | studyRoute: rally_routes.loginRoute, |
86 | ), |
87 | _CarouselCard( |
88 | demo: studyDemos['crane'], |
89 | asset: const AssetImage('assets/studies/crane_card.png', package: 'flutter_gallery_assets'), |
90 | assetColor: const Color(0xFFFBF6F8), |
91 | assetDark: const AssetImage( |
92 | 'assets/studies/crane_card_dark.png', |
93 | package: 'flutter_gallery_assets', |
94 | ), |
95 | assetDarkColor: const Color(0xFF591946), |
96 | textColor: cranePurple700, |
97 | studyRoute: crane_routes.defaultRoute, |
98 | ), |
99 | _CarouselCard( |
100 | demo: studyDemos['fortnightly'], |
101 | asset: const AssetImage( |
102 | 'assets/studies/fortnightly_card.png', |
103 | package: 'flutter_gallery_assets', |
104 | ), |
105 | assetColor: Colors.white, |
106 | assetDark: const AssetImage( |
107 | 'assets/studies/fortnightly_card_dark.png', |
108 | package: 'flutter_gallery_assets', |
109 | ), |
110 | assetDarkColor: const Color(0xFF1F1F1F), |
111 | studyRoute: fortnightly_routes.defaultRoute, |
112 | ), |
113 | _CarouselCard( |
114 | demo: studyDemos['starterApp'], |
115 | asset: const AssetImage( |
116 | 'assets/studies/starter_card.png', |
117 | package: 'flutter_gallery_assets', |
118 | ), |
119 | assetColor: const Color(0xFFFAF6FE), |
120 | assetDark: const AssetImage( |
121 | 'assets/studies/starter_card_dark.png', |
122 | package: 'flutter_gallery_assets', |
123 | ), |
124 | assetDarkColor: const Color(0xFF3F3D45), |
125 | textColor: Colors.black, |
126 | studyRoute: starter_app_routes.defaultRoute, |
127 | ), |
128 | ]; |
129 | |
130 | if (isDesktop) { |
131 | // Desktop layout |
132 | final List<_DesktopCategoryItem> desktopCategoryItems = <_DesktopCategoryItem>[ |
133 | _DesktopCategoryItem( |
134 | category: GalleryDemoCategory.material, |
135 | asset: const AssetImage( |
136 | 'assets/icons/material/material.png', |
137 | package: 'flutter_gallery_assets', |
138 | ), |
139 | demos: Demos.materialDemos(localizations), |
140 | ), |
141 | _DesktopCategoryItem( |
142 | category: GalleryDemoCategory.cupertino, |
143 | asset: const AssetImage( |
144 | 'assets/icons/cupertino/cupertino.png', |
145 | package: 'flutter_gallery_assets', |
146 | ), |
147 | demos: Demos.cupertinoDemos(localizations), |
148 | ), |
149 | _DesktopCategoryItem( |
150 | category: GalleryDemoCategory.other, |
151 | asset: const AssetImage( |
152 | 'assets/icons/reference/reference.png', |
153 | package: 'flutter_gallery_assets', |
154 | ), |
155 | demos: Demos.otherDemos(localizations), |
156 | ), |
157 | ]; |
158 | |
159 | return Scaffold( |
160 | body: ListView( |
161 | // Makes integration tests possible. |
162 | key: const ValueKey<String>('HomeListView'), |
163 | primary: true, |
164 | padding: const EdgeInsetsDirectional.only(top: firstHeaderDesktopTopPadding), |
165 | children: <Widget>[ |
166 | _DesktopHomeItem(child: _GalleryHeader()), |
167 | _DesktopCarousel(height: _carouselHeight(0.7, context), children: carouselCards), |
168 | _DesktopHomeItem(child: _CategoriesHeader()), |
169 | SizedBox( |
170 | height: 585, |
171 | child: _DesktopHomeItem( |
172 | child: Row( |
173 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
174 | children: spaceBetween(28, desktopCategoryItems), |
175 | ), |
176 | ), |
177 | ), |
178 | const SizedBox(height: 81), |
179 | _DesktopHomeItem( |
180 | child: Row( |
181 | children: <Widget>[ |
182 | MouseRegion( |
183 | cursor: SystemMouseCursors.click, |
184 | child: GestureDetector( |
185 | onTap: () async { |
186 | final Uri url = Uri.parse('https://flutter.dev'); |
187 | if (await canLaunchUrl(url)) { |
188 | await launchUrl(url); |
189 | } |
190 | }, |
191 | excludeFromSemantics: true, |
192 | child: FadeInImage( |
193 | image: |
194 | Theme.of(context).colorScheme.brightness == Brightness.dark |
195 | ? const AssetImage( |
196 | 'assets/logo/flutter_logo.png', |
197 | package:'flutter_gallery_assets', |
198 | ) |
199 | : const AssetImage( |
200 | 'assets/logo/flutter_logo_color.png', |
201 | package:'flutter_gallery_assets', |
202 | ), |
203 | placeholder: MemoryImage(kTransparentImage), |
204 | fadeInDuration: entranceAnimationDuration, |
205 | ), |
206 | ), |
207 | ), |
208 | const Expanded( |
209 | child: Wrap( |
210 | crossAxisAlignment: WrapCrossAlignment.center, |
211 | alignment: WrapAlignment.end, |
212 | children: <Widget>[ |
213 | SettingsAbout(), |
214 | SettingsFeedback(), |
215 | SettingsAttribution(), |
216 | ], |
217 | ), |
218 | ), |
219 | ], |
220 | ), |
221 | ), |
222 | const SizedBox(height: 109), |
223 | ], |
224 | ), |
225 | ); |
226 | } else { |
227 | // Mobile layout |
228 | return Scaffold( |
229 | body: _AnimatedHomePage( |
230 | restorationId:'animated_page', |
231 | isSplashPageAnimationFinished: SplashPageAnimation.of(context)!.isFinished, |
232 | carouselCards: carouselCards, |
233 | ), |
234 | ); |
235 | } |
236 | } |
237 | |
238 | List<Widget> spaceBetween(double paddingBetween, List<Widget> children) { |
239 | return <Widget>[ |
240 | for (int index = 0; index < children.length; index++) ...<Widget>[ |
241 | Flexible(child: children[index]), |
242 | if (index < children.length - 1) SizedBox(width: paddingBetween), |
243 | ], |
244 | ]; |
245 | } |
246 | } |
247 | |
248 | class _GalleryHeader extends StatelessWidget { |
249 | @override |
250 | Widget build(BuildContext context) { |
251 | return Header( |
252 | color: Theme.of(context).colorScheme.primaryContainer, |
253 | text: GalleryLocalizations.of(context)!.homeHeaderGallery, |
254 | ); |
255 | } |
256 | } |
257 | |
258 | class _CategoriesHeader extends StatelessWidget { |
259 | @override |
260 | Widget build(BuildContext context) { |
261 | return Header( |
262 | color: Theme.of(context).colorScheme.primary, |
263 | text: GalleryLocalizations.of(context)!.homeHeaderCategories, |
264 | ); |
265 | } |
266 | } |
267 | |
268 | class Header extends StatelessWidget { |
269 | const Header({super.key, required this.color, required this.text}); |
270 | |
271 | final Color color; |
272 | final String text; |
273 | |
274 | @override |
275 | Widget build(BuildContext context) { |
276 | return Align( |
277 | alignment: AlignmentDirectional.centerStart, |
278 | child: Padding( |
279 | padding: EdgeInsets.only( |
280 | top: isDisplayDesktop(context) ? 63 : 15, |
281 | bottom: isDisplayDesktop(context) ? 21 : 11, |
282 | ), |
283 | child: SelectableText( |
284 | text, |
285 | style: Theme.of(context).textTheme.headlineMedium!.apply( |
286 | color: color, |
287 | fontSizeDelta: isDisplayDesktop(context) ? desktopDisplay1FontDelta : 0, |
288 | ), |
289 | ), |
290 | ), |
291 | ); |
292 | } |
293 | } |
294 | |
295 | class _AnimatedHomePage extends StatefulWidget { |
296 | const _AnimatedHomePage({ |
297 | required this.restorationId, |
298 | required this.carouselCards, |
299 | required this.isSplashPageAnimationFinished, |
300 | }); |
301 | |
302 | final String restorationId; |
303 | final List<Widget> carouselCards; |
304 | final bool isSplashPageAnimationFinished; |
305 | |
306 | @override |
307 | _AnimatedHomePageState createState() => _AnimatedHomePageState(); |
308 | } |
309 | |
310 | class _AnimatedHomePageState extends State<_AnimatedHomePage> |
311 | with RestorationMixin, SingleTickerProviderStateMixin { |
312 | late AnimationController _animationController; |
313 | Timer? _launchTimer; |
314 | final RestorableBool _isMaterialListExpanded = RestorableBool(false); |
315 | final RestorableBool _isCupertinoListExpanded = RestorableBool(false); |
316 | final RestorableBool _isOtherListExpanded = RestorableBool(false); |
317 | |
318 | @override |
319 | String get restorationId => widget.restorationId; |
320 | |
321 | @override |
322 | void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
323 | registerForRestoration(_isMaterialListExpanded,'material_list'); |
324 | registerForRestoration(_isCupertinoListExpanded,'cupertino_list'); |
325 | registerForRestoration(_isOtherListExpanded,'other_list'); |
326 | } |
327 | |
328 | @override |
329 | void initState() { |
330 | super.initState(); |
331 | _animationController = AnimationController( |
332 | vsync: this, |
333 | duration: const Duration(milliseconds: 800), |
334 | ); |
335 | |
336 | if (widget.isSplashPageAnimationFinished) { |
337 | // To avoid the animation from running when changing the window size from |
338 | // desktop to mobile, we do not animate our widget if the |
339 | // splash page animation is finished on initState. |
340 | _animationController.value = 1.0; |
341 | } else { |
342 | // Start our animation halfway through the splash page animation. |
343 | _launchTimer = Timer(halfSplashPageAnimationDuration, () { |
344 | _animationController.forward(); |
345 | }); |
346 | } |
347 | } |
348 | |
349 | @override |
350 | void dispose() { |
351 | _animationController.dispose(); |
352 | _launchTimer?.cancel(); |
353 | _launchTimer = null; |
354 | _isMaterialListExpanded.dispose(); |
355 | _isCupertinoListExpanded.dispose(); |
356 | _isOtherListExpanded.dispose(); |
357 | super.dispose(); |
358 | } |
359 | |
360 | @override |
361 | Widget build(BuildContext context) { |
362 | final GalleryLocalizations localizations = GalleryLocalizations.of(context)!; |
363 | final bool isTestMode = GalleryOptions.of(context).isTestMode; |
364 | return Stack( |
365 | children: <Widget>[ |
366 | ListView( |
367 | // Makes integration tests possible. |
368 | key: const ValueKey<String>('HomeListView'), |
369 | primary: true, |
370 | restorationId:'home_list_view', |
371 | children: <Widget>[ |
372 | const SizedBox(height: 8), |
373 | Container( |
374 | margin: const EdgeInsets.symmetric(horizontal: _horizontalPadding), |
375 | child: _GalleryHeader(), |
376 | ), |
377 | _MobileCarousel( |
378 | animationController: _animationController, |
379 | restorationId:'home_carousel', |
380 | children: widget.carouselCards, |
381 | ), |
382 | Container( |
383 | margin: const EdgeInsets.symmetric(horizontal: _horizontalPadding), |
384 | child: _CategoriesHeader(), |
385 | ), |
386 | _AnimatedCategoryItem( |
387 | startDelayFraction: 0.00, |
388 | controller: _animationController, |
389 | child: CategoryListItem( |
390 | key: const PageStorageKey<GalleryDemoCategory>(GalleryDemoCategory.material), |
391 | restorationId:'home_material_category_list', |
392 | category: GalleryDemoCategory.material, |
393 | imageString:'assets/icons/material/material.png', |
394 | demos: Demos.materialDemos(localizations), |
395 | initiallyExpanded: _isMaterialListExpanded.value || isTestMode, |
396 | onTap: (bool shouldOpenList) { |
397 | _isMaterialListExpanded.value = shouldOpenList; |
398 | }, |
399 | ), |
400 | ), |
401 | _AnimatedCategoryItem( |
402 | startDelayFraction: 0.05, |
403 | controller: _animationController, |
404 | child: CategoryListItem( |
405 | key: const PageStorageKey<GalleryDemoCategory>(GalleryDemoCategory.cupertino), |
406 | restorationId:'home_cupertino_category_list', |
407 | category: GalleryDemoCategory.cupertino, |
408 | imageString:'assets/icons/cupertino/cupertino.png', |
409 | demos: Demos.cupertinoDemos(localizations), |
410 | initiallyExpanded: _isCupertinoListExpanded.value || isTestMode, |
411 | onTap: (bool shouldOpenList) { |
412 | _isCupertinoListExpanded.value = shouldOpenList; |
413 | }, |
414 | ), |
415 | ), |
416 | _AnimatedCategoryItem( |
417 | startDelayFraction: 0.10, |
418 | controller: _animationController, |
419 | child: CategoryListItem( |
420 | key: const PageStorageKey<GalleryDemoCategory>(GalleryDemoCategory.other), |
421 | restorationId:'home_other_category_list', |
422 | category: GalleryDemoCategory.other, |
423 | imageString:'assets/icons/reference/reference.png', |
424 | demos: Demos.otherDemos(localizations), |
425 | initiallyExpanded: _isOtherListExpanded.value || isTestMode, |
426 | onTap: (bool shouldOpenList) { |
427 | _isOtherListExpanded.value = shouldOpenList; |
428 | }, |
429 | ), |
430 | ), |
431 | ], |
432 | ), |
433 | Align( |
434 | alignment: Alignment.topCenter, |
435 | child: GestureDetector( |
436 | onVerticalDragEnd: (DragEndDetails details) { |
437 | if (details.velocity.pixelsPerSecond.dy > 200) { |
438 | ToggleSplashNotification().dispatch(context); |
439 | } |
440 | }, |
441 | child: SafeArea( |
442 | child: Container( |
443 | height: 40, |
444 | // If we don't set the color, gestures are not detected. |
445 | color: Colors.transparent, |
446 | ), |
447 | ), |
448 | ), |
449 | ), |
450 | ], |
451 | ); |
452 | } |
453 | } |
454 | |
455 | class _DesktopHomeItem extends StatelessWidget { |
456 | const _DesktopHomeItem({required this.child}); |
457 | |
458 | final Widget child; |
459 | |
460 | @override |
461 | Widget build(BuildContext context) { |
462 | return Align( |
463 | child: Container( |
464 | constraints: const BoxConstraints(maxWidth: maxHomeItemWidth), |
465 | padding: const EdgeInsets.symmetric(horizontal: _horizontalDesktopPadding), |
466 | child: child, |
467 | ), |
468 | ); |
469 | } |
470 | } |
471 | |
472 | class _DesktopCategoryItem extends StatelessWidget { |
473 | const _DesktopCategoryItem({required this.category, required this.asset, required this.demos}); |
474 | |
475 | final GalleryDemoCategory category; |
476 | final ImageProvider asset; |
477 | final List<GalleryDemo> demos; |
478 | |
479 | @override |
480 | Widget build(BuildContext context) { |
481 | final ColorScheme colorScheme = Theme.of(context).colorScheme; |
482 | return Material( |
483 | borderRadius: BorderRadius.circular(10), |
484 | clipBehavior: Clip.antiAlias, |
485 | color: colorScheme.surface, |
486 | child: Semantics( |
487 | container: true, |
488 | child: FocusTraversalGroup( |
489 | policy: WidgetOrderTraversalPolicy(), |
490 | child: Column( |
491 | children: <Widget>[ |
492 | _DesktopCategoryHeader(category: category, asset: asset), |
493 | Divider(height: 2, thickness: 2, color: colorScheme.background), |
494 | Flexible( |
495 | child: ListView.builder( |
496 | // Makes integration tests possible. |
497 | key: ValueKey<String>('${category.name}DemoList'), |
498 | primary: false, |
499 | itemBuilder: |
500 | (BuildContext context, int index) => CategoryDemoItem(demo: demos[index]), |
501 | itemCount: demos.length, |
502 | ), |
503 | ), |
504 | ], |
505 | ), |
506 | ), |
507 | ), |
508 | ); |
509 | } |
510 | } |
511 | |
512 | class _DesktopCategoryHeader extends StatelessWidget { |
513 | const _DesktopCategoryHeader({required this.category, required this.asset}); |
514 | |
515 | final GalleryDemoCategory category; |
516 | final ImageProvider asset; |
517 | |
518 | @override |
519 | Widget build(BuildContext context) { |
520 | final ColorScheme colorScheme = Theme.of(context).colorScheme; |
521 | return Material( |
522 | // Makes integration tests possible. |
523 | key: ValueKey<String>('${category.name}CategoryHeader'), |
524 | color: colorScheme.onBackground, |
525 | child: Row( |
526 | children: <Widget>[ |
527 | Padding( |
528 | padding: const EdgeInsets.all(10), |
529 | child: FadeInImage( |
530 | image: asset, |
531 | placeholder: MemoryImage(kTransparentImage), |
532 | fadeInDuration: entranceAnimationDuration, |
533 | width: 64, |
534 | height: 64, |
535 | excludeFromSemantics: true, |
536 | ), |
537 | ), |
538 | Flexible( |
539 | child: Padding( |
540 | padding: const EdgeInsetsDirectional.only(start: 8), |
541 | child: Semantics( |
542 | header: true, |
543 | child: SelectableText( |
544 | category.displayTitle(GalleryLocalizations.of(context)!)!, |
545 | style: Theme.of( |
546 | context, |
547 | ).textTheme.headlineSmall!.apply(color: colorScheme.onSurface), |
548 | ), |
549 | ), |
550 | ), |
551 | ), |
552 | ], |
553 | ), |
554 | ); |
555 | } |
556 | } |
557 | |
558 | /// Animates the category item to stagger in. The [_AnimatedCategoryItem.startDelayFraction] |
559 | /// gives a delay in the unit of a fraction of the whole animation duration, |
560 | /// which is defined in [_AnimatedHomePageState]. |
561 | class _AnimatedCategoryItem extends StatelessWidget { |
562 | _AnimatedCategoryItem({ |
563 | required double startDelayFraction, |
564 | required this.controller, |
565 | required this.child, |
566 | }) : topPaddingAnimation = Tween<double>(begin: 60.0, end: 0.0).animate( |
567 | CurvedAnimation( |
568 | parent: controller, |
569 | curve: Interval( |
570 | 0.000 + startDelayFraction, |
571 | 0.400 + startDelayFraction, |
572 | curve: Curves.ease, |
573 | ), |
574 | ), |
575 | ); |
576 | |
577 | final Widget child; |
578 | final AnimationController controller; |
579 | final Animation<double> topPaddingAnimation; |
580 | |
581 | @override |
582 | Widget build(BuildContext context) { |
583 | return AnimatedBuilder( |
584 | animation: controller, |
585 | builder: (BuildContext context, Widget? child) { |
586 | return Padding(padding: EdgeInsets.only(top: topPaddingAnimation.value), child: child); |
587 | }, |
588 | child: child, |
589 | ); |
590 | } |
591 | } |
592 | |
593 | /// Animates the carousel to come in from the right. |
594 | class _AnimatedCarousel extends StatelessWidget { |
595 | _AnimatedCarousel({required this.child, required this.controller}) |
596 | : startPositionAnimation = Tween<double>(begin: 1.0, end: 0.0).animate( |
597 | CurvedAnimation( |
598 | parent: controller, |
599 | curve: const Interval(0.200, 0.800, curve: Curves.ease), |
600 | ), |
601 | ); |
602 | |
603 | final Widget child; |
604 | final AnimationController controller; |
605 | final Animation<double> startPositionAnimation; |
606 | |
607 | @override |
608 | Widget build(BuildContext context) { |
609 | return LayoutBuilder( |
610 | builder: (BuildContext context, BoxConstraints constraints) { |
611 | return Stack( |
612 | children: <Widget>[ |
613 | SizedBox(height: _carouselHeight(.4, context)), |
614 | AnimatedBuilder( |
615 | animation: controller, |
616 | builder: (BuildContext context, Widget? child) { |
617 | return PositionedDirectional( |
618 | start: constraints.maxWidth * startPositionAnimation.value, |
619 | child: child!, |
620 | ); |
621 | }, |
622 | child: SizedBox( |
623 | height: _carouselHeight(.4, context), |
624 | width: constraints.maxWidth, |
625 | child: child, |
626 | ), |
627 | ), |
628 | ], |
629 | ); |
630 | }, |
631 | ); |
632 | } |
633 | } |
634 | |
635 | /// Animates a carousel card to come in from the right. |
636 | class _AnimatedCarouselCard extends StatelessWidget { |
637 | _AnimatedCarouselCard({required this.child, required this.controller}) |
638 | : startPaddingAnimation = Tween<double>(begin: _horizontalPadding, end: 0.0).animate( |
639 | CurvedAnimation( |
640 | parent: controller, |
641 | curve: const Interval(0.900, 1.000, curve: Curves.ease), |
642 | ), |
643 | ); |
644 | |
645 | final Widget child; |
646 | final AnimationController controller; |
647 | final Animation<double> startPaddingAnimation; |
648 | |
649 | @override |
650 | Widget build(BuildContext context) { |
651 | return AnimatedBuilder( |
652 | animation: controller, |
653 | builder: (BuildContext context, Widget? child) { |
654 | return Padding( |
655 | padding: EdgeInsetsDirectional.only(start: startPaddingAnimation.value), |
656 | child: child, |
657 | ); |
658 | }, |
659 | child: child, |
660 | ); |
661 | } |
662 | } |
663 | |
664 | class _MobileCarousel extends StatefulWidget { |
665 | const _MobileCarousel({ |
666 | required this.animationController, |
667 | this.restorationId, |
668 | required this.children, |
669 | }); |
670 | |
671 | final AnimationController animationController; |
672 | final String? restorationId; |
673 | final List<Widget> children; |
674 | |
675 | @override |
676 | _MobileCarouselState createState() => _MobileCarouselState(); |
677 | } |
678 | |
679 | class _MobileCarouselState extends State<_MobileCarousel> |
680 | with RestorationMixin, SingleTickerProviderStateMixin { |
681 | late PageController _controller; |
682 | |
683 | final RestorableInt _currentPage = RestorableInt(0); |
684 | |
685 | @override |
686 | String? get restorationId => widget.restorationId; |
687 | |
688 | @override |
689 | void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
690 | registerForRestoration(_currentPage,'carousel_page'); |
691 | } |
692 | |
693 | @override |
694 | void didChangeDependencies() { |
695 | super.didChangeDependencies(); |
696 | // The viewPortFraction is calculated as the width of the device minus the |
697 | // padding. |
698 | final double width = MediaQuery.of(context).size.width; |
699 | const double padding = _carouselItemMobileMargin * 2; |
700 | _controller = PageController( |
701 | initialPage: _currentPage.value, |
702 | viewportFraction: (_carouselItemWidth + padding) / width, |
703 | ); |
704 | } |
705 | |
706 | @override |
707 | void dispose() { |
708 | _controller.dispose(); |
709 | _currentPage.dispose(); |
710 | super.dispose(); |
711 | } |
712 | |
713 | Widget builder(int index) { |
714 | final AnimatedBuilder carouselCard = AnimatedBuilder( |
715 | animation: _controller, |
716 | builder: (BuildContext context, Widget? child) { |
717 | double value; |
718 | if (_controller.position.haveDimensions) { |
719 | value = _controller.page! - index; |
720 | } else { |
721 | // If haveDimensions is false, use _currentPage to calculate value. |
722 | value = (_currentPage.value - index).toDouble(); |
723 | } |
724 | // .3 is an approximation of the curve used in the design. |
725 | value = (1 - (value.abs() * .3)).clamp(0, 1).toDouble(); |
726 | value = Curves.easeOut.transform(value); |
727 | |
728 | return Transform.scale(scale: value, child: child); |
729 | }, |
730 | child: widget.children[index], |
731 | ); |
732 | |
733 | // We only want the second card to be animated. |
734 | if (index == 1) { |
735 | return _AnimatedCarouselCard(controller: widget.animationController, child: carouselCard); |
736 | } else { |
737 | return carouselCard; |
738 | } |
739 | } |
740 | |
741 | @override |
742 | Widget build(BuildContext context) { |
743 | return _AnimatedCarousel( |
744 | controller: widget.animationController, |
745 | child: PageView.builder( |
746 | // Makes integration tests possible. |
747 | key: const ValueKey<String>('studyDemoList'), |
748 | onPageChanged: (int value) { |
749 | setState(() { |
750 | _currentPage.value = value; |
751 | }); |
752 | }, |
753 | controller: _controller, |
754 | pageSnapping: false, |
755 | itemCount: widget.children.length, |
756 | itemBuilder: (BuildContext context, int index) => builder(index), |
757 | allowImplicitScrolling: true, |
758 | ), |
759 | ); |
760 | } |
761 | } |
762 | |
763 | /// This creates a horizontally scrolling [ListView] of items. |
764 | /// |
765 | /// This class uses a [ListView] with a custom [ScrollPhysics] to enable |
766 | /// snapping behavior. A [PageView] was considered but does not allow for |
767 | /// multiple pages visible without centering the first page. |
768 | class _DesktopCarousel extends StatefulWidget { |
769 | const _DesktopCarousel({required this.height, required this.children}); |
770 | |
771 | final double height; |
772 | final List<Widget> children; |
773 | |
774 | @override |
775 | _DesktopCarouselState createState() => _DesktopCarouselState(); |
776 | } |
777 | |
778 | class _DesktopCarouselState extends State<_DesktopCarousel> { |
779 | late ScrollController _controller; |
780 | |
781 | @override |
782 | void initState() { |
783 | super.initState(); |
784 | _controller = ScrollController(); |
785 | _controller.addListener(() { |
786 | setState(() {}); |
787 | }); |
788 | } |
789 | |
790 | @override |
791 | void dispose() { |
792 | _controller.dispose(); |
793 | super.dispose(); |
794 | } |
795 | |
796 | @override |
797 | Widget build(BuildContext context) { |
798 | bool showPreviousButton = false; |
799 | bool showNextButton = true; |
800 | // Only check this after the _controller has been attached to the ListView. |
801 | if (_controller.hasClients) { |
802 | showPreviousButton = _controller.offset > 0; |
803 | showNextButton = _controller.offset < _controller.position.maxScrollExtent; |
804 | } |
805 | |
806 | final bool isDesktop = isDisplayDesktop(context); |
807 | |
808 | return Align( |
809 | child: Container( |
810 | height: widget.height, |
811 | constraints: const BoxConstraints(maxWidth: maxHomeItemWidth), |
812 | child: Stack( |
813 | children: <Widget>[ |
814 | ListView.builder( |
815 | padding: EdgeInsets.symmetric( |
816 | horizontal: |
817 | isDesktop |
818 | ? _horizontalDesktopPadding - _carouselItemDesktopMargin |
819 | : _horizontalPadding - _carouselItemMobileMargin, |
820 | ), |
821 | scrollDirection: Axis.horizontal, |
822 | primary: false, |
823 | physics: const _SnappingScrollPhysics(), |
824 | controller: _controller, |
825 | itemExtent: _carouselItemWidth, |
826 | itemCount: widget.children.length, |
827 | itemBuilder: |
828 | (BuildContext context, int index) => Padding( |
829 | padding: const EdgeInsets.symmetric(vertical: 8.0), |
830 | child: widget.children[index], |
831 | ), |
832 | ), |
833 | if (showPreviousButton) |
834 | _DesktopPageButton( |
835 | onTap: () { |
836 | _controller.animateTo( |
837 | _controller.offset - _carouselItemWidth, |
838 | duration: const Duration(milliseconds: 200), |
839 | curve: Curves.easeInOut, |
840 | ); |
841 | }, |
842 | ), |
843 | if (showNextButton) |
844 | _DesktopPageButton( |
845 | isEnd: true, |
846 | onTap: () { |
847 | _controller.animateTo( |
848 | _controller.offset + _carouselItemWidth, |
849 | duration: const Duration(milliseconds: 200), |
850 | curve: Curves.easeInOut, |
851 | ); |
852 | }, |
853 | ), |
854 | ], |
855 | ), |
856 | ), |
857 | ); |
858 | } |
859 | } |
860 | |
861 | /// Scrolling physics that snaps to the new item in the [_DesktopCarousel]. |
862 | class _SnappingScrollPhysics extends ScrollPhysics { |
863 | const _SnappingScrollPhysics({super.parent}); |
864 | |
865 | @override |
866 | _SnappingScrollPhysics applyTo(ScrollPhysics? ancestor) { |
867 | return _SnappingScrollPhysics(parent: buildParent(ancestor)); |
868 | } |
869 | |
870 | double _getTargetPixels(ScrollMetrics position, Tolerance tolerance, double velocity) { |
871 | final double itemWidth = position.viewportDimension / 4; |
872 | double item = position.pixels / itemWidth; |
873 | if (velocity < -tolerance.velocity) { |
874 | item -= 0.5; |
875 | } else if (velocity > tolerance.velocity) { |
876 | item += 0.5; |
877 | } |
878 | return math.min(item.roundToDouble() * itemWidth, position.maxScrollExtent); |
879 | } |
880 | |
881 | @override |
882 | Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { |
883 | if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) || |
884 | (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) { |
885 | return super.createBallisticSimulation(position, velocity); |
886 | } |
887 | final Tolerance tolerance = toleranceFor(position); |
888 | final double target = _getTargetPixels(position, tolerance, velocity); |
889 | if (target != position.pixels) { |
890 | return ScrollSpringSimulation( |
891 | spring, |
892 | position.pixels, |
893 | target, |
894 | velocity, |
895 | tolerance: tolerance, |
896 | ); |
897 | } |
898 | return null; |
899 | } |
900 | |
901 | @override |
902 | bool get allowImplicitScrolling => true; |
903 | } |
904 | |
905 | class _DesktopPageButton extends StatelessWidget { |
906 | const _DesktopPageButton({this.isEnd = false, this.onTap}); |
907 | |
908 | final bool isEnd; |
909 | final GestureTapCallback? onTap; |
910 | |
911 | @override |
912 | Widget build(BuildContext context) { |
913 | const double buttonSize = 58.0; |
914 | const double padding = _horizontalDesktopPadding - buttonSize / 2; |
915 | return ExcludeSemantics( |
916 | child: Align( |
917 | alignment: isEnd ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart, |
918 | child: Container( |
919 | width: buttonSize, |
920 | height: buttonSize, |
921 | margin: EdgeInsetsDirectional.only(start: isEnd ? 0 : padding, end: isEnd ? padding : 0), |
922 | child: Tooltip( |
923 | message: |
924 | isEnd |
925 | ? MaterialLocalizations.of(context).nextPageTooltip |
926 | : MaterialLocalizations.of(context).previousPageTooltip, |
927 | child: Material( |
928 | color: Colors.black.withOpacity(0.5), |
929 | shape: const CircleBorder(), |
930 | clipBehavior: Clip.antiAlias, |
931 | child: InkWell( |
932 | onTap: onTap, |
933 | child: Icon( |
934 | isEnd ? Icons.arrow_forward_ios : Icons.arrow_back_ios, |
935 | color: Colors.white, |
936 | ), |
937 | ), |
938 | ), |
939 | ), |
940 | ), |
941 | ), |
942 | ); |
943 | } |
944 | } |
945 | |
946 | class _CarouselCard extends StatelessWidget { |
947 | const _CarouselCard({ |
948 | required this.demo, |
949 | this.asset, |
950 | this.assetDark, |
951 | this.assetColor, |
952 | this.assetDarkColor, |
953 | this.textColor, |
954 | required this.studyRoute, |
955 | }); |
956 | |
957 | final GalleryDemo? demo; |
958 | final ImageProvider? asset; |
959 | final ImageProvider? assetDark; |
960 | final Color? assetColor; |
961 | final Color? assetDarkColor; |
962 | final Color? textColor; |
963 | final String studyRoute; |
964 | |
965 | @override |
966 | Widget build(BuildContext context) { |
967 | final TextTheme textTheme = Theme.of(context).textTheme; |
968 | final bool isDark = Theme.of(context).colorScheme.brightness == Brightness.dark; |
969 | final ImageProvider<Object>? asset = isDark ? assetDark : this.asset; |
970 | final Color? assetColor = isDark ? assetDarkColor : this.assetColor; |
971 | final Color? textColor = isDark ? Colors.white.withOpacity(0.87) : this.textColor; |
972 | final bool isDesktop = isDisplayDesktop(context); |
973 | |
974 | return Container( |
975 | padding: EdgeInsets.symmetric( |
976 | horizontal: isDesktop ? _carouselItemDesktopMargin : _carouselItemMobileMargin, |
977 | ), |
978 | margin: const EdgeInsets.symmetric(vertical: 16.0), |
979 | height: _carouselHeight(0.7, context), |
980 | width: _carouselItemWidth, |
981 | child: Material( |
982 | // Makes integration tests possible. |
983 | key: ValueKey<String>(demo!.describe), |
984 | color: assetColor, |
985 | elevation: 4, |
986 | shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), |
987 | clipBehavior: Clip.antiAlias, |
988 | child: Stack( |
989 | fit: StackFit.expand, |
990 | children: <Widget>[ |
991 | if (asset != null) |
992 | FadeInImage( |
993 | image: asset, |
994 | placeholder: MemoryImage(kTransparentImage), |
995 | fit: BoxFit.cover, |
996 | height: _carouselHeightMin, |
997 | fadeInDuration: entranceAnimationDuration, |
998 | ), |
999 | Padding( |
1000 | padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 16), |
1001 | child: Column( |
1002 | crossAxisAlignment: CrossAxisAlignment.start, |
1003 | mainAxisAlignment: MainAxisAlignment.end, |
1004 | children: <Widget>[ |
1005 | Text( |
1006 | demo!.title, |
1007 | style: textTheme.bodySmall!.apply(color: textColor), |
1008 | maxLines: 3, |
1009 | overflow: TextOverflow.visible, |
1010 | ), |
1011 | Text( |
1012 | demo!.subtitle, |
1013 | style: textTheme.labelSmall!.apply(color: textColor), |
1014 | maxLines: 5, |
1015 | overflow: TextOverflow.visible, |
1016 | ), |
1017 | ], |
1018 | ), |
1019 | ), |
1020 | Positioned.fill( |
1021 | child: Material( |
1022 | color: Colors.transparent, |
1023 | child: InkWell( |
1024 | onTap: () { |
1025 | Navigator.of( |
1026 | context, |
1027 | ).popUntil((Route<void> route) => route.settings.name =='/'); |
1028 | Navigator.of(context).restorablePushNamed(studyRoute); |
1029 | }, |
1030 | ), |
1031 | ), |
1032 | ), |
1033 | ], |
1034 | ), |
1035 | ), |
1036 | ); |
1037 | } |
1038 | } |
1039 | |
1040 | double _carouselHeight(double scaleFactor, BuildContext context) => math.max( |
1041 | _carouselHeightMin * GalleryOptions.of(context).textScaleFactor(context) * scaleFactor, |
1042 | _carouselHeightMin, |
1043 | ); |
1044 | |
1045 | /// Wrap the studies with this to display a back button and allow the user to |
1046 | /// exit them at any time. |
1047 | class StudyWrapper extends StatefulWidget { |
1048 | const StudyWrapper({ |
1049 | super.key, |
1050 | required this.study, |
1051 | this.alignment = AlignmentDirectional.bottomStart, |
1052 | this.hasBottomNavBar = false, |
1053 | }); |
1054 | |
1055 | final Widget study; |
1056 | final bool hasBottomNavBar; |
1057 | final AlignmentDirectional alignment; |
1058 | |
1059 | @override |
1060 | State<StudyWrapper> createState() => _StudyWrapperState(); |
1061 | } |
1062 | |
1063 | class _StudyWrapperState extends State<StudyWrapper> { |
1064 | @override |
1065 | Widget build(BuildContext context) { |
1066 | final ColorScheme colorScheme = Theme.of(context).colorScheme; |
1067 | final TextTheme textTheme = Theme.of(context).textTheme; |
1068 | return ApplyTextOptions( |
1069 | child: Stack( |
1070 | children: <Widget>[ |
1071 | Semantics( |
1072 | sortKey: const OrdinalSortKey(1), |
1073 | child: RestorationScope(restorationId:'study_wrapper', child: widget.study), |
1074 | ), |
1075 | SafeArea( |
1076 | child: Align( |
1077 | alignment: widget.alignment, |
1078 | child: Padding( |
1079 | padding: EdgeInsets.symmetric( |
1080 | horizontal: 16.0, |
1081 | vertical: widget.hasBottomNavBar ? kBottomNavigationBarHeight + 16.0 : 16.0, |
1082 | ), |
1083 | child: Semantics( |
1084 | sortKey: const OrdinalSortKey(0), |
1085 | label: GalleryLocalizations.of(context)!.backToGallery, |
1086 | button: true, |
1087 | enabled: true, |
1088 | excludeSemantics: true, |
1089 | child: FloatingActionButton.extended( |
1090 | heroTag: _BackButtonHeroTag(), |
1091 | key: const ValueKey<String>('Back'), |
1092 | onPressed: () { |
1093 | Navigator.of( |
1094 | context, |
1095 | ).popUntil((Route<void> route) => route.settings.name =='/'); |
1096 | }, |
1097 | icon: IconTheme( |
1098 | data: IconThemeData(color: colorScheme.onPrimary), |
1099 | child: const BackButtonIcon(), |
1100 | ), |
1101 | label: Text( |
1102 | MaterialLocalizations.of(context).backButtonTooltip, |
1103 | style: textTheme.labelLarge!.apply(color: colorScheme.onPrimary), |
1104 | ), |
1105 | ), |
1106 | ), |
1107 | ), |
1108 | ), |
1109 | ), |
1110 | ], |
1111 | ), |
1112 | ); |
1113 | } |
1114 | } |
1115 | |
1116 | class _BackButtonHeroTag {} |
1117 |
Definitions
- _horizontalPadding
- _horizontalDesktopPadding
- _carouselHeightMin
- _carouselItemDesktopMargin
- _carouselItemMobileMargin
- _carouselItemWidth
- ToggleSplashNotification
- HomePage
- HomePage
- build
- spaceBetween
- _GalleryHeader
- build
- _CategoriesHeader
- build
- Header
- Header
- build
- _AnimatedHomePage
- _AnimatedHomePage
- createState
- _AnimatedHomePageState
- restorationId
- restoreState
- initState
- dispose
- build
- _DesktopHomeItem
- _DesktopHomeItem
- build
- _DesktopCategoryItem
- _DesktopCategoryItem
- build
- _DesktopCategoryHeader
- _DesktopCategoryHeader
- build
- _AnimatedCategoryItem
- _AnimatedCategoryItem
- build
- _AnimatedCarousel
- _AnimatedCarousel
- build
- _AnimatedCarouselCard
- _AnimatedCarouselCard
- build
- _MobileCarousel
- _MobileCarousel
- createState
- _MobileCarouselState
- restorationId
- restoreState
- didChangeDependencies
- dispose
- builder
- build
- _DesktopCarousel
- _DesktopCarousel
- createState
- _DesktopCarouselState
- initState
- dispose
- build
- _SnappingScrollPhysics
- _SnappingScrollPhysics
- applyTo
- _getTargetPixels
- createBallisticSimulation
- allowImplicitScrolling
- _DesktopPageButton
- _DesktopPageButton
- build
- _CarouselCard
- _CarouselCard
- build
- _carouselHeight
- StudyWrapper
- StudyWrapper
- createState
- _StudyWrapperState
- build
Learn more about Flutter for embedded and desktop on industrialflutter.com