การสร้างการเปลี่ยนฉากอย่างสวยงามด้วยการเคลื่อนไหวของวัสดุเพื่อ Flutter

1. บทนำ

Material Design เป็นระบบสำหรับสร้างผลิตภัณฑ์ดิจิทัลที่โดดเด่นและสวยงาม โดยการรวมสไตล์ การสร้างแบรนด์ การโต้ตอบ และการเคลื่อนไหวเข้าด้วยกันภายใต้ชุดหลักการและองค์ประกอบที่สอดคล้องกัน ทำให้ทีมผลิตภัณฑ์ตระหนักถึงศักยภาพด้านการออกแบบที่ดีที่สุดของตนเอง

logo_components_color_2x_web_96dp.png

Material Components (MDC) ช่วยให้นักพัฒนานำดีไซน์ Material มาใช้ MDC สร้างขึ้นโดยทีมวิศวกรและนักออกแบบ UX จาก Google โดยมีคอมโพเนนต์ UI ที่สวยงามและใช้งานได้จริงหลายสิบรายการ และพร้อมใช้งานสำหรับ Android, iOS, เว็บ และ Flutter ที่ material.io/develop

ระบบการเคลื่อนไหวของ Material สำหรับ Flutter คืออะไร

ระบบการเคลื่อนไหวแบบ Material สำหรับ Flutter คือชุดรูปแบบการเปลี่ยนภายในแพ็กเกจภาพเคลื่อนไหวที่ช่วยให้ผู้ใช้เข้าใจและไปยังส่วนต่างๆ ของแอปได้ ตามที่อธิบายไว้ในหลักเกณฑ์ของดีไซน์ Material

รูปแบบการเปลี่ยน Material 4 รูปแบบหลักๆ มีดังนี้

  • การเปลี่ยนรูปแบบคอนเทนเนอร์: การเปลี่ยนองค์ประกอบ UI ที่มีคอนเทนเนอร์ สร้างการเชื่อมต่อที่มองเห็นได้ระหว่างองค์ประกอบ UI 2 อย่างที่แตกต่างกันด้วยการแปลงองค์ประกอบหนึ่งเป็นอีกองค์ประกอบหนึ่งอย่างราบรื่น

11807bdf36c66657.gif

  • แกนร่วม: การเปลี่ยนระหว่างองค์ประกอบ UI ที่มีความสัมพันธ์เชิงพื้นที่หรือการนําทาง ใช้การเปลี่ยนรูปแบบร่วมกันบนแกน x, y หรือ z เพื่อเสริมความสัมพันธ์ระหว่างองค์ประกอบ

71218f390abae07e.gif

  • การจางผ่าน: การเปลี่ยนระหว่างองค์ประกอบ UI ที่ไม่มีความสัมพันธ์ที่มีอิทธิพลต่อกัน ใช้การจางออกและจางเข้าตามลำดับโดยปรับขนาดขององค์ประกอบที่เข้ามา

385ba37b8da68969.gif

  • จางลง: ใช้กับองค์ประกอบ UI ที่เข้าหรือออกภายในขอบเขตของหน้าจอ

cfc40fd6e27753b6.gif

แพ็กเกจภาพเคลื่อนไหวมีวิดเจ็ตการเปลี่ยนสำหรับรูปแบบเหล่านี้ ซึ่งสร้างขึ้นที่ด้านบนของทั้งไลบรารีภาพเคลื่อนไหว Flutter (flutter/animation.dart) และไลบรารีวัสดุ Flutter (flutter/material.dart)

ใน Codelab นี้ คุณจะได้ใช้ทรานซิชันของ Material ที่สร้างจากเฟรมเวิร์ก Flutter และไลบรารี Material ซึ่งหมายความว่าคุณต้องจัดการกับวิดเจ็ต :)

สิ่งที่คุณจะสร้าง

โค้ดแล็บนี้จะแนะนำการสร้างทรานซิชันบางอย่างในแอปอีเมล Flutter ตัวอย่างชื่อ Reply โดยใช้ Dart เพื่อสาธิตวิธีใช้ทรานซิชันจากแพ็กเกจภาพเคลื่อนไหวเพื่อปรับแต่งรูปลักษณ์ของแอป

โค้ดเริ่มต้นสำหรับแอปตอบกลับจะมีให้ และคุณจะต้องใส่การเปลี่ยนของ Material ต่อไปนี้ลงในแอป ซึ่งสามารถดูได้ใน GIF ของ Codelab ที่สมบูรณ์ด้านล่างนี้

  • การเปลี่ยนจากการเปลี่ยนรูปแบบคอนเทนเนอร์จากรายชื่ออีเมลเป็นหน้ารายละเอียดอีเมล
  • การเปลี่ยนการแปลงคอนเทนเนอร์จาก FAB เป็นหน้าเขียนอีเมล
  • แกน Z ที่ใช้ร่วมกันเปลี่ยนจากไอคอนการค้นหาเป็นหน้ามุมมองการค้นหา
  • การเปลี่ยนผ่าน Fade Through ระหว่างหน้ากล่องจดหมาย
  • การเปลี่ยนจางลงระหว่าง FAB เขียนและตอบ
  • การเปลี่ยนจางลงระหว่างชื่อกล่องจดหมายที่หายไป
  • การเปลี่ยนเลือนระหว่างการดําเนินการของแถบแอปด้านล่าง

