MDC-104 Flutter: 머티리얼 고급 구성요소

1. 소개

logo_components_color_2x_web_96dp.png

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

Codelab MDC-103에서는 머티리얼 구성요소(MDC)의 색상, 고도, 서체, 형태를 맞춤설정하여 앱 스타일을 지정했습니다.

Material Design 시스템의 구성요소는 일련의 사전 정의된 태스크를 실행하며 버튼과 같은 특정 특성을 가집니다. 하지만 버튼은 사용자가 동작을 진행하는 단순한 방법에 불과하지 않습니다. 버튼은 사용자에게 상호작용 방식이며 터치나 클릭 시 특정 동작이 발생함을 알려주는 형태, 크기, 색상의 시각적 표현이기도 합니다.

Material Design 가이드라인은 디자이너의 관점에서 구성요소를 설명합니다. 플랫폼에서 사용할 수 있는 다양한 기본 기능과 각 구성요소를 구성하는 개별 요소를 설명합니다. 예를 들어 배경화면에는 뒷면 레이어와 콘텐츠, 전면 레이어와 콘텐츠, 모션 규칙, 디스플레이 옵션이 포함되어 있습니다. 이러한 각 구성요소는 각 앱의 요건, 사용 사례, 콘텐츠에 따라 맞춤설정할 수 있습니다.

빌드할 항목

이 Codelab에서는 Shrine 앱의 UI를 '배경화면'이라는 2단계 표현으로 변경합니다. 배경화면에는 선택 가능한 카테고리를 나열하는 메뉴가 포함됩니다. 이 카테고리는 비대칭 그리드에 표시된 제품을 필터링하는 데 사용됩니다. 이 Codelab에서는 다음과 같은 Flutter 구성요소를 사용합니다.

  • 형태
  • 모션
  • (이전 Codelab에서 사용한) Flutter 위젯

Android

iOS

상단 앱 바와 제품으로 가득 찬 비대칭 수평 스크롤 그리드가 있는 분홍색 및 갈색 테마가 지정된 전자상거래 앱

상단 앱 바와 제품으로 가득 찬 비대칭 수평 스크롤 그리드가 있는 분홍색 및 갈색 테마가 지정된 전자상거래 앱

4개의 카테고리가 나열된 메뉴

4개의 카테고리가 나열된 메뉴

이 Codelab의 MDC-Flutter 구성요소 및 하위 시스템

  • 형태

Flutter 개발 경험 수준을 평가해주세요.

초급 중급 고급

2. Flutter 개발 환경 설정

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

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

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

3. Codelab 시작 앱 다운로드

MDC-103에서 계속 진행하시겠어요?

MDC-103을 완료했다면 이 Codelab을 위한 코드가 준비된 것입니다. 배경화면 메뉴 추가 단계로 건너뜁니다.

처음부터 새로 시작

시작 앱은 material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_series 디렉터리에 있습니다.

...또는 GitHub에서 클론

이 Codelab을 GitHub에서 클론하려면 다음 명령어를 실행하세요.

git clone https://github.com/material-components/material-components-flutter-codelabs.git
cd material-components-flutter-codelabs/mdc_100_series
git checkout 104-starter_and_103-complete

프로젝트 열기 및 앱 실행

  1. 원하는 편집기에서 프로젝트를 엽니다.
  2. 선택한 편집기에 맞게 시작하기: 시험 운용의 '앱 실행'에 대한 안내를 따릅니다.

완료되었습니다. 이전 Codelab의 Shrine 로그인 페이지가 기기에 표시됩니다.

Android

iOS

Shrine 로그인 페이지

Shrine 로그인 페이지

4. 배경화면 메뉴 추가

다른 모든 콘텐츠와 구성요소 뒤에 배경화면이 표시됩니다. 배경화면은 후면 레이어(동작과 필터 표시)와 전면 레이어(콘텐츠 표시)의 두 가지 레이어로 구성됩니다. 배경화면을 사용하여 탐색 또는 콘텐츠 필터 같은 대화형 정보와 동작을 표시할 수 있습니다.

홈 앱 바 삭제

HomePage 위젯은 전면 레이어의 콘텐츠가 됩니다. 지금 HomePage 위젯에는 앱 바가 있습니다. 앱 바를 후면 레이어로 옮겨 HomePage에 AsymmetricView만 포함해 보겠습니다.

