בונים מעברים יפים עם תנועה מהותית לריחוף

1. מבוא

Material Design היא מערכת ליצירת מוצרים דיגיטליים נועזים ומרהיבים. כשמשלבים סגנון, מיתוג, אינטראקציה ותנועה במערך עקבי של עקרונות ורכיבים, צוותי המוצרים יכולים לממש את פוטנציאל העיצוב הגדול ביותר.

logo_components_color_2x_web_96dp.png

Material Components (MDC) עוזר למפתחים להטמיע Material Design. MDC נוצר על ידי צוות של מהנדסים ומעצבי חוויית המשתמש ב-Google, שכולל עשרות רכיבים יפים ופונקציונליים של ממשק המשתמש. זמין ל-Android, ל-iOS, לאינטרנט ול-Flutter.material.io/develop

מהי מערכת התנועה של Material ל-Flutter?

מערכת התנועה Material של Flutter היא קבוצה של דפוסי מעבר בתוך חבילת האנימציות, שיכולים לעזור למשתמשים להבין אפליקציה ולנווט בה, כפי שמתואר בהנחיות של עיצוב Material Design.

ארבעת הדפוסים העיקריים של מעבר Material הם:

  • טרנספורמציה של קונטיינר: מעבר בין רכיבי ממשק משתמש שכוללים קונטיינר; יוצרת חיבור גלוי בין שני רכיבים נפרדים בממשק המשתמש על ידי המרה חלקה של רכיב אחד לרכיב אחר.

11807bdf36c66657.gif

  • ציר משותף: מעברים בין רכיבי ממשק משתמש שיש להם קשר מרחבי או ניווטי; משתמשת בטרנספורמציה משותפת על ציר ה-x, ה-y או ה-z כדי לחזק את הקשר בין יסודות.

71218f390abae07e.gif

  • עמעום: מעבר בין אלמנטים של ממשק משתמש שאין להם קשר חזק זה לזה; משתמשת באפקט הדרגתי ועמעום הדרגתי, עם קנה מידה של הרכיב הנכנס.

385ba37b8da68969.gif

  • עמעום: משמש לרכיבים בממשק המשתמש שנכנסים לגבולות המסך או יוצאים מהם.

cfc40fd6e27753b6.gif

חבילת האנימציות מציעה ווידג'טים של מעבר לתבניות האלה, שבנויים על ספריית האנימציות של Flutter (flutter/animation.dart) וספריית החומרים של Flutter (flutter/material.dart):

ב-Codelab הזה תשתמשו במעברי Material שנבנו על ידי Flutter framework ו-Material Library, כלומר אתם משתמשים בווידג'טים. :)

מה תפַתחו

ה-Codelab הזה ינחה אותך בתהליך הבנייה של כמה מעברים לאפליקציית אימייל לדוגמה של Flutter בשם Reply, בעזרת Drt, במטרה להדגים איך אפשר להשתמש במעברים מחבילת האנימציות כדי להתאים אישית את המראה והסגנון של האפליקציה.

הקוד ההתחלתי של אפליקציית 'תשובה' יסופק, ואתם תשלבו את המעברים הבאים ב-Material באפליקציה. את המעברים הבאים אפשר לראות בקובץ ה-GIF שהושלם על ידי Codelab:

  • מעבר Container Transform מרשימת כתובות האימייל לדף הפרטים של האימייל
  • מעבר של Container Transform מ-FAB לכתיבת דף אימייל
  • מעבר ציר ה-Z המשותף מסמל החיפוש לדף של תצוגת החיפוש
  • מעבר עמעום בין דפי תיבות דואר
  • מעבר Fade Through בין הכתיבה לבין אישור FAB לתשובה.
  • מעבר עמעום בין כותרת תיבת הדואר שנעלמת
  • מעבר עמעום בין פעולות בסרגל התחתון של האפליקציה

b26fe84fed12d17d.gif

מה צריך להכין

  • ידע בסיסי בהתפתחות של Flutter וב-Dart
  • עורך קוד
  • אמולטור או מכשיר של Android/iOS
  • הקוד לדוגמה (מידע נוסף מופיע בשלב הבא)

איזה דירוג מגיע לדעתך לרמת הניסיון שלך בפיתוח אפליקציות Flutter?

מתחילים בינונית בקיאים

מה היית רוצה ללמוד מ-Codelab הזה?

הנושא חדש ורציתי לקבל סקירה כללית טובה. יש לי מושג בנושא הזה, אבל אני רוצה לרענן את הידע שלי. אני רוצה קוד לדוגמה לשימוש בפרויקט שלי. אני רוצה הסבר למשהו ספציפי.

2. הגדרת סביבת הפיתוח של Flutter

כדי להשלים את שיעור ה-Lab הזה אתם צריכים שתי תוכנות: Flutter SDK וכלי עריכה.

