[Flutter] CupertinoでiOSスタイルのUIを実装してみた

2022.05.26

こんにちは、CX事業本部IoT事業部の高橋雄大です。

Flutterを使用したiOSアプリの開発で、iOSスタイルのデザインを採用する案件がありましたので、Cupertino Widgetsを利用してiOS風のUIを実装してみたいと思います。比較のためにMaterial Component WidgetsでもUIを実装します。

本記事のゴール

Cupertino WidgetsでiOS風のUIを実装します。

Flutter Cupertino Widgets

環境情報

項目 内容
OS macOS Monterey 12.3.1 (21E258)
Visual Studio Code 1.67.2
Xcode 13.3 (13E113)
Flutter 3.0.1
Dart 2.17.1

Cupertino WidgetsでUIを実装

Cupertino WidgetsでUIを実装します。

lib/main.dart

import 'package:flutter/cupertino.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const CupertinoApp(
      title: 'Flutter Demo',
      home: RootPage(),
    );
  }
}

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

  @override
  State<RootPage> createState() => _RootPage();
}

class _RootPage extends State<RootPage> with WidgetsBindingObserver {
  final _controller = CupertinoTabController();

  static const List<Widget> _screens = <Widget>[
    HomePage(),
    Center(child: Text('FavoritePage')),
    Center(child: Text('NotificationPage')),
    Center(child: Text('AccountPage')),
  ];

  @override
  Widget build(BuildContext context) {
    return CupertinoTabScaffold(
      tabBar: CupertinoTabBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.house_fill),
            label: 'ホーム',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.heart_fill),
            label: 'お気に入り',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.bell_fill),
            label: 'お知らせ',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.person_fill),
            label: 'アカウント',
          ),
        ],
      ),
      tabBuilder: (BuildContext context, int index) {
        return CupertinoTabView(
          builder: (BuildContext context) {
            return _screens[index];
          },
        );
      },
      controller: _controller,
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: const CupertinoNavigationBar(
        middle: Text('Cupertino Widgets'),
      ),
      child: Container(
        padding: const EdgeInsets.only(
          left: 30,
          top: 120,
          right: 30,
        ),
        child: const Center(
          child: WidgetList(),
        ),
      ),
    );
  }
}

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

  @override
  State<WidgetList> createState() => _WidgetListState();
}

class _WidgetListState extends State<WidgetList> {
  static const List<String> _fruitNames = <String>[
    'りんご',
    'バナナ',
    'オレンジ',
    'パイナップル',
    'いちご',
  ];

  void _showActionSheet(BuildContext context) {
    showCupertinoModalPopup<void>(
      context: context,
      builder: (BuildContext context) => CupertinoActionSheet(
        title: const Text('タイトル'),
        message: const Text('メッセージ'),
        actions: <CupertinoActionSheetAction>[
          CupertinoActionSheetAction(
            isDefaultAction: true,
            onPressed: () {
              Navigator.pop(context);
            },
            child: const Text('デフォルトアクション'),
          ),
          CupertinoActionSheetAction(
            onPressed: () {
              Navigator.pop(context);
            },
            child: const Text('アクション'),
          ),
          CupertinoActionSheetAction(
            isDestructiveAction: true,
            onPressed: () {
              Navigator.pop(context);
            },
            child: const Text('破壊的アクション'),
          )
        ],
      ),
    );
  }

  void _showAlertDialog(BuildContext context) {
    showCupertinoModalPopup<void>(
      context: context,
      builder: (BuildContext context) => CupertinoAlertDialog(
        title: const Text('アラート'),
        content: const Text('通知内容'),
        actions: <CupertinoDialogAction>[
          CupertinoDialogAction(
            isDefaultAction: true,
            onPressed: () {
              Navigator.pop(context);
            },
            child: const Text('キャンセル'),
          ),
          CupertinoDialogAction(
            isDestructiveAction: true,
            onPressed: () {
              Navigator.pop(context);
            },
            child: const Text('OK'),
          )
        ],
      ),
    );
  }

