Flutter용 머티리얼 모션을 사용하여 멋진 전환 빌드

1. 소개

Material Design은 대담하고 멋진 디지털 제품을 빌드하는 시스템입니다. 일련의 일관된 원칙과 구성요소 아래 스타일과 브랜딩, 상호작용, 모션을 통합하여 제품팀은 가능한 최고의 디자인을 실현할 수 있습니다.

logo_components_color_2x_web_96dp.png

머티리얼 구성요소(MDC)를 통해 개발자는 머티리얼 디자인을 구현할 수 있습니다. Google의 엔지니어와 UX 디자이너로 구성된 팀에서 만든 MDC는 아름답고 기능적인 수십 가지의 UI 구성요소가 특징이며 Android, iOS, 웹, Flutter.material.io/develop에서 제공됩니다.

Flutter용 머티리얼 모션 시스템이란 무엇인가요?

Flutter용 머티리얼 모션 시스템은 애니메이션 패키지 내에 있는 일련의 전환 패턴으로, 머티리얼 디자인 가이드라인에 설명된 것처럼 사용자가 앱을 파악하고 탐색하는 데 도움이 됩니다.

주요 머티리얼 전환 패턴 네 가지는 다음과 같습니다.

  • 컨테이너 변환: 컨테이너가 포함된 UI 요소 간 전환. 한 요소에서 다른 요소로 매끄럽게 변환하여 고유한 두 UI 요소 사이에 시각적인 연결을 만듭니다.

11807bdf36c66657.gif

  • 공유 축: 공간 관계나 탐색 관계가 있는 UI 요소 간 전환. x축이나 y축, z축에서 공유 변환을 사용하여 요소 간 관계를 강화합니다.

71218f390abae07e.gif

  • 페이드 스루: 서로 관계가 밀접하지 않은 UI 요소 간 전환. 들어오는 요소의 배율과 함께 순차적인 페이드 아웃과 페이드 인을 사용합니다.

385ba37b8da68969.gif

  • 페이드: 화면 경계 내에서 나타나거나 사라지는 UI 요소에 사용됩니다.

cfc40fd6e27753b6.gif

애니메이션 패키지는 Flutter 애니메이션 라이브러리(flutter/animation.dart)와 Flutter 머티리얼 라이브러리(flutter/material.dart)에 모두 기반한 이러한 패턴의 전환 위젯을 제공합니다.

이 Codelab에서는 Flutter 프레임워크와 머티리얼 라이브러리에 기반한 머티리얼 전환을 사용합니다. 즉, 위젯을 다룹니다. ^^

빌드할 항목

이 Codelab에서는 Dart를 사용하여 Reply라는 Flutter 이메일 앱으로 전환을 빌드하는 방법을 안내합니다. 이를 통해 애니메이션 패키지의 전환을 사용하여 앱의 디자인과 분위기를 맞춤설정하는 방법을 보여줍니다.

Reply 앱의 시작 코드가 제공되고 아래 완료된 Codelab의 GIF에서 확인할 수 있는 다음 머티리얼 전환을 앱에 통합합니다.

  • 이메일 목록에서 이메일 세부정보 페이지로 컨테이너 변환 전환
  • FAB에서 이메일 작성 페이지로 컨테이너 변환 전환
  • 검색 아이콘에서 검색 뷰 페이지로 공유 Z축 전환
  • 편지함 페이지 간 페이드 스루 전환
  • 편지쓰기 및 답장 FAB 간 페이드 스루 전환
  • 사라지는 편지함 제목 간 페이드 스루 전환
  • 하단 앱 바 작업 간 페이드 스루 전환

b26fe84fed12d17d.gif

필요한 항목

  • Flutter 개발 및 Dart에 관한 기본 지식
  • 코드 편집기
  • Android/iOS 에뮬레이터 또는 기기
  • 샘플 코드(다음 단계 참고)

Flutter 앱 빌드 경험 수준을 평가해주세요.

초급 중급 고급

이 Codelab에서 배우고 싶은 내용은 무엇인가요?

주제를 처음 접하기 때문에 간단하게 내용을 파악하고 싶습니다. 이 주제에 관해 약간 알고 있지만 한 번 더 확인하고 싶습니다. 프로젝트에 사용할 코드 예시를 찾고 있습니다. 구체적인 항목에 관한 설명을 찾고 있습니다.

2. Flutter 개발 환경 설정

이 실습을 완료하려면 Flutter SDK편집기라는 두 가지 소프트웨어가 필요합니다.

다음 기기 중 하나를 사용하여 이 Codelab을 실행할 수 있습니다.

  • 컴퓨터에 연결되어 있으며 개발자 모드로 설정된 실제 Android 또는 iOS 기기
  • iOS 시뮬레이터(Xcode 도구 설치 필요)
  • Android Emulator(Android 스튜디오 설정 필요)
  • 브라우저(디버깅 시 Chrome 필요)
  • Windows, Linux 또는 macOS 데스크톱 애플리케이션. 배포에 사용할 플랫폼에서 개발해야 합니다. 따라서 Windows 데스크톱 앱을 개발하려면 적절한 빌드 체인에 액세스할 수 있도록 Windows에서 개발해야 합니다. docs.flutter.dev/desktop에 운영체제별 요구사항이 자세히 설명되어 있습니다.