home.dart에서 AsymmetricView만 반환하도록 build() 함수를 변경합니다.

// TODO: Return an AsymmetricView (104)
return AsymmetricView(products: ProductsRepository.loadProducts(Category.all));

Backdrop 위젯 추가

frontLayerbackLayer를 포함하는 Backdrop이라는 위젯을 만듭니다.

backLayer에는 카테고리를 선택하여 목록을 필터링할 수 있는 메뉴(currentCategory)가 있습니다. 메뉴 선택을 유지하기 위해 Backdrop을 스테이트풀(Stateful) 위젯으로 만들겠습니다.

/libbackdrop.dart라는 새 파일을 추가합니다.

import 'package:flutter/material.dart';

import 'model/product.dart';

// TODO: Add velocity constant (104)

class Backdrop extends StatefulWidget {
  final Category currentCategory;
  final Widget frontLayer;
  final Widget backLayer;
  final Widget frontTitle;
  final Widget backTitle;

  const Backdrop({
    required this.currentCategory,
    required this.frontLayer,
    required this.backLayer,
    required this.frontTitle,
    required this.backTitle,
    Key? key,
  }) : super(key: key);

  @override
  _BackdropState createState() => _BackdropState();
}

// TODO: Add _FrontLayer class (104)
// TODO: Add _BackdropTitle class (104)
// TODO: Add _BackdropState class (104)

특정 속성이 required로 표시되어 있습니다. 이 방법은 기본값이 없고 null일 수도 없기 때문에 잊어서는 안 되는 속성이 생성자에 있을 경우에 대한 권장사항입니다.

Backdrop 클래스 정의에서 _BackdropState 상태를 추가합니다.

// TODO: Add _BackdropState class (104)
class _BackdropState extends State<Backdrop>
    with SingleTickerProviderStateMixin {
  final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');

  // TODO: Add AnimationController widget (104)

  // TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
  Widget _buildStack() {
    return Stack(
    key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        widget.backLayer,
        widget.frontLayer,
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    var appBar = AppBar(
      elevation: 0.0,
      titleSpacing: 0.0,
      // TODO: Replace leading menu icon with IconButton (104)
      // TODO: Remove leading property (104)
      // TODO: Create title with _BackdropTitle parameter (104)
      leading: Icon(Icons.menu),
      title: Text('SHRINE'),
      actions: <Widget>[
        // TODO: Add shortcut to login screen from trailing icons (104)
        IconButton(
          icon: Icon(
            Icons.search,
            semanticLabel: 'search',
          ),
          onPressed: () {
          // TODO: Add open login (104)
          },
        ),
        IconButton(
          icon: Icon(
            Icons.tune,
            semanticLabel: 'filter',
          ),
          onPressed: () {
          // TODO: Add open login (104)
          },
        ),
      ],
    );
    return Scaffold(
      appBar: appBar,
      // TODO: Return a LayoutBuilder widget (104)
      body: _buildStack(),
    );
  }
}

build() 함수는 사용된 HomePage처럼 앱 바가 있는 Scaffold를 반환합니다. 하지만 Scaffold의 본문은 Stack입니다. Stack의 하위 요소는 중첩될 수 있습니다. 각 하위 요소의 크기와 위치는 Stack의 상위 요소를 기준으로 지정됩니다.

이제 ShrineApp에 Backdrop 인스턴스를 추가합니다.

app.dart에서 backdrop.dartmodel/product.dart를 가져옵니다.

import 'backdrop.dart'; // New code
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart'; // New code
import 'supplemental/cut_corners_border.dart';

app.dart,에서 HomePagefrontLayer로 있는 Backdrop을 반환하여 / 경로를 수정합니다.

// TODO: Change to a Backdrop with a HomePage frontLayer (104)
'/': (BuildContext context) => Backdrop(
     // TODO: Make currentCategory field take _currentCategory (104)
     currentCategory: Category.all,
     // TODO: Pass _currentCategory for frontLayer (104)
     frontLayer: HomePage(),
     // TODO: Change backLayer field value to CategoryMenuPage (104)
     backLayer: Container(color: kShrinePink100),
     frontTitle: Text('SHRINE'),
     backTitle: Text('MENU'),
),

프로젝트를 저장하면 다음과 같이 홈페이지와 앱 바가 표시됩니다.

Android

iOS

분홍색 배경의 Shrine 제품 페이지

분홍색 배경의 Shrine 제품 페이지

backLayer가 frontLayer 홈페이지 뒤의 새로운 레이어에 분홍색 영역을 표시합니다.

Flutter Inspector를 사용하여 Stack에서 실제로 HomePage 뒤에 Container가 있는지 확인할 수 있습니다. 다음과 같이 표시됩니다.

4783ed30f1cc010.png

이제 두 레이어의 디자인과 콘텐츠를 조정할 수 있습니다.

5. 형태 추가

이 단계에서는 전면 레이어의 스타일을 지정하여 왼쪽 상단 모서리에 자르기를 추가합니다.

머티리얼 디자인에서는 이런 유형의 맞춤설정을 형태라고 합니다. 머티리얼 노출 영역은 임의 형태를 가질 수 있습니다. 형태는 노출 영역에 강조와 스타일을 추가하고, 브랜드 표현에도 사용할 수 있습니다. 일반 직사각형 형태는 둥글거나 각진 모서리를 사용하거나 면의 개수를 원하는 만큼 사용해 맞춤설정할 수 있습니다. 형태는 대칭적이거나 불규칙적일 수 있습니다.

전면 레이어에 형태 추가

각진 Shrine 로고는 Shrine 앱의 형태 스토리에 영감을 주었습니다. 형태 스토리에서는 앱 전반에 적용되는 형태를 일반적으로 사용합니다. 예를 들어, 로고 형태는 로그인 페이지 요소에 반영되어 그 형태가 적용됩니다. 이 단계에서는 왼쪽 상단 모서리를 각진 형태로 잘라 전면 레이어의 스타일을 지정합니다.

backdrop.dart에서 새로운 클래스 _FrontLayer를 추가합니다.

// TODO: Add _FrontLayer class (104)
class _FrontLayer extends StatelessWidget {
  // TODO: Add on-tap callback (104)
  const _FrontLayer({
    Key? key,
    required this.child,
  }) : super(key: key);

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 16.0,
      shape: const BeveledRectangleBorder(
        borderRadius: BorderRadius.only(topLeft: Radius.circular(46.0)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          // TODO: Add a GestureDetector (104)
          Expanded(
            child: child,
          ),
        ],
      ),
    );
  }
}

