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

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

logo_components_color_2x_web_96dp.png

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

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

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

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

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

b9fd67c205755d55.gif

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

76622de33a19179.gif

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

18a525c038443492.gif

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

cd10a0580a159644.gif

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

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

빌드할 항목

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

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

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

5f7b8860db2c70e2.gif

필요한 항목

  • Flutter 개발 및 Dart에 관한 기본 지식
  • Android 스튜디오(아직 다운로드하지 않은 경우 여기에서 다운로드)
  • Android Emulator 또는 기기(Android 스튜디오를 통해 사용 가능)
  • 샘플 코드(다음 단계 참고)

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

초급 중급 고급

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

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

시작하기 전에

Flutter를 사용하여 모바일 앱을 개발하려면 다음을 실행해야 합니다.

  1. Flutter SDK를 다운로드하고 설치합니다.
  2. Flutter SDK를 사용하여 PATH를 업데이트합니다.
  3. Flutter와 Dart 플러그인이 포함된 Android 스튜디오나 선호하는 편집기를 설치합니다.
  4. Android Emulator, iOS 시뮬레이터(Xcode가 포함된 Mac 필요)를 설치하거나 실제 기기를 사용합니다.

Flutter 설치에 관한 자세한 내용은 시작하기: 설치를 참고하세요. 편집기를 설정하려면 시작하기: 편집기 설정을 참고하세요. Android Emulator를 설치할 때 최신 시스템 이미지가 있는 Pixel 3 휴대전화와 같은 기본 옵션을 자유롭게 사용하세요. VM 가속을 사용 설정하는 것이 좋지만 필수는 아닙니다. 위 네 단계를 완료한 후 Codelab으로 돌아가면 됩니다. 이 Codelab을 완료하려면 플랫폼(Android나 iOS) 하나에만 Flutter를 설치하면 됩니다.

Flutter SDK가 올바른 상태인지 확인

이 Codelab을 진행하기 전에 SDK가 올바른 상태인지 확인하세요. 이전에 Flutter SDK를 설치한 경우 flutter upgrade를 사용하여 SDK가 최신 상태인지 확인합니다.

 flutter upgrade

flutter upgrade를 실행하면 flutter doctor.가 자동으로 실행됩니다. 최신 Flutter가 설치되어 있어 업그레이드가 필요하지 않으면 flutter doctor를 수동으로 실행합니다. 그러면 설정 완료를 위해 설치해야 하는 종속 항목이 있는지 보고됩니다. 관련 없는 체크표시(예: iOS용으로 개발하지 않으려는 경우 Xcode)는 무시해도 됩니다.

 flutter doctor

자주 묻는 질문(FAQ)

Android 스튜디오 시작

Android 스튜디오를 열면 'Welcome to Android Studio'라는 창이 표시됩니다. 그러나 Android 스튜디오를 처음 실행하는 경우 기본값을 사용하여 Android 스튜디오 설정 마법사 단계를 진행합니다. 이 단계는 필요한 파일을 다운로드하고 설치하는 데 몇 분 정도 걸릴 수 있으므로 다음 섹션을 진행하는 동안 이 작업이 백그라운드에서 실행되도록 하면 됩니다.

옵션 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 디렉터리에 있습니다.

Android 스튜디오에서 시작 코드 로드

  1. 설정 마법사가 완료되고 Welcome to Android Studio 창이 표시되면 Open an existing Android Studio project를 클릭합니다.

e3f200327a67a53.png

  1. 샘플 코드를 설치한 디렉터리로 이동하고 샘플 디렉터리를 선택하여 프로젝트를 엽니다.
  2. Android 스튜디오 창 하단의 활동 표시기에 나타나는 것처럼 Android 스튜디오가 프로젝트를 빌드하고 동기화할 때까지 잠시 기다립니다.
  3. 이 시점에서 Android 스튜디오에 아래와 같은 빌드 오류가 발생할 수 있습니다. Android SDK나 빌드 도구가 누락되었기 때문입니다. Android 스튜디오의 안내를 따라 이러한 항목을 설치/업데이트하고 프로젝트를 동기화합니다. 여전히 문제가 발생한다면 SDK Manager로 도구 업데이트 가이드를 따르세요.

