Tech Blog

BottomSheet でポート内機体選択 UI を作る — Flutter の DraggableScrollableSheet と FutureBuilder の組み合わせ

Flutter Dart BottomSheet UI/UX Riverpod

この記事でわかること

  • ポートタップ時の子画面に「機体一覧」を出すための Flutter 実装パターン
  • DraggableScrollableSheetshowModalBottomSheet と組み合わせる時の initialChildSize の決め方
  • FutureBuilder で API 経由のリストを「待ち / エラー / 空 / 結果」の4状態でハンドリングする最小実装
  • ホーム画面と destination 画面で同じ見た目を共通化するための widget 抽出

対象読者

  • Flutter で地図アプリの POI タップ後の子画面を実装する人
  • BottomSheet を「ちょっとしたモーダル」から「業務的な選択UI」に育てたい人
  • Riverpod を使いつつ、ローカルな非同期取得は FutureBuilder で素朴に書きたい人

動作環境

項目バージョン
Flutter3.x(Material 3)
flutter_map7.x
flutter_riverpod2.x
dio5.x

シリーズ記事(開発進行中) — この記事は 街中の電動キックボードシェアリングを自作して理解する — 設計・実装・運用の記録シリーズ の一部です。プロジェクトは現在も継続中で、新しい記事や設計判断の追記が随時行われます。プロジェクト全体の動機・採用技術・画面IDなどの用語表・他記事への索引はリンク先にまとめています。

はじめに

電動キックボードシェアリングの S02 ホーム画面で、ポートピンをタップすると元はこんなシートが出ていました。

  • ポート名
  • 「今すぐ利用できるスクーターがあります」or「ありません」の一文
  • 「利用可能 N台」「返却枠 M台分」の2枚カード

これだと利用可能台数が分かるだけで、どの機体を借りるか の選択ができません。「ピンタップ → 機体一覧 → 機体タップで遷移」までを子画面で完結させる必要がありました。

改修後の S02 ポートシート — 集計値カード + 機体一覧


なぜ DraggableScrollableSheet

選択肢は3つ:

  1. showDialog のフルスクリーンダイアログ
  2. 新しい画面に push で遷移
  3. showModalBottomSheet + DraggableScrollableSheet

(1) は地図の文脈を見失う。「今選んだポート周辺」の感覚が消える。

(2) は遷移コストが大きい。戻る操作が増える。

(3) は 地図の上に重ねたまま、機体リストをスクロールで操作できる。これが UX 的に一番自然でした。

DraggableScrollableSheet を選んだ理由は、シートを 上にドラッグして拡大 できること。機体が多いポートでも、ユーザーがシートを引き上げて全体を見られる。


showModalBottomSheet の呼び出し

void _showPortSheet(PortSummary port) {
  final api = DiscoveryApi(ref.read(apiClientProvider));
  showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true, // ← これを true にしないと半画面で止まる
    builder: (sheetContext) {
      return _PortDetailSheet(
        port: port,
        api: api,
        onScooterSelected: (scooter) {
          Navigator.of(sheetContext).pop();
          final scooterId = Uri.encodeComponent(scooter.scooterId);
          context.push('/destination?scooterId=$scooterId');
        },
      );
    },
  );
}

注意:

  • isScrollControlled: true を忘れると DraggableScrollableSheet の高さ制御が効かない
  • sheetContext と外側の context を区別する。Navigator.of(sheetContext).pop() でシートを閉じ、context.push(...) で遷移する

DraggableScrollableSheet の initialChildSize

class _PortDetailSheet extends StatefulWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return DraggableScrollableSheet(
      expand: false,
      initialChildSize: 0.55,
      minChildSize: 0.3,
      maxChildSize: 0.9,
      builder: (context, scrollController) {
        return SingleChildScrollView(
          controller: scrollController,
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(port.name, style: Theme.of(context).textTheme.titleLarge),
                // 集計値カード + 機体リスト + フッター
              ],
            ),
          ),
        );
      },
    );
  }
}

initialChildSize: 0.55 は「画面の 55% を初期高さとする」設定。下記の理由でこの値に落ち着きました:

  • 0.4 だと機体リスト1〜2件しか見えず、スクロールが必須になる
  • 0.7 だと地図が小さくなりすぎて、「今ここのポート」の感覚が薄れる
  • 0.55 は 集計値カード + 機体3件くらいまで見えて、地図も半分残る バランス

minChildSize: 0.3 でユーザーが下にドラッグして縮小しても情報は最低限残し、maxChildSize: 0.9 で上に引き上げると地図はほぼ隠れて機体リスト全体が見える。

destination 画面(返却ポート選択)でも同じ initialChildSize: 0.55 で揃え、活性・非活性の両ケースを同サイズで出しています。

destination の「ここで返却」活性

destination の「ここで返却」非活性(満杯ポート)

非活性の判定は port.availableEmptySlots == 0ElevatedButton.onPressed: null。「乗れない / 返せない」状態がすべて 「タップ可否」で表現される 一貫性が、画面横断のルールとして効きます。


FutureBuilder で機体一覧を取得

機体一覧は API 呼び出しが必要で、待ち / エラー / 空 / 結果の4状態を扱います。

class _PortDetailSheetState extends State<_PortDetailSheet> {
  late Future<List<ScooterSummary>> _scootersFuture;

