Material Components (MDC) help developers implement Material Design. Created by a team of engineers and UX designers at Google, MDC features dozens of beautiful and functional UI components and is available for Android, iOS, web and Flutter.

material.io/develop

In codelab MDC-103, you customized the color, elevation, typography, and shape of Material Components (MDC) to style your app.

A component in the Material Design system performs a set of predefined tasks and has certain characteristics, like a button. However, a button is more than just a way for a user to perform an action, it's also a visual expression of shape, size, and color that lets the user know that it's interactive, and that something will happen upon touch or click.

While the Material Design guidelines name many components, not all of them exist in MDC. You can still create these components yourself to achieve a customized style for your app, all using traditional code.

The Material Design guidelines describe components from a designer's point of view. They describe a wide range of basic functions available across platforms, as well as the anatomic elements that make up each component. For instance, a backdrop contains a back layer and its content, the front layer and its content, motion rules, and display options. Each of these components can be customized for each app's needs, use cases, and content. These pieces are, for the most part, traditional views, controls, and functions from your platform's SDK.

What you'll build

In this codelab, you'll change the UI in the Shrine app to a two-level presentation called a "backdrop". The backdrop includes a menu that lists selectable categories used to filter the products shown in the asymmetrical grid. In this codelab, you'll use the following Flutter components:

MDC-Flutter component in this codelab

What you'll need

To build and run Flutter apps on iOS:

To build and run Flutter apps on Android:

How would you rate your level of experience with Flutter development?

Novice Intermediate Proficient

Prerequisites

To start developing mobile apps with Flutter you need:

Flutter's IDE tools are available for Android Studio, IntelliJ IDEA Community (free), and IntelliJ IDEA Ultimate.

To build and run Flutter apps on iOS:

To build and run Flutter apps on Android:

Get detailed Flutter setup information

Before proceeding with this codelab, run the flutter doctor command and see that all the checkmarks are showing; this will download any missing SDK files you need and ensure that your codelab machine is set up correctly for Flutter development.

 flutter doctor

Continuing from MDC-103?

If you completed MDC-103, your code should be ready for this codelab. Skip to step: Add the backdrop menu.

Starting from scratch?

Download starter app

The starter app is located in the material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_series directory.

...or clone it from GitHub

To clone this codelab from GitHub, run the following commands:

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

Set up your project

The following instructions assume you're using Android Studio (IntelliJ).

Create the project

1. In Terminal, navigate to material-components-flutter-codelabs

2. Run flutter create mdc_100_series

Open the project

1. Open Android Studio.

2. If you see the welcome screen, click Open an existing Android Studio project.

3. Navigate to the material-components-flutter-codelabs/mdc_100_series directory and click Open. The project should open.

You can ignore any errors you see in analysis until you've built the project once.

4. In the project panel on the left, delete the testing file ../test/widget_test.dart

5. If prompted, install any platform and plugin updates or FlutterRunConfigurationType, then restart Android Studio.

Run the starter app

The following instructions assume you're testing on an Android emulator or device but you can also test on an iOS Simulator or device if you have Xcode installed.

1. Select the device or emulator.

If the Android emulator is not already running, select Tools -> Android -> AVD Manager to create a virtual device and start the emulator. If an AVD already exists, you can start the emulator directly from the device selector in IntelliJ, as shown in the next step.

(For the iOS Simulator, if it is not already running, launch the simulator on your development machine by selecting Flutter Device Selection -> Open iOS Simulator.)

2. Start your Flutter app:

  • Look for the Flutter Device Selection dropdown menu at the top of your editor screen, and select the device (for example, iPhone SE or Android SDK built for <version>).
  • Press the Play icon ().

Success! You should see the Shrine login page from the previous codelabs in the simulator or emulator.

A backdrop appears behind all other content and components. It's composed of two surfaces: a back layer (that displays actions and filters) and a front layer (that displays content). You can use a backdrop to display interactive information and actions, such as navigation or content filters.

Remove the home app bar

The HomePage widget will be the content of our front panel. Right now it has an app bar. We'll move the app bar to the back panel and the HomePage will only include the AsymmetricView.