  void _showPickerDialog(BuildContext context) {
    showCupertinoModalPopup<void>(
      context: context,
      builder: (BuildContext context) => Container(
        height: 216,
        padding: const EdgeInsets.only(top: 6.0),
        margin: EdgeInsets.only(
          bottom: MediaQuery.of(context).viewInsets.bottom,
        ),
        color: CupertinoColors.systemBackground.resolveFrom(context),
        child: SafeArea(
          top: false,
          child: CupertinoPicker(
            magnification: 1.22,
            squeeze: 1.2,
            useMagnifier: true,
            itemExtent: 32.0,
            onSelectedItemChanged: (_) {},
            children: List<Widget>.generate(_fruitNames.length, (int index) {
              return Center(
                child: Text(
                  _fruitNames[index],
                ),
              );
            }),
          ),
        ),
      ),
    );
  }

  bool _switch = true;
  double _slider = 20;

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      CupertinoSlidingSegmentedControl(
        thumbColor: CupertinoColors.activeBlue,
        onValueChanged: (_) {},
        children: const {
          0: Padding(
            padding: EdgeInsets.symmetric(horizontal: 30),
            child: Text('タブA'),
          ),
          1: Padding(
            padding: EdgeInsets.symmetric(horizontal: 30),
            child: Text('タブB'),
          ),
          2: Padding(
            padding: EdgeInsets.symmetric(horizontal: 30),
            child: Text('タブC'),
          ),
        },
      ),
      const SizedBox(height: 30),
      SizedBox(
        width: double.infinity,
        child: CupertinoButton(
          color: CupertinoColors.systemBlue,
          onPressed: () => _showActionSheet(context),
          child: const Text('アクションシート'),
        ),
      ),
      const SizedBox(height: 20),
      SizedBox(
        width: double.infinity,
        child: CupertinoButton(
          color: CupertinoColors.systemRed,
          onPressed: () => _showAlertDialog(context),
          child: const Text('アラートダイアログ'),
        ),
      ),
      const SizedBox(height: 20),
      SizedBox(
        width: double.infinity,
        child: CupertinoButton(
          color: CupertinoColors.systemGreen,
          onPressed: () => _showPickerDialog(context),
          child: const Text('ピッカーダイアログ'),
        ),
      ),
      const SizedBox(height: 30),
      CupertinoTextField(
        controller: TextEditingController(text: 'テキストフィールド'),
        padding: const EdgeInsets.all(14),
      ),
      const SizedBox(height: 20),
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: <Widget>[
          Row(
            children: const <Widget>[
              Padding(
                padding: EdgeInsets.only(left: 2, right: 40),
                child: Icon(CupertinoIcons.alarm,
                    color: CupertinoColors.systemGrey),
              ),
              Text('スイッチ'),
            ],
          ),
          CupertinoSwitch(
            value: _switch,
            onChanged: (bool? value) => setState(() => _switch = value!),
          ),
        ],
      ),
      const SizedBox(height: 16),
      SizedBox(
        width: double.infinity,
        child: CupertinoSlider(
          key: const Key('slider'),
          value: _slider,
          divisions: 5,
          max: 100,
          onChanged: (double value) => setState(() => _slider = value),
        ),
      ),
    ]);
  }
}

iOSアプリで多く見られるUIが表示されました。

Flutter Cupertino Widgets

Material Components WidgetsでUIを実装

Material Components WidgetsでUIを実装します。

lib/main.dart

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Demo',
      home: RootPage(),
    );
  }
}

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

  @override
  State<RootPage> createState() => _RootPageState();
}

class _RootPageState extends State<RootPage> {
  static const List<Widget> _screens = <Widget>[
    HomePage(),
    Center(child: Text('FavoritePage')),
    Center(child: Text('NotificationPage')),
    Center(child: Text('AccountPage')),
  ];

  int _selectedIndex = 0;

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _screens[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'ホーム',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.favorite),
            label: 'お気に入り',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.notifications),
            label: 'お知らせ',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            label: 'アカウント',
          ),
        ],
        type: BottomNavigationBarType.fixed,
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      initialIndex: 0,
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Material Components Widgets'),
          bottom: const TabBar(
            tabs: <Widget>[
              Tab(text: 'タブA'),
              Tab(text: 'タブB'),
              Tab(text: 'タブC'),
            ],
          ),
        ),
        body: TabBarView(
          children: <Widget>[
            Container(
              padding: const EdgeInsets.only(
                left: 30,
                top: 30,
                right: 30,
              ),
              child: const Center(
                child: WidgetList(),
              ),
            ),
            const Center(
              child: Text('タブB'),
            ),
            const Center(
              child: Text('タブC'),
            ),
          ],
        ),
      ),
    );
  }
}

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

  @override
  State<WidgetList> createState() => _WidgetListState();
}

