MDC-104 Flutter:Material 高级组件 (Flutter)

1. 简介

logo_components_color_2x_web_96dp.png

Material Components (MDC) 可帮助开发者实施 Material Design。MDC 由 Google 的工程师和用户体验设计师团队创建,拥有数十个精美而强大的界面组件,可用于 Android、iOS、Web 和 Flutter。material.io/develop

在 Codelab MDC-103 中,您通过自定义 Material Components (MDC) 的颜色、高度、字体和形状,设置了应用的样式。

Material Design 系统中的组件可用于执行一组预定义的任务,并且具有某些特性,例如按钮。但按钮不只是用户执行操作的一种方式,还是形状、大小和颜色的一种视觉表达,可以让用户知道它是可交互的,轻触或点击将会触发一些行为。

Material Design 指南以设计师的角度来描述组件。具体描述了可用于各个平台的各种基本功能,以及组成每个组件的结构元素。例如,背景包含背图层及其内容、前图层及其内容、运动规则和显示选项。其中每个组件都可针对每个应用的需求、使用情况和内容进行自定义。主要包括传统的视图、控件以及您所处平台 SDK 的功能。

虽然 Material Design 指南命名了很多组件,但并非所有的组件都适合用作可重复使用的代码, 因此您在 MDC 中找不到这些组件。您可以全部使用传统代码自行打造这些体验,为您的应用自定义风格。

您将构建的应用

在本 Codelab 中,您会将 Shrine 应用中的界面更改为称作"背景"的二级显示。背景包括一个 菜单,其中列出了用于过滤非对称网格中显示的产品的可选择类别。在本 Codelab 中,您将使用以下 Flutter 组件:

  • 形状
  • 动作
  • Flutter 微件(在之前的 Codelab 中所使用的)

Android

iOS

本 Codelab 中的 MDC-Flutter 组件

  • 形状

您如何评价您在 Flutter 开发方面的经验?

初级 中级 专家

2. 设置您的 Flutter 环境

准备工作

要开始使用 Flutter 开发移动应用,您需要执行以下操作:

  1. 下载并安装 Flutter SDK。
  2. 更新 PATH 以包含 Flutter SDK。
  3. 安装 Android Studio 并添加 Flutter 和 Dart 插件,或者选择您喜欢的编辑器。
  4. 安装 Android 模拟器、iOS 模拟器(需要装有 Xcode 的 Mac),或者使用实体设备。

如需了解有关 Flutter 安装的详细信息,请参阅 使用入门:安装。要设置编辑器,请参阅 使用 入门:设置编辑器。安装 Android 模拟器时,您可以使用默认选项(如 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

常见问题解答

3. 下载 Codelab 起始应用

接续 MDC-103?

如您完成了 MDC-103,那么您的代码应该已经可以用于此 Codelab。请跳到以下步骤:添加背景菜单。

从头开始?

起始应用位于 material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_ series 目录中。

......或者从 GitHub 克隆

要从 GitHub 克隆此 Codelab,请运行以下命令:

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 Studio (IntelliJ)。

打开项目

1.打开 Android Studio。

2.如果看到欢迎屏幕,请点击 Open an existing Android Studio project(打开现有的 Android Studio 项目)

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'"(运行 ‘flutter packages get')。

然后重新启动 Android Studio。

运行起始应用

以下说明假定您是在 Android 模拟器或设备上进行的测试,但如果您安装了 Xcode,也可以在 iOS 模拟器或设备上测试。

1.选择设备或模拟器。如果 Android 模拟器尚未运行,请选择 Tools(工具)-> Android -> AVD Manager(AVD 管理器)以创建虚拟设备,然后启动模拟器。如果 AVD 已存在,您可以直接从 Android Studio 中的设备选择器启动模拟器,如下一步中所示。(对于 iOS 模拟器,如果它尚未运行,则通过选择 Flutter Device Selection(Flutter 设备选择)-> Open iOS Simulator(打开 iOS 模拟器),以在开发机器上启动模拟器。)

2.启动 Flutter 应用:

  • 查看编辑器屏幕顶部的"Flutter Device Selection"(Flutter 设备选择)下拉菜单并选择设备(例如 iPhone SE 或 Android SDK built for <版本>)。
  • 点按 Play(播放)图标 ()。

成功!您应该会在模拟器中看到上一个 Codelab 中的 Shrine 登录页面。

Android

iOS

4. 添加背景菜单

背景出现在所有其他内容和组件后面。它由两个图层组成:后图层(用于显示操作和过滤器)和前图层(用于显示内容)。您可以使用背景来显示交互式信息和操作,例如导航或内容过滤。

删除主应用栏

HomePage 微件将成为前图层的内容。现在这上面有一个应用栏。我们会将应用栏移到后图层中,而 HomePage 将只包含 AsymmetricView。

home.dart 中,更改 build() 函数使其只返回 AsymmetricView:

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

添加 Backdrop 微件