In home.dart, change the build() function to just return an AsymmetricView:

return  AsymmetricView(products: getProducts(Category.all));

Add the Backdrop widget

Create a widget called Backdrop that includes the frontPanel and the backPanel.

The backPanel includes a menu that allows you to select a category to filter the list (_currentCategory). Since we want the menu selection to persist, we'll make Backdrop a stateful widget.

Add a new file to /lib named backdrop.dart:

import 'package:flutter/material.dart';
import 'package:meta/meta.dart';

import 'model/product.dart';

class Backdrop extends StatefulWidget {
  final Category currentCategory;
  final Widget frontPanel;
  final Widget backPanel;
  final Widget frontTitle;
  final Widget backTitle;

  const Backdrop({
    @required this.currentCategory,
    @required this.frontPanel,
    @required this.backPanel,
    @required this.frontTitle,
    @required this.backTitle,
  })  : assert(currentCategory != null),
        assert(frontPanel != null),
        assert(backPanel != null),
        assert(frontTitle != null),
        assert(backTitle != null);

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

Import the meta package to mark the properties @required. This is a best practice when you have properties in the constructor that have no default value and cannot be null and therefore should not be forgotten. Notice that we also have asserts after the constructor that check the values passed into those fields are indeed not null.

Under the Backdrop class definition, add _BackdropState class:

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