b26fe84fed12d17d.gif

สิ่งที่ต้องมี

  • ความรู้พื้นฐานเกี่ยวกับการพัฒนา Flutter และ Dart
  • ตัวแก้ไขโค้ด
  • โปรแกรมจำลองหรืออุปกรณ์ Android/iOS
  • โค้ดตัวอย่าง (ดูขั้นตอนถัดไป)

คุณจะให้คะแนนระดับประสบการณ์ในการสร้างแอป Flutter เท่าใด

ผู้ฝึกหัด ระดับกลาง ผู้ชำนาญ

คุณต้องการเรียนรู้อะไรจาก Codelab นี้

ฉันยังใหม่กับหัวข้อและต้องการภาพรวมที่ดี ฉันพอจะรู้เกี่ยวกับหัวข้อนี้ แต่อยากทบทวนข้อมูล ฉันอยากได้โค้ดตัวอย่างที่จะใช้ในโปรเจ็กต์ ฉันต้องการคำอธิบายบางอย่างที่เจาะจง

2. ตั้งค่าสภาพแวดล้อมการพัฒนา Flutter

ห้องทดลองนี้ต้องมีซอฟต์แวร์ 2 ประเภท ได้แก่ Flutter SDK และเครื่องมือแก้ไข

คุณเรียกใช้โค้ดแล็บได้โดยใช้อุปกรณ์ต่อไปนี้

  • อุปกรณ์ 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. ทำตามวิธีการ "เรียกใช้แอป" ในเริ่มต้นใช้งาน: ลองใช้สำหรับโปรแกรมตัดต่อที่คุณเลือก

สำเร็จ! รหัสเริ่มต้นสำหรับหน้าแรกของการตอบควรทำงานในอุปกรณ์/โปรแกรมจำลอง คุณควรเห็นกล่องจดหมายที่มีรายการอีเมล

หน้าแรกของการตอบ

ไม่บังคับ: ชะลอภาพเคลื่อนไหวของอุปกรณ์

เนื่องจากโค้ดแล็บนี้เกี่ยวข้องกับการเปลี่ยนภาพที่รวดเร็วแต่ดูดี จึงอาจมีประโยชน์ในการทำให้ภาพเคลื่อนไหวของอุปกรณ์ช้าลงเพื่อสังเกตรายละเอียดเล็กๆ น้อยๆ ของการเปลี่ยนภาพขณะที่คุณใช้งาน ซึ่งทำได้ผ่านการตั้งค่าในแอป ซึ่งเข้าถึงได้ด้วยการแตะไอคอนการตั้งค่าเมื่อลิ้นชักด้านล่างเปิดอยู่ ไม่ต้องกังวล การทำให้ภาพเคลื่อนไหวในอุปกรณ์ช้าลงนี้จะไม่ส่งผลต่อภาพเคลื่อนไหวบนอุปกรณ์ที่อยู่นอกแอป "ตอบกลับ"

d23a7bfacffac509.gif

ไม่บังคับ: โหมดมืด

ถ้าการตอบกลับเป็นธีมที่สดใสทำให้สายตาของคุณรำคาญ ก็ไม่ต้องมองอีกต่อไป การตั้งค่าในแอปช่วยให้คุณเปลี่ยนธีมของแอปเป็นโหมดมืดเพื่อให้สบายตายิ่งขึ้นได้ การตั้งค่านี้เข้าถึงได้โดยแตะไอคอนการตั้งค่าเมื่อลิ้นชักด้านล่างเปิดอยู่

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

นี่เป็นโปรแกรมนำทางระดับรูท ซึ่งจัดการหน้าจอของแอปที่ใช้ทั้งผืนผ้าใบ เช่น 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,
             ),
           )
         ],
       );
     },
   );
 }
...
}

นี่คือเครื่องมือนำทางภายใน ซึ่งจะจัดการหน้าจอภายในของแอปที่ใช้เฉพาะเนื้อหาของ Canvas เช่น 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. เพิ่มการเปลี่ยนคอนเทนเนอร์จากรายชื่ออีเมลไปยังหน้ารายละเอียดอีเมล

ในการเริ่มต้น คุณจะต้องเพิ่มทรานซิชันเมื่อคลิกอีเมล สำหรับการเปลี่ยนแปลงการนำทางนี้ รูปแบบการเปลี่ยนรูปแบบคอนเทนเนอร์นี้เหมาะสมอย่างยิ่ง เนื่องจากออกแบบมาสำหรับการเปลี่ยนระหว่างองค์ประกอบ UI ที่มีคอนเทนเนอร์ รูปแบบนี้สร้างการเชื่อมต่อที่มองเห็นได้ระหว่างองค์ประกอบ UI 2 รายการ

