BottomSheet でポート内機体選択 UI を作る — Flutter の DraggableScrollableSheet と FutureBuilder の組み合わせ
この記事でわかること
- ポートタップ時の子画面に「機体一覧」を出すための Flutter 実装パターン
DraggableScrollableSheetをshowModalBottomSheetと組み合わせる時のinitialChildSizeの決め方FutureBuilderで API 経由のリストを「待ち / エラー / 空 / 結果」の4状態でハンドリングする最小実装- ホーム画面と destination 画面で同じ見た目を共通化するための widget 抽出
対象読者
- Flutter で地図アプリの POI タップ後の子画面を実装する人
- BottomSheet を「ちょっとしたモーダル」から「業務的な選択UI」に育てたい人
- Riverpod を使いつつ、ローカルな非同期取得は
FutureBuilderで素朴に書きたい人
動作環境
| 項目 | バージョン |
|---|---|
| Flutter | 3.x(Material 3) |
| flutter_map | 7.x |
| flutter_riverpod | 2.x |
| dio | 5.x |
シリーズ記事(開発進行中) — この記事は 街中の電動キックボードシェアリングを自作して理解する — 設計・実装・運用の記録シリーズ の一部です。プロジェクトは現在も継続中で、新しい記事や設計判断の追記が随時行われます。プロジェクト全体の動機・採用技術・画面IDなどの用語表・他記事への索引はリンク先にまとめています。
はじめに
電動キックボードシェアリングの S02 ホーム画面で、ポートピンをタップすると元はこんなシートが出ていました。
- ポート名
- 「今すぐ利用できるスクーターがあります」or「ありません」の一文
- 「利用可能 N台」「返却枠 M台分」の2枚カード
これだと利用可能台数が分かるだけで、どの機体を借りるか の選択ができません。「ピンタップ → 機体一覧 → 機体タップで遷移」までを子画面で完結させる必要がありました。

なぜ DraggableScrollableSheet か
選択肢は3つ:
showDialogのフルスクリーンダイアログ- 新しい画面に
pushで遷移 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 で揃え、活性・非活性の両ケースを同サイズで出しています。


非活性の判定は port.availableEmptySlots == 0 で ElevatedButton.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(),
);
},
)
ポイント:
_scootersFutureをinitStateで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),
),
);
}
}
onTap を null にすれば 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 リファクタリングの罠
_PortStatCard を PortStatCard に書き換えるとき、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 の流れを切らずに業務操作を完結させる工夫の余地は多い、と感じた実装でした。