3. Codelab 시작 앱 다운로드

옵션 1: GitHub에서 Codelab 시작 앱 클론

Codelab을 GitHub에서 클론하려면 다음 명령어를 실행합니다.

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

옵션 2: 시작 Codelab 앱 zip 파일 다운로드

시작 앱은 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. 샘플 앱 코드 익히기

이제 코드를 살펴보겠습니다. Google에서는 애니메이션 패키지를 사용하여 애플리케이션에서 여러 화면 간에 전환하는 앱을 제공했습니다.

  • HomePage: 선택한 편지함이 표시됩니다.
  • InboxPage: 이메일 목록이 표시됩니다.
  • MailPreviewCard: 이메일 미리보기가 표시됩니다.
  • MailViewPage: 하나의 전체 이메일이 표시됩니다.
  • ComposePage: 새 이메일 작성이 허용됩니다.
  • SearchPage: 검색 뷰가 표시됩니다.

router.dart

먼저 앱의 루트 탐색이 설정되는 방법을 이해하려면 lib 디렉터리에서 router.dart를 엽니다.

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

이는 루트 탐색기이며 HomePageSearchPage 등 전체 캔버스를 사용하는 앱의 화면을 처리합니다. 앱 상태를 수신하여 경로를 ReplySearchPath로 설정했는지 확인합니다. 설정했다면 스택 상단의 SearchPage를 사용하여 탐색기를 다시 빌드합니다. 화면은 정의된 전환 없이 CustomTransitionPage에 래핑됩니다. 이를 통해 맞춤 전환 없이 화면 간에 이동하는 한 가지 방법을 확인할 수 있습니다.

home.dart

home.dart_BottomAppBarActionItems 내부에서 다음을 실행하여 경로를 앱 상태의 ReplySearchPath로 설정합니다.

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에 액세스하여 routePathReplySearchPath로 설정합니다. RouterProvider는 루트 탐색기 상태를 추적합니다.

mail_view_router.dart

이제 앱의 내부 탐색이 어떻게 설정되어 있는지 살펴보겠습니다. lib 디렉터리에서 mail_view_router.dart를 엽니다. 위와 비슷한 탐색기가 표시됩니다.

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는 앱 상태에 있는 현재 편지함에 따라 이메일 목록을 표시합니다. 탐색기는 앱 상태의 currentlySelectedInbox 속성에 변경이 있을 때마다 스택 위 올바른 InboxPage로 다시 빌드됩니다.

home.dart

home.dart_HomePageState 내부에서 다음을 실행하여 앱의 상태에서 현재 편지함을 설정합니다.

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

마지막으로, 사용되는 탐색 라우팅의 예를 확인하려면 lib 디렉터리에서 home.dart를 엽니다. InkWell 위젯의 onTap 속성 내에서 다음과 같이 표시되는 _ReplyFabState 클래스를 찾습니다.

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의 코드를 알아봅니다.

시작 코드를 알아봤으므로 이제 첫 번째 전환을 구현해보겠습니다.

5. 이메일 목록에서 이메일 세부정보 페이지로의 컨테이너 변환 전환 추가

먼저 이메일을 클릭할 때 전환을 추가합니다. 이러한 탐색 변경의 경우에는 컨테이너 변환 패턴이 적합합니다. 컨테이너가 포함된 UI 요소 간 전환을 위해 설계되었기 때문입니다. 이 패턴은 두 UI 요소 간 시각적인 연결을 만듭니다.

코드를 추가하기 전에 Reply 앱을 실행하여 이메일을 클릭해보세요. 간단한 점프컷이 실행됩니다. 즉, 화면이 전환 없이 대체됩니다.

48b00600f73c7778.gif

먼저 다음 스니펫과 같이 mail_card_preview.dart 상단에서 애니메이션 패키지 가져오기를 추가합니다.

mail_card_preview.dart

import 'package:animations/animations.dart';

이제 애니메이션 패키지 가져오기가 있으므로 멋진 전환을 앱에 추가할 수 있습니다. 먼저 OpenContainer 위젯을 수용할 StatelessWidget 클래스를 만들어보겠습니다.

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

이제 새 래퍼를 사용해보겠습니다. MailPreviewCard 클래스 정의 내에서 build() 함수의 Material 위젯을 새 _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의 색상 속성이 포함된 컨테이너의 색상을 정의합니다. 따라서 머티리얼 및 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도 설정됩니다. onClosedOpenContainer 경로가 표시되었거나 닫힌 상태로 돌아왔을 때 호출되는 ClosedCallback입니다. 이 트랜잭션의 반환 값이 이 함수에 인수로 전달됩니다. 이 Callback을 사용하여 앱의 제공자에 ComposePage 경로를 떠났다고 알리므로 모든 리스너에게 알릴 수 있습니다.