  @override
  void initState() {
    super.initState();
    _scootersFuture = widget.api.getScootersInPort(widget.port.portId);
  }

  void _retry() {
    setState(() {
      _scootersFuture = widget.api.getScootersInPort(widget.port.portId);
    });
  }

  // build 内の FutureBuilder 部分
  FutureBuilder<List<ScooterSummary>>(
    future: _scootersFuture,
    builder: (context, snapshot) {
      if (snapshot.connectionState == ConnectionState.waiting) {
        return const Padding(
          padding: EdgeInsets.symmetric(vertical: 16),
          child: Center(child: CircularProgressIndicator()),
        );
      }
      if (snapshot.hasError) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('機体情報を取得できませんでした。',
                style: TextStyle(color: Colors.red.shade700)),
            const SizedBox(height: 8),
            OutlinedButton(onPressed: _retry, child: const Text('再試行')),
          ],
        );
      }
      final scooters = snapshot.data ?? const <ScooterSummary>[];
      if (scooters.isEmpty) {
        return const Text('このポートに登録された機体はありません。');
      }
      return Column(
        children: scooters.map((s) => _ScooterListTile(
          scooter: s,
          onTap: s.isAvailable ? () => widget.onScooterSelected(s) : null,
        )).toList(),
      );
    },
  )

ポイント:

  • _scootersFutureinitState で1回だけ作る。build の中で widget.api.getScootersInPort(...) を直接呼ぶと、setState のたびに再フェッチされてしまう
  • 「エラー時の再試行」はシンプルに setState_scootersFuture を作り直す
  • 空配列の表示は別途分岐。snapshot.data ?? const <ScooterSummary>[] で null 安全に

Riverpod の FutureProvider を使う選択肢もありますが、「このシートが開いている間だけ生きる Future」なら、ローカルな FutureBuilder の方が状態管理がシンプルでした。


機体タイル(AVAILABLE のみタップ可能)

class _ScooterListTile extends StatelessWidget {
  const _ScooterListTile({required this.scooter, required this.onTap});

  final ScooterSummary scooter;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final disabled = onTap == null;
    final statusColor = switch (scooter.status.toUpperCase()) {
      'AVAILABLE' => Colors.green.shade700,
      'IN_USE' => Colors.orange.shade700,
      'MAINTENANCE' => Colors.grey.shade600,
      _ => Colors.grey.shade600,
    };
    return Card(
      margin: const EdgeInsets.symmetric(vertical: 4),
      child: ListTile(
        onTap: onTap,
        leading: Icon(Icons.electric_scooter,
            color: disabled ? Colors.grey : statusColor),
        title: Text(scooter.scooterId,
            style: TextStyle(color: disabled ? Colors.grey : null)),
        subtitle: Row(/* バッテリー残量 + 状態バッジ */),
        trailing: disabled ? null : const Icon(Icons.chevron_right),
      ),
    );
  }
}

onTapnull にすれば ListTile 全体が非活性になる。これは Flutter の Material の標準挙動で、わざわざ disabled プロパティを実装する必要がない。

switch 式(Dart 3)で状態 → 色のマッピングを宣言的に書けるのも気持ちいい。


共通 widget の抽出

ホーム画面と destination 画面の両方で同じ「利用可能 / 返却枠」カードを出したくなりました。最初は各画面に _PortStatCard を private で持っていたのを、共通の widgets/port_stat_card.dart に抽出。

class PortStatCard extends StatelessWidget {
  const PortStatCard({
    super.key,
    required this.label,
    required this.value,
    required this.accent,
  });

  final String label;
  final String value;
  final Color accent;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: accent.withValues(alpha: 0.08),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
          const SizedBox(height: 4),
          Text(value, style: Theme.of(context).textTheme.titleMedium),
        ],
      ),
    );
  }
}

accent 色だけ受け取って、背景・テキスト色をそこから派生させる。Colors.green.shade700 を渡せば利用可能カード、Colors.indigo.shade700 を渡せば返却枠カード、と1つの widget で両方の見た目をカバーできました。


落とし穴:replace_all リファクタリングの罠

_PortStatCardPortStatCard に書き換えるとき、IDE の「全置換」を使ったら クラス定義名 (class _PortStatCard) は置換されず、コンストラクタ呼び出しだけが置換 されてしまい、Dart 文法エラーになりました。

class _PortStatCard extends StatelessWidget {  // ← クラス名は _ 付き
  const PortStatCard({                          // ← コンストラクタは _ 無し
    // ...
  });
}

これは「_PortStatCard( 文字列」だけが置換対象になったから(クラス定義の class _PortStatCard には ( が続かない)。

解決は、共通 widget を新ファイルで作って、旧クラスを丸ごと削除すること。replace_all を信用しすぎないこと。


学び

  • 地図上の POI タップ後の子画面は showModalBottomSheet + DraggableScrollableSheet が一番自然
  • initialChildSize: 0.55集計値 + リスト数件 + 地図半分が残る 経験則のバランス
  • ローカルな非同期取得は FutureBuilder で十分。Riverpod の FutureProvider まで持ち出さなくていい
  • 共通カードは accent だけ受け取って色を派生させる設計にすると、画面横断で使い回せる

シンプルな子画面ひとつでも、地図 UX の流れを切らずに業務操作を完結させる工夫の余地は多い、と感じた実装でした。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。