Flutter: Material Designと共通コンポーネント
Material DesignはFlutterのネイティブ言語です — マスターすれば、アプリにプラットフォーム整合性のある「ネイティブ感」が生まれます。
📋 前提条件: 以下を先に完了している必要があります
- レッスン5:StatefulWidgetとインタラクション
1. このレッスンで学ぶこと
- Scaffoldグローバルスキャフォールド:AppBar / BottomNavigationBar / FloatingActionButton / Drawer
- カードとリスト:Card、ListTile、Divider
- ダイアログ:AlertDialog、BottomSheet、Snackbar
- トグルコンポーネント:Switch、Checkbox、Radio、Slider
- ShopApp商品詳細ページ:SliverAppBar + BottomSheetサイズ選択
2. デザイン仕様からコードへのストーリー
(1) 悩み:UIコンポーネントのゼロからの構築は時間がかかる
BobはShopAppのUIデザイン仕様を受け取り、80%のコンポーネントが標準的なMaterial Designであることに気付きました:AppBar、Card、FAB、BottomSheet。これらをすべて手作りすると、推定3週間の開発が必要です。さらに、カスタムコンポーネントはシステムのネイティブスタイルと合わず、ユーザーから「ちゃんとしたアプリに見えない」と不満が出ました。
(2) Materialコンポーネントライブラリのソリューション
Flutterには完全なMaterial Designコンポーネントライブラリが含まれており — 標準コンポーネントを1行で使用でき、アニメーション、アクセシビリティ、テーマ適応が組み込まれています。
DART
import 'package:flutter/material.dart';
// 5 lines = full Material page
Scaffold(
appBar: AppBar(title: const Text('ShopApp')),
body: const ProductList(),
floatingActionButton: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)),
bottomNavigationBar: BottomNavigationBar(items: [...]),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(3) 結果:開発速度3倍向上
Materialコンポーネントを使用後、詳細ページの開発は3日から1日に短縮され、ライト/ダークテーマとアクセシビリティモードの自動適応も実現しました。
3. Scaffold
ScaffoldはMaterial Designページの基盤フレームワークで、標準化されたページ構造を提供します。
graph TD
S[Scaffold] --> A[AppBar]
S --> B[Body]
S --> F[FAB]
S --> BN[BottomNavigationBar]
S --> D[Drawer]
B --> C[Card]
B --> LT[ListTile]
B --> GV[GridView]
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(1) Scaffoldの構成要素
| プロパティ | 型 | 説明 |
|---|---|---|
appBar |
PreferredSizeWidget | 上部ナビゲーションバー |
body |
Widget | ページ本文 |
floatingActionButton |
Widget | 右下のフローティングボタン |
bottomNavigationBar |
Widget | 下部ナビゲーションバー |
drawer |
Widget | 左側ドロワーメニュー |
bottomSheet |
Widget | 下部ポップアップパネル |
snackBar |
SnackBar | 下部通知バー |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: ShopAppホームページのScaffold
DART
import 'package:flutter/material.dart';
Scaffold(
appBar: AppBar(
title: const Text('ShopApp'),
actions: [
IconButton(icon: const Icon(Icons.search), onPressed: () {}),
IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () {}),
],
),
body: const ProductGrid(),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {},
icon: const Icon(Icons.flash_on),
label: const Text('Flash Sale'),
),
bottomNavigationBar: NavigationBar(
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.category), label: 'Categories'),
NavigationDestination(icon: Icon(Icons.favorite), label: 'Wishlist'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
drawer: Drawer(
child: ListView(
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blue),
child: Text('ShopApp', style: TextStyle(color: Colors.white, fontSize: 24)),
),
ListTile(leading: const Icon(Icons.home), title: const Text('Home'), onTap: () {}),
ListTile(leading: const Icon(Icons.settings), title: const Text('Settings'), onTap: () {}),
],
),
),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
4. CardとListコンポーネント
(1) CardとListTile
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 商品カード(Card + ListTile)
DART
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String imageUrl;
final String name;
final double price;
final String category;
const Product({required this.imageUrl, required this.name, required this.price, required this.category});
}
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(product.imageUrl, width: 56, height: 56, fit: BoxFit.cover),
),
title: Text(product.name, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('$${product.price.toStringAsFixed(2)} - ${product.category}'),
trailing: IconButton(
icon: const Icon(Icons.add_shopping_cart),
onPressed: () => addToCart(product),
),
onTap: () => navigateToDetail(product),
),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 注文リスト(ListTile + Divider)
DART
import 'package:flutter/material.dart';
// Simplified class definition
class Order {
final int id;
final double total;
final String date;
final String status;
const Order({required this.id, required this.total, required this.date, required this.status});
}
ListView.separated(
itemCount: orders.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 16),
itemBuilder: (context, index) {
final order = orders[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: order.status == 'Shipped' ? Colors.green : Colors.orange,
child: Icon(order.status == 'Shipped' ? Icons.local_shipping : Icons.schedule,
color: Colors.white),
),
title: Text('Order #${order.id}'),
subtitle: Text('$${order.total.toStringAsFixed(2)} - ${order.date}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => navigateToOrderDetail(order),
);
},
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
5. ダイアログコンポーネント
(1) ダイアログの種類比較
| コンポーネント | 位置 | インタラクション | 用途 |
|---|---|---|---|
| AlertDialog | 中央ポップアップ | 確認/キャンセル | 重要な操作の確認(注文削除) |
| BottomSheet | 下からスライドアップ | カスタム内容 | サイズ選択、フィルタリング |
| Snackbar | 下部一時通知 | アクションを含めることが可能 | 軽量フィードバック(カートに追加) |
| Dialog | 中央ポップアップ | 完全カスタマイズ | カスタムポップアップ内容 |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 削除確認AlertDialog
DART
import 'package:flutter/material.dart';
// Simplified class definition
class CartItem {
final String name;
const CartItem({required this.name});
}
void showDeleteConfirmation(BuildContext context, CartItem item) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove Item'),
content: Text('Remove "${item.name}" from your cart?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
removeItem(item);
Navigator.pop(context);
},
child: const Text('Remove'),
),
],
),
);
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: サイズ選択BottomSheet
DART
import 'package:flutter/material.dart';
// Product class definition shown above
void showSizeSelector(BuildContext context, Product product) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.8,
expand: false,
builder: (context, scrollController) => Column(
children: [
// Handle bar
Center(child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 40, height: 4,
decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(2)),
)),
Padding(
padding: const EdgeInsets.all(16),
child: Text('Select Size', style: Theme.of(context).textTheme.titleLarge),
),
Expanded(
child: ListView(
controller: scrollController,
children: ['S', 'M', 'L', 'XL', 'XXL'].map((size) => ListTile(
title: Text('Size $size'),
trailing: size == 'M' ? const Icon(Icons.check, color: Colors.green) : null,
onTap: () => Navigator.pop(context, size),
)).toList(),
),
),
],
),
),
);
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: カートSnackbarフィードバック
DART
import 'package:flutter/material.dart';
// Product class definition shown above
void showCartSnackbar(BuildContext context, Product product) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${product.name} added to cart'),
duration: const Duration(seconds: 2),
action: SnackBarAction(
label: 'View Cart',
onPressed: () => navigateToCart(),
),
),
);
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. トグルコンポーネント
(1) トグルコンポーネント比較
| コンポーネント | インタラクション | データ型 | 用途 |
|---|---|---|---|
| Switch | スライド切り替え | bool |
オン/オフ設定(ダークモード) |
| Checkbox | クリックでチェック | bool |
複数選択の確認(規約同意) |
| Radio | クリックで単一選択 | T |
グループからの単一選択(支払い方法) |
| Slider | スライダーのドラッグ | double |
範囲選択(価格範囲) |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 支払い方法のラジオ選択
DART
import 'package:flutter/material.dart';
class PaymentSelector extends StatefulWidget {
const PaymentSelector({super.key});
@override
State<PaymentSelector> createState() => _PaymentSelectorState();
}
class _PaymentSelectorState extends State<PaymentSelector> {
String _payment = 'credit_card';
@override
Widget build(BuildContext context) {
return Column(
children: [
RadioListTile<String>(
title: const Text('Credit Card'),
subtitle: const Text('Visa **** 4242'),
secondary: const Icon(Icons.credit_card),
value: 'credit_card',
groupValue: _payment,
onChanged: (v) => setState(() => _payment = v!),
),
RadioListTile<String>(
title: const Text('PayPal'),
secondary: const Icon(Icons.account_balance_wallet),
value: 'paypal',
groupValue: _payment,
onChanged: (v) => setState(() => _payment = v!),
),
RadioListTile<String>(
title: const Text('Apple Pay'),
secondary: const Icon(Icons.phone_iphone),
value: 'apple_pay',
groupValue: _payment,
onChanged: (v) => setState(() => _payment = v!),
),
],
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 価格範囲Slider
DART
import 'package:flutter/material.dart';
RangeValues _priceRange = const RangeValues(0, 500);
RangeSlider(
values: _priceRange,
min: 0,
max: 2000,
divisions: 40,
labels: RangeLabels(
'$${_priceRange.start.toStringAsFixed(0)}',
'$${_priceRange.end.toStringAsFixed(0)}',
),
onChanged: (values) => setState(() => _priceRange = values),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
7. 完成例:ShopApp商品詳細ページ
DART
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String name;
final double price;
final String imageUrl;
final double? originalPrice;
final double rating;
final int reviewCount;
const Product({required this.name, required this.price, required this.imageUrl, this.originalPrice, this.rating = 0.0, this.reviewCount = 0});
}
class ProductDetailPage extends StatelessWidget {
final Product product;
const ProductDetailPage({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsible app bar with product image
SliverAppBar(
expandedHeight: 300,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(product.name, style: const TextStyle(fontSize: 16)),
background: Stack(
fit: StackFit.expand,
children: [
Image.network(product.imageUrl, fit: BoxFit.cover),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter,
colors: [Colors.black54, Colors.transparent]),
),
),
],
),
),
),
// Product info
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text('$${product.price.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)),
const SizedBox(width: 12),
if (product.originalPrice != null)
Text('$${product.originalPrice!.toStringAsFixed(2)}',
style: const TextStyle(decoration: TextDecoration.lineThrough, color: Colors.grey)),
]),
const SizedBox(height: 8),
Row(children: [
Icon(Icons.star, color: Colors.amber[700], size: 18),
Text(' ${product.rating} (${product.reviewCount} reviews)'),
]),
const SizedBox(height: 16),
const Text('Description', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('Premium quality product with fast shipping worldwide. '
'Free returns within 30 days. USD settlement supported.'),
],
),
),
),
],
),
// Bottom action bar
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
IconButton.outlined(onPressed: () {}, icon: const Icon(Icons.favorite_border)),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: () => _showSizeSelector(context),
icon: const Icon(Icons.shopping_cart),
label: const Text('Add to Cart'),
),
),
]),
),
),
);
}
void _showSizeSelector(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (ctx) => const SizeSelectorSheet(),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
❓ よくある質問
Q ScaffoldのbodyとbottomNavigationBarは一緒に使えますか?
A はい、これが標準的な使い方です。bodyがメインスペースを取り、bottomNavigationBarは下部に固定されます。
Q Snackbarが表示されない場合は?
A Scaffoldのコンテキストを使って
ScaffoldMessenger.of(context).showSnackBar()を呼び出す必要があります。BuilderやネストされたScaffoldを使っている場合、コンテキストが間違っている可能性があります。Q BottomSheetに角丸を設定するには?
A
shapeプロパティを使用してください:shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20)))。Q RadioのgroupValueとは何ですか?
A 現在選択されている値で、グループ内のすべてのRadioで共有されます。RadioのvalueがgroupValueと等しい場合、選択状態が表示されます。
Q CardとContainerの違いは?
A Cardにはデフォルトのelevation(影)と角丸があり、Material Design仕様に準拠しています。Containerはより柔軟ですが、デフォルトの装飾はありません。カード外観が必要な場合はCardを使用してください。
Q SliverAppBarのpinnedとfloatingの違いは?
A pinned=trueはスクロール中もAppBarを上部に固定します。floating=trueは下にスクロールするとAppBarがすぐに表示されます。両方を同時に使用できます。
📖 まとめ
- Scaffoldが標準的なMaterial Designページ構造を提供:AppBar + Body + FAB + BottomNav + Drawer
- Card + ListTileでリストアイテムを素早く構築、Dividerで区切る
- AlertDialogは確認アクションに、BottomSheetは複雑な選択に、Snackbarは軽量フィードバックに
- Switch/Checkbox/Radio/Sliderがすべてのトグルシナリオをカバー
- SliverAppBarが折りたたみ可能なヘッダーを作成し、CustomScrollViewと組み合わせて没入型の詳細ページを実現
📝 練習問題
- 基本(難易度 ⭐): 5つのメニューアイテム(ホーム/カート/注文/設定/About)を含むDrawerを持つScaffoldを作成してください。
- 中級(難易度 ⭐⭐): 価格SliderとカテゴリCheckboxを含むBottomSheetフィルタパネルを実装し、確認後にSnackbarでフィルタ結果を表示してください。
- チャレンジ(難易度 ⭐⭐⭐): 完全な商品詳細ページを実装してください:折りたたみ画像付きSliverAppBar + 商品情報 + サイズ選択BottomSheet + カート追加Snackbarフィードバック。