Flutter: Material Designと共通コンポーネント

Material DesignはFlutterのネイティブ言語です — マスターすれば、アプリにプラットフォーム整合性のある「ネイティブ感」が生まれます。

📋 前提条件: 以下を先に完了している必要があります

1. このレッスンで学ぶこと


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ページの基盤フレームワークで、標準化されたページ構造を提供します。

100%
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がすぐに表示されます。両方を同時に使用できます。

📖 まとめ


📝 練習問題

  1. 基本(難易度 ⭐): 5つのメニューアイテム(ホーム/カート/注文/設定/About)を含むDrawerを持つScaffoldを作成してください。
  2. 中級(難易度 ⭐⭐): 価格SliderとカテゴリCheckboxを含むBottomSheetフィルタパネルを実装し、確認後にSnackbarでフィルタ結果を表示してください。
  3. チャレンジ(難易度 ⭐⭐⭐): 完全な商品詳細ページを実装してください:折りたたみ画像付きSliverAppBar + 商品情報 + サイズ選択BottomSheet + カート追加Snackbarフィードバック。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%