6e026ae171f5b1eb.png

  1. 메시지가 표시되면 다음 단계를 따릅니다.
  • 플랫폼 및 플러그인 업데이트나 FlutterRunConfigurationType을 설치합니다.
  • Dart 또는 Flutter SDK가 구성되지 않은 경우 Flutter 플러그인용 Flutter SDK 경로를 설정합니다.
  • Android 프레임워크를 구성합니다.
  • 'Get dependencies'나 'Run ‘flutter packages get''을 클릭합니다.

그런 다음 Android 스튜디오를 다시 시작합니다.

53b7195f1c1deedb.png

be5ce477ba09225e.png 24810642cf859588.png

프로젝트 종속 항목 확인

프로젝트에는 애니메이션 패키지의 종속 항목이 필요합니다. 다운로드한 샘플 코드에는 이 종속 항목이 이미 나열되어 있지만 구성을 살펴보며 확인해보겠습니다.

app 모듈의 pubspec.yaml 파일로 이동하여 dependencies 섹션에 애니메이션 패키지의 종속 항목이 포함되어 있는지 확인합니다.

animations: ^1.1.2

시작 앱 실행

  1. 기기 선택 왼쪽에 있는 빌드 구성이 app인지 확인합니다.
  2. 녹색 실행/재생 버튼을 눌러 앱을 빌드하고 실행합니다.

a34cba7fab0a2af9.png

  1. 편집기 화면 상단의 Flutter Device Selection 드롭다운 메뉴에서 사용 가능한 기기에 이미 기기가 나열되어 있다면 8단계로 건너뜁니다. 나열되어 있지 않으면 Create New Virtual Device를 클릭합니다.
  2. Select Hardware 화면에서 Pixel 3와 같은 휴대전화 기기를 선택한 후 Next를 클릭합니다.
  3. System Image 화면에서 최신 Android 버전(가장 높은 API 수준 권장)을 선택합니다. 설치되어 있지 않은 경우 표시되는 다운로드 링크를 클릭하고 다운로드를 완료합니다.
  4. Next를 클릭합니다.
  5. Android Virtual Device(AVD) 화면에서 설정을 그대로 두고 Finish를 클릭합니다.
  6. 기기(예: Flutter Device Selection 드롭다운 메뉴에서 <version>용으로 빌드된 iPhone SE나 Android SDK)를 선택합니다.
  7. 재생 아이콘(b8c998094aa23ac2.png)을 누릅니다.
  8. Android 스튜디오가 앱을 빌드하고 배포한 후 자동으로 대상 기기에서 앱을 엽니다.

완료되었습니다. Reply의 홈페이지 시작 코드가 에뮬레이터에서 실행되고 이메일 목록이 포함된 받은편지함이 표시됩니다.

Android

iOS

선택사항: 기기 애니메이션 속도 늦추기

이 Codelab에는 빠르면서도 세련된 전환이 포함되어 있으므로 구현하는 동안 전환을 자세하게 관찰하기 위해 기기 애니메이션 속도를 늦추는 것이 유용할 수 있습니다. 하단 창이 열려 있을 때 설정 아이콘을 탭하여 액세스할 수 있는 인앱 설정을 통해 이 작업을 실행할 수 있습니다. 기기 애니메이션의 속도를 늦추는 이 방법은 Reply 앱 외부의 기기 애니메이션에 영향을 미치지 않으므로 걱정하지 않아도 됩니다.

Android

iOS

선택사항: 어두운 모드

Reply의 밝은 테마로 인해 눈이 피로하다면 사용을 중지하세요. 포함된 인앱 설정으로 앱 테마를 어두운 모드로 변경하여 눈의 피로를 덜 수 있습니다. 하단 창이 열려 있을 때 설정 아이콘을 탭하여 이 설정에 액세스할 수 있습니다.