class _WidgetListState extends State<WidgetList> {
  void _showAlertDialog(BuildContext context) {
    showDialog<void>(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return AlertDialog(
          title: const Text('タイトル'),
          content: SingleChildScrollView(
            child: ListBody(
              children: const <Widget>[
                Text('通知内容1'),
                Text('通知内容2'),
              ],
            ),
          ),
          actions: <Widget>[
            TextButton(
              child: const Text('OK'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );
  }

  void _showSnackbar(BuildContext context) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: const Text('スナックバー'),
        action: SnackBarAction(
          label: 'アクション',
          onPressed: () {},
        ),
      ),
    );
  }

  bool _checkbox = true;
  bool _switch = true;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        ElevatedButton(
          style: ElevatedButton.styleFrom(
            primary: Colors.blue,
            minimumSize: const Size.fromHeight(50),
          ),
          onPressed: () => _showAlertDialog(context),
          child: const Text('アラートダイアログ'),
        ),
        const SizedBox(height: 20),
        ElevatedButton(
          style: ElevatedButton.styleFrom(
            primary: Colors.red,
            minimumSize: const Size.fromHeight(50),
          ),
          onPressed: () => _showSnackbar(context),
          child: const Text('スナックバー'),
        ),
        const SizedBox(height: 20),
        const TextField(
          decoration: InputDecoration(
            border: OutlineInputBorder(),
            labelText: 'テキストフィールド',
          ),
        ),
        const SizedBox(height: 20),
        DropdownButton(
          onChanged: (_) {},
          value: 'ドロップダウン',
          items: <String>[
            'ドロップダウン',
            'ドロップダウン2',
            'ドロップダウン3',
            'ドロップダウン4',
          ].map<DropdownMenuItem<String>>((String value) {
            return DropdownMenuItem<String>(
              value: value,
              child: Text(value),
            );
          }).toList(),
          isExpanded: true,
        ),
        const SizedBox(height: 10),
        CheckboxListTile(
          title: const Text('チェックボックス'),
          value: _checkbox,
          onChanged: (bool? value) => setState(() => _checkbox = value!),
          secondary: const Icon(Icons.access_time_sharp),
        ),
        SwitchListTile(
          title: const Text('スイッチ'),
          value: _switch,
          onChanged: (bool? value) => setState(() => _switch = value!),
          secondary: const Icon(Icons.lightbulb_outline),
        ),
        const SizedBox(height: 10),
        DataTable(
          columns: const <DataColumn>[
            DataColumn(
              label: Text('名前'),
            ),
            DataColumn(
              label: Text('年齢'),
            ),
            DataColumn(
              label: Text('役職'),
            ),
          ],
          rows: const <DataRow>[
            DataRow(
              cells: <DataCell>[
                DataCell(Text('田中')),
                DataCell(Text('26')),
                DataCell(Text('システムエンジニア')),
              ],
            ),
            DataRow(
              cells: <DataCell>[
                DataCell(Text('佐藤')),
                DataCell(Text('37')),
                DataCell(Text('マネージャー')),
              ],
            ),
          ],
        ),
      ],
    );
  }
}

Material Designの特徴である立体的なUIが表示されました。

Flutter Material Components Widgets

注意事項

Cupertino Widgetsは技術的にはAndroidアプリで実行できますが、ライブラリに含まれるフォント等のライセンスの都合上、iOSを搭載したデバイスでのみ利用が可能です。

まとめ

Cupertino WidgetsとMaterial Components Widgetsでは見た目や操作性の違いはありますが、実装方法はどちらも同じようなものでした。

iOSアプリのみを開発するのであれば、Cupertino Widgetsを採用するのもありだと思います。AndroidアプリやWebアプリなどクロスプラットフォーム開発が前提であれば、基本的にはMaterial Components Widgetsを使うことになります。もしくは、通知やダイアログなど一部だけOS別にWidgetを切り替えても良いかもしれません。

参考資料