[Flutter] CupertinoでiOSスタイルのUIを実装してみた
こんにちは、CX事業本部IoT事業部の高橋雄大です。
Flutterを使用したiOSアプリの開発で、iOSスタイルのデザインを採用する案件がありましたので、Cupertino Widgetsを利用してiOS風のUIを実装してみたいと思います。比較のためにMaterial Component WidgetsでもUIを実装します。
本記事のゴール
Cupertino WidgetsでiOS風のUIを実装します。
環境情報
項目 | 内容 |
---|---|
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を実装します。
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が表示されました。
Material Components WidgetsでUIを実装
Material Components WidgetsでUIを実装します。
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が表示されました。
注意事項
Cupertino Widgetsは技術的にはAndroidアプリで実行できますが、ライブラリに含まれるフォント等のライセンスの都合上、iOSを搭載したデバイスでのみ利用が可能です。
まとめ
Cupertino WidgetsとMaterial Components Widgetsでは見た目や操作性の違いはありますが、実装方法はどちらも同じようなものでした。
iOSアプリのみを開発するのであれば、Cupertino Widgetsを採用するのもありだと思います。AndroidアプリやWebアプリなどクロスプラットフォーム開発が前提であれば、基本的にはMaterial Components Widgetsを使うことになります。もしくは、通知やダイアログなど一部だけOS別にWidgetを切り替えても良いかもしれません。