본문 바로가기

개발/Study

[GoToLearn] Flutter CodeLab - MDC-104 Flutter:Material Advanced Components

GoToLearn 5주차

GoToLearn 5주차 CodeLab과제인  MDC-104 Flutter:Material Advanced Components을 진행해보겠습니다. 이번에는 고급 머터리얼 컴포넌트를 사용해보는 것 같습니다. 그럼 시작해보겠습니다.

 

MDC-104 Flutter:Material Advanced Components

https://codelabs.developers.google.com/codelabs/mdc-104-flutter?hl=en#0

 

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

디자인을 개선하고 Flutter의 고급 구성요소 배경화면 메뉴를 사용하는 방법을 알아보세요.

codelabs.developers.google.com

 

소개

이번 코드랩에서 빌드할 항목

이번 코드랩은 MDC-103에 이어 Shrine 앱의 배경화면을 추가하는 작업을 진행합니다. 배경화면에는 카테고리를 나열하는 메뉴가 포함되고, 카테고리는 그리드 화면에 표시되는 제품을 필터링하는데 사용합니다. 완성 시 아래 이미지와 같은 앱이 완성된다고 합니다.

사용하는 머터리얼 컴포넌트 및 하위 위젯

  • Shape - 형태

Flutter 환경 설정 및 Starter App

환경설정은 패스하고, 지난 주에 진행했던 MDC-103에 이어 진행하도록 하겠습니다.


배경화면 메뉴 추가

홈 앱 바 삭제

홈 페이지의 위젯을 프론트 레이어에 표시될 수 있도록 앱 바를 삭제 시키고, 비대칭 표시하는 위젯만 남깁니다.

  @override
  Widget build(BuildContext context) {
    return AsymmetricView(
      products: ProductsRepository.loadProducts(Category.all),
    );
  }

배경 위젯 추가

프론트 레이어와 백 레이어를 포함하는 Backdrop이라는 위젯을 만듭니다. (lib/backdrop.dart)

BackDrop 위젯에는 현재 선택된 카테고리를 필터링하기 위한 변수와 프론트/백 레이어의 타이틀과 위젯이 들어간 것을 확인할 수 있습니다. BackDrop 위젯은 Scaffold를 반환하고 body는 프론트/백 레이어를 가지는 Stack 위젯이 반환되는 걸로 파악됩니다.

import 'package:flutter/material.dart';

import 'model/product.dart';

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

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

  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,
      leading: Icon(Icons.menu),
      title: Text('SHRINE'),
      actions: <Widget>[
        IconButton(
          icon: Icon(
            Icons.search,
            semanticLabel: 'search',
          ),
          onPressed: () {
          },
        ),
        IconButton(
          icon: Icon(
            Icons.tune,
            semanticLabel: 'filter',
          ),
          onPressed: () {
          },
        ),
      ],
    );
    return Scaffold(
      appBar: appBar,
      body: _buildStack(),
    );
  }
}

 

이후로 첫 라우팅 되는 Material 앱에서 '/'의 호출을 Backdrop으로 변경 해 줍니다.

'/': (BuildContext context) => Backdrop(
  currentCategory: Category.all,
  frontLayer: HomePage(),
  backLayer: Container(color: kShrinePink100),
  frontTitle: Text('SHRINE'),
  backTitle: Text('MENU'),
),

변경 후 저장하게 되면 아래와 같은 결과를 얻을 수 있습니다.


Shape 추가

전면 레이어에 형태 추가

전면 레이어에 왼쪽 상단 모서리를 각진 형태로 변경하도록 합니다.

각진 배경을 나타내는 _FrontLayer를 생성하고 Backdrop 위젯의 widget.frontLayer를 자식으로 가져 배치하도록 변경합니다.

class _FrontLayer extends StatelessWidget {
  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>[
          Expanded(
            child: child,
          ),
        ],
      ),
    );
  }
}

 

  Widget _buildStack() {
    return Stack(
      key: _backdropKey,
      children: <Widget>[
        widget.backLayer,
        _FrontLayer(child: widget.frontLayer),
      ],
    );
  }

 

위 코드를 적용 후 저장하면 아래와 같은 결과를 확인할 수 있습니다.


모션 추가

Shrine 앱에 활기를 불어 넣는 모션을 추가합니다. 모션은 너무 과해서도 작아서도 안되며 상황에 맞게 사용되어야 합니다.

메뉴 버튼에 표시 모션 추가

애니메이션에 적용할 속도를 나타내는 상수와 AnimationController를 추가합니다.