이전 단계에서 했던 것과 마찬가지로 위젯에서 Material 위젯을 삭제합니다. OpenContainer 위젯이 closedBuilder에서 반환한 위젯의 색상을 closedColor로 처리하기 때문입니다. InkWell 위젯의 onTap 내부에서 Navigator.push() 호출도 삭제하고 OpenContainer 위젯의 closedBuilder에서 제공한 openContainer() Callback으로 대체합니다. 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 위젯이 이제 onClosed ClosedCallback을 통해 ComposePage에 더 이상 없다고 앱 제공자에 알리는 작업을 처리하므로 이전 구현을 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 속성 아래에서 검색 화면을 CustomTransitionPage 대신 SharedAxisTransitionPageWrapper로 래핑합니다.

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

점점 완벽해지고 있습니다. 하단 앱 바에서 검색 아이콘을 클릭하면 공유 축 전환이 검색 페이지를 뷰로 조정합니다. 그러나 홈페이지가 사라지지 않고 대신 그 위로 검색 페이지가 나타날 때 정적으로 유지됩니다. 또한 뒤로 버튼을 누르면 홈페이지가 뷰로 조정되지 않고 대신 검색 페이지가 뷰에서 사라질 때 정적으로 유지됩니다. 따라서 아직 작업이 완료되지 않았습니다.

HomePageCustomTransitionPage 대신 SharedAxisTransitionWrapper로 래핑하여 두 가지 문제를 모두 해결합니다.

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. 편지함 페이지 간 페이드 스루 전환 추가

이 단계에서는 서로 다른 편지함 간 전환을 추가합니다. 공간 관계나 계층 관계를 강조하지 않으려고 하므로 페이드 스루를 사용하여 이메일 목록 간에 간단한 '전환'을 실행합니다.

코드를 더 추가하기 전에 앱을 실행하고 하단 앱 바에서 Reply 로고를 탭하여 편지함을 전환해보세요. 이메일 목록이 전환 없이 변경됩니다.

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

나머지 Codelab은 home.dart에서 작업하므로 애니메이션 패키지 가져오기 추가에 대해서는 걱정하지 않아도 됩니다. 2단계의 home.dart에서 이미 했기 때문입니다.

다음 몇 가지 전환을 구성하는 방법은 매우 유사합니다. 모두 재사용 가능한 클래스 _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에서 이미 작업을 대부분 실행했기 때문입니다.

이제 home.dart_AnimatedBottomAppBar 클래스로 이동하여 전환을 추가해보겠습니다. 이전 단계의 _FadeThroughTransitionSwitcher를 재사용하고 빈 SizedBox를 반환하거나 하단 창과 동기화되어 페이드 인되는 편지함 제목을 반환하는 onMailView 조건을 래핑합니다.

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
                   .bodyText1!
                   .copyWith(
                     color: ReplyColors.white50,
                   ),
             );
           },
         ),
       ),
),

이로써 이 단계를 마쳤습니다.

앱을 다시 실행합니다. 이메일을 열어 이메일 뷰로 이동하면 하단 앱 바의 편지함 제목이 페이드 아웃되며 사라집니다. 좋습니다.

3f1a3db01a481124.gif

11. 하단 앱 바 작업 간 페이드 스루 전환 추가

이 단계에서는 페이드 스루 전환을 추가하여 애플리케이션 컨텍스트에 따라 하단 앱 바 작업을 페이드 스루합니다. 공간 관계나 계층 관계를 강조하지 않으려고 하므로 페이드 스루를 사용하여 앱이 홈페이지에 있을 때, 하단 창이 보일 때, 사용자가 이메일 뷰에 있을 때 하단 앱 바 작업 간에 간단한 '전환'을 실행합니다.

코드를 더 추가하기 전에 앱을 실행하고 이메일을 탭하여 이메일 뷰를 열어보세요. Reply 로고를 탭해도 됩니다. 하단 앱 바 작업이 전환 없이 변경됩니다.

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. 수고하셨습니다.

Dart 코드 100줄 미만으로 애니메이션 패키지를 사용하여 Material Design 가이드라인을 준수하고 모든 기기에서 일관되게 보이고 동작하는 기존 앱에서 멋진 전환을 만들 수 있었습니다.

d5637de49eb64d8a.gif

다음 단계

머티리얼 모션 시스템에 관한 자세한 내용은 사양 및 전체 개발자 문서를 참고하세요. 앱에 머티리얼 전환을 추가해보세요.

머티리얼 모션을 사용해주셔서 감사합니다. 이 Codelab에 만족하셨길 바랍니다.

적절한 시간과 노력을 들여 이 Codelab을 완료할 수 있었습니다.

매우 동의함 동의함 보통 동의하지 않음 전혀 동의하지 않음

앞으로 머티리얼 모션 시스템을 계속 사용하고 싶습니다.

매우 동의함 동의함 보통 동의하지 않음 전혀 동의하지 않음

머티리얼 Flutter 라이브러리에서 제공하는 위젯과 Flutter 프레임워크를 사용하는 방법에 관한 더 많은 데모는 Flutter Gallery를 참고하세요.

46ba920f17198998.png

6ae8ae284bf4f9fa.png