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

1. บทนำ

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

logo_components_color_2x_web_96dp.png

Material Components (MDC) ช่วยให้นักพัฒนาแอปใช้งาน Material Design ได้ 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) และไลบรารี Material ของ Flutter (flutter/material.dart)

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

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

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

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

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

b26fe84fed12d17d.gif

สิ่งที่คุณต้องมี

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

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

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

คุณต้องการเรียนรู้อะไรจากโค้ดแล็บนี้

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

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

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

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

  • อุปกรณ์ Android หรือ iOS จริงที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาแอป
  • โปรแกรมจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
  • Android Emulator (ต้องตั้งค่าใน 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);
 }
}

นี่คือตัวนำทางรูทของเรา ซึ่งจะจัดการหน้าจอของแอปที่ใช้พื้นที่ทั้ง Canvas เช่น HomePage และ SearchPage โดยจะคอยฟังสถานะของแอปเพื่อตรวจสอบว่าเราได้ตั้งค่าเส้นทางไปยัง ReplySearchPath หรือไม่ หากมี ระบบจะสร้าง Navigator ใหม่โดยมี 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 จะแสดงรายการอีเมลตามกล่องจดหมายปัจจุบันในสถานะของแอป ระบบจะสร้าง Navigator ใหม่โดยมี 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 ที่ทำงานร่วมกับ Actions การนำทางต่างๆ ทั่วทั้งแอป

เมื่อคุ้นเคยกับโค้ดเริ่มต้นแล้ว มาใช้การเปลี่ยนฉากแรกกัน

5. เพิ่มการเปลี่ยน Container Transform จากรายการอีเมลไปยังหน้ารายละเอียดอีเมล

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

ก่อนเพิ่มโค้ดใดๆ ให้ลองเรียกใช้แอป 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. เพิ่มการเปลี่ยนฉาก Container Transform จาก FAB ไปยังหน้าเขียนอีเมล

มาต่อกันที่ Container Transform และเพิ่มการเปลี่ยนจากปุ่มการทำงานแบบลอยไปเป็น 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 ที่ใช้ร่วมกันเพื่อเสริมความสัมพันธ์เชิงพื้นที่ระหว่าง 2 หน้าจอและระบุการเลื่อนขึ้น 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 ให้ใช้ FadeThroughTransitionPageWrapper แทนการห่อหุ้มหน้าจอกล่องจดหมายด้วย CustomTransitionPage ดังนี้

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-3 รายการถัดไปจะคล้ายกันมาก เนื่องจากทั้งหมดจะใช้คลาสที่นำกลับมาใช้ใหม่ได้ _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. ยินดีด้วย

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

d5637de49eb64d8a.gif

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

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

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

ฉันทำ Codelab นี้เสร็จได้โดยใช้เวลาและความพยายามที่สมเหตุสมผล

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

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

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

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

46ba920f17198998.png

6ae8ae284bf4f9fa.png