그런 다음 _BackdropState의 _buildStack() 함수에서 _FrontLayer에 전면 레이어를 래핑합니다.

  Widget _buildStack() {
    // TODO: Create a RelativeRectTween Animation (104)

    return Stack(
    key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        widget.backLayer,
        // TODO: Add a PositionedTransition (104)
        // TODO: Wrap front layer in _FrontLayer (104)
          _FrontLayer(child: widget.frontLayer),
      ],
    );
  }

새로고칩니다.

Android

iOS

맞춤 형태의 Shrine 제품 페이지

맞춤 형태의 Shrine 제품 페이지

Shrine의 기본 노출 영역에 맞춤 형태를 부여했습니다. 노출 영역의 고도로 인해 사용자는 전면 흰색 레이어 뒤에 무언가가 있음을 알 수 있습니다. 사용자가 배경화면의 후면 레이어를 볼 수 있도록 모션을 추가해 보겠습니다.

6. 모션 추가

모션은 앱에 활기를 불어넣는 방법입니다. 모션은 크고 역동적이거나 미미할 정도로 작을 수 있고 아니면 그 중간일 수 있습니다. 하지만 사용하는 모션 유형은 상황에 맞아야 합니다. 반복되는 규칙 동작에 적용되는 모션은 작고 알아채기 힘들어야 합니다. 그래야 동작으로 인해 사용자가 산만해지거나 시간을 자주 오래 빼앗기지 않습니다. 하지만 사용자가 처음 앱을 열 때처럼, 좀 더 눈길을 끌 수 있는 적합한 상황도 있습니다. 사용자에게 앱 사용 방법을 안내할 때 애니메이션이 도움이 될 수 있습니다.

메뉴 버튼에 표시 모션 추가

클래스 또는 함수의 범위를 벗어나 backdrop.dart 상단에서 애니메이션에 적용할 속도를 나타내는 상수를 추가합니다.

// TODO: Add velocity constant (104)
const double _kFlingVelocity = 2.0;

