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

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 ו-Material, משמעות הדבר היא שעוסקים בווידג'טים. :)

מה תפַתחו

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

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

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

b26fe84fed12d17d.gif

מה צריך להכין

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

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

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

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

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

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

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

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

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

3. הורדת האפליקציה למתחילים ב-Codelab

אפשרות 1: יצירת עותקים (cloning) של אפליקציית ה-codelab למתחילים מ-GitHub

כדי להעתיק (clone) את 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. פועלים לפי ההוראות ל'הפעלת האפליקציה' בקטע תחילת העבודה: נסיעת מבחן בעורך שבחרתם.

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

דף הבית של Reply

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

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

d23a7bfacffac509.gif

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

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

87618d8418eee19e.gif

4. מעיינים בקוד לדוגמה של האפליקציה

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

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

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();
     },
   ),
 );
},

כך אפשר לנווט לדף של כתיבת האימייל, בלי מעבר מותאם אישית. במהלך סדנת הקוד הזו, נצלול לקוד של Reply כדי להגדיר מעברים של 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 כדי לראות שאין מעבר כשמפעילים את מסך כתיבת האימייל.

לפני

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 צריך להשתנות ללא מעבר.

לפני

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,
             ),
     );
...

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

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

אחרי

c55bacd9a144ec69.gif

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

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

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

לפני

59eb57a6c71725c0.gif

שאר הקודלאב הזה יהיה קצר, כי כבר ביצענו את רוב העבודה ב-_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 שורות קוד ב-Dart, כך שהיא תתאים להנחיות של Material Design ותהיה עקבית במראה ובהתנהגות בכל המכשירים.

d5637de49eb64d8a.gif

השלבים הבאים

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

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

הצלחתי להשלים את הקודלהב הזה בזמן ובמאמץ סבירים

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

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

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

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

46ba920f17198998.png

6ae8ae284bf4f9fa.png