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 |
You can now use MDC to customize your apps' unique style more than ever. Material Design's recent expansion gives designers and developers increased flexibility to express their product's brand.
In codelabs MDC-101 and MDC-102, you used Material Components (MDC) to build the basics of an app called Shrine, an e-commerce app that sells clothing and home goods. This app contains a user flow that starts with a login screen, then takes the user to a home screen that displays products.
In this codelab, you'll customize the Shrine app using:
If you completed MDC-102, your code should be ready to go for this codelab. Skip to step: Change the colors.
The starter app is located in the material-components-flutter-codelabs-103-starter_and_102-complete/mdc_100_series
directory.
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/mdc_100_series git checkout 103-starter_and_102-complete
The following instructions assume you're using Android Studio (IntelliJ).
1. Open Android Studio. |
2. If you see the welcome screen, click Open an existing Android Studio project. |
3. Navigate to the You can ignore any errors you see in Dart Analysis until you've built the project once. |
4. If prompted:
Then restart Android Studio. |
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 Android Studio, 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:
|
Success! You should see the Shrine login page from the previous codelabs in the simulator or emulator.
Android | iOS |
Click "Next" to see the home page from the previous codelab.
Android | iOS |
A color scheme has been created that represents the Shrine brand, and the designer would like you to implement that color scheme across the Shrine app
To start, let's import those colors into our project.
colors.dart
Create a new dart file in lib
called colors.dart
. Import Material Components and add const Color values:
import 'package:flutter/material.dart';
const kShrinePink50 = Color(0xFFFEEAE6);
const kShrinePink100 = Color(0xFFFEDBD0);
const kShrinePink300 = Color(0xFFFBB8AC);
const kShrinePink400 = Color(0xFFEAA4A4);
const kShrineBrown900 = Color(0xFF442B2D);
const kShrineErrorRed = Color(0xFFC5032B);
const kShrineSurfaceWhite = Color(0xFFFFFBFA);
const kShrineBackgroundWhite = Colors.white;
This color theme has been created by a designer with custom colors (shown in the image below). It contains colors that have been selected from Shrine's brand and applied to the Material Theme Editor, which has expanded them to create a fuller palette. (These colors aren't from the 2014 Material color palettes.)
The Material Theme Editor has organized them into shades labelled numerically, including labels 50, 100, 200, .... to 900 of each color. Shrine only uses shades 50, 100, and 300 from the pink swatch and 900 from the brown swatch.
Each colored parameter of a widget is mapped to a color from these schemes. For example, the color for a text field's decorations when it's actively receiving input should be the theme's Primary color. If that color isn't accessible (easy to see against its background), use the PrimaryVariant instead.
Now that we have the colors we want to use, we can apply them to the UI. We'll do this by setting the values of a ThemeData widget that we apply to the MaterialApp instance at the top of our widget hierarchy.
Flutter includes a few built-in themes. The light theme is one of them. Rather than making a ThemeData widget from scratch, we'll copy the light theme and change the values to customize them for our app.
Let's import colors.dart
in app.dart.
import 'colors.dart';
Then add the following to app.dart outside the scope of the ShrineApp class:
// TODO: Build a Shrine Theme (103)
final ThemeData _kShrineTheme = _buildShrineTheme();
ThemeData _buildShrineTheme() {
final ThemeData base = ThemeData.light();
return base.copyWith(
accentColor: kShrineBrown900,
primaryColor: kShrinePink100,
buttonTheme: base.buttonTheme.copyWith(
buttonColor: kShrinePink100,
colorScheme: base.colorScheme.copyWith(
secondary: kShrineBrown900,
),
),
buttonBarTheme: base.buttonBarTheme.copyWith(
buttonTextTheme: ButtonTextTheme.accent,
),
scaffoldBackgroundColor: kShrineBackgroundWhite,
cardColor: kShrineBackgroundWhite,
textSelectionColor: kShrinePink100,
errorColor: kShrineErrorRed,
// TODO: Add the text themes (103)
// TODO: Add the icon themes (103)
// TODO: Decorate the inputs (103)
);
}
Now, set the theme:
at the end of ShrineApp's build()
function (in the MaterialApp widget) to be our new theme:
// TODO: Add a theme (103)
theme: _kShrineTheme, // New code
Click the Play button. Your login screen should now look like this:
Android | iOS |
And your home screen should look like this:
Android | iOS |
In addition to color changes, the designer has also given us specific typography to use. Flutter's ThemeData includes 3 text themes. Each text theme is a collection of text styles, like "headline" and "title". We'll use a couple of styles for our app and change some of the values.
In order to import fonts into the project, they have to be added to the pubspec.yaml file.
In pubspec.yaml, add the following immediately after the flutter:
tag:
# TODO: Insert Fonts (103)
fonts:
- family: Rubik
fonts:
- asset: fonts/Rubik-Regular.ttf
- asset: fonts/Rubik-Medium.ttf
weight: 500
Now you can access and use the Rubik font.
You may get errors in running pub get if you cut and paste the declaration above. If you get errors, start by removing the leading whitespace and replacing it with spaces using 2-space indentation. (Two spaces before
fonts:
, four spaces before
family: Rubik
, and so on.)
If you see Mapping values are not allowed here, check the indentation of the line that has the problem and the indentation of the lines above it.
In login.dart
, change the following inside Column()
:
Column(
children: <Widget>[
Image.asset('assets/diamond.png'),
SizedBox(height: 16.0),
Text(
'SHRINE',
style: Theme.of(context).textTheme.headline5,
),
],
)
In app.dart
, add the following after _buildShrineTheme()
:
// TODO: Build a Shrine Text Theme (103)
TextTheme _buildShrineTextTheme(TextTheme base) {
return base.copyWith(
headline5: base.headline5.copyWith(
fontWeight: FontWeight.w500,
),
headline6: base.headline6.copyWith(
fontSize: 18.0
),
caption: base.caption.copyWith(
fontWeight: FontWeight.w400,
fontSize: 14.0,
),
bodyText1: base.bodyText1.copyWith(
fontWeight: FontWeight.w500,
fontSize: 16.0,
),
).apply(
fontFamily: 'Rubik',
displayColor: kShrineBrown900,
bodyColor: kShrineBrown900,
);
}
This takes a TextTheme and changes how the headlines, titles, and captions look.
Applying the fontFamily
in this way applies the changes only to the typography scale values specified in copyWith()
(headline, title, caption).
For some fonts, we're setting a custom fontWeight. The FontWeight widget has convenient values on the 100s. In fonts, w500 (the 500 weight) is usually the medium and w400 is usually the regular.
Add the following themes to _buildShrineTheme
after errorColor:
// TODO: Add the text themes (103)
textTheme: _buildShrineTextTheme(base.textTheme),
primaryTextTheme: _buildShrineTextTheme(base.primaryTextTheme),
accentTextTheme: _buildShrineTextTheme(base.accentTextTheme),
Click the Stop button and then the Play button.
Text in the login and home screens look different—some text uses the Rubik font, and other text renders in brown, instead of black or white.
Android | iOS |
Notice that the icons are still white. That's because there's a separate theme for icons.
Add it to the _buildShrineTheme()
function:
// TODO: Add the icon theme (103)
primaryIconTheme: base.iconTheme.copyWith(
color: kShrineBrown900
),
Click the Play button.
Android | iOS |
Brown icons in the app bar!
The labels are just a little too big.
In home.dart
, change the children:
of the innermost Column:
// TODO: Change innermost Column (103)
children: <Widget>[
// TODO: Handle overflowing labels (103)
Text(
product == null ? '' : product.name,
style: theme.textTheme.button,
softWrap: false,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
SizedBox(height: 4.0),
Text(
product == null ? '' : formatter.format(product.price),
style: theme.textTheme.caption,
),
// End new code
],
We want to center the labels, and align the text to the bottom of each card, instead of the bottom of each image.
Move the labels to the end (bottom) of the main axis and change them to be centered::
// TODO: Align labels to the bottom and center (103)
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
Save the project.
Android | iOS |
It's close, but the text isn't centered on the card.
Change the parent Column's cross-axis alignment:
// TODO: Center items on the card (103)
crossAxisAlignment: CrossAxisAlignment.center,
Save the project. Your home screen should now look like this:
Android | iOS |
That looks much better.
You can also theme the decoration on text fields with an InputDecorationTheme.
In app.dart
, in the _buildShrineTheme()
method, specify an inputDecorationTheme:
value:
// TODO: Decorate the inputs (103)
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(),
),
Right now, the text fields have a filled
decoration. Let's remove that. Removing filled
and specifying the inputDecorationTheme
will give the text fields the outline style.
In login.dart
, remove the filled: true
values:
// Remove filled: true values (103)
TextField(
controller: _usernameController,
decoration: InputDecoration(
// Removed filled: true
labelText: 'Username',
),
),
SizedBox(height: 12.0),
TextField(
controller: _passwordController,
decoration: InputDecoration(
// Removed filled: true
labelText: 'Password',
),
obscureText: true,
),
Click the Flutter Hot Restart button under the Run menu (to restart the app from the beginning). Your login screen should look like this when the Username field is active (when you're typing in it):
Android | iOS |
Type into a text field—decorations and floating placeholder renders in the primary color. But we can't see it very easily. It's not accessible to people who have trouble distinguishing pixels that don't have a high enough color contrast. (For more information, see "Accessible colors" in the Material Guidelines Color article.) Let's specify the focusedBorder:
in inputDecorationTheme:
to override the Accent color for the text field to be the PrimaryVariant the designer gave us in the color theme above.
In app.dart
, specify a focusedBorder:
under inputDecorationTheme:
:
// TODO: Decorate the inputs (103)
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
width: 2.0,
color: kShrineBrown900,
),
),
border: OutlineInputBorder(),
),
Next, we will change the labelStyle
property for both text fields to color the label in the color theme given by the designer.
In login.dart
, add labelStyle:
under both TextField
widgets InputDecoration()
:
TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
labelStyle: TextStyle(color: Theme.of(context).accentColor),
),
),
SizedBox(height: 12.0),
TextField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(color: Theme.of(context).accentColor),
),
),
In order to have different label styles for when the text field is focused and unfocused, we want to set up a FocusNode
for each of the TextField
widgets, as well as a conditional to make the labelStyle
dynamic based on whether the widget is in focus or not.
In login.dart
let's initialize our FocusNodes
and our unfocused label color at the top of our _LoginPageState
class under our text field controllers :
class _LoginPageState extends State<LoginPage> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _unfocusedColor = Colors.grey[600];
final _usernameFocusNode = FocusNode();
final _passwordFocusNode = FocusNode();
Override initState
and add a listener for our FocusNodes
:
@override
void initState() {
super.initState();
_usernameFocusNode.addListener(() {
setState(() {
//Redraw so that the username label reflects the focus state
});
});
_passwordFocusNode.addListener(() {
setState(() {
//Redraw so that the password label reflects the focus state
});
});
}
Finally, add the focusNode:
property inside the TextField
widgets and add a conditional for the labelStyle:
under InputDecoration()
:
TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
labelStyle: TextStyle(
color: _usernameFocusNode.hasFocus
? Theme.of(context).accentColor
: _unfocusedColor),
),
focusNode: _usernameFocusNode,
),
SizedBox(height: 12.0),
TextField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(
color: _passwordFocusNode.hasFocus
? Theme.of(context).accentColor
: _unfocusedColor),
),
focusNode: _passwordFocusNode,
),
Click the Play button.
Android | iOS |
Now that you've styled the page with specific color and typography that matches Shrine, let's take a look at the cards that show Shrine's products. Right now, the cards lay on a white surface next to the site's navigation.
In home.dart
, add an elevation:
value to the Cards:
// TODO: Adjust card heights (103)
elevation: 0.0,
Save the project.
Android | iOS |
You've removed the shadow under the cards.
Let's change the elevation of the components on the login screen to complement it.
The default elevation for RaisedButtons is 2. Let's raise them higher.
In login.dart
, add an elevation:
value to the NEXT RaisedButton:
RaisedButton(
child: Text('NEXT'),
elevation: 8.0, // New code
Click the Flutter Hot Restart button under the Run menu (to restart the app from the beginning). Your login screen should now look like this:
Android | iOS |
Shrine has a cool geometric style, defining elements with an octagonal or rectangular shape. Let's implement that shape styling in the cards on the home screen, and the text fields and buttons on the login screen.
In app.dart
, import a special cut corners border file:
import 'supplemental/cut_corners_border.dart';
Still in app.dart
, add a shape with cut corners to the text field decoration theme:
// TODO: Decorate the inputs (103)
inputDecorationTheme: InputDecorationTheme(
focusedBorder: CutCornersBorder(
borderSide: BorderSide(
width: 2.0,
color: kShrineBrown900,
),
),
border: CutCornersBorder(), // Replace code
),
In login.dart
, add a beveled rectangular border to the CANCEL button:
FlatButton(
child: Text('CANCEL'),
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(7.0)),
),
The FlatButton has no visible shape, so why are we adding a border shape? So the ripple animation is bound to the same shape when touched.
Now add the same shape to the NEXT button:
RaisedButton(
child: Text('NEXT'),
elevation: 8.0,
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(7.0)),
),
To change the shape of all buttons, we can also update the buttonTheme
in app.dart
. That is left as a challenge to the learner!
Click the Flutter Hot Restart button under the Run menu (to restart the app from the beginning):
Android | iOS |
Next, let's change the layout to show the cards at different aspect ratios and sizes, so that each card looks unique from the others.
We've already written the files for an asymmetrical layout.
In home.dart
, change the whole file to the following:
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)
@override
Widget build(BuildContext context) {
// TODO: Return an AsymmetricView (104)
// TODO: Pass Category variable to AsymmetricView (104)
return Scaffold(
appBar: AppBar(
brightness: Brightness.light,
leading: IconButton(
icon: Icon(Icons.menu),
onPressed: () {
print('Menu button');
},
),
title: Text('SHRINE'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.search),
onPressed: () {
print('Search button');
},
),
IconButton(
icon: Icon(Icons.tune),
onPressed: () {
print('Filter button');
},
),
],
),
body: AsymmetricView(products: ProductsRepository.loadProducts(Category.all)),
);
}
}
Save the project.
Android | iOS |
Now the products scroll horizontally in a woven-inspired pattern. Also, the status bar text (time and network at the top) is now black. That's because we changed the AppBar's brightness to light, brightness: Brightness.light
Color is a powerful way to express your brand, and a small change in color can have a large effect on your user experience. To test this out, let's see what Shrine looks like if the color scheme of the brand were completely different.
In colors.dart
, add the following:
const kShrinePurple = Color(0xFF5D1049);
const kShrineBlack = Color(0xFF000000);
In app.dart
, change the _buildShrineTheme()
and _buildShrineTextTheme
functions to the following:
ThemeData _buildShrineTheme() {
final ThemeData base = ThemeData.light();
return base.copyWith(
primaryColor: kShrinePurple,
buttonTheme: base.buttonTheme.copyWith(
buttonColor: kShrinePurple,
textTheme: ButtonTextTheme.primary,
colorScheme: ColorScheme.light().copyWith(primary: kShrinePurple)
),
scaffoldBackgroundColor: kShrineSurfaceWhite,
textTheme: _buildShrineTextTheme(base.textTheme),
primaryTextTheme: _buildShrineTextTheme(base.primaryTextTheme),
accentTextTheme: _buildShrineTextTheme(base.accentTextTheme),
primaryIconTheme: base.iconTheme.copyWith(
color: kShrineSurfaceWhite
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: CutCornersBorder(
borderSide: BorderSide(
width: 2.0,
color: kShrinePurple,
),
),
border: CutCornersBorder(),
),
);
}
TextTheme _buildShrineTextTheme(TextTheme base) {
return base.copyWith(
headline5: base.headline5.copyWith(
fontWeight: FontWeight.w500,
),
headline6: base.headline6.copyWith(
fontSize: 18.0,
),
caption: base.caption.copyWith(
fontWeight: FontWeight.w400,
fontSize: 14.0,
),
bodyText1: base.bodyText1.copyWith(
fontWeight: FontWeight.w500,
fontSize: 16.0,
),
).apply(
fontFamily: 'Rubik',
);
}
In login.dart
, color the logo diamond black:
Image.asset(
'assets/diamond.png',
color: kShrineBlack, // New code
),
In home.dart
, change the AppBar
's brightness to dark:
brightness: Brightness.dark,
Save the project. The new theme should now appear.
Android | iOS |
Android | iOS |
The result is very different! Let's revert this color code before moving on to 104.
By now, you've created an app that resembles the design specifications from your designer.
You've now used the following MDC components: theme, typography, elevation, and shape. You can explore more components and subsystems in the MDC-Flutter library.
Dig into the files in the supplemental
directory to learn how we made the horizontally scrolling, asymmetric layout grid.
What if your planned app design contains elements that don't have components in the MDC library? In MDC-104: Material Design Advanced Components we show how to create custom components using the MDC library to achieve a specific look.