אפשר להריץ את Codelab באמצעות כל אחד מהמכשירים הבאים:

  • מכשיר פיזי שמשמש ל-Android או ל-iOS שמחובר למחשב ומוגדר ל'מצב פיתוח'.
  • הסימולטור של iOS (צריך להתקין כלים של Xcode).
  • האמולטור של Android (נדרשת הגדרה ב-Android Studio).
  • דפדפן (Chrome נדרש לניפוי באגים).
  • בתור אפליקציית Windows , Linux או macOS למחשב. צריך לפתח בפלטפורמה שבה אתם מתכננים לפרוס. לכן, כדי לפתח אפליקציה למחשב של Windows, צריך לפתח את האפליקציה ב-Windows כדי לגשת לשרשרת ה-build המתאימה. יש דרישות ספציפיות למערכת ההפעלה שמפורטות בהרחבה בכתובת docs.flutter.dev/desktop.

3. הורדת האפליקציה לתחילת העבודה של Codelab

אפשרות 1: שכפול אפליקציית Codelab למתחילים מ-GitHub

כדי לשכפל את codelab הזה מ-GitHub, מריצים את הפקודות הבאות:

git clone https://github.com/material-components/material-components-flutter-motion-codelab.git
cd material-components-flutter-motion-codelab

אפשרות 2: מורידים את קובץ ה-ZIP של אפליקציית Codelab למתחילים

האפליקציה לתחילת הפעולה נמצאת בספרייה material-components-flutter-motion-codelab-starter.

אימות יחסי התלות של פרויקטים

הפרויקט תלוי בחבילת האנימציות. בpubspec.yaml, חשוב לשים לב שהקטע dependencies כולל את הדברים הבאים:

animations: ^2.0.0

פותחים את הפרויקט ומפעילים את האפליקציה

  1. פותחים את הפרויקט בכלי עריכה לבחירתכם.
  2. פועלים לפי ההוראות ל'הפעלת האפליקציה'. בקטע שנתחיל?: נסיעת מבחן בכלי העריכה שבחרתם.

הצלחת! קוד הסימן לתחילת הפעולה בדף הבית של התשובה אמור לפעול במכשיר או באמולטור שלכם. תיבת הדואר הנכנס אמורה להכיל רשימה של כתובות אימייל.

דף הבית של תשובות

אופציונלי: האטה של האנימציות במכשיר

מאחר שה-Codelab הזה כולל מעברים מהירים אבל מלוטשים, כדאי להאט את האנימציות במכשיר כדי לראות פרטים מדויקים יותר לגבי המעברים במהלך ההטמעה. ניתן לעשות זאת באמצעות הגדרה בתוך האפליקציה. ניתן לגשת אליה בהקשה על סמל ההגדרות כשחלונית ההזזה התחתונה פתוחה. אל דאגה, השיטה הזו של האטת האנימציות במכשיר לא תשפיע על האנימציות במכשיר מחוץ לאפליקציית 'תשובה'.

d23a7bfacffac509.gif

אופציונלי: מצב כהה

אם הנושא הבהיר של 'תשובה' פוגע לעיניים, אל תסתכלו עוד. באפליקציה יש הגדרה שמאפשרת לשנות את עיצוב האפליקציה למצב כהה כדי שיתאימו לעיניים שלך. כדי לגשת להגדרה הזו, מקישים על סמל ההגדרות כשחלונית ההזזה התחתונה פתוחה.

87618d8418eee19e.gif

4. היכרות עם קוד האפליקציה לדוגמה

בואו נסתכל על הקוד. אנחנו מספקים אפליקציה שמשתמשת בחבילת האנימציות כדי לעבור בין מסכים שונים באפליקציה.

  • דף הבית: מציג את תיבת הדואר שנבחרה
  • InboxPage: מציג רשימה של כתובות אימייל
  • MailPreviewCard: הצגת תצוגה מקדימה של הודעת אימייל
  • MailViewPage: מציג הודעת אימייל מלאה אחת.
  • ComposePage: מאפשר לכתוב הודעת אימייל חדשה
  • דף החיפוש: מציג תצוגה של חיפוש

router.dart

כדי להבין איך מוגדר הניווט ברמה הבסיסית של האפליקציה, אפשר לפתוח את router.dart בספרייה lib:

class ReplyRouterDelegate extends RouterDelegate<ReplyRoutePath>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin<ReplyRoutePath> {
 ReplyRouterDelegate({required this.replyState})
     : navigatorKey = GlobalObjectKey<NavigatorState>(replyState) {
   replyState.addListener(() {
     notifyListeners();
   });
 }

 @override
 final GlobalKey<NavigatorState> navigatorKey;

 RouterProvider replyState;

 @override
 void dispose() {
   replyState.removeListener(notifyListeners);
   super.dispose();
 }

 @override
 ReplyRoutePath get currentConfiguration => replyState.routePath!;

 @override
 Widget build(BuildContext context) {
   return MultiProvider(
     providers: [
       ChangeNotifierProvider<RouterProvider>.value(value: replyState),
     ],
     child: Selector<RouterProvider, ReplyRoutePath?>(
       selector: (context, routerProvider) => routerProvider.routePath,
       builder: (context, routePath, child) {
         return Navigator(
           key: navigatorKey,
           onPopPage: _handlePopPage,
           pages: [
             // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
             const CustomTransitionPage(
               transitionKey: ValueKey('Home'),
               screen: HomePage(),
             ),
             if (routePath is ReplySearchPath)
               const CustomTransitionPage(
                 transitionKey: ValueKey('Search'),
                 screen: SearchPage(),
               ),
           ],
         );
       },
     ),
   );
 }