AnimationController는 애니메이션을 조정, 재생, 역방향 재생, 중지를 위한 API를 제공합니다.

const double _kFlingVelocity = 2.0;
  late AnimationController _controller;

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

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

 

프론트 레이어의 전면상태를 확인하고 변경하는 함수도 추가해줍니다.

  // 전면 상태 여부
  bool get _frontLayerVisible {
    final AnimationStatus status = _controller.status;
    return status == AnimationStatus.completed ||
        status == AnimationStatus.forward;
  }

  // 전면 상태일때는 -Velocity, 아닌경우 +Velocity
  void _toggleBackdropLayerVisibility() {
    _controller.fling(
        velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity);
  }

 

ExcludeSemantics를 backLayer에 추가해주고 제외되는 조건으로 frontLayer가 전면인 경우로 판단합니다.

ExcludeSemantics 위젯은 제외가 true인 경우 자식 위젯이 의미 트리(?)에서 삭제된다고 합니다.

  Widget _buildStack() {
    return Stack(
      key: _backdropKey,
      children: <Widget>[
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
        _FrontLayer(child: widget.frontLayer),
      ],
    );
  }

 

_buildStack() 함수를 BuildContext, BoxConstraints 매개변수를 받도록 변경하고, RelativeRectTween 애니메이션을 취하는 PositionedTransition도 추가합니다. 또한, build 함수내에서 LayoutBuilder를 _buildStack에 래핑합니다.

  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
    const double layerTitleHeight = 48.0;
    final Size layerSize = constraints.biggest;
    final double layerTop = layerSize.height - layerTitleHeight;

    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>[
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
        PositionedTransition(
          rect: layerAnimation,
          child: _FrontLayer(
            child: widget.frontLayer,
          ),
        ),
      ],
    );
  }
return Scaffold(
      appBar: appBar,
      body: LayoutBuilder(
        builder: _buildStack,
      ),
    );

Column을 ListView로 변경

 

메뉴 버튼을 눌렀을때 아래로 내려가면서 오버플로우 오류가 발생합니다. 이유는 애니메이션에 의해 AsymmetricView가 줄어들고 크기가 더 작아져 Columns의 공간이 줄어들어 오버플로우 오류가 발생하였습니다. 따라서 Columns을 ListView로 변경해줍니다.

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

  final Product product;

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

 

오버플로우 오류가 아직 완전히 해결되지 않아 TwoProductCardColumn의 imageAspectRatio 계산 방식과 Columns을 ListView로 변경해 줍니다.

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
      const spacerHeight = 44.0;

      double heightOfCards = (constraints.biggest.height - spacerHeight) / 2.0;
      double heightOfImages = heightOfCards - ProductCard.kTextBoxHeight;
      double imageAspectRatio = heightOfImages >= 0.0
          ? constraints.biggest.width / heightOfImages
          : 49.0 / 33.0;

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

 


후면 레이어의 메뉴 추가

메뉴 추가

후면 레이어에는 대화형 버튼을 추가하기 위한 메뉴 페이지를 추가합니다.

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.bodyLarge,
            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.bodyLarge!.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()),
      ),
    );
  }
}

 

현재 선택된 카테고리를 관리하기 위해 기존 SharineApp Widget을 Stateful 위젯으로 변경합니다. 또한 카테고리를 선택했을 때 콜백 함수와 변수를 바꿔주고 backLayer를 CategoryMenuPage로 감싸줍니다.

class ShrineApp extends StatefulWidget {
  const ShrineApp({Key? key}) : super(key: key);

  @override
  State<ShrineApp> createState() => _ShrineAppState();
}

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shrine',
      initialRoute: '/login',
      routes: {
        '/login': (BuildContext context) => const LoginPage(),
        '/': (BuildContext context) => Backdrop(
          currentCategory: Category.all,
          frontLayer: HomePage(),
          backLayer: CategoryMenuPage(
            currentCategory: _currentCategory,
            onCategoryTap: _onCategoryTap,
          ),
          frontTitle: Text('SHRINE'),
          backTitle: Text('MENU'),
        ),
      },
      theme: _kShrineTheme,
    );
  }
}

 

데이터는 바뀌었지만 화면은 바뀌지 않는걸 확인할 수 있습니다. 이제 현재 선택된 카테고리에 따라 화면이 표시되도록 변경합니다.

HomePage 위젯에 카테고리를 전달해서 카테고리에 따라 필터링되도록 변경합니다.

import 'package:flutter/material.dart';

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

