Material & Cupertino Widgets

Material & Cupertino Widgets

How to adapt the style of your Flutter app according to the platform

ยท

6 min read

Creating an enchanting visual style is one of the most important tasks when creating an app. A user interface must be beautiful, fast, and intuitive if we want our users to have the best experience possible.

The Material style is probably the most standardized pattern when creating a Flutter app, it looks nice and sharp, at least for most Android users; but it might not be the best experience for everyone.

People tend to feel better in known environments, that's why it might be important to preserve the platform design guidelines when creating cross-platform apps. That's why the Flutter team created the Cupertino widgets in the first place: a way to provide iOS users a UI that looks familiar to them.

In this article I will share some samples on how to approach this challenge in the most efficient way I could found. I will be using heavily flutter_platform_widgets package. A great tool to synthesize as much as possible our code (๐Ÿ‘ thanks to its creator and contributors by the magnificent job).

Preparing the app

First, install the plugin with flutter pub add flutter_platform_widgets, then create a base app using PlatformProvider and PlatformApp. Create also a styles file, we will be using it in the following samples:

import 'package:flutter/widgets.dart';
import 'package:flutter_adapt_style_platform/selector_screen.dart';
import 'package:flutter_adapt_style_platform/styles.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return PlatformProvider(
        builder: (BuildContext context) => PlatformApp(
            title: 'Flutter platform style sample',
            material: (_, __) => MaterialAppData(
                theme: materialTheme, debugShowCheckedModeBanner: false),
            cupertino: (_, __) => CupertinoAppData(
                theme: cupertinoTheme, debugShowCheckedModeBanner: false),
            home: SelectorScreen()));
  }
}
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';

final materialTheme = ThemeData(
    primarySwatch: Colors.blue,
    visualDensity: VisualDensity.adaptivePlatformDensity);

final cupertinoTheme = CupertinoThemeData(
    textTheme: CupertinoTextThemeData(primaryColor: Colors.white),
    primaryColor: CupertinoDynamicColor.withBrightness(
        color: materialTheme.primaryColor,
        darkColor: materialTheme.primaryColorDark));

final materialRaisedButtonData = MaterialRaisedButtonData(
    color: materialTheme.primaryColor, textColor: Colors.white);

final cupertinoFilledButtonData = CupertinoFilledButtonData(
    minSize: 50,
    padding: EdgeInsets.only(left: 5, top: 0, right: 5, bottom: 0));

final materialBarData = MaterialAppBarData(brightness: Brightness.dark);

final cupertinoBarData = CupertinoNavigationBarData(
    backgroundColor: materialTheme.primaryColor, brightness: Brightness.dark);

A sample screen

simple.png

In this example I will show a very simple screen, just a top bar, a button, and a dialog that pops up when this button is pressed. We will be using PlatformScaffold, PlatformButton and PlatformAlertDialog. These are wrapper widgets: they end up rendering a material widget in a material context and a cupertino widget in a cupertino context. For instance, PlatformScaffold renders a Scaffold on Android and a CupertinoTabScaffold on iOS:

import 'package:flutter/material.dart';
import 'package:flutter_adapt_style_platform/styles.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';

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

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

class _SimpleScreenState extends State<SimpleScreen> {
  @override
  Widget build(BuildContext context) {
    return PlatformScaffold(
      appBar: PlatformAppBar(
        title: Text(
          'Sample Screen',
          style: TextStyle(color: Colors.white),
        ),
        material: (_, __) => materialBarData,
        cupertino: (_, __) => cupertinoBarData,
      ),
      body: Center(
        child: IntrinsicHeight(
          child: Column(
            children: [
              PlatformButton(
                child: Text('Open dialog'),
                onPressed: _showDialog,
              )
            ],
          ),
        ),
      ),
    );
  }

  _showDialog() {
    showPlatformDialog(
        context: context,
        builder: (BuildContext context) {
          return PlatformAlertDialog(
            content:
                Text('The style of this dialog is adapted to the platform.'),
            actions: [
              PlatformDialogAction(
                child: Text('OK'),
                onPressed: () => Navigator.pop(context),
              )
            ],
          );
        });
  }
}