 bool _handlePopPage(Route<dynamic> route, dynamic result) {
   // _handlePopPage should not be called on the home page because the
   // PopNavigatorRouterDelegateMixin will bubble up the pop to the
   // SystemNavigator if there is only one route in the navigator.
   assert(route.willHandlePopInternally ||
       replyState.routePath is ReplySearchPath);

   final bool didPop = route.didPop(result);
   if (didPop) replyState.routePath = const ReplyHomePath();
   return didPop;
 }

 @override
 Future<void> setNewRoutePath(ReplyRoutePath configuration) {
   replyState.routePath = configuration;
   return SynchronousFuture<void>(null);
 }
}

זהו כלי הניווט הבסיסי שלנו, והוא מטפל במסכי האפליקציה שמציגים את כל התוכן של אזור העריכה, למשל HomePage ו-SearchPage. היא מאזינה למצב של האפליקציה כדי לבדוק אם הגדרנו את המסלול אל ReplySearchPath. אם עשינו את זה, אנחנו בונים מחדש את הניווט עם SearchPage בחלק העליון של הערימה. שימו לב שהמסכים שלנו תחומים בתוך CustomTransitionPage ולא מוגדרים מעברים. זוהי דרך אחת לנווט בין מסכים ללא מעבר מותאם אישית.

home.dart

הגדרנו את המסלול אל ReplySearchPath במצב האפליקציה שלנו על ידי ביצוע הפעולות הבאות בתוך _BottomAppBarActionItems בhome.dart:

Align(
 alignment: AlignmentDirectional.bottomEnd,
 child: IconButton(
   icon: const Icon(Icons.search),
   color: ReplyColors.white50,
   onPressed: () {
     Provider.of<RouterProvider>(
       context,
       listen: false,
     ).routePath = const ReplySearchPath();
   },
 ),
);

בפרמטר onPressed, אנחנו ניגשים ל-RouterProvider שלנו ומגדירים את ה-routePath שלו ל-ReplySearchPath. RouterProvider שלנו עוקב אחרי מצב כלי הניווט הבסיסיים.

mail_view_router.dart

עכשיו נראה איך מוגדר הניווט הפנימי של האפליקציה, ואפשר לפתוח את mail_view_router.dart בספרייה lib. תראו ניווט שדומה לזה שלמעלה:

class MailViewRouterDelegate extends RouterDelegate<void>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin {
 MailViewRouterDelegate({required this.drawerController});

 final AnimationController drawerController;

 @override
 Widget build(BuildContext context) {
   bool _handlePopPage(Route<dynamic> route, dynamic result) {
     return false;
   }

   return Selector<EmailStore, String>(
     selector: (context, emailStore) => emailStore.currentlySelectedInbox,
     builder: (context, currentlySelectedInbox, child) {
       return Navigator(
         key: navigatorKey,
         onPopPage: _handlePopPage,
         pages: [
           // TODO: Add Fade through transition between mailbox pages (Motion)
           CustomTransitionPage(
             transitionKey: ValueKey(currentlySelectedInbox),
             screen: InboxPage(
               destination: currentlySelectedInbox,
             ),
           )
         ],
       );
     },
   );
 }
...
}

זהו כלי הניווט הפנימי שלנו. הוא מטפל במסכים הפנימיים של האפליקציה שלנו שצורכים רק את גוף הקנבס, למשל InboxPage. ב-InboxPage מוצגת רשימה של הודעות אימייל, בהתאם למצב תיבת הדואר הנוכחי במצב האפליקציה שלנו. כלי הניווט נוצר מחדש עם ערך InboxPage הנכון מעל המקבץ, בכל פעם שמתרחש שינוי בנכס currentlySelectedInbox במצב של האפליקציה שלנו.

home.dart

אנחנו מגדירים את תיבת הדואר הנוכחית במצב של האפליקציה שלנו על ידי ביצוע הפעולות הבאות בתוך _HomePageState ב-home.dart:

void _onDestinationSelected(String destination) {
 var emailStore = Provider.of<EmailStore>(
   context,
   listen: false,
 );

 if (emailStore.onMailView) {
   emailStore.currentlySelectedEmailId = -1;
 }

 if (emailStore.currentlySelectedInbox != destination) {
   emailStore.currentlySelectedInbox = destination;
 }

 setState(() {});
}

בפונקציה _onDestinationSelected, אנחנו ניגשים ל-EmailStore שלנו ומגדירים את הערך currentlySelectedInbox שלו ליעד שנבחר. EmailStore שלנו עוקב אחרי מצב המנווטים הפנימיים שלנו.

home.dart

לסיום, כדי לראות דוגמה של מסלול ניווט שנעשה בו שימוש, אפשר לפתוח את home.dart בספרייה lib. מחפשים את המחלקה _ReplyFabState בתוך המאפיין onTap של הווידג'ט של InkWell, שאמורה להיראות כך:

onTap: () {
 Provider.of<EmailStore>(
   context,
   listen: false,
 ).onCompose = true;
 Navigator.of(context).push(
   PageRouteBuilder(
     pageBuilder: (
       BuildContext context,
       Animation<double> animation,
       Animation<double> secondaryAnimation,
     ) {
       return const ComposePage();
     },
   ),
 );
},