  Widget _buildStack() {
    return Container(
      key: _backdropKey,
      child: Stack(
        children: <Widget>[
          widget.backPanel,
          widget.frontPanel,
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    var appBar = AppBar(
      brightness: Brightness.light,
      elevation: 0.0,
      leading: Icon(Icons.menu),
      title: Text('SHRINE'),
      actions: <Widget>[
        IconButton(
          icon: Icon(Icons.search),
          onPressed: () {
// TODO: Add open login (104)
          },
        ),
        IconButton(
          icon: Icon(Icons.tune),
          onPressed: () {
// TODO: Add open login (104)
          },
        ),
      ],
    );
    return Scaffold(
      appBar: appBar,
      body: _buildStack(),
    );
  }
}

The build() function returns a Scaffold with an app bar just like HomePage used to. But the body of the Scaffold is a Stack. A Stack's children can overlap. Each child's size and location is specified relative to the Stack's parent.

Now add a Backdrop instance to ShrineApp.

In app.dart, import backdrop.dart:

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

In app.dart, modify the ShrineApp's build() function. Change home: to a Backdrop that has a HomePage as its frontPanel:

      home: Backdrop(
        currentCategory: Category.all,
        frontPanel: HomePage(),
        backPanel: Container(color: kShrinePink100),
        frontTitle: Text('SHRINE'),
        backTitle: Text('MENU'),
      ),

If you hit the Play button, you should see that our home page is showing up and so is the app bar:

The backPanel shows the pink area in a new layer behind the frontPanel home page.

You can use the Flutter Inspector to verify that the Stack indeed has a Container behind a HomePage. It should similar to this:

You can now adjust the design and content of the two layers.

In this step, you'll style the front layer to add a cut in the upper left corner.

Material Design refers to this type of customization as a shape. Material surfaces can have arbitrary shapes. Shapes add emphasis and style to surfaces and can be used to express branding. Ordinary rectangular shapes can be customized with curved or angled corners and edges, and any number of sides. They can be symmetrical or irregular.

Add a shape to the front panel

The the angled Shrine logo inspired the shape story for the Shrine app. The shape story is the common use of shapes that are applied throughout an app. For example, the logo shape is echoed in the login page elements that have shape applied to them. In this step, you'll style the front layer with an angled cut in the upper-left corner.

In backdrop.dart, add a new class _BackdropPanel:

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

Then, in _BackdropState's _buildStack() function, wrap the front panel in a _BackdropPanel:

  Widget _buildStack() {
    return Container(
      key: _backdropKey,
      child: Stack(
        children: <Widget>[
          widget.backPanel,
          _BackdropPanel(child: widget.frontPanel),
        ],
      ),
    );
  }

Reload.

We've given Shrine's primary surface a custom shape. Because of the surface elevation, users can see that there is something just behind the front white layer. Let's add motion so that users can see the backdrop's back layer.

Motion is a way to bring your app to life. It can be big and dramatic, subtle and minimal, or anywhere in between. But keep in mind that the type of motion you use should be suitable to the situation. Motion that's applied to repeated, regular actions should be small and subtle, so that the actions don't distract the user or take up too much time on a regular basis. But there are appropriate situations, like the first time a user opens an app, that can be more eye-catching, and some animations can help educate the user about how to use your app.

Add reveal motion to the menu button

At the top of backdrop.dart, outside the scope of any class of function, add a constant to represent the velocity we want our animation to have:

const double _kFlingVelocity = 2.0;

Add an AnimationController widget to _BackdropState, instantiate it in the initState() function, and dispose of it in the state's dispose() function:

  AnimationController _controller;

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

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

The AnimationController coordinates Animations and gives you API to play, reverse, and stop the animation. Now we need functions that make it move.

Add functions that determine as well as change the visibility of the front panel:

  bool get _backdropPanelVisible {
    final AnimationStatus status = _controller.status;
    return status == AnimationStatus.completed ||
        status == AnimationStatus.forward;
  }

  void _toggleBackdropPanelVisibility() {
    _controller.fling(
        velocity: _backdropPanelVisible ? -_kFlingVelocity : _kFlingVelocity);
  }

Change the _buildStack() function to take a BuildContext and BoxConstraints. Also, include a PositionedTransition that takes a RelativeRectTween Animation:

  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
    const double panelTitleHeight = 48.0;
    final Size panelSize = constraints.biggest;
    final double panelTop = panelSize.height - panelTitleHeight;

    Animation<RelativeRect> panelAnimation = RelativeRectTween(
      begin: RelativeRect.fromLTRB(
          0.0, panelTop, 0.0, panelTop - panelSize.height),
      end: RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
    ).animate(_controller.view);

    return Container(
      key: _backdropKey,
      child: Stack(
        children: <Widget>[
          widget.backPanel,
          PositionedTransition(
            rect: panelAnimation,
            child: _BackdropPanel(
              child: widget.frontPanel,
            ),
          ),
        ],
      ),
    );
  }

Finally, instead of calling the _buildStack function for the body of the Scaffold, return a LayoutBuilder widget that uses _buildStack as its builder:

    return Scaffold(
      appBar: appBar,
      body: LayoutBuilder(builder: _buildStack),
    );

LayoutBuilder is a special widget that gives us information about our parent's size. We also use LayoutBuilder in the TwoProductCardColumn widget.

In the build() function, turn the leading menu icon in the app bar into an IconButton and use it to toggle the visibility of the front layer when the button is tapped.

      leading: IconButton(
        icon: Icon(Icons.menu),
        onPressed: _toggleBackdropPanelVisibility,
      ),

Reload then tap the menu button in the simulator.

The front panel animates (slides) down. But if you look down, there's a red error and an overflow error. This is because the AsymmetricView is squeezed and becomes smaller by this animation, which in turn gives less room to the Columns. Eventually, the Columns can't lay themselves out with the space given and they result in an error. If we replace the Columns with ListViews, the column size should remain as they animate.

Wrap product columns in a ListView

In supplemental/product_columns.dart, replace the Column in OneProductCardColumn with a ListView:

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

  final Product product;

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

The Column includes MainAxisAlignment.end. To begin the layout from the bottom, mark reverse: true. The order of the children is reversed to compensate for the change.

Reload and then tap the menu button.

The overflow warning on OneProductCardColumn is gone! Now let's fix the other.

In supplemental/product_columns.dart, change the way the imageAspectRatio is calculated, and replace the Column in TwoProductCardColumn with a ListView:

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

      return ListView(
        children: <Widget>[
          Container(
            padding: EdgeInsetsDirectional.only(start: 28.0),
            child: top != null
                ? ProductCard(
                    imageAspectRatio: imageAspectRatio,
                    product: top,
                  )
                : SizedBox(
                    height: heightOfCards,
                  ),
          ),
          SizedBox(height: spacerHeight),
          Container(
            padding: EdgeInsetsDirectional.only(end: 28.0),
            child: ProductCard(
              imageAspectRatio: imageAspectRatio,
              product: bottom,
            ),
          ),
        ],
      );
    });

