MDC (Material Components) 帮助开发者实现 Material Design。

MDC 由谷歌的工程师和交互团队联合呈现,其具备数种精美的 UI 组件,适用于 Android、iOS、Web 和 Flutter。

查看详细:material.io/develop

在 codelab MDC-103 里,你已经完成了具有 Material 组件(MDC)风格的颜色、阴影、文本样式、几何形状的定制。

Material Design 体系里的组件都会有预设的职责和明确的特征。例如按钮,它不仅仅只有一种表现形式,在视觉上,它会用形状、大小、颜色等让用户知道这个按钮是可操作的,当手指放上去或者点击时是会有事情会发生的。

Material Design 手册的制定是从设计师的角度出发来描述组件的样子。它包括了不同平台功能的一些基础元素,而后构成了每个组件。比如,一个背景板(backdrop)包含着背景内容、前景内容、运动轨迹和显示行选项。你可以根据不同应用不同平台不同的需求来定制这些组件,这些内容大部分都来自于不同平台 SDK 里的传统视图,控件和功能。

虽然 Material Design 手册已经帮你定义和命名了很多组件,但不要受限于这些已有的内容,你也可以创建一些新的设计或组件。

本次你将做出什么

在本期 Codelab 中,你将会把 Shrine 应用的 UI 更改为一个叫做 "backdrop" 的两级演示文稿。这个 backdrop 包括一个菜单,该菜单列出了用于过滤产品类别来对列表中的产品进行筛选。在本期 Codelab 中,你将使用以下 Flutter 组件:

我们最终会构建一个卖衣服和家居的电子商务应用 - SHRINE,总共通过四个 Codelabs 搞定它,本 Codelab 是最后一个,相关的 Codelabs 如下:

在这个 Codelab 中用到的 MDC-Flutter 组件

你可能需要准备

在 iOS 上运行 Flutter 应用:

在 Android 上运行 Flutter 应用:

如何定位你的 Flutter 开发水平?

1 初学者 2 中级水平 3 专业

你需要安装两部分来完成本次实验,Flutter 的 SDK编辑器(editor),这个 codelab 里,我们以 Android Studio 作为编辑器(editor),但你可以用个人更顺手的编辑器。

你可以通过如下任何设备完成本 codelab:

从 MDC 103 继续?

如果你已经完成了 MDC Flutter 教程 3:Material 组件主题、形状、阴影和类型(MDC 103),你可以跳过这一节,直接从下一节继续......

从本节开始?

点击下载

起步应用的路径在 material-components-flutter-Codelabs-104-starter_and_103-complete/mdc_100_series 文件夹内。

...或者直接从 Github 上进行 clone

使用以下命令从 Github 上 clone 这个 Codelab 项目:

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

设置你的工程

如下设置工程的方法是使用 Android Studio 做演示的。

创建新的工程

1. 在你的命令行工具里,切换到这个文件夹 material-components-flutter-codelabs

2. 执行命令 flutter create mdc_100_series

打开你的工程

1. 打开 Android Studio

2. 当你看到欢迎界面时候,选择打开一个已存在的工程(Open an existing Android Studio project.)

3. 打开这个文件夹 material-components-flutter-codelabs/mdc_100_series

如果此时提示一些错误请忽略,等第一次成功编译之后应该会好一些。

4. 在项目面板左侧,删除测试文件 ../test/widget_test.dart

5. 如果提示"升级平台和插件"或要配置 FlutterRunConfigurationType,请重启 Android Studio。

运行起步应用

下面我们以在 Android 模拟器或真机上为例做如下的步骤示范,当然如果你装有 Xcode,你也可以在 iOS 模拟器或真机上进行尝试。

1. 选择设备或模拟器

如果 Android 模拟器还没有运行起来,请选择 Tools -> Android -> AVD Manager 先进行 创建并启动模拟器 的操作。如果 AVD 里已经有创建好的模拟器,你可以直接在 IntelliJ 的设备选择列表里启动模拟器,如下一步所示。

(对于 iOS 模拟器,如果你还没有启动,请在你的电脑上选择 Flutter Device Selection -> Open iOS Simulator 来启动。)

2. 运行你的 Flutter 应用:

  • 在你的编辑器上方, Flutter 设备下拉菜单里,选择一款设备(比如选择 iPhone SE 或者某个 Android SDK 编译版本的设备)。
  • 点击 Play 按钮 ()。

你成功啦!你将从之前的 Codelab 中运行代码,就可以在模拟器上看到 Shrine 的登录界面了。

所有的内容和组件背后都有 backdrop,它包括了后层(back layer)(用来显示 actions 和 filters)以及前层(front layer)(用来显示内容)。你可以使用 backdrop 来显示交互信息和操作,比如导航栏和内容过滤器。

移除主页的应用栏

HomePage widget 将是前层的内容。现在它有一个应用栏,我将会把应用栏移到后层,让 HomePage 只包含 AsymmetricView。