כך אפשר לנווט לדף של כתיבת האימייל, בלי מעבר מותאם אישית. במהלך ה-Codelab הזה, תתנסו בקוד התשובה כדי להגדיר מעברי Material פועלים במקביל לפעולות הניווט השונות באפליקציה.

עכשיו, אחרי שקראתם את הקוד לתחילת העבודה, ניישם את המעבר הראשון.

5. הוספת מעבר של Container Transform מרשימת כתובות האימייל לדף הפרטים של האימייל

כדי להתחיל, צריך להוסיף מעבר כשלוחצים על הודעת אימייל. לשינוי הזה בניווט, דפוס הטרנספורמציה של הקונטיינר מתאים מאוד כי הוא מיועד למעברים בין רכיבי ממשק משתמש שמכילים קונטיינר. הדפוס הזה יוצר חיבור גלוי בין שני רכיבים בממשק המשתמש.

לפני שמוסיפים קוד, כדאי לנסות להפעיל את אפליקציית 'תשובה' וללחוץ על הודעת אימייל. היא אמורה לבצע דילוג פשוט, כך שהמסך יוחלף ללא מעבר:

לפני

48b00600f73c7778.gif

מתחילים בהוספת ייבוא של חבילת האנימציות בחלק העליון של mail_card_preview.dart, כפי שמוצג בקטע הקוד הבא:

mail_card_preview.dart

import 'package:animations/animations.dart';

עכשיו, לאחר הייבוא של חבילת האנימציות, נוכל להתחיל להוסיף מעברים יפים לאפליקציה. בתור התחלה, יוצרים כיתה StatelessWidget שתכיל את הווידג'ט של OpenContainer.

ב-mail_card_preview.dart, צריך להוסיף את קטע הקוד הבא אחרי הגדרת הכיתה של MailPreviewCard:

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
class _OpenContainerWrapper extends StatelessWidget {
 const _OpenContainerWrapper({
   required this.id,
   required this.email,
   required this.closedChild,
 });

 final int id;
 final Email email;
 final Widget closedChild;

 @override
 Widget build(BuildContext context) {
   final theme = Theme.of(context);
   return OpenContainer(
     openBuilder: (context, closedContainer) {
       return MailViewPage(id: id, email: email);
     },
     openColor: theme.cardColor,
     closedShape: const RoundedRectangleBorder(
       borderRadius: BorderRadius.all(Radius.circular(0)),
     ),
     closedElevation: 0,
     closedColor: theme.cardColor,
     closedBuilder: (context, openContainer) {
       return InkWell(
         onTap: () {
           Provider.of<EmailStore>(
             context,
             listen: false,
           ).currentlySelectedEmailId = id;
           openContainer();
         },
         child: closedChild,
       );
     },
   );
 }
}