创建一个名为 Backdrop 的微件,其中包含 frontLayerbackLayer

backLayer 包含一个菜单,可用于选择类别来过滤列表 (currentCategory)。由于我们希望菜单的选择保持不变,因此将 Backdrop 设为有状态的微件。

/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 时,采用这样的最佳做法以提醒自己不要遗漏。请注意,我们在构造函数后面还有用于检查传递到这些字段的值确实不是 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 的父项指定。

现在将 Backdrop 实例添加到 ShrineApp。

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() 函数。将 home: 更改为以 HomePage 作为其 frontLayer 的 Backdrop:

      // 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"(播放)按钮,您应该会看到主页和应用栏:

Android

iOS

backLayer 在 frontLayer 主页后面的新图层中显示了一个粉红色区域。

您可以使用 Flutter Inspector 来验证 Stack 在 HomePage 后面确实有一个容器。具体应如下所示:

68c8b7217951bb7a.png

您现在可以调整两个图层的设计和内容。

5. 添加形状

在此步骤中,您将设置前图层的样式,以在左上角添加一个切口。

Material Design 将此类定制视为形状。Material 表面可以是任意形状。形状为表面增加了重点和风格,可用于表达品牌形象。普通矩形可使用弯曲或成角度的角和边以及任意数量的边来自定义。它们可以是对称的,也可以是不规则的。

将形状添加到前图层

从角形的 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 的主要表面提供自定义的形状。由于表面具有高度,用户可以看出,位于上方的白色图层的后方有一些内容。让我们加入动作,以便用户看到背景的后图层。

6. 添加动作

动作可以让应用变得生动有趣。它可以热烈奔放,也可以舒缓轻柔,或介于两者之间。 但请注意,您使用的动作类型应与情景相符。应用于重复性、规律性操作的动作应轻柔,以免分散用户的注意力,或者经常耗费过多时间。但在适当的情景中,比如用户第一次打开应用时, 可以使用更加吸引眼球的动作,并且有些动画也可以帮助用户了解如何使用应用。

为菜单按钮添加呈现动作

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 来播放、倒放和停止动画。现在我们需要能够使其移动的函数。

添加用于确定和更改前图层可见性的函数:

  // 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 微件。当后图层不可见时,此微件将从语义树中排除 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>[
        // 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,并在按钮被点按时用它来切换前图层的可见性。

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

重新加载,然后点按模拟器中的菜单按钮。

Android

iOS

前图层动画(幻灯片)关闭。但如果您向下看,会发现红色错误和溢出错误。这是因为,AsymmetricView 被此动画压缩而变得更小,从而减小了 Column 的空间。最后,Column 在提供的空间内无法布置,从而产生错误。如果我们将 Column 替换为 ListView,列大小在动画播放时应会保持不变。

将产品列封装在 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

不再出现溢出了。

7. 在后图层上添加一个菜单

菜单是可点按的文本项列表,当文本项被轻触时菜单会向监听器发送通知。在这一步中,您将添加一个类别过滤菜单。

添加菜单

将菜单加入前图层,并将交互式按钮加入后图层。

创建名为 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,其子项是类别的名称。下划线用于表示所选的类别。

app.dart 中,将 ShrineApp 微件从无状态转换为有状态。

  1. 突出显示 ShrineApp.
  2. 点按 alt (option) + enter。
  3. 选择"Convert to StatefulWidget"(转换为 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;
    });
  }

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

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

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

重新加载,然后点按前图层的顶部。前图层会随着您每次点按其顶部而打开和关闭。

8. 添加品牌图标

品牌图标的表示方法也可扩展到我们熟悉的图标。我们将呈现图标设为自定义,并将其与我们的标题合并,形成独特的品牌外观。

更改菜单按钮图标

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

_BackdropTitle 是一个自定义微件,用于替换 AppBar 微件 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 处理程序会传递到 _BackdropTitlefrontTitlebackTitle 也会被传递,这样它们就可以在背景标题内渲染。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 的"一切都是微件"架构允许更改默认 AppBar 的布局,而无需创建全新的自定义 AppBar 微件。title 参数(最初是一个 Text 微件)可替换为更复杂的 _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';

重新加载应用,然后点按搜索或微调按钮以返回登录屏幕。

9. 总结

在学习这四个 Codelab 的过程中,您已经了解如何使用 Material Components 打造独特、 优雅的用户体验,以表现品牌的个性和风格。

后续步骤

本 Codelab(即 MDC-104)是此 Codelab 系列的最后一个。您可以访问 Flutter 微件目录探索 MDC-Flutter 中的更多组件。

如果想更进一步,可尝试将品牌图标替换为 AnimatedIcon,以在背景设为可见时切换播放两个图标的动画。

我能够用合理的时间和精力完成此 Codelab

非常赞同 赞同 中立 不赞同 非常不赞同

我未来会继续使用 Material Components

非常赞同 赞同 中立 不赞同 非常不赞同