Android

iOS

이제 코드를 살펴보겠습니다. 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})
     : assert(replyState != null),
       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) {
   assert(configuration != null);
   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 = ReplySearchPath();
     },
   ),
 );

onPressed 매개변수에서 RouterProvider에 액세스하여 routePathReplySearchPath로 설정합니다. RouterProvider는 루트 탐색기 상태를 추적합니다.

mail_view_router.dart

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

class MailViewRouterDelegate extends RouterDelegate<void>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin {
 MailViewRouterDelegate({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의 코드를 알아봅니다.

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

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

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

Android

iOS

먼저 다음 스니펫과 같이 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,
 })  : assert(id != null),
       assert(email != null),
       assert(closedChild != null);

 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() 함수의 return 위젯을 새 _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(
     color: theme.cardColor,
     child: InkWell(
       onTap: () {
         Provider.of<EmailStore>(
           context,
           listen: false,
         ).currentlySelectedEmailId = id;

         mobileMailNavKey.currentState.push(
           PageRouteBuilder(
             pageBuilder: (BuildContext context, Animation<double> animation,
                 Animation<double> secondaryAnimation) {
               return MailViewPage(id: id, email: email);
             },
           ),
         );
       },
       child: 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.of(context).scaffoldBackgroundColor,
           iconColor: currentEmailStarred
               ? colorScheme.onSecondary
               : colorScheme.onBackground,
           alignment: Alignment.centerRight,
           padding: const EdgeInsetsDirectional.only(end: 20),
         ),
         child: mailPreview,
       ),
     ),
   ),
 );
}

위젯에서 InkWell을 삭제해야 합니다. 이제 로직이 _OpenContainerWrapper 클래스 내부에 있기 때문입니다. Material 위젯을 삭제할 수도 있습니다. OpenContainer의 색상 속성이 포함된 컨테이너의 색상을 정의하기 때문입니다.

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,
   }.......

이 단계에서는 완전히 작동하는 컨테이너 변환이 있어야 합니다. 이메일을 클릭하면 목록 항목이 세부정보 화면으로 펼쳐지면서 이메일 목록은 사라집니다. 뒤로를 누르면 이메일 세부정보 화면이 다시 목록 항목으로 접히면서 이메일 목록이 나타납니다.

Android

iOS

컨테이너 변환을 계속 진행하며 플로팅 작업 버튼에서 ComposePage로의 전환을 추가하여 FAB를 사용자가 작성할 새 이메일로 확장해보겠습니다. 먼저 앱을 다시 실행하고 FAB를 클릭하여 이메일 작성 화면을 실행할 때 전환이 없는지 확인합니다.

Android

iOS

이 전환을 구성하는 방법은 이전 단계에서 실행한 방법과 매우 유사합니다. 동일한 위젯 클래스 OpenContainer를 사용하기 때문입니다.

home.dart에서 다음 스니펫을 _ReplyFabState 클래스 정의에 추가하여 파일 상단에 package:animations/animations.dart를 가져와야 합니다. 여기서 _ReplyFabState 클래스 정의 build() 함수의 반환 위젯을 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,
     shape: circleFabBorder,
     child: Tooltip(
       message: tooltip,
       child: InkWell(
         customBorder: circleFabBorder,
         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();
               },
             ),
           );
         },
         child: SizedBox(
           height: _mobileFabDimension,
           width: _mobileFabDimension,
           child: Center(
             child: fabSwitcher,
           ),
         ),
       ),
     ),
   );
 },
);

이전 OpenContainer 위젯을 구성하는 데 사용된 매개변수 외에 이제 onClosed도 설정됩니다. onClosedOpenContainer 경로가 표시되었거나 닫힌 상태로 돌아왔을 때 호출되는 ClosedCallback입니다. 이 트랜잭션의 반환 값이 이 함수에 인수로 전달됩니다. 이 Callback을 사용하여 앱의 제공자에 ComposePage 경로를 떠났다고 알리므로 모든 리스너에게 알릴 수 있습니다.