home.dart 文件中更改 build() 函数让它值返回 AsymmetricView

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

增加 Backdrop widget

创建一个名为 Backdrop 的 widget,包含 frontLayerbackLayer

其中 backLayer 包含了可以允许你选择某个目录来对列表(currentCategory)进行筛选的菜单。由于我们希望菜单内的数据保持不变,因此我们将 Backdrop 作为 stateful widget。

/lib 文件夹内新增名为 backdrop.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)

导入 meta 包,并将其标记为 @required。 这是当构造函数中的属性没有默认值且不能为 null 时的最佳做法,切记这一标记。 请注意,可在构造函数之后添加了 asserts 语句,用来检查传递过来的值不会为 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>[
        widget.backLayer,
        widget.frontLayer,
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    var appBar = AppBar(
      brightness: Brightness.light,
      elevation: 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 的子项是可以进行重叠的,每个子项的大小和位置都是相对于它们的父级指定的。

现在为 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 文件中,更改 ShrineAppbuild() 方法,将 home 的值更改为 Backdrop,其内部 frontLayer 的值为 HomePage:

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

如果你点击 Play 按钮,你将会看到我们的主界面同应用栏一起展示了出来。

backLayer 在 frontLayer 主页后面的新图层中显示粉红色区域。你可以使用 Flutter Inspector 来验证 Stack 中的 HomePage 之后是否有一个 Container,它跟这个应该是类似的:

你现在可以调整两个图层的样式和内容。

在这一步中,你将为 front layer 设置样式,在其左上角添加一个剪裁效果。

Material Design 将此类的定制称为 shape。 现实世界中的材料表面可以具有任意形状。 shape 使表面看起来更突出,可用于设计概念的表达。普通的矩形可以定制成具有弯曲或有角度的角和边缘的形状,也可以定位成具有任意数量边的形状。 它们可以是对称的或不规则的。

在 front layer 中添加 shape

具有一定角度的 logo 让整个 Shrine 应用看上去有棱有角,也会体现出整个应用的设计语言。 设计语言是贯穿于整个应用中的。例如,logo 的形状在应用的登录页面元素中会体现出来。 在此步骤中,你将为 front layer 更改为具有倾斜切割风格的样式。

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 方法传入 front layer

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

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

刷新。

我们已为 Shrine 的主界面创建出了一个自定义的图形。因为表面阴影层次的关系,白色层下面还有一层。接下来我们增加一点移动效果让用户能够看到背景层。

Motion 是一种让应用看起来仿佛展示于真实世界的方法。它体现出来的效果可以是大而戏剧性的,也可以是很微妙的,也可以介于两者之间。 但请记住,你使用的 motion 类型应该根据使用场景进行选择。应用于重复、常规动作的 motion 效果应该小而精细,这样的动作不会分散用户的注意力或占用太多时间。 但也有特殊情况,例如用户第一次打开应用时,可能要有一些引人注目的效果来吸引用户,而某些 motion 效果可以起到教育用户如何使用的目的。

为 menu 按钮增加 reveal motion 效果

backdrop.dart 文件中任意一个类的作用域之外,添加一个常量,代表我们想添加的动画的表现速度。

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

在 _BackdropState 中添加 AnimationController widget,然后在 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 接口,现在我们需要使用它实现移动的效果。

添加可更改 front layer 可见性的函数:

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

将 backLayer 嵌套在 ExcludeSemantics widget 之中。当 back layer 不可见时,这个 widget 会将 backLayer 的菜单选项全部从层级中移除。

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

更改 _buildStack() 方法来添加 BuildContext 和 BoxConstraints,同样,添加一个 PositionedTransition 来达到添加 RelativeRectTween Animation 的效果。

  // 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>[
        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 widget。

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

我们使用 LayoutBuilder 让 front/back layer 在布局时候才进行构建,这样可以高效计算出 backdrop 的实际高度。LayerBuilder 是一个特殊的 widget,其构建函数的回调会提供大小的限制。

build() 函数中,将应用栏中的菜单图标转换为 IconButton ,并在点击按钮时使用它来切换 front layer 的可见性。

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

刷新,然后点击模拟器中的菜单按钮。

front layer 会有一个滑下去的动画效果。但如果往下看,会发现有个红色的溢出错误。这是因为 AsymmetricView 被这个动画挤压并变小,这导致给 Columns 提供了更少的空间。最终,Columns 不能用给定的空间自行进行排列,因为这样会排列出错。如果我们使用 ListViews 来代替 Columns,那每一列的大小在动画的显示过程中都会保持不变。

在 ListView 中对列进行嵌套

supplemental/product_columns.dart 文件中,用 ListView 代替 Column 中的 OneProductCardColumn

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

  final Product product;

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

这个 Column 包含了 MainAxisAlignment.end。为了从底部进行排列,将 reverse 的值改为 true。更改之后,子视图的渲染顺序是相反的。

刷新,然后点击菜单按钮。

OneProductCardColumn 上的灰色溢出警告消失了! 现在让我们修复另一个。

supplemental/product_columns.dart 文件中,更改 imageAspectRatio 的计算方式,然后用 ListView 来替换 TwoProductCardColumn

      // TODO: Change imageAspectRatio calculation (104)
      double imageAspectRatio =
          (heightOfImages >= 0.0 && constraints.biggest.width > heightOfImages)
              ? constraints.biggest.width / heightOfImages
              : 33 / 49;

      // TODO: Replace Column with a ListView (104)
      return ListView(
        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 里添加了一些安全检查的措施。

刷新,然后点击菜单按钮。

不会再有溢出的现象出现了。

菜单是可触发文本选项展示的列表,文本被选中时触发相关的回调监听。在这一步,你会实现一个类别过滤的菜单。

添加菜单

在 front layer 中添加菜单,并在 back layer 中添加一个可交互的按钮。

创建一个新文件,路径为 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.body2,
              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.body2.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 widget 从一个 stateless widget 转变为 stateful widget。

  1. 选中 ShrineApp
  2. 按下 alt(option) + enter 按键
  3. 选择 "Convert to StatefulWidget";
  4. 将 ShrineAppState 类更改为私有的(_ShrineAppState)。你可以通过 IDE 中的 Refactor > Rename 方法来实现。另外,你还可以在代码中选择类的名称,ShrineAppState,然后右击,选择 Refactor > Rename。输入 _ShrineAppState 来实现让类变为私有的目的。

app.dart 文件中,为所选的类别添加一个 _ShrineAppState 变量,以及一个用于点击之后触发的回调函数。

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

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

然后将 backLayer 更改为 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'),
      ),

刷新,然后点击菜单按钮。

如果你点击菜单选项,什么事都不会发生... 接下来我们来修复这个问题。

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

刷新,点击模拟器中的菜单按钮,然后选择一个类别。

点击菜单图标查看销售的产品,然后进行筛选吧!

在选择菜单之后关闭 front layer

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

热重载之后点击菜单栏,选择一个分类。在你看到所选择的项目类别之后菜单应该自动关闭,现在将这个功能添加到 front layer 中。

打开 front layer

backdrop.dart 文件中,为 backdrop layer 增加一个点击触发的回调。

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;

在 _FrontLayer's child: Column 的 children 字段里添加一个 GestureDetector :

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

刷新,点击 front layer 上方,这个图层会打开,在你再次点击 front layer 上方时,它会自动关闭。

一款品牌图标应做到被大众所熟悉。让我们来展示出一个独一无二的品牌图标,并将把它和应用标题合起来吧。

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

_BackdropTitle 是一个自定义的 widget,它将替换 AppBar widget 的 title 参数中的文本。它提供了有着动画效果的菜单图标,和可以在不同层级的标题之间切换的过渡动画。这个菜单图标将会使用一个新的资源,这个资源将通过在 pubsepc.yaml 文件中添加 slanted_menu.png 来进行引入。

assets:
    - assets/diamond.png
    - assets/slanted_menu.png
    - packages/shrine_images/0-0.jpg

删除 AppBar 构建方法中的 leading 属性,需要删除自定义的品牌图标才能在原始的 widget 中进行显示。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 中,以便它可以被按下。 然后将 IconButton 嵌套在 SizedBox 中,以便为水平图标的移动动画效果腾出空间。

Flutter的"一切都是 widget "的设计理念允许更改默认 AppBar 的布局,而无需从零自定义 AppBar widget。 title 参数最初是一个 Text widget,可以用更复杂的 _BackdropTitle 替换。 由于 _BackdropTitle 还包含自定义图标,因此它取代了 leading 属性,现在可以省略。 这个简单的 widget 替换是在不改变任何其他参数的情况下完成的,例如表示用户行为的图标,它们仍然可以按照之前的定义进行工作。

backdrop.dart 文件中,添加快捷方式,使用户可以从应用顶部栏的两个尾部图标返回到登录界面:为 IconButton 添加 semanticLabel 以用于新的目的。

        // 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 的学习过程中,你已经了解到了如何使用 Material Components 来构建出能够表达品牌个性、风格独特且具有优雅的用户体验的应用。

下一步

本期 Codelab 是这个系列的最后一篇。你可以通过访问 Flutter Widgets 目录来探索更多 MDC-Flutter 的组件。

你可以将你的品牌图标替换为 AnimatedIcon 来达到一些特殊的目的。

如果你想学习到如何在 Flutter 应用中连接 Firebase 作为后端服务,可以查看 在 Flutter 里使用 Firebase 这个 Codelab。

我能够用合理的时间和精力完成这个代码库

5 非常赞同 4 赞同 3 一般 2 不赞同 1 非常不赞同

我希望将来继续使用 Material Components

5 非常赞同 4 赞同 3 一般 2 不赞同 1 非常不赞同