עכשיו נשתמש ב-wrapper החדש. בתוך הגדרת המחלקה MailPreviewCard נקיף את הווידג'ט Material מהפונקציה build() עם _OpenContainerWrapper החדש שלנו:

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
return _OpenContainerWrapper(
 id: id,
 email: email,
 closedChild: Material(
...

ב-_OpenContainerWrapper שלנו יש ווידג'ט InkWell ומאפייני הצבעים של המאפיין OpenContainer מגדירים את הצבע של הקונטיינר שהוא מכיל. לכן, אנחנו יכולים להסיר את הווידג'טים Material ו-Inkwell. הקוד שמתקבל נראה כך:

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
return _OpenContainerWrapper(
 id: id,
 email: email,
 closedChild: Dismissible(
   key: ObjectKey(email),
   dismissThresholds: const {
     DismissDirection.startToEnd: 0.8,
     DismissDirection.endToStart: 0.4,
   },
   onDismissed: (direction) {
     switch (direction) {
       case DismissDirection.endToStart:
         if (onStarredInbox) {
           onStar();
         }
         break;
       case DismissDirection.startToEnd:
         onDelete();
         break;
       default:
     }
   },
   background: _DismissibleContainer(
     icon: 'twotone_delete',
     backgroundColor: colorScheme.primary,
     iconColor: ReplyColors.blue50,
     alignment: Alignment.centerLeft,
     padding: const EdgeInsetsDirectional.only(start: 20),
   ),
   confirmDismiss: (direction) async {
     if (direction == DismissDirection.endToStart) {
       if (onStarredInbox) {
         return true;
       }
       onStar();
       return false;
     } else {
       return true;
     }
   },
   secondaryBackground: _DismissibleContainer(
     icon: 'twotone_star',
     backgroundColor: currentEmailStarred
         ? colorScheme.secondary
         : theme.scaffoldBackgroundColor,
     iconColor: currentEmailStarred
         ? colorScheme.onSecondary
         : colorScheme.onBackground,
     alignment: Alignment.centerRight,
     padding: const EdgeInsetsDirectional.only(end: 20),
   ),
   child: mailPreview,
 ),
);

בשלב הזה, צריכה להיות לכם טרנספורמציה של קונטיינר שפועל באופן מלא. לחיצה על הודעת אימייל מרחיבה את הפריט לרשימה במסך פרטים, תוך ביטול רשימת כתובות האימייל. הקשה על מקש 'הקודם' מכווצת את מסך פרטי האימייל בחזרה לפריט ברשימה, תוך הגדלת קנה המידה ברשימת האימיילים.

אחרי

663e8594319bdee3.gif

6. הוספת מעבר של Container Transform מ-FAB לכתיבת דף אימייל

נמשיך בטרנספורמציה של קונטיינר ונוסיף מעבר מלחצן הפעולה הצפה לComposePage הרחבה של לחצן ה-FAB לאימייל חדש שהמשתמש ייכתב. קודם כול, מריצים מחדש את האפליקציה ולוחצים על FAB כדי לראות שאין מעבר כשמפעילים את מסך כתיבת האימייל.

לפני

4aa2befdc5170c60.gif

הדרך שבה נגדיר את המעבר הזה תהיה דומה מאוד לאופן שבו עשינו אותו בשלב הקודם, כי אנחנו משתמשים באותה מחלקה של ווידג'ט – OpenContainer.

ב-home.dart, נייבא את package:animations/animations.dart שבחלק העליון של הקובץ, ונשנה את השיטה _ReplyFabState build(). שנוסיף את הווידג'ט Material שהוחזר עם הווידג'ט OpenContainer:

home.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
return OpenContainer(
 openBuilder: (context, closedContainer) {
   return const ComposePage();
 },
 openColor: theme.cardColor,
 onClosed: (success) {
   Provider.of<EmailStore>(
     context,
     listen: false,
   ).onCompose = false;
 },
 closedShape: circleFabBorder,
 closedColor: theme.colorScheme.secondary,
 closedElevation: 6,
 closedBuilder: (context, openContainer) {
   return Material(
     color: theme.colorScheme.secondary,
     ...

בנוסף לפרמטרים שמשמשים להגדרת הווידג'ט הקודם של OpenContainer, אנחנו מגדירים עכשיו גם את onClosed. onClosed הוא ClosedCallback, שמתבצעת אליו קריאה לאחר שהמסלול OpenContainer קופץ או חוזר למצב סגור. הערך המוחזר של הטרנזקציה הזו מועבר לפונקציה הזו כארגומנט. אנחנו משתמשים בCallback הזה כדי להודיע לספק האפליקציה שלנו שיצאנו מהמסלול ComposePage, כדי שהוא יוכל להודיע לכל המאזינים.

בדומה למה שעשינו בשלב האחרון, נסיר את הווידג'ט Material מהווידג'ט של OpenContainer כי הוא מטפל בצבע של הווידג'ט שהוחזר על ידי closedBuilder עם closedColor. בנוסף, נסיר את הקריאה Navigator.push() מתוך onTap של הווידג'ט InkWell ונחליף אותה ב-openContainer() Callback שהוקצה על ידי closedBuilder של הווידג'ט של OpenContainer, כי עכשיו הווידג'ט OpenContainer מטפל בניתוב שלו.

הקוד שתתקבל תיראה כך:

home.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
return OpenContainer(
 openBuilder: (context, closedContainer) {
   return const ComposePage();
 },
 openColor: theme.cardColor,
 onClosed: (success) {
   Provider.of<EmailStore>(
     context,
     listen: false,
   ).onCompose = false;
 },
 closedShape: circleFabBorder,
 closedColor: theme.colorScheme.secondary,
 closedElevation: 6,
 closedBuilder: (context, openContainer) {
   return Tooltip(
     message: tooltip,
     child: InkWell(
       customBorder: circleFabBorder,
       onTap: () {
         Provider.of<EmailStore>(
           context,
           listen: false,
         ).onCompose = true;
         openContainer();
       },
       child: SizedBox(
         height: _mobileFabDimension,
         width: _mobileFabDimension,
         child: Center(
           child: fabSwitcher,
         ),
       ),
     ),
   );
 },
);

עכשיו אפשר לנקות קוד ישן. מכיוון שהווידג'ט OpenContainer שלנו מטפל עכשיו בהודעה לספק האפליקציה שאנחנו כבר לא נמצאים ב-ComposePage דרך onClosed ClosedCallback, אנחנו יכולים להסיר את ההטמעה הקודמת שלנו ב-mail_view_router.dart:

mail_view_router.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
emailStore.onCompose = false; /// delete this line
return SynchronousFuture<bool>(true);

זהו זה השלב הזה! אמור להיות מעבר מ-FAB למסך הכתיבה שנראה כך:

אחרי

5c7ad1b4b40f9f0c.gif

7. הוספת מעבר של ציר ה-Z המשותף מסמל החיפוש לדף תצוגת החיפוש

בשלב הזה, נוסיף מעבר מסמל החיפוש לתצוגת החיפוש במסך מלא. מאחר שאין מאגר תגים קבוע שמעורב בשינוי הניווט הזה, אנחנו יכולים להשתמש במעבר מציר ה-Z המשותף כדי לחזק את הקשר המרחבי בין שני המסכים ולציין שזזים רמה אחת למעלה בהיררכיית האפליקציה.

לפני שמוסיפים קוד נוסף, כדאי לנסות להפעיל את האפליקציה ולהקיש על סמל החיפוש בפינה השמאלית התחתונה של המסך. פעולה זו אמורה להציג את מסך תצוגת החיפוש ללא מעבר.

לפני

df7683a8ad7b920e.gif

כדי להתחיל, ניגשים לקובץ router.dart שלנו. אחרי הגדרת הכיתה ReplySearchPath, מוסיפים את קטע הקוד הבא:

router.dart

// TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
class SharedAxisTransitionPageWrapper extends Page {
 const SharedAxisTransitionPageWrapper(
     {required this.screen, required this.transitionKey})
     : super(key: transitionKey);

 final Widget screen;
 final ValueKey transitionKey;

 @override
 Route createRoute(BuildContext context) {
   return PageRouteBuilder(
       settings: this,
       transitionsBuilder: (context, animation, secondaryAnimation, child) {
         return SharedAxisTransition(
           fillColor: Theme.of(context).cardColor,
           animation: animation,
           secondaryAnimation: secondaryAnimation,
           transitionType: SharedAxisTransitionType.scaled,
           child: child,
         );
       },
       pageBuilder: (context, animation, secondaryAnimation) {
         return screen;
       });
 }
}

עכשיו נשתמש במודל SharedAxisTransitionPageWrapper החדש כדי להשיג את המעבר הרצוי. בתוך הגדרת הכיתה ReplyRouterDelegate, בנכס pages, נעטוף את מסך החיפוש בSharedAxisTransitionPageWrapper במקום בCustomTransitionPage:

router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
   const CustomTransitionPage(
     transitionKey: ValueKey('Home'),
     screen: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('Search'),
       screen: SearchPage(),
     ),
 ],
);

עכשיו אפשר לנסות להפעיל מחדש את האפליקציה.

81b3ea098926931.gif

הדברים מתחילים להיראות מצוין! כשלוחצים על סמל החיפוש בסרגל התחתון של האפליקציה, מוצגים על ידי ציר משותף הגדלה של דף החיפוש. עם זאת, שימו לב איך דף הבית לא גדל בהתאם לעומס, ובמקום זאת נשאר סטטי בזמן שדף החיפוש מותאם לעומס. בנוסף, כשלוחצים על הלחצן 'הקודם', דף הבית לא משתנה לתצוגה, אלא נשאר סטטי בזמן שגודל דף החיפוש מוגדל מהתצוגה. כך שעדיין לא סיימנו.

כדי לפתור את שתי הבעיות, צריך גם לשלב את HomePage עם SharedAxisTransitionWrapper במקום בCustomTransitionPage:

router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
   const SharedAxisTransitionPageWrapper(
     transitionKey: ValueKey('home'),
     screen: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: SearchPage(),
     ),
 ],
);

זהו! עכשיו אפשר לנסות להפעיל מחדש את האפליקציה ולהקיש על סמל החיפוש. מסך תצוגת הבית ומסך החיפוש אמורים להתעמעם ולהתקדם לאורך ציר ה-Z באופן עומק, וכך ליצור אפקט חלק בין שני המסכים.

אחרי

462d890086a3d18a.gif

8. הוספת מעבר עמעום בין דפי תיבות דואר

בשלב הזה נוסיף מעבר בין תיבות דואר שונות. מכיוון שאנחנו לא רוצים להדגיש קשר מרחבי או היררכי, נשתמש בהדגשה כדי לבצע פעולת החלפה פשוטה. בין רשימות של כתובות אימייל.

לפני שמוסיפים קוד נוסף, נסו להפעיל את האפליקציה, להקיש על הלוגו של 'תשובה' בסרגל האפליקציות התחתון ולעבור בין תיבות דואר. רשימת כתובות האימייל אמורה להשתנות ללא מעבר.

לפני

89033988ce26b92e.gif

כדי להתחיל, ניגשים לקובץ mail_view_router.dart שלנו. אחרי הגדרת הכיתה MailViewRouterDelegate, מוסיפים את קטע הקוד הבא:

mail_view_router.dart

// TODO: Add Fade through transition between mailbox pages (Motion)
class FadeThroughTransitionPageWrapper extends Page {
 const FadeThroughTransitionPageWrapper({
   required this.mailbox,
   required this.transitionKey,
 })  : super(key: transitionKey);

 final Widget mailbox;
 final ValueKey transitionKey;

 @override
 Route createRoute(BuildContext context) {
   return PageRouteBuilder(
       settings: this,
       transitionsBuilder: (context, animation, secondaryAnimation, child) {
         return FadeThroughTransition(
           fillColor: Theme.of(context).scaffoldBackgroundColor,
           animation: animation,
           secondaryAnimation: secondaryAnimation,
           child: child,
         );
       },
       pageBuilder: (context, animation, secondaryAnimation) {
         return mailbox;
       });
 }
}

בדומה לשלב האחרון, נשתמש ב-FadeThroughTransitionPageWrapper החדש כדי להשיג את המעבר הרצוי. בתוך ההגדרה של הכיתה MailViewRouterDelegate, בנכס pages, במקום לעטוף את מסך תיבת הדואר שלנו באמצעות CustomTransitionPage, צריך להשתמש בשדה FadeThroughTransitionPageWrapper במקום זאת:

mail_view_router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Fade through transition between mailbox pages (Motion)
   FadeThroughTransitionPageWrapper(
     mailbox: InboxPage(destination: currentlySelectedInbox),
     transitionKey: ValueKey(currentlySelectedInbox),
   ),
 ],
);