ก่อนเพิ่มโค้ดใดๆ ให้ลองเรียกใช้แอปตอบกลับและคลิกอีเมล โดยควรใช้การตัดต่อแบบกระโดด ซึ่งหมายความว่าระบบจะแทนที่หน้าจอโดยไม่มีทรานซิชัน

ก่อน

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 ที่แชร์เพื่อเน้นย้ำความสัมพันธ์เชิงพื้นที่ระหว่างสองหน้าจอและบ่งบอกถึงการเลื่อนขึ้น 1 ระดับในลำดับชั้นของแอป

ก่อนเพิ่มโค้ดอื่นๆ ให้ลองเรียกใช้แอปแล้วแตะไอคอนค้นหาที่มุมขวาล่างของหน้าจอ ซึ่งจะแสดงหน้าจอมุมมองการค้นหาโดยไม่มีการเปลี่ยนภาพ

ก่อน

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

ทุกอย่างเริ่มไปได้สวย! เมื่อคุณคลิกไอคอนค้นหาในแถบแอปด้านล่าง การเปลี่ยนของแกนที่ใช้ร่วมกันจะปรับขนาดหน้าการค้นหาให้อยู่ในมุมมอง อย่างไรก็ตาม โปรดสังเกตด้วยว่าหน้าแรกไม่ปรับขนาดออกและยังคงไม่เปลี่ยนแปลงเมื่อหน้าค้นหาปรับใหญ่ขึ้น นอกจากนี้ เมื่อกดปุ่มย้อนกลับ หน้าแรกจะไม่ปรับขนาดให้แสดง แต่จะเป็นภาพนิ่งขณะที่หน้าค้นหาปรับขนาดให้ออกจากมุมมอง เรายังต้องดำเนินการต่อ

เราจะแก้ไขปัญหาทั้ง 2 อย่างโดยการรวม 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 ในเชิงลึกพร้อมกัน ซึ่งจะทำให้เกิดเอฟเฟกต์ที่ราบรื่นระหว่าง 2 หน้าจอ

หลัง

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 ง่ายๆ

ก่อนเพิ่มโค้ดใดๆ เพิ่มเติม ให้ลองเรียกใช้แอป แตะอีเมลและเปิดมุมมองอีเมล ไอคอน FAB ควรเปลี่ยนโดยไม่มีทรานซิชัน

ก่อน

d8e3afa0447cfc20.gif

เราจะทํางานใน home.dart ตลอดช่วงที่เหลือของโค้ดแล็บนี้ ดังนั้นไม่ต้องกังวลเกี่ยวกับการเพิ่มการนําเข้าสําหรับแพ็กเกจภาพเคลื่อนไหว เนื่องจากเราได้ทําสําหรับ home.dart ไปแล้วในขั้นตอนที่ 2

วิธีการกําหนดค่าการเปลี่ยนภาพ 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,
   );
 }
}

มองหาวิดเจ็ต fabSwitcher ใน _ReplyFabState ของเรา 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. ยินดีด้วย

แพ็กเกจภาพเคลื่อนไหวช่วยให้คุณสร้างทรานซิชันที่สวยงามในแอปที่มีอยู่ซึ่งเป็นไปตามหลักเกณฑ์ของ Material Design โดยใช้โค้ด Dart ไม่ถึง 100 บรรทัด ทั้งยังช่วยให้แอปมีรูปลักษณ์และลักษณะการทำงานที่สอดคล้องกันในทุกอุปกรณ์

d5637de49eb64d8a.gif

ขั้นตอนถัดไป

สำหรับข้อมูลเพิ่มเติมเกี่ยวกับระบบการเคลื่อนไหวแบบ Material นี้ โปรดอ่านหลักเกณฑ์และเอกสารสำหรับนักพัฒนาซอฟต์แวร์ฉบับเต็ม แล้วลองเพิ่มการเปลี่ยนของ Material ลงในแอปของคุณ

ขอขอบคุณที่ลองใช้ Material Motion เราหวังว่าคุณจะชอบ Codelab นี้

ฉันทำ Codelab นี้เสร็จภายในระยะเวลาและความพยายามที่เหมาะสม

เห็นด้วยอย่างยิ่ง เห็นด้วย เป็นกลาง ไม่เห็นด้วย ไม่เห็นด้วยอย่างยิ่ง

ฉันต้องการใช้ระบบการเคลื่อนไหวแบบ Material ต่อไปในอนาคต

เห็นด้วยอย่างยิ่ง เห็นด้วย เฉยๆ ไม่เห็นด้วย ไม่เห็นด้วยอย่างยิ่ง

โปรดไปที่แกลเลอรี Flutter เพื่อดูการสาธิตเพิ่มเติมเกี่ยวกับวิธีใช้วิดเจ็ตจากไลบรารี Material Flutter รวมถึงเฟรมเวิร์ก Flutter

46ba920f17198998.png

6ae8ae284bf4f9fa.png