この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
こんにちは、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を実装します。
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が表示されました。
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が表示されました。
注意事項
Cupertino Widgetsは技術的にはAndroidアプリで実行できますが、ライブラリに含まれるフォント等のライセンスの都合上、iOSを搭載したデバイスでのみ利用が可能です。
まとめ
Cupertino WidgetsとMaterial Components Widgetsでは見た目や操作性の違いはありますが、実装方法はどちらも同じようなものでした。
iOSアプリのみを開発するのであれば、Cupertino Widgetsを採用するのもありだと思います。AndroidアプリやWebアプリなどクロスプラットフォーム開発が前提であれば、基本的にはMaterial Components Widgetsを使うことになります。もしくは、通知やダイアログなど一部だけOS別にWidgetを切り替えても良いかもしれません。