מפעילים מחדש את האפליקציה. כשפותחים את חלונית ההזזה לניווט התחתונה ומחליפים תיבות דואר, הרשימה הנוכחית של הודעות האימייל אמורה להתעמעם ולהקטין את התצוגה בזמן שהרשימה החדשה נעלמת. איזה יופי!

אחרי

8186940082b630d.gif

9. הוספת מעבר עמעום בין 'אימייל חדש' ל'תשובה'

בשלב הזה, נוסיף מעבר בין סמלי FAB שונים. מכיוון שאנחנו לא רוצים להדגיש קשר מרחבי או היררכי, נשתמש בהדגשה כדי לבצע פעולת החלפה פשוטה. בין הסמלים ב-FAB.

לפני שמוסיפים קוד כלשהו, כדאי לנסות להפעיל את האפליקציה, להקיש על הודעת אימייל ולפתוח את תצוגת האימייל. סמל ה-FAB אמור להשתנות ללא מעבר.

לפני

d8e3afa0447cfc20.gif

נעבוד ב-home.dart עד סוף השיעור, אז אין צורך להוסיף את הייבוא של חבילת האנימציות כי כבר עשינו home.dart בחזרה בשלב 2.

הדרך שבה נגדיר את המעברים הבאים תהיה דומה מאוד, כי בכולם ייעשה שימוש במחלקה לשימוש חוזר, _FadeThroughTransitionSwitcher.