AnimationController 위젯을 _BackdropState에 추가하고 initState() 함수에서 인스턴스화한 다음 상태의 dispose() 함수에서 버립니다.

  // TODO: Add AnimationController widget (104)
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      value: 1.0,
      vsync: this,
    );
  }

  // TODO: Add override for didUpdateWidget (104)

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  // TODO: Add functions to get and change front layer visibility (104)

AnimationController는 애니메이션을 조정하고, 애니메이션 재생, 역방향 재생 및 중지를 위한 API를 제공합니다. 이제 AnimationController를 실행할 함수가 필요합니다.

전면 레이어의 공개 상태를 확인하고 변경하는 함수를 다음과 같이 추가합니다.

  // TODO: Add functions to get and change front layer visibility (104)
  bool get _frontLayerVisible {
    final AnimationStatus status = _controller.status;
    return status == AnimationStatus.completed ||
        status == AnimationStatus.forward;
  }

  void _toggleBackdropLayerVisibility() {
    _controller.fling(
        velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity);
  }

ExcludeSemantics 위젯에서 backLayer를 래핑합니다. 이 위젯은 후면 레이어가 표시되지 않으면 시맨틱 트리에서 backLayer의 메뉴 항목을 제외합니다.

    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
      ...

BuildContext 및 BoxConstraints를 취하도록 _buildStack() 함수를 변경합니다. 또한 RelativeRectTween 애니메이션을 취하는 PositionedTransition도 포함합니다.

  // TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
    const double layerTitleHeight = 48.0;
    final Size layerSize = constraints.biggest;
    final double layerTop = layerSize.height - layerTitleHeight;

    // TODO: Create a RelativeRectTween Animation (104)
    Animation<RelativeRect> layerAnimation = RelativeRectTween(
      begin: RelativeRect.fromLTRB(
          0.0, layerTop, 0.0, layerTop - layerSize.height),
      end: const RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
    ).animate(_controller.view);

    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
        // TODO: Add a PositionedTransition (104)
        PositionedTransition(
          rect: layerAnimation,
          child: _FrontLayer(
            // TODO: Implement onTap property on _BackdropState (104)
            child: widget.frontLayer,
          ),
        ),
      ],
    );
  }

마지막으로 Scaffold 본문에 _buildStack 함수를 호출하는 대신 _buildStack을 빌더로 사용하는 LayoutBuilder 위젯을 반환합니다.

    return Scaffold(
      appBar: appBar,
      // TODO: Return a LayoutBuilder widget (104)
      body: LayoutBuilder(builder: _buildStack),
    );

배경화면의 실제 전체 높이를 통합할 수 있도록 LayoutBuilder를 사용하여 레이아웃 시간까지 전면/후면 레이어 스택의 빌드를 지연했습니다. LayoutBuilder는 크기 제약 조건을 제공하는 빌더 콜백이 있는 특수 위젯입니다.

build() 함수에서 앱 바의 선행 메뉴 아이콘을 IconButton으로 바꾸고, 버튼이 탭되면 IconButton을 사용하여 전면 레이어의 공개 상태를 전환합니다.

      // TODO: Replace leading menu icon with IconButton (104)
      leading: IconButton(
        icon: const Icon(Icons.menu),
        onPressed: _toggleBackdropLayerVisibility,
      ),

새로고침한 후 시뮬레이터에서 메뉴 버튼을 탭합니다.

Android

iOS

오류가 두 개 있는 빈 Shrine 메뉴

오류가 두 개 있는 빈 Shrine 메뉴

전면 레이어가 아래로 애니메이션(슬라이드)됩니다. 하지만 아래를 보면 빨간색 오류와 오버플로 오류가 있습니다. 애니메이션에 의해 AsymmetricView가 줄어들고 크기가 더 작아져 Columns의 공간이 줄어들었기 때문입니다. 결국 Columns는 지정된 공간에 자신을 배치할 수 없어 오류가 발생한 것입니다. Columns를 ListViews로 바꾸면 애니메이션될 때 열 크기가 그대로 유지됩니다.

ListView에서 제품 열 래핑

supplemental/product_columns.dart에서 OneProductCardColumn의 Column을 ListView로 바꿉니다.

class OneProductCardColumn extends StatelessWidget {
  const OneProductCardColumn({required this.product, Key? key}) : super(key: key);

  final Product product;