We also added some safety to imageAspectRatio.

Reload. Then tap the menu button.

No more overflows.

You're almost finished! Motion is a great way to express your brand.

A backdrop is the furthest back surface of an app, appearing behind all other content and components. It's composed of two surfaces: a backlayer (which displays actions and filters) and a front layer (which displays content). You can use a backdrop to display interactive information and actions, such as navigation or content filters.

Add the menu

A menu is a list of tappable text items that notify listeners when the text items are touched. In this step, you'll add a category menu.

Create a new file called lib/menu_page.dart:

import 'package:flutter/material.dart';
import 'package:meta/meta.dart';

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

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

  const MenuPage({
    Key key,
    @required this.currentCategory,
    this.onCategoryTap,
  }) : assert(currentCategory != null);

  Widget _buildCategory(Category category, BuildContext context) {
    var categoryString =
        category.toString().replaceAll('Category.', '').toUpperCase();
    return GestureDetector(
      onTap: () => onCategoryTap(category),
      child: category == currentCategory
          ? Column(
              children: <Widget>[
                SizedBox(height: 16.0),
                Text(
                  categoryString,
                  style: Theme.of(context).textTheme.body2,
                  textAlign: TextAlign.center,
                ),
                SizedBox(height: 14.0),
                Container(
                  width: 70.0,
                  height: 2.0,
                  color: Color(0xFFEAA4A4),
                ),
              ],
            )
          : Container(
              padding: EdgeInsets.symmetric(vertical: 16.0),
              child: Text(
                categoryString,
                style: Theme.of(context).textTheme.body2.copyWith(
                      color: kShrineBrown900.withAlpha(153),
                    ),
                textAlign: TextAlign.center,
              ),
            ),
    );
  }

  @override
  Widget build(BuildContext context) {
    var menuItems = <Widget>[];
    _categories.forEach((Category c) {
      menuItems.add(_buildCategory(c, context));
    });

    return Center(
      child: Container(
        padding: EdgeInsets.only(top: 40.0),
        color: kShrinePink100,
        child: ListView(children: menuItems),
      ),
    );
  }
}

It's a GestureDetector wrapping a Column whose children are the category names. An underline is used to indicate the selected category.

In app.dart, convert the ShrineApp widget from stateless to stateful.

  1. Highlight ShrineApp.
  2. Press alt (option) + enter.
  3. Select "Convert to StatefulWidget".
  4. Change the ShrineAppState class to private (_ShrineAppState). To do this from the IDE main menu, select Refactor > Rename. Alternatively, from within the code, you can highlight the class name, ShrineAppState, then right-click and select Refactor > Rename. Enter _ShrineAppState to make the class private.

In app.dart, add a variable to _ShrineAppState for the selected Category and a callback when it's tapped:

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

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

Then change the back panel to a MenuPage.

In app.dart, import the MenuPage:

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

In the build() function, change the backpanel field to MenuPage and the currentCategory field to take the instance variable.

      home: Backdrop(
        currentCategory: _currentCategory,
        frontPanel: HomePage(),
        backPanel: MenuPage(
          currentCategory: _currentCategory,
          onCategoryTap: _onCategoryTap,
        ),
        frontTitle: Text('SHRINE'),
        backTitle: Text('MENU'),
      ),

Reload and then tap the Menu button.

If you tap a menu option, nothing happens...yet. Let's fix that.

In home.dart, add a variable for Category and pass it to the AsymmetricView.

import 'package:flutter/material.dart';

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

class HomePage extends StatelessWidget {
  final Category category;

  const HomePage({this.category: Category.all});

  @override
  Widget build(BuildContext context) {
    return AsymmetricView(products: getProducts(category));
  }
}

In app.dart, pass the _currentCategory for frontPanel:.

        frontPanel: HomePage(category: _currentCategory),

Reload. Tap the menu button in the simulator and select a Category.

Tap the menu icon to view the products. They're filtered!

