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

1. מבוא

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

logo_components_color_2x_web_96dp.png

רכיבי Material ‏ (MDC) עוזרים למפתחים להטמיע את Material Design. ‫MDC נוצרה על ידי צוות של מהנדסים ומעצבי UX ב-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, כלומר תעבדו עם ווידג'טים. :)

מה תפַתחו

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

קוד לתחילת הדרך של אפליקציית Reply יסופק, ותצטרכו לשלב באפליקציה את המעברים הבאים של Material, שמוצגים ב-GIF של ה-codelab המלא שבהמשך:

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

b26fe84fed12d17d.gif

מה תצטרכו

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

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

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

מה היית רוצה ללמוד בסדנת הקוד הזו?

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

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

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

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

  • מכשיר פיזי עם Android או iOS שמחובר למחשב ומוגדר למצב פיתוח.
  • סימולטור iOS (נדרשת התקנה של כלי Xcode).
  • אמולטור Android (נדרשת הגדרה ב-Android Studio).
  • דפדפן (חובה להשתמש ב-Chrome לצורך ניפוי באגים).
  • כאפליקציה למחשב Windows,‏ Linux או macOS. צריך לפתח בפלטפורמה שבה מתכננים לפרוס. לכן, אם רוצים לפתח אפליקציה למחשב שולחני עם Windows, צריך לפתח ב-Windows כדי לגשת לשרשרת הבנייה המתאימה. יש דרישות ספציפיות למערכות הפעלה שמוסברות בפירוט במאמר 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. פועלים לפי ההוראות שבקטע תחילת העבודה: ניסיון נסיעה כדי להפעיל את האפליקציה בעורך שבחרתם.

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

דף הבית של Reply

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

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

d23a7bfacffac509.gif

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

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

87618d8418eee19e.gif

4. הסבר על קוד האפליקציה לדוגמה

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

  • דף הבית: מוצגת תיבת הדואר שנבחרה
  • 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);
 }
}

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

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

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

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

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

לפני

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. הוספת מעבר של טרנספורמציה של קונטיינר מלחצן ה-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

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

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

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. הוספת מעבר Fade Through בין לחצן ה-FAB של כתיבת הודעה לבין לחצן ה-FAB של תשובה

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

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

לפני

d8e3afa0447cfc20.gif

נמשיך לעבוד ב-home.dart עד סוף ה-codelab, אז אין צורך להוסיף את הייבוא לחבילת האנימציות כי כבר עשינו את זה ב-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

השלבים הבאים ב-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. הוספת מעבר Fade Through בין פעולות בסרגל האפליקציות התחתון

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

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

לפני

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. מעולה!

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

d5637de49eb64d8a.gif

השלבים הבאים

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

תודה שניסית את Material motion. אנחנו מקווים שנהניתם מה-Codelab הזה.

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

נכון מאוד נכון אין לי דעה לכאן או לכאן לא נכון לא נכון בכלל

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

נכון מאוד נכון אין לי דעה לכאן או לכאן לא נכון לא נכון בכלל

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

46ba920f17198998.png

6ae8ae284bf4f9fa.png