  @override
  Widget build(BuildContext context) {
    // TODO: Replace Column with a ListView (104)
    return ListView(
      physics: const ClampingScrollPhysics(),
      reverse: true,
      children: <Widget>[
        const SizedBox(
          height: 40.0,
        ),
        ProductCard(
          product: product,
        ),
      ],
    );
  }
}

Column에는 MainAxisAlignment.end가 포함되어 있습니다. 하단에서 레이아웃을 시작하려면 reverse: true를 표시합니다. 변경을 보완하기 위해 하위 요소 순서가 반대가 됩니다.

새로고침한 후 메뉴 버튼을 탭합니다.

Android

iOS

오류가 한 개 있는 빈 Shrine 메뉴

오류가 한 개 있는 빈 Shrine 메뉴

OneProductCardColumn에 관한 회색 오버플로 경고가 사라졌습니다. 이제 나머지 하나를 수정해 보겠습니다.

supplemental/product_columns.dart에서 imageAspectRatio가 계산되는 방식을 변경하고 TwoProductCardColumn의 Column을 ListView로 바꿉니다.

      // TODO: Change imageAspectRatio calculation (104)
      double imageAspectRatio = heightOfImages >= 0.0
          ? constraints.biggest.width / heightOfImages
          : 49.0 / 33.0;
      // TODO: Replace Column with a ListView (104)
      return ListView(
        physics: const ClampingScrollPhysics(),
        children: <Widget>[
          Padding(
            padding: const EdgeInsetsDirectional.only(start: 28.0),
            child: top != null
                ? ProductCard(
                    imageAspectRatio: imageAspectRatio,
                    product: top!,
                  )
                : SizedBox(
                    height: heightOfCards,
                  ),
          ),
          const SizedBox(height: spacerHeight),
          Padding(
            padding: const EdgeInsetsDirectional.only(end: 28.0),
            child: ProductCard(
              imageAspectRatio: imageAspectRatio,
              product: bottom,
            ),
          ),
        ],
      );

imageAspectRatio에 안전 기능도 추가했습니다.

새로고칩니다. 그런 다음 메뉴 버튼을 탭합니다.

Android

iOS

빈 Shrine 메뉴

빈 Shrine 메뉴

더 이상 오버플로가 발생하지 않습니다.

7. 후면 레이어에 메뉴 추가

메뉴는 텍스트 항목을 터치하면 이를 리스너에게 알려주는 탭 가능한 텍스트 항목의 목록입니다. 이 단계에서는 카테고리 필터링 메뉴를 추가합니다.

메뉴 추가

전면 레이어에는 메뉴를, 후면 레이어에는 대화형 버튼을 추가합니다.

lib/category_menu_page.dart라는 새 파일을 만듭니다.

import 'package:flutter/material.dart';

import 'colors.dart';
import 'model/product.dart';

class CategoryMenuPage extends StatelessWidget {
  final Category currentCategory;
  final ValueChanged<Category> onCategoryTap;
  final List<Category> _categories = Category.values;

  const CategoryMenuPage({
    Key? key,
    required this.currentCategory,
    required this.onCategoryTap,
  }) : super(key: key);

  Widget _buildCategory(Category category, BuildContext context) {
    final categoryString =
        category.toString().replaceAll('Category.', '').toUpperCase();
    final ThemeData theme = Theme.of(context);

    return GestureDetector(
      onTap: () => onCategoryTap(category),
      child: category == currentCategory
        ? Column(
            children: <Widget>[
              const SizedBox(height: 16.0),
              Text(
                categoryString,
                style: theme.textTheme.bodyText1,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 14.0),
              Container(
                width: 70.0,
                height: 2.0,
                color: kShrinePink400,
              ),
            ],
          )
      : Padding(
        padding: const EdgeInsets.symmetric(vertical: 16.0),
        child: Text(
          categoryString,
          style: theme.textTheme.bodyText1!.copyWith(
              color: kShrineBrown900.withAlpha(153)
            ),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        padding: const EdgeInsets.only(top: 40.0),
        color: kShrinePink100,
        child: ListView(
          children: _categories
            .map((Category c) => _buildCategory(c, context))
            .toList()),
      ),
    );
  }
}

GestureDetector는 카테고리 이름을 하위 요소로 갖는 Column을 래핑합니다. 밑줄은 선택한 카테고리를 나타내는 데 사용됩니다.

app.dart에서 ShrineApp 앱을 스테이트리스(Stateless)에서 스테이트풀(Stateful)로 변환합니다.

  1. ShrineApp.을 강조 표시합니다.
  2. IDE에 따라 코드 작업을 표시합니다.
  3. Android 스튜디오: ⌥Enter(macOS) 또는 alt + enter를 누릅니다.
  4. VS 코드: ⌘.(macOS) 또는 Ctrl+를 누릅니다.
  5. 'Convert to StatefulWidget'을 선택합니다.
  6. ShrineAppState 클래스를 비공개(_ShrineAppState)로 변경합니다. 마우스 오른쪽 버튼으로 ShrineAppState를 클릭합니다. 그리고,
  7. Android 스튜디오: Refactor > Rename을 선택합니다.
  8. VS 코드: Rename 심볼을 선택합니다.
  9. _ShrineAppState를 입력하여 클래스를 비공개로 설정합니다.

app.dart에서 선택된 카테고리와 카테고리가 탭되었을 때의 콜백에 관한 변수를 _SrineAppState에 추가합니다.

class _ShrineAppState extends State<ShrineApp> {
  Category _currentCategory = Category.all;

  void _onCategoryTap(Category category) {
    setState(() {
      _currentCategory = category;
    });
  }

그런 다음 후면 레이어를 CategoryMenuPage로 변경합니다.

app.dart에서 CategoryMenuPage를 가져옵니다.

import 'backdrop.dart';
import 'category_menu_page.dart';
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart';
import 'supplemental/cut_corners_border.dart';

build() 함수에서 backLayer 필드를 CategoryMenuPage로 변경하고, currentCategory 필드를 변경하여 인스턴스 변수를 가져옵니다.

'/': (BuildContext context) => Backdrop(
              // TODO: Make currentCategory field take _currentCategory (104)
              currentCategory: _currentCategory,
              // TODO: Pass _currentCategory for frontLayer (104)
              frontLayer: HomePage(),
              // TODO: Change backLayer field value to CategoryMenuPage (104)
              backLayer: CategoryMenuPage(
                currentCategory: _currentCategory,
                onCategoryTap: _onCategoryTap,
              ),
              frontTitle: const Text('SHRINE'),
              backTitle: const Text('MENU'),
            ),

새로고침한 후 메뉴 버튼을 탭합니다.

Android

iOS

4개의 카테고리가 있는 Shrine 메뉴

4개의 카테고리가 있는 Shrine 메뉴

메뉴 옵션을 탭해도 아무 반응이 없습니다. 이 사항을 수정해 보겠습니다.

home.dart에서 Category에 관한 변수를 추가한 후 AsymmetricView에 전달합니다.

import 'package:flutter/material.dart';

import 'model/product.dart';
import 'model/products_repository.dart';
import 'supplemental/asymmetric_view.dart';

class HomePage extends StatelessWidget {
  // TODO: Add a variable for Category (104)
  final Category category;

  const HomePage({this.category = Category.all, Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // TODO: Pass Category variable to AsymmetricView (104)
    return AsymmetricView(
      products: ProductsRepository.loadProducts(category),
    );
  }
}

app.dart에서 frontLayer_currentCategory를 전달합니다.

// TODO: Pass _currentCategory for frontLayer (104)
frontLayer: HomePage(category: _currentCategory),

새로고칩니다. 시뮬레이터에서 메뉴 버튼을 탭하고 Category를 선택합니다.

Android

iOS

Shrine으로 필터링한 제품 페이지

Shrine으로 필터링한 제품 페이지

메뉴 아이콘을 탭하여 제품을 확인합니다. 이제 필터링되었습니다.

메뉴 선택 후 전면 레이어 닫기

backdrop.dart에서 _BackdropState에 didUpdateWidget()(위젯 구성이 변경될 때마다 호출) 함수에 관한 재정의를 추가합니다.

  // TODO: Add override for didUpdateWidget() (104)
  @override
  void didUpdateWidget(Backdrop old) {
    super.didUpdateWidget(old);

    if (widget.currentCategory != old.currentCategory) {
      _toggleBackdropLayerVisibility();
    } else if (!_frontLayerVisible) {
      _controller.fling(velocity: _kFlingVelocity);
    }
  }

프로젝트를 저장하여 핫 리로드를 트리거합니다. 메뉴 아이콘을 탭하고 카테고리를 선택합니다. 메뉴가 자동으로 닫히고 선택한 항목 카테고리가 표시됩니다. 이제 이 기능을 전면 레이어에도 추가하겠습니다.

전면 레이어 전환

backdrop.dart에서 온탭 콜백을 배경화면 레이어에 추가합니다.

class _FrontLayer extends StatelessWidget {
  // TODO: Add on-tap callback (104)
  const _FrontLayer({
    Key? key,
    this.onTap, // New code
    required this.child,
  }) : super(key: key);

  final VoidCallback? onTap; // New code
  final Widget child;

그런 다음 GestureDetector를 _FrontLayer의 하위 요소인 Column의 하위 요소에 추가합니다.

      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          // TODO: Add a GestureDetector (104)
          GestureDetector(
            behavior: HitTestBehavior.opaque,
            onTap: onTap,
            child: Container(
              height: 40.0,
              alignment: AlignmentDirectional.centerStart,
            ),
          ),
          Expanded(
            child: child,
          ),
        ],
      ),

그런 다음 _buildStack() 함수에서 _BackdropState에 새 onTap 속성을 구현합니다.

          PositionedTransition(
            rect: layerAnimation,
            child: _FrontLayer(
              // TODO: Implement onTap property on _BackdropState (104)
              onTap: _toggleBackdropLayerVisibility,
              child: widget.frontLayer,
            ),
          ),

새로고침한 다음 전면 레이어 상단을 탭합니다. 전면 레이어 상단을 탭할 때마다 레이어가 열리고 닫힙니다.

8. 브랜딩된 아이콘 추가

브랜딩된 아이콘도 익숙한 아이콘으로 확장됩니다. 브랜딩된 고유한 스타일을 위해 표시 아이콘을 맞춤설정하고 제목과 병합해 보겠습니다.

메뉴 버튼 아이콘 변경

Android

iOS

브랜딩된 아이콘이 있는 Shrine 제품 페이지

브랜딩된 아이콘이 있는 Shrine 제품 페이지

backdrop.dart에서 새 클래스 _BackdropTitle을 만듭니다.

// TODO: Add _BackdropTitle class (104)
class _BackdropTitle extends AnimatedWidget {
  final void Function() onPress;
  final Widget frontTitle;
  final Widget backTitle;

  const _BackdropTitle({
    Key? key,
    required Animation<double> listenable,
    required this.onPress,
    required this.frontTitle,
    required this.backTitle,
  }) : _listenable = listenable,
       super(key: key, listenable: listenable);

  final Animation<double> _listenable;

  @override
  Widget build(BuildContext context) {
    final Animation<double> animation = _listenable;

    return DefaultTextStyle(
      style: Theme.of(context).textTheme.headline6!,
      softWrap: false,
      overflow: TextOverflow.ellipsis,
      child: Row(children: <Widget>[
        // branded icon
        SizedBox(
          width: 72.0,
          child: IconButton(
            padding: const EdgeInsets.only(right: 8.0),
            onPressed: this.onPress,
            icon: Stack(children: <Widget>[
              Opacity(
                opacity: animation.value,
                child: const ImageIcon(AssetImage('assets/slanted_menu.png')),
              ),
              FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: const Offset(1.0, 0.0),
                ).evaluate(animation),
                child: const ImageIcon(AssetImage('assets/diamond.png')),
              )]),
          ),
        ),
        // Here, we do a custom cross fade between backTitle and frontTitle.
        // This makes a smooth animation between the two texts.
        Stack(
          children: <Widget>[
            Opacity(
              opacity: CurvedAnimation(
                parent: ReverseAnimation(animation),
                curve: const Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: const Offset(0.5, 0.0),
                ).evaluate(animation),
                child: backTitle,
              ),
            ),
            Opacity(
              opacity: CurvedAnimation(
                parent: animation,
                curve: const Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: const Offset(-0.25, 0.0),
                  end: Offset.zero,
                ).evaluate(animation),
                child: frontTitle,
              ),
            ),
          ],
        )
      ]),
    );
  }
}

_BackdropTitleAppBar 위젯의 title 매개변수에 관한 일반 Text 위젯을 대체하는 맞춤 위젯입니다. 그리고 이 위젯에는 전면 제목과 후면 제목 간의 애니메이션 전환과 애니메이션 메뉴 아이콘이 있습니다. 애니메이션 메뉴 아이콘은 새 애셋을 사용합니다. 새 slanted_menu.png에 관한 참조를 pubspec.yaml에 추가해야 합니다.

assets:
    - assets/diamond.png
    # TODO: Add slanted menu asset (104)
    - assets/slanted_menu.png
    - packages/shrine_images/0-0.jpg

AppBar 빌더에서 leading 속성을 삭제합니다. 브랜딩된 맞춤 아이콘을 원래 leading 위젯의 위치에서 렌더링하려면 이 속성을 삭제해야 합니다. 브랜딩된 아이콘의 애니메이션 listenableonPress 핸들러가 _BackdropTitle에 전달됩니다. frontTitlebackTitle도 배경화면 제목 내에서 렌더링 가능하도록 전달됩니다. AppBartitle 매개변수는 다음과 같습니다.

// TODO: Create title with _BackdropTitle parameter (104)
title: _BackdropTitle(
  listenable: _controller.view,
  onPress: _toggleBackdropLayerVisibility,
  frontTitle: widget.frontTitle,
  backTitle: widget.backTitle,
),

브랜딩된 아이콘이 _BackdropTitle.에서 생성됩니다. 이 아이콘은 애니메이션 아이콘(경사진 메뉴와 다이아몬드)의 Stack을 포함하고 있으며, IconButton에 래핑되기 때문에 누를 수 있습니다. 그런 다음 가로 아이콘 모션 공간을 확보하기 위해 IconButtonSizedBox에 래핑됩니다.

Flutter의 'everything is a widget' 아키텍처를 사용하면 완전히 새로운 맞춤 AppBar 위젯을 만들지 않고도 기본 AppBar의 레이아웃을 변경할 수 있습니다. 원래 Text 위젯인 title 매개변수를 좀 더 복잡한 _BackdropTitle로 바꿀 수 있습니다. _BackdropTitle은 맞춤 아이콘도 포함하고 있기 때문에 leading 속성(지금 생략 가능함)을 대신합니다. 이 같은 간단한 위젯 대체는 자체적으로 계속 작동하는 다른 매개변수(예: 동작 아이콘)를 변경하지 않고도 가능합니다.

로그인 화면에 바로가기 다시 추가

backdrop.dart,에서 바로가기를 앱 바의 두 후행 아이콘에서 로그인 화면으로 다시 추가합니다. 새로운 용도를 반영하도록 아이콘의 시맨틱 라벨을 변경합니다.

        // TODO: Add shortcut to login screen from trailing icons (104)
        IconButton(
          icon: const Icon(
            Icons.search,
            semanticLabel: 'login', // New code
          ),
          onPressed: () {
            // TODO: Add open login (104)
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (BuildContext context) => LoginPage()),
            );
          },
        ),
        IconButton(
          icon: const Icon(
            Icons.tune,
            semanticLabel: 'login', // New code
          ),
          onPressed: () {
            // TODO: Add open login (104)
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (BuildContext context) => LoginPage()),
            );
          },
        ),

새로고침하면 오류가 표시됩니다. login.dart를 가져와 오류를 수정합니다.

import 'login.dart';

앱을 새로고침하고 검색 또는 미세 조정 버튼을 탭하여 로그인 화면으로 돌아갑니다.

9. 수고하셨습니다.

지금까지 네 가지 Codelab 과정에서 머티리얼 구성요소를 사용하여 브랜드 개성과 스타일을 표현하는 독특하고 세련된 사용자 환경을 빌드하는 방법을 살펴보았습니다.

다음 단계

이 Codelab MDC-104로 이번 Codelab 과정이 끝났습니다. 머티리얼 구성요소 위젯 카탈로그를 방문하면 MDC-Flutter의 더 많은 구성요소를 살펴볼 수 있습니다.

도전적 목표를 위해 브랜딩된 아이콘을 AnimatedIcon으로 교체해 보세요. 그러면 배경화면이 표시될 때 두 아이콘 간에 애니메이션이 이루어집니다.

관심 분야에 따라 시도해 볼 수 있는 다양한 Flutter Codelab이 많이 있습니다. 또 다른 머티리얼별 Codelab인 Flutter용 머티리얼 모션을 사용하여 멋진 전환 빌드하기도 있습니다.

이 Codelab을 완료하는 데 들인 시간과 노력이 적절했습니다.

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

앞으로 머티리얼 구성요소를 계속 사용하고 싶습니다.

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