이전 단계에서 했던 것과 마찬가지로 위젯에서 Material 위젯을 삭제합니다. OpenContainer 위젯이 closedBuilder에서 반환한 위젯의 색상을 closedColor로 처리하기 때문입니다. InkWell 위젯의 onTap 내부에서 Navigator.push() 호출도 삭제하고 OpenContainer 위젯의 closedBuilder에서 제공한 openContainer() Callback으로 대체합니다. OpenContainer 위젯이 이제 자체 라우팅을 처리하기 때문입니다.

_ReplyFabState 클래스 정의 내부의 home.dart에서:

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>(false);

이 단계는 여기까지입니다. 다음과 같이 FAB에서 편지쓰기 화면으로 전환됩니다.

Android

iOS

이 단계에서는 검색 아이콘에서 전체 화면검색 뷰로의 전환을 추가합니다. 이 탐색 변경과 관련된 영구 컨테이너가 없으므로 공유 Z축 전환을 사용하여 두 화면 사이의 공간 관계를 강화하고 앱 계층 구조에서 한 수준 위로 이동을 나타낼 수 있습니다.

코드를 더 추가하기 전에 앱을 실행하고 화면 오른쪽 하단의 검색 아이콘을 탭해보세요. 그러면 전환 없이 검색 뷰 화면이 표시됩니다.

Android

iOS

먼저 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})
      : assert(screen != null),
        assert(transitionKey != null),
        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로 래핑하는 대신 새 래퍼를 사용합니다.

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(
     pageBuilder: (context, animation, secondaryAnimation) {
       return const HomePage();
     },
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: const SearchPage(),
     ),
 ],
);

이제 앱을 다시 실행해봅니다.

Android

iOS

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

홈페이지의 전환 문제를 해결하려면 HomePagerouter.dartSharedAxisTransitionWrapper로 래핑하면 됩니다.

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: const HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: const SearchPage(),
     ),
 ],
);

완료되었습니다. 이제 앱을 다시 실행하여 검색 아이콘을 탭해보세요. 홈 화면 및 검색 뷰 화면이 Z축을 따라 동시에 깊이 페이드 아웃되면서 조정되어 두 화면 사이에 매끄러운 효과가 나타납니다.

Android

iOS

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

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

Android

iOS

먼저 mail_view_router.dart 파일로 이동합니다. MailViewRouterDelegate 클래스 정의 뒤에 다음 스니펫을 추가합니다.

mail_view_router.dart

// TODO: Add Fade through transition between mailbox pages (Motion)
class FadeThroughTransitionPageWrapper extends Page {
  FadeThroughTransitionPageWrapper({
    @required this.mailbox,
    @required this.transitionKey,
  })  : assert(mailbox != null),
        assert(transitionKey != null),
        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로 래핑하지 않고 대신 새 래퍼를 사용합니다.

mail_view_router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Fade through transition between different mailbox pages (Motion)
   FadeThroughTransitionPageWrapper(
     mailbox: InboxPage(destination: currentlySelectedInbox),
     transitionKey: ValueKey(currentlySelectedInbox),
   ),
 ],
);

앱을 다시 실행합니다. 하단 탐색 창을 열고 편지함을 변경하면 현재 이메일 목록이 페이드 아웃되며 사라지고 새 목록이 페이드 인되며 나타납니다. 훌륭합니다.

Android

iOS

이 단계에서는 여러 FAB 아이콘 간 전환을 추가합니다. 공간 관계나 계층 관계를 강조하지 않으려고 하므로 페이드 스루를 사용하여 FAB 아이콘 간에 간단한 '전환'을 실행합니다.

코드를 더 추가하기 전에 앱을 실행하고 이메일을 탭하여 이메일 뷰를 열어보세요. FAB 아이콘이 전환 없이 변경됩니다.