ב-home.dart, נוסיף את קטע הקוד הבא בקטע _ReplyFabState:

home.dart

// TODO: Add Fade through transition between compose and reply FAB (Motion)
class _FadeThroughTransitionSwitcher extends StatelessWidget {
 const _FadeThroughTransitionSwitcher({
   required this.fillColor,
   required this.child,
 });

 final Widget child;
 final Color fillColor;

 @override
 Widget build(BuildContext context) {
   return PageTransitionSwitcher(
     transitionBuilder: (child, animation, secondaryAnimation) {
       return FadeThroughTransition(
         fillColor: fillColor,
         child: child,
         animation: animation,
         secondaryAnimation: secondaryAnimation,
       );
     },
     child: child,
   );
 }
}

עכשיו, ב_ReplyFabState שלנו, מחפשים את הווידג'ט של fabSwitcher. הסמל fabSwitcher מחזיר סמל אחר, בהתאם לסטטוס של תצוגת האימייל. נסיים ב_FadeThroughTransitionSwitcher:

home.dart

// TODO: Add Fade through transition between compose and reply FAB (Motion)
static final fabKey = UniqueKey();
static const double _mobileFabDimension = 56;

@override
Widget build(BuildContext context) {
 final theme = Theme.of(context);
 final circleFabBorder = const CircleBorder();

 return Selector<EmailStore, bool>(
   selector: (context, emailStore) => emailStore.onMailView,
   builder: (context, onMailView, child) {
     // TODO: Add Fade through transition between compose and reply FAB (Motion)
     final fabSwitcher = _FadeThroughTransitionSwitcher(
       fillColor: Colors.transparent,
       child: onMailView
           ? Icon(
               Icons.reply_all,
               key: fabKey,
               color: Colors.black,
             )
           : const Icon(
               Icons.create,
               color: Colors.black,
             ),
     );
...

אנחנו מעניקים fillColor שקוף ב_FadeThroughTransitionSwitcher שלנו, כך שאין רקע בין הרכיבים במהלך המעבר. אנחנו גם יוצרים UniqueKey ומקצים אותו לאחד מהסמלים.

בשלב הזה, צריכה להיות לכם לחצן FAB מונפש לפי הקשר. מעבר לתצוגת אימייל גורמת לסמל FAB הישן להתעמעם ולהקטין אותו, בזמן שהסמל החדש עמעום ומתפתח.

אחרי

c55bacd9a144ec69.gif

10. הוספת מעבר עמעום בין כותרת תיבת הדואר שנעלמת

בשלב הזה, נוסיף עמעום באמצעות מעבר, כדי לעמעם הכותרת של תיבת הדואר בין מצב גלוי ובלתי נראה בתצוגת אימייל. מכיוון שאנחנו לא רוצים להדגיש קשר מרחבי או היררכי, נשתמש בהדגשה כדי לבצע פעולת החלפה פשוטה. בין הווידג'ט Text שכולל את הכותרת של תיבת הדואר, לבין SizedBox ריק.

לפני שמוסיפים קוד כלשהו, כדאי לנסות להפעיל את האפליקציה, להקיש על הודעת אימייל ולפתוח את תצוגת האימייל. הכותרת של תיבת הדואר אמורה להיעלם ללא מעבר.

לפני

59eb57a6c71725c0.gif

שאר העבודה של ה-Codelab הזה תהיה מהירה, כי כבר עשינו את רוב העבודה ב_FadeThroughTransitionSwitcher בשלב האחרון.

עכשיו נעבור לכיתה _AnimatedBottomAppBar בhome.dart כדי להוסיף את המעבר שלנו. נשתמש שוב בכתובת _FadeThroughTransitionSwitcher מהשלב האחרון שלנו, ונעטפים את התנאי של onMailView המותנה, שתחזיר SizedBox ריק, או כותרת של תיבת דואר שמתעמעמת בסנכרון עם חלונית ההזזה התחתונה:

home.dart

...
const _ReplyLogo(),
const SizedBox(width: 10),
// TODO: Add Fade through transition between disappearing mailbox title (Motion)
_FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: onMailView
     ? const SizedBox(width: 48)
     : FadeTransition(
         opacity: fadeOut,
         child: Selector<EmailStore, String>(
           selector: (context, emailStore) =>
               emailStore.currentlySelectedInbox,
           builder: (
             context,
             currentlySelectedInbox,
             child,
           ) {
             return Text(
               currentlySelectedInbox,
               style: Theme.of(context)
                   .textTheme
                   .bodyMedium!
                   .copyWith(
                     color: ReplyColors.white50,
                   ),
             );
           },
         ),
       ),
),

זהו, סיימנו את השלב הזה!

מפעילים מחדש את האפליקציה. כשפותחים הודעת אימייל ומועברים לתצוגת האימייל, הכותרת של תיבת הדואר בסרגל התחתון של האפליקציה אמורה להתעמעם ולהקטין את התצוגה. מדהים!

אחרי

3f1a3db01a481124.gif

11. הוספת מעבר של עמעום הדרגתי בין פעולות בסרגל האפליקציה התחתון

בשלב הזה, נוסיף עמעום באמצעות מעבר, כדי לעמעם דרך הפעולות בסרגל התחתון של האפליקציה בהתאם להקשר של האפליקציות. מכיוון שאנחנו לא רוצים להדגיש קשר מרחבי או היררכי, נשתמש בהדגשה כדי לבצע פעולת החלפה פשוטה. בין הפעולות בסרגל האפליקציה התחתון כשהאפליקציה נמצאת בדף הבית, כשחלונית ההזזה התחתונה גלויה וכשאנחנו בתצוגת האימייל.

לפני שמוסיפים קוד כלשהו, כדאי לנסות להפעיל את האפליקציה, להקיש על הודעת אימייל ולפתוח את תצוגת האימייל. אפשר גם לנסות להקיש על הלוגו של 'תשובה'. הפעולות בסרגל האפליקציה התחתון אמורות להשתנות ללא מעבר.

לפני

5f662eac19fce3ed.gif

בדומה לשלב האחרון, נשתמש שוב ב-_FadeThroughTransitionSwitcher. כדי להשיג את המעבר הרצוי, עוברים להגדרת המחלקה _BottomAppBarActionItems ועוטפים את הווידג'ט החוזר של הפונקציה build() באמצעות _FadeThroughTransitionSwitcher:

home.dart

// TODO: Add Fade through transition between bottom app bar actions (Motion)
return _FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: drawerVisible
     ? Align(
         key: UniqueKey(),
         alignment: AlignmentDirectional.bottomEnd,
         child: IconButton(
           icon: const Icon(Icons.settings),
           color: ReplyColors.white50,
           onPressed: () async {
             drawerController.reverse();
             showModalBottomSheet(
               context: context,
               shape: RoundedRectangleBorder(
                 borderRadius: modalBorder,
               ),
               builder: (context) => const SettingsBottomSheet(),
             );
           },
         ),
       )
     : onMailView
...

רוצה לנסות? כשפותחים אימייל ומועברים לתצוגת האימייל, הפעולות הישנות בסרגל האפליקציה התחתון אמורות להתעמעם ולהקטין את התצוגה בזמן שהפעולות החדשות הולכות וגדלות. כל הכבוד!

אחרי

cff0fa2afa1c5a7f.gif

12. מעולה!

חבילת האנימציות מכילה פחות מ-100 שורות של קוד Drt, ועוזרת לכם ליצור מעברים יפים באפליקציה קיימת שעומדת בהנחיות של Material Design, וגם מראה ומתנהג באופן עקבי בכל המכשירים.

d5637de49eb64d8a.gif

השלבים הבאים

לקבלת מידע נוסף על מערכת התנועה של Material, כדאי לעיין בהנחיות ובתיעוד המלא למפתחים, ולנסות להוסיף מעברי Material לאפליקציה!

תודה שניסית תנועה בעיצוב חדשני. אנחנו מקווים שנהניתם מה-Codelab הזה!

הצלחתי להשלים את ה-Codelab הזה תוך השקעה של זמן ומאמץ סבירים

נכון מאוד נכון נייטרלי לא נכון לא נכון בכלל

אני רוצה להמשיך להשתמש בעתיד במערכת התנועה מסוג Material

נכון מאוד נכון נייטרלי לא נכון לא נכון בכלל

להדגמות נוספות על אופן השימוש בווידג'טים של ספריית Material Flutter ושל Flutter, הקפידו לבקר בFlutter Gallery.

46ba920f17198998.png

6ae8ae284bf4f9fa.png