Showing an overflow menu

overflow.png

One of the most common tasks is to create an overflow menu in the top bar: the '3 dots' icon in which we can find secondary contextual options. PopupMenuButton is a widget that is designed to such end but might not fit well in a cupertino context, so we are going to fix this with a CupertinoActionSheet:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adapt_style_platform/styles.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';

enum Overflow { OPTION_A, OPTION_B }

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

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

class _OverflowMenuScreenState extends State<OverflowMenuScreen> {
  @override
  Widget build(BuildContext context) {
    return PlatformScaffold(
      appBar: PlatformAppBar(
        title: Text(
          'Overflow menu',
          style: TextStyle(color: Colors.white),
        ),
        material: (_, __) => _getMaterialBar(),
        cupertino: (_, __) => _getCupertinoBar(),
      ),
      body: Center(
        child: Container(
          padding: EdgeInsets.all(36.0),
          child: Text(
            'Click on the overflow button in the top bar, '
            'its style will adapt to the platform.',
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }

  MaterialAppBarData _getMaterialBar() {
    return MaterialAppBarData(actions: [
      PopupMenuButton<Overflow>(
        itemBuilder: (BuildContext context) {
          return {Overflow.OPTION_A, Overflow.OPTION_B}.map((Overflow option) {
            var text = '';
            switch (option) {
              case Overflow.OPTION_A:
                text = 'Option A';
                break;
              case Overflow.OPTION_B:
                text = 'Option B';
                break;
            }
            return PopupMenuItem<Overflow>(value: option, child: Text(text));
          }).toList();
        },
        onSelected: (Overflow option) {
          // Perform action.
        },
      )
    ], brightness: materialBarData.brightness);
  }

  CupertinoNavigationBarData _getCupertinoBar() {
    return CupertinoNavigationBarData(
        trailing: CupertinoButton(
          padding: EdgeInsets.zero,
          child: Icon(CupertinoIcons.ellipsis, color: Colors.white),
          onPressed: () => showCupertinoModalPopup(
              context: context,
              builder: (BuildContext context) => CupertinoActionSheet(
                    actions: [
                      CupertinoActionSheetAction(
                        child: Text('Option A'),
                        onPressed: () {
                          // Perform action.
                          Navigator.pop(context);
                        },
                      ),
                      CupertinoActionSheetAction(
                        child: Text('Option B'),
                        onPressed: () {
                          // Perform action.
                          Navigator.pop(context);
                        },
                      )
                    ],
                  )),
        ),
        backgroundColor: cupertinoBarData.backgroundColor,
        brightness: cupertinoBarData.brightness);
  }
}

Implementing tabs

tabs.png

PlatformTabScaffold is a wrapper widget that allows us to create tabs, but I found that sometimes is better to create each layout separately:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adapt_style_platform/styles.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';

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

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

class _TabsScreenState extends State<TabsScreen> {
  int _index = 0;

  final _items = [
    BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Tab A'),
    BottomNavigationBarItem(
        icon: Icon(Icons.account_circle_rounded), label: 'Tab B'),
  ];

  @override
  Widget build(BuildContext context) {
    if (isMaterial(context)) {
      return _getMaterialWidget();
    } else {
      return _getCupertinoWidget(context);
    }
  }

  Widget _getMaterialWidget() {
    return Scaffold(
      appBar: AppBar(
        title: Text('Tabs Screen'),
        brightness: materialBarData.brightness,
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: _items,
        currentIndex: _index,
        onTap: (int index) {
          setState(() {
            _index = index;
          });
        },
      ),
      body: _getCurrentTab(_index),
    );
  }

  Widget _getCupertinoWidget(BuildContext mainContext) {
    return CupertinoTabScaffold(
      tabBar: CupertinoTabBar(
        onTap: (int index) {
          setState(() {
            _index = index;
          });
        },
        items: _items,
      ),
      tabBuilder: (BuildContext context, int index) {
        return CupertinoTabView(
          builder: (BuildContext context) => CupertinoPageScaffold(
            navigationBar: CupertinoNavigationBar(
              backgroundColor: cupertinoBarData.backgroundColor,
              brightness: cupertinoBarData.brightness,
              leading: CupertinoNavigationBarBackButton(
                onPressed: () => Navigator.of(mainContext).pop(),
              ),
              middle: Text(
                'Tabs Screen',
                style: TextStyle(color: Colors.white),
              ),
            ),
            child: _getCurrentTab(index),
          ),
        );
      },
    );
  }

  Widget _getCurrentTab(int index) {
    return Center(
      child: Text('Tab ${index == 0 ? 'A' : 'B'}'),
    );
  }
}

Applying extra style

selector.png

Now we are going to create a screen with buttons to travel to the previous screens. I will show how to add a little style to the buttons. Note that for navigation we have the convenience method platformPageRoute() included in flutter_platform_widgets. We are going also to add a button to swap the platform style in runtime:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adapt_style_platform/overflow_menu_screen.dart';
import 'package:flutter_adapt_style_platform/simple_screen.dart';
import 'package:flutter_adapt_style_platform/styles.dart';
import 'package:flutter_adapt_style_platform/tabs_screen.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';

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

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

class _SelectorScreenState extends State<SelectorScreen> {
  @override
  Widget build(BuildContext context) {
    return PlatformScaffold(
      appBar: PlatformAppBar(
        title: Text(
          'Selector Screen',
          style: TextStyle(color: Colors.white),
        ),
        material: (_, __) => materialBarData,
        cupertino: (_, __) => cupertinoBarData,
      ),
      body: Center(
        child: IntrinsicHeight(
          child: Column(
            children: [
              PlatformButton(
                material: (_, __) => materialRaisedButtonData,
                cupertinoFilled: (_, __) => cupertinoFilledButtonData,
                child: IntrinsicWidth(
                  child: Row(
                    children: [
                      PlatformWidgetBuilder(
                        material: (_, child, __) => Icon(Icons.swap_horiz),
                        cupertino: (_, child, __) =>
                            Icon(CupertinoIcons.arrow_swap),
                      ),
                      Padding(
                          padding: EdgeInsets.only(left: 10.0),
                          child: Text('Swap style'))
                    ],
                  ),
                ),
                onPressed: () {
                  if (isMaterial(context)) {
                    PlatformProvider.of(context)?.changeToCupertinoPlatform();
                  } else {
                    PlatformProvider.of(context)?.changeToMaterialPlatform();
                  }
                },
              ),
              Padding(
                padding: EdgeInsets.only(top: 40.0),
                child: PlatformButton(
                  child: Text('Open simple screen'),
                  material: (_, __) => materialRaisedButtonData,
                  cupertinoFilled: (_, __) => cupertinoFilledButtonData,
                  onPressed: () => _goToScreen(SimpleScreen()),
                ),
              ),
              Padding(
                padding: EdgeInsets.only(top: 20.0),
                child: PlatformButton(
                  child: Text('Open overflow menu sample'),
                  material: (_, __) => materialRaisedButtonData,
                  cupertinoFilled: (_, __) => cupertinoFilledButtonData,
                  onPressed: () => _goToScreen(OverflowMenuScreen()),
                ),
              ),
              Padding(
                padding: EdgeInsets.only(top: 20.0),
                child: PlatformButton(
                  child: Text('Open tabs sample'),
                  material: (_, __) => materialRaisedButtonData,
                  cupertinoFilled: (_, __) => cupertinoFilledButtonData,
                  onPressed: () => _goToScreen(TabsScreen()),
                ),
              )
            ],
          ),
        ),
      ),
    );
  }

  _goToScreen(Widget screen) {
    Navigator.push(
        context,
        platformPageRoute(
            context: context, builder: (BuildContext context) => screen));
  }
}

As you can see it's possible to achieve highly personalized results playing with both cupertino and material widgets, it will definitively boost the UI/UX of your Flutter app.

You can find and launch yourself the samples of this article here.

Did you find this article valuable?

Support David Serrano by becoming a sponsor. Any amount is appreciated!