Android

iOS

나머지 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,
 })  : assert(fillColor != null),
       assert(child != null);

 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 위젯을 사용하면 FAB가 컨텍스트에 따라 전환할 수 있습니다. fabSwitcher는 사용자가 이메일 뷰에 있는지 확인하고 이메일 뷰에 있다면 다른 FAB 아이콘을 제공합니다.

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를 제공하므로 전환할 때 요소 사이에 배경이 없습니다.

이제 이 단계에서 완전히 애니메이션 처리된 상황별 FAB가 있어야 합니다. 이메일 뷰로 이동하면 이전 FAB 아이콘이 페이드 아웃되어 사라지고 새 아이콘이 페이드 인되며 나타납니다.

Android

iOS

이 단계에서는 페이드 스루 전환을 추가하여 이메일 뷰에 있을 때 보이는 상태와 보이지 않는 상태 간에 편지함 제목을 페이드 스루합니다. 공간 관계나 계층 관계를 강조하지 않으려고 하므로 페이드 스루를 사용하여 편지함 제목을 포함하는 Text 위젯과 빈 SizedBox 간에 간단한 '전환'을 실행합니다.

코드를 더 추가하기 전에 앱을 실행하고 이메일을 탭하여 이메일 뷰를 열어보세요. 편지함 제목이 전환 없이 사라집니다.

Android

iOS

이 Codelab의 나머지 부분은 간단합니다. 이전 단계의 _FadeThroughTransitionSwitcher에서 이미 작업을 대부분 실행했기 때문입니다.

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

home.dart

const SizedBox(width: 8),
const _ReplyLogo(),
const SizedBox(width: 10),
// TODO: Add Fade through transition between disappearing mailbox title (Motion)
_FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: onMailView
     ? const SizedBox(height: 0, 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,
                   ),
             );
           },
         ),
       ),
),

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

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

Android

iOS

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

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

Android

iOS

이전 단계와 마찬가지로 _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
         ? Row(
             mainAxisSize: MainAxisSize.max,
             mainAxisAlignment: MainAxisAlignment.end,
             children: [
               IconButton(
                 icon: ImageIcon(
                   const AssetImage(
                     '$_iconAssetLocation/twotone_star.png',
                     package: _assetsPackage,
                   ),
                   color: starIconColor,
                 ),
                 onPressed: () {
                   model.starEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );
                   if (model.currentlySelectedInbox == 'Starred') {
                     mobileMailNavKey.currentState.pop();
                     model.currentlySelectedEmailId = -1;
                   }
                 },
                 color: ReplyColors.white50,
               ),
               IconButton(
                 icon: const ImageIcon(
                   AssetImage(
                     '$_iconAssetLocation/twotone_delete.png',
                     package: _assetsPackage,
                   ),
                 ),
                 onPressed: () {
                   model.deleteEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );

                   mobileMailNavKey.currentState.pop();
                   model.currentlySelectedEmailId = -1;
                 },
                 color: ReplyColors.white50,
               ),
               IconButton(
                 icon: const Icon(Icons.more_vert),
                 onPressed: () {},
                 color: ReplyColors.white50,
               ),
             ],
           )
         : Align(
             alignment: AlignmentDirectional.bottomEnd,
             child: IconButton(
               icon: const Icon(Icons.search),
               color: ReplyColors.white50,
               onPressed: () {
                 Provider.of<RouterProvider>(
                   context,
                   listen: false,
                 ).routePath = ReplySearchPath();
               },
             ),
           ),
);

이제 시도해보겠습니다. 이메일을 열어 이메일 뷰로 이동하면 이전 하단 앱 바 작업이 페이드 아웃되며 사라지고 새 작업이 페이드 인되며 나타납니다. 잘하셨습니다.

Android

iOS

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

Android

iOS

다음 단계

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

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

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

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

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

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

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

52f7119a30bb8f5c.png

dd11628e4c0f3fd3.png