class HomePage extends StatelessWidget {
  final Category category;

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

  @override
  Widget build(BuildContext context) {
    return AsymmetricView(
      products: ProductsRepository.loadProducts(category),
    );
  }
}
frontLayer: HomePage(category: _currentCategory),

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

메뉴 선택이 될 때 전면 레이어가 닫히도록 변경사항을 감지하면 새로 그릴 수 있도록 호출합니다. Stateful의 생명주기 중 didUpdateWidget를 사용해서 다시 그리도록 진행합니다.

  @override
  void didUpdateWidget(Backdrop old) {
    super.didUpdateWidget(old);

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

전면 레이어 전환

전면 레이어에 상단을 선택했을때 레이어가 전환되도록 추가합니다.

class _FrontLayer extends StatelessWidget {
  const _FrontLayer({
    Key? key,
    this.onTap,
    required this.child,
  }) : super(key: key);

  final VoidCallback? onTap;
  final Widget child;

 

높이가 40짜리인 컨테이너에 터치이벤트를 감지하는 GestureDector를 사용 해 터치했을때 레이어가 전환되도록 추가합니다.

  @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>[
          GestureDetector(
            behavior: HitTestBehavior.opaque,
            onTap: onTap,
            child: Container(
              height: 40.0,
              alignment: AlignmentDirectional.centerStart,
            ),
          ),
          Expanded(
            child: child,
          ),
        ],
      ),
    );
  }

 

이후 _buildStack 함수에서 토글 이벤트를 전달을 추가하면 프론트 레이어에서 상단을 선택한 경우에 레이어가 전환되는것을 확인할 수 있습니다.

  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
    const double layerTitleHeight = 48.0;
    final Size layerSize = constraints.biggest;
    final double layerTop = layerSize.height - layerTitleHeight;

    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>[
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
        PositionedTransition(
          rect: layerAnimation,
          child: _FrontLayer(
            onTap: _toggleBackdropLayerVisibility,
            child: widget.frontLayer,
          ),
        ),
      ],
    );
  }

브랜딩된 아이콘 추가

메뉴 버튼 아이콘 변경

메뉴 버튼 아이콘을 나타내는 _BackdropTitle을 추가합니다. _BackdropTitle 위젯은 AppBar의 title과 leading을 대체하는 위젯으로 전면/후면 상태에 따라 전환 애니메이션이 추가됩니다.

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.titleLarge!,
      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: 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,
              ),
            ),
          ],
        )
      ]),
    );
  }
}

 

브랜딩 아이콘을 추가하기 위해 pubspec.yaml에 이미지 asset을 추가합니다.

assets:
    - assets/slanted_menu.png

 

이후 leading과 title을 제거한 뒤 _BackdropTitle로 대체해줍니다.

title: _BackdropTitle(
  listenable: _controller.view,
  onPress: _toggleBackdropLayerVisibility,
  frontTitle: widget.frontTitle,
  backTitle: widget.backTitle,
),

로그인 화면 이동 추가

action 버튼을 로그인 페이지로 이동하도록 변경합니다.

      actions: <Widget>[
        IconButton(
          icon: Icon(
            Icons.search,
            semanticLabel: 'login',
          ),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                  builder: (BuildContext context) => LoginPage()),
            );
          },
        ),
        IconButton(
          icon: Icon(
            Icons.tune,
            semanticLabel: 'login',
          ),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                  builder: (BuildContext context) => LoginPage()),
            );
          },
        ),
      ],

완성

코드랩의 완성하면 아래와 같은 결과물을 얻을 수 있습니다. 조금더 완성도 있는 앱으로 변경된 것 같네요!


후기

이번 코드랩에서는 머터리얼 고급 컴포넌트를 사용할 수 있는 시간이었던 것 같습니다. 앱의 특징을 살려 각진 디자인을 넣고 커스텀하는 방법을 체험해 볼 수 있던것 같습니다. 또한 ExcludeSemantics라는 위젯을 처음 사용해 봤는데 제외 조건이 참인 경우에 위젯트리에서 삭제하는 것 같고 이런 방법이 있는지 처음 알았습니다.

 

실습코드

https://github.com/wonyong-park/flutter_codelabs/tree/main/material-components-flutter-codelabs-101-starter

 

flutter_codelabs/material-components-flutter-codelabs-101-starter at main · wonyong-park/flutter_codelabs

flutter_codelabs_repo. Contribute to wonyong-park/flutter_codelabs development by creating an account on GitHub.

github.com