Close the front panel after a menu selection

In backdrop.dart, add an override for the didUpdateWidget() function in _BackdropState:

  @override
  void didUpdateWidget(Backdrop old) {
    super.didUpdateWidget(old);
    if (widget.currentCategory != old.currentCategory) {
      setState(() {
        _controller.fling(
            velocity:
            _backdropPanelVisible ? -_kFlingVelocity : _kFlingVelocity);
      });
    } else if (!_backdropPanelVisible) {
      setState(() {
        _controller.fling(velocity: _kFlingVelocity);
      });
    }
  }

Hot reload then tap the menu icon and select a category. The menu should close automatically and your should see the category of items selected. Now you'll add that functionality to the front panel too.

Toggle the front panel

In backdrop.dart, add an on-tap callback to the backdrop panel:

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

  final VoidCallback onTap;

Then add a GestureDetector to the backdrop panel's child: Column's children:.

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

Then implement the new onTap property on the _BackdropState in the _buildStack() function:

          PositionedTransition(
            rect: panelAnimation,
            child: _BackdropPanel(
              onTap: _toggleBackdropPanelVisibility,
              child: widget.frontPanel,
            ),
          ),

Reload and then tap on the top of the front panel. The panel should open and close each time you tap the top of the front panel.

Intricate animations, animations with , and not just cross-fades, scales, or translations, are very difficult to accomplish in most software. Flutter has an amazing AnimatedIcon widget that includes several built-in Material icon animations.

In backdrop.dart, replace the leading IconButton's Icon with an AnimatedIcon:

        leading: IconButton(
        onPressed: _toggleBackdropPanelVisibility,
        icon: AnimatedIcon(
          icon: AnimatedIcons.close_menu,
          progress: _controller.view,
        ),
      ),

Reload the app and tap the menu button.

After you tap the Menu button, it changes to a Close button.

Add a shortcut back to the login screen

In backdrop.dart,add a shortcut back to the login screen from the two trailing icons in the app bar:

        IconButton(
          icon: Icon(Icons.search),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (BuildContext context) => LoginPage()),
            );
          },
        ),
        IconButton(
          icon: Icon(Icons.tune),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (BuildContext context) => LoginPage()),
            );
          },
        ),

You'll get an error if you try a reload. Import login.dart to fix the error:

import 'login.dart';

Reload the app and tap the search or tune buttons to return to the login screen.

Add an animated title

To continue improving the app, add an animation to change the menu title from Shrine to Menu when the menu is displayed.

Add the _BackdropTitle class to backdrop.dart:

class _BackdropTitle extends AnimatedWidget {
  final Widget frontTitle;
  final Widget backTitle;

  const _BackdropTitle({
    Key key,
    Listenable listenable,
    this.frontTitle,
    this.backTitle,
  }) : 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,
      // Here, we do a custom cross fade between backTitle and frontTitle.
      // This makes a smooth animation between the two texts.
      child: Stack(
        children: <Widget>[
          Opacity(
            opacity: CurvedAnimation(
              parent: ReverseAnimation(animation),
              curve: Interval(0.5, 1.0),
            ).value,
            child: backTitle,
          ),
          Opacity(
            opacity: CurvedAnimation(
              parent: animation,
              curve: Interval(0.5, 1.0),
            ).value,
            child: frontTitle,
          ),
        ],
      ),
    );
  }
}

In _BackdropState's build() function, change the app bar's title to this new class:

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

Reload to view the menu title change.

The app bar shows the Menu title when the menu is displayed.

Over the course of these four codelabs, you've seen how to use Material Components to build unique, elegant user experiences that express brand personality and style.

Next steps

This codelab, MDC-104, completes this sequence of codelabs. You can explore even more components in MDC-Flutter by visiting the Flutter Widgets Catalog.

To learn how to connect an app to Firebase for a working backend, see the codelab Firebase for Flutter.

I was able to complete this codelab with a reasonable amount of time and effort

Strongly agree Agree Neutral Disagree Strongly disagree

I would like to continue using Material Components in the future

Strongly agree Agree Neutral Disagree Strongly disagree