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

logo_components_color_2x_web_96dp.png

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

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

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

머티리얼 디자인 가이드라인에서는 디자이너 관점에서 구성요소를 설명합니다. 그리고 플랫폼에서 사용할 수 있는 다양한 기본 기능은 물론 각 구성요소를 구성하는 개별 요소도 설명합니다. 예를 들어 배경화면에는 뒷면 레이어와 콘텐츠, 전면 레이어와 콘텐츠, 모션 규칙, 디스플레이 옵션이 포함되어 있습니다. 이러한 각 구성요소는 각 앱의 요건, 사용 사례 및 콘텐츠에 따라 맞춤설정할 수 있습니다. 대부분의 경우 이러한 부분은 플랫폼 SDK의 기존 뷰, 컨트롤 및 기능에 해당합니다.

머티리얼 디자인 가이드라인에는 많은 구성요소가 나열되어 있지만 일부는 재사용 가능 코드에 사용할 만하지 않기 때문에 MDC에 없습니다. 기존 코드를 사용해 환경을 직접 만들어 앱 스타일을 맞춤설정할 수 있습니다.

빌드할 항목

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

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

Android

iOS

이 Codelab의 MDC-Flutter 구성요소

  • 형태

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

초급 중급 고급

시작하기 전에

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

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

Flutter 설치에 관한 자세한 내용은 시작하기: 설치를 참고하세요. 편집기를 설정하려면 시작하기: 편집기 설정을 참고하세요. Android Emulator를 설치할 때 최신 시스템 이미지가 있는 Pixel 3 휴대전화와 같은 기본 옵션을 자유롭게 사용하세요. VM 가속을 사용 설정하는 것이 좋지만 필수는 아닙니다. 위의 4개 단계를 완료한 후 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)

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

프로젝트 설정

다음 안내에서는 Android 스튜디오(IntelliJ)를 사용한다고 가정합니다.

프로젝트 열기

1. Android 스튜디오를 엽니다.

2 시작 화면이 표시되면 Open an existing Android Studio project를 클릭합니다.

3. material-components-flutter-codelabs/mdc_100_series 디렉터리로 이동하고 'Open'을 클릭합니다. 프로젝트가 열립니다. 프로젝트를 한 번 빌드할 때까지는 Dart Analysis에 표시되는 오류를 무시해도 됩니다.

4. 메시지가 표시되면 다음 단계를 따릅니다.

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

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

시작 앱 실행

다음 안내에서는 Android Emulator 또는 기기에서 테스트한다고 가정하지만 Xcode가 설치된 경우 iOS 시뮬레이터 또는 기기에서 테스트해도 됩니다.

1. 기기나 에뮬레이터를 선택합니다. Android Emulator가 아직 실행되지 않은 경우 Tools -> Android -> AVD Manager를 선택하여 가상 기기를 만들고 에뮬레이터를 시작합니다. AVD가 이미 있는 경우 다음 단계와 같이 Android 스튜디오의 기기 선택기에서 바로 에뮬레이터를 시작할 수 있습니다. iOS 시뮬레이터의 경우 아직 실행되고 있지 않으면 Flutter Device Selection -> Open iOS Simulator를 선택하여 개발 머신에서 시뮬레이터를 실행합니다.

2 Flutter 앱을 시작합니다.

  • 편집기 화면 상단에서 Flutter Device Selection 드롭다운 메뉴를 찾아 기기를 선택합니다(예: <version>용으로 빌드된 iPhone SE 또는 Android SDK).
  • 재생 아이콘()을 누릅니다.

완료되었습니다. 이전 Codelab의 Shrine 로그인 페이지가 시뮬레이터 또는 에뮬레이터에 표시됩니다.

Android

iOS

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

홈 앱 바 삭제

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 'package:meta/meta.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,
  })  : assert(currentCategory != null),
        assert(frontLayer != null),
        assert(backLayer != null),
        assert(frontTitle != null),
        assert(backTitle != null);

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

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

@required 속성을 표시하기 위한 meta 패키지가 가져와집니다. 이 방법은 기본값이 없고 null일 수도 없기 때문에 잊어서는 안 되는 속성이 생성자에 있을 때 가장 좋습니다. 또한 관련 필드에 전달된 값이 실제로 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(
      brightness: Brightness.light,
      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,에서 ShrineApp의 build() 함수를 수정합니다. HomePage가 frontLayer인 Backdrop으로 home:을 변경합니다.

      // TODO: Change home: to a Backdrop with a HomePage frontLayer (104)
      home: 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

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

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

ad988a22875b5e82.png

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

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

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

전면 레이어에 형태 추가

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

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

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

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 16.0,
      shape: 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의 기본 노출 영역에 맞춤 형태를 부여했습니다. 노출 영역의 고도로 인해 사용자는 전면 흰색 레이어 뒤에 무언가가 있음을 알 수 있습니다. 사용자가 배경화면의 후면 레이어를 볼 수 있도록 모션을 추가해 보겠습니다.

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

메뉴 버튼에 표시 모션 추가

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

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

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

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

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: 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: 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: Icon(Icons.menu),
        onPressed: _toggleBackdropLayerVisibility,
      ),

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

Android

iOS

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

ListView에서 제품 열 래핑

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

class OneProductCardColumn extends StatelessWidget {
  OneProductCardColumn({this.product});

  final Product product;

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

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

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

Android

iOS

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: EdgeInsetsDirectional.only(start: 28.0),
            child: top != null
                ? ProductCard(
                    imageAspectRatio: imageAspectRatio,
                    product: top,
                  )
                : SizedBox(
                    height: heightOfCards,
                  ),
          ),
          SizedBox(height: spacerHeight),
          Padding(
            padding: EdgeInsetsDirectional.only(end: 28.0),
            child: ProductCard(
              imageAspectRatio: imageAspectRatio,
              product: bottom,
            ),
          ),
        ],
      );

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

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

Android

iOS

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

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

메뉴 추가

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

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

import 'package:flutter/material.dart';
import 'package:meta/meta.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,
  })  : assert(currentCategory != null),
        assert(onCategoryTap != null);

  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>[
            SizedBox(height: 16.0),
            Text(
              categoryString,
              style: theme.textTheme.bodyText1,
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 14.0),
            Container(
              width: 70.0,
              height: 2.0,
              color: kShrinePink400,
            ),
          ],
        )
      : Padding(
        padding: 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: 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. Alt(옵션) + Enter를 누릅니다.
  3. 'Convert to StatefulWidget'을 선택합니다.
  4. ShrineAppState 클래스를 private(_ShrineAppState)으로 변경합니다. IDE 기본 메뉴에서 이 작업을 하려면 Refactor > Rename을 선택합니다. 또는 코드에서 ShrineAppState 클래스 이름을 강조표시한 다음 마우스 오른쪽 버튼을 클릭하고 Refactor > Rename을 선택해도 됩니다. _ShineAppState를 입력하여 클래스를 private으로 설정합니다.

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

// TODO: Convert ShrineApp to stateful widget (104)
class _ShrineAppState extends State<ShrineApp> {
  Category _currentCategory = Category.all;

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

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

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

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

build() 함수에서 backlayer 필드를 CategoryMenuPage로 변경하고, 인스턴스 변수를 취하도록 currentCategory 필드를 변경합니다.

      home: 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: Text('SHRINE'),
        backTitle: Text('MENU'),
      ),

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

Android

iOS

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

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

import 'package:flutter/material.dart';

import 'model/products_repository.dart';
import 'model/product.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});

  @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

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

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

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

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

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

메뉴 버튼 아이콘 변경

Android

iOS

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

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

  const _BackdropTitle({
    Key key,
    Listenable listenable,
    this.onPress,
    @required this.frontTitle,
    @required this.backTitle,
  })  : assert(frontTitle != null),
        assert(backTitle != null),
        super(key: key, listenable: listenable);

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

    return DefaultTextStyle(
      style: Theme.of(context).primaryTextTheme.headline6,
      softWrap: false,
      overflow: TextOverflow.ellipsis,
      child: Row(children: <Widget>[
        // branded icon
        SizedBox(
          width: 72.0,
          child: IconButton(
            padding: EdgeInsets.only(right: 8.0),
            onPressed: this.onPress,
            icon: Stack(children: <Widget>[
              Opacity(
                opacity: animation.value,
                child: ImageIcon(AssetImage('assets/slanted_menu.png')),
              ),
              FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: Offset(1.0, 0.0),
                ).evaluate(animation),
                child: 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: Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: Offset(0.5, 0.0),
                ).evaluate(animation),
                child: backTitle,
              ),
            ),
            Opacity(
              opacity: CurvedAnimation(
                parent: animation,
                curve: Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: 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: Icon(
            Icons.search,
            semanticLabel: 'login', // New code
          ),
          onPressed: () {
            // TODO: Add open login (104)
            Navigator.push(
              context,
              MaterialPageRoute(builder: (BuildContext context) => LoginPage()),
            );
          },
        ),
        IconButton(
          icon: 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';

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

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

다음 단계

이 Codelab MDC-104로 일련의 Codelab이 끝났습니다. Flutter Widgets Catalog를 방문하여 MDC-Flutter의 더 많은 구성요소를 살펴볼 수 있습니다.

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

작동하는 백엔드를 위해 앱을 Firebase에 연결하는 방법을 알아보려면 Codelab Flutter의 Firebase를 참고하세요.

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

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

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

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