masalibの日記

システム開発、運用と猫の写真ブログです

自分のサイトにピン付きの地図を無料で載せたい:Googleマイマップと、Google以外の選択肢

自分のサイトにGoogleマップを載せて、行った場所やおすすめのお店にピンを立てたいと思いました。

ところが調べてみると、Googleマップでピンを立てて表示するとお金がかかるという話が出てきます。一方で、自分で作った地図を表示するだけならお金はかからないという話も見かけました。

どちらが本当なのか気になったので、整理してみました。ついでに、Google以外でピンを立てられるサービスも調べています。

この記事の前提

  • 料金や条件は、2026年10月5日時点の公式ページで確認した内容です。料金は変わることがあるので、使う前に一次情報(記事末尾の参考リンク)を確認してください。
  • 1つのレイヤーにピンをいくつまで立てられるかは、公式ヘルプで見つけられず、まだ確認できていません。

先に結論を書くと、自分で作った地図を埋め込むだけなら無料です。Googleなら「マイマップ」を使えば、APIキーも請求先の登録もいりません。お金がかかる可能性があるのは、プログラム(Maps JavaScript API)でピンを立てる方法だけでした。


1. Googleマップをサイトに載せる方法は4つある

ひとくちに「Googleマップを載せる」と言っても、やり方によって料金が違います。

方法 APIキー 料金 複数ピン
① マイマップ(Google My Maps)を作って埋め込む 不要 無料 ◎ 自由に立てられる
② Googleマップの「共有 → 地図を埋め込む」 不要 無料 × 1地点のみ
③ Maps Embed API 必要 無料・回数制限なし △ 1地点か経路
④ Maps JavaScript API 必要 月10,000回まで無料、超えると1,000回ごとに$7 ◎

「ピンを立てるとお金がかかる」と言われているのは④のことでした。

2. お金がかかるのは Maps JavaScript API

④は、JavaScriptで地図を表示して、プログラムからピンを立てる方法です。ピンの数やデザインを自由に決められる代わりに、地図を表示した回数に応じて料金がかかります。

以前は毎月$200分の無料クレジットがありました。しかし2025年3月1日に、このクレジットが廃止されました。代わりに、機能(SKU)ごとに無料で使える回数が決まる方式になっています。

  • 地図の表示(Dynamic Maps):月10,000回まで無料
  • 10,000回を超えると:1,000回ごとに$7

個人ブログで月10,000回を超えることは少ないかもしれません。ただ、APIキーの発行には請求先アカウント(クレジットカード)の登録が必要です。アクセスが急に増えたときに請求される可能性があるので、少し不安が残ります。

3. Maps Embed API は無料だけど、ピンは1つだけ

③のMaps Embed APIは、iframeで地図を埋め込む公式の方法です。公式ページにも「Maps Embed usage is available at no charge」と書かれていて、回数制限もなく無料です。

ただし、こちらもAPIキーが必要なので、Google Cloudのプロジェクトと請求先アカウントの登録はしないといけません。また、表示できるのは1地点か経路だけです。複数のピンを立てる用途には向きません。

1地点だけでいいなら、②の「共有 → 地図を埋め込む」で十分です。こちらはキーもいりません。お店や会社の場所を1つ見せたいだけなら、これが一番手軽です。

4. 本命:マイマップなら無料で複数ピンを立てられる

複数のピンを立てたいなら、①のマイマップが一番手軽でした。

作り方

  1. Googleマイマップ を開いて、新しい地図を作る
  2. ピン・線・範囲を描いて、説明文や写真を追加する
  3. 「共有」から地図を一般公開にする
  4. メニューの「自分のサイトに埋め込む」で出てくるiframeのコードをコピーする
  5. ブログやサイトに貼り付ける

APIキーも請求先の登録もいりません。

実際に作ってみた

試しに、八千代緑が丘駅の周辺で「テスト用」という地図を作ってみました。編集画面はこんな感じです。

  • 「店舗」レイヤーにピンを1つ立てた
  • 「飯屋」レイヤーに、駅の北側を囲む範囲(「500mぐらい」)を描いた
  • 検索窓の下にあるツールバーから、ピン・線・距離の測定などを選んで描ける

レイヤーごとに表示・非表示を切り替えられるので、「お店」「飲食店」のように種類ごとに分けておくと見やすくなります。

埋め込むと、次のように表示されます。

できないこと

  • 1つの地図に作れるレイヤーは最大10個まで
  • 地図の見た目やピンのアイコンは、細かく変えられない
  • プログラムからピンを自動で増やすことはできない(手で追加する)

ピンを自動で増やしたい、デザインにこだわりたい、という場合は④を使うことになります。

5. Google以外にもピンを立てられるサービスはある

Google以外にも、ピンを立てられるサービスがあります。無料で使えるものも多いです。

uMap:マイマップと同じように画面で作れる

uMap は、OpenStreetMapの地図を使って、マイマップと同じように画面上で地図を作れるサービスです。

  • ピン・線・範囲を描いて、iframeで埋め込める
  • 無料で、APIキーもいらない
  • ピンの色・形・アイコンを変えられるので、見た目の自由度はマイマップより高い
  • CSVなどからピンをまとめて読み込める
  • 地図の出典表記は自動で入る

コードを書けるなら Leaflet

Leafletは、地図を表示するためのJavaScriptライブラリです。地図の画像(タイル)をどこから持ってくるかによって、条件が変わります。

  • OpenStreetMapの地図を使う場合
    • APIキーも料金もいらない
    • 「© OpenStreetMap contributors」を表示する必要がある
    • 大量アクセスでサーバーに負担をかけると、予告なくブロックされることがある
  • 国土地理院の地図(地理院タイル)を使う場合
    • 無料。Webサイトで表示するだけなら申請はいらず、出典に「国土地理院」と書けば使える
    • 地図の種類によっては、別に出典の書き方や注意事項を確認する必要がある
    • 日本国内向けで、日本語の地図として見やすい

Mapbox:無料枠が大きい

Mapboxは、地図の表示が月50,000回まで無料です。超えると1,000回ごとに$5からかかります。Googleの無料枠(月10,000回)より多く、デザインも細かく変えられます。ただし、APIキーの登録は必要です。

まとめ:どれを選べばいいか

やりたいこと おすすめ
1地点だけ見せたい Googleマップの「共有 → 地図を埋め込む」
複数のピンを無料で手軽に立てたい Googleマイマップ
見た目にもこだわりたい uMap
コードを書いて自由に作りたい Leaflet+OpenStreetMap(キーも料金も不要)
Googleの地図でプログラムからピンを立てたい Maps JavaScript API(月10,000回まで無料)

「Googleマップでピンを立てるとお金がかかる」というのは、APIを使う場合の話でした。自分で地図を作って埋め込むだけなら、マイマップやuMapで無料で実現できます。

参考

今更だけどAndroidのCompose画面設計 補足:起動から表示までの流れ

← 前: Step 6:設定と仕上げ | 目次に戻る


補足:起動から表示までの流れ

アプリを起動してから、画面が表示されるまでに、何がどの順で実行されるかを説明します。 実機(SH-51C・Android 14)のログで、実際の順序を確認した内容です。

全体の流れ

①  アイコンをタップ
      ↓
②  Android が AndroidManifest.xml を見る
    (MAIN + LAUNCHER の Activity = MainActivity)
      ↓
③  プロセスを作り、MainActivity を生成
    起動直後の見た目は XML テーマ(values/themes.xml、ダークなら values-night/themes.xml)
      ↓
④  onCreate → onStart → onResume            ← MainActivity.kt
      ↓
⑤  画面が Window に取り付けられる
      ↓
⑥  Compose が UI を組み立てる                ← XRStudyTheme → XrStudyApp → HomeScreen
      ↓
⑦  描画

⑥(Compose の組み立て)は、onCreate の中ではなく、onResume の後に始まります。

onCreate の中で起きること

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)      // 親クラスの初期化
    Log.d("LIFECYCLE", "MainActivity onCreate")
    enableEdgeToEdge()                      // 画面をバーの裏まで広げる設定
    setContent {                            // ★ UI を「登録」する
        XRStudyTheme {
            XrStudyApp()
        }
    }
    Log.d("LIFECYCLE", "MainActivity onCreate END  ← setContent は登録だけ。組み立てはまだ")
}

setContent は、その場で画面を作るのではなく、「この Composable を表示する」と登録するだけです。 登録が済むと、すぐ onCreate が終わります。組み立ては、そのあと、画面が Window に取り付けられてから始まります。

ログで見る、起動から表示まで

adb logcat -s LIFECYCLE

アプリを完全に終了してから起動した(コールドスタート)ときの、実際のログです。

11:44:28.476  MainActivity onCreate
11:44:28.506  MainActivity onCreate END  ← setContent は登録だけ。組み立てはまだ
11:44:28.512  MainActivity onStart
11:44:28.514  MainActivity onResume
11:44:28.690  [Compose] XRStudyTheme
11:44:28.708  [Compose] XrStudyApp
11:44:28.999  [Compose] HomeScreen
11:44:29.184  [Nav] 宛先が変わった → null
11:44:29.204  [Compose] XrStudyApp
11:44:29.238  [Nav] 宛先が変わった → HomeRoute
  • onCreate END が、onResume より前に出ています。setContent が、登録だけで戻っている証拠です。
  • [Compose] のログは、onResume より後に出ています。組み立てが、そのあとに始まっています。
  • XRStudyTheme → XrStudyApp → HomeScreen の順に、外側から内側へ実行されています。 HomeScreen が少し後に出るのは、Scaffold が本文を、サイズを測る段階で組み立てるためです。
  • [Nav] 宛先が変わった → null が、組み立ての後に出ています。 最初の組み立てでは、NavController の「今の宛先」が、まだ決まっていない(null)ためです。 そのあと XrStudyApp が再実行され、本当の宛先(HomeRoute)が入ります(Step 3 の 3-9 を参照)。

このログの結果、画面には最終的にこれが表示されます(startDestination = HomeRoute なので、ホーム画面です)。

Compose が組み立てる中身

onResume の後、setContent に渡した中身が、外側から順に実行されます。

XRStudyTheme
  └ isSystemInDarkTheme() でライト/ダークを決め、MaterialTheme に色と文字を渡す
     ↓
XrStudyApp
  ├ rememberNavController() で NavController を作る
  ├ currentBackStackEntryAsState() で「今の宛先」を取る(最初は null)
  └ Scaffold
       ├ topBar:CenterAlignedTopAppBar
       ├ bottomBar:NavigationBar(ホーム/一覧/設定)
       └ 本文:NavHost(startDestination = HomeRoute)
            └ composable<HomeRoute>
                 ↓
              HomeScreen
                 ├ BannerPager(HorizontalPager。Step 4 で、場所取りから置き換え)
                 ├ SectionHeader
                 └ notices.forEach { NoticeRow(...) }

下部ナビをタップしたとき(recomposition)

「設定」をタップしたときのログです。

11:46:14.481  [Compose] XrStudyApp
11:46:14.523  [Nav] 宛先が変わった → SettingsRoute
11:46:14.547  [Compose] SettingsScreen
タップ
  → navigate(SettingsRoute) が、NavController のバックスタックを書き換える
  → 「今の宛先」が変わったので、それを読んでいる XrStudyApp が再実行される(recomposition)
  → NavHost が、SettingsRoute の画面(SettingsScreen)を組み立てる
  → 画面が更新される

(「一覧」は、開くと読み込みが始まり、画面が、読み込み中 → 成功と、続けて作り直されます。Step 5 の 5-8 を参照)

このログのあと、画面には設定画面が表示されています。

  • XRStudyTheme は、再実行されていません。 入力(darkTheme)が変わっていないためです。 Compose は、状態が変わった部分だけを作り直します。
  • Phase 1 のカウンターと同じ、「状態が変わると、それを読んでいる部分だけが再描画される」仕組みです。 ここでは、「状態」が NavController の「今の宛先」です。

回転したとき

回転して、「設定」を選んでいた状態のログです。

11:43:04.221  MainActivity onPause
11:43:04.223  MainActivity onStop
11:43:04.309  MainActivity onDestroy
11:43:04.333  MainActivity onCreate
11:43:04.339  MainActivity onCreate END  ← setContent は登録だけ。組み立てはまだ
11:43:04.340  MainActivity onStart
11:43:04.342  MainActivity onResume
11:43:04.367  [Compose] XRStudyTheme
11:43:04.368  [Compose] XrStudyApp
11:43:04.419  [Compose] SettingsScreen
11:43:04.527  [Nav] 宛先が変わった → null
11:43:04.534  [Compose] XrStudyApp
11:43:04.601  [Nav] 宛先が変わった → SettingsRoute

Activity が作り直されるので、④からやり直しです(onDestroy の後に onCreate)。 それでも、HomeScreen ではなく SettingsScreen が組み立てられています。 rememberNavController が、バックスタックを回転をまたいで保存しているため、選んでいた画面が復元されます。

横向きにしても、同じことが起きます(画像は、分かりやすいホーム画面の例です)。

ホームボタンで離れて戻ったとき

10:33:37.374  MainActivity onStart
10:33:37.377  MainActivity onResume

[Compose] のログは出ません。 Activity は破棄されていない(プロセスも生きている)ので、 Compose の組み立て結果もそのまま残っています。状態が変わっていないので、再実行もされません。

プログラム側に残しているログ

上のログは、コードに恒久的に入れてあります。タグは LIFECYCLE で、adb logcat -s LIFECYCLE で、 Activity のライフサイクル、ViewModel、Compose の組み立てが、1つの流れで見られます。

場所 ログ
MainActivity.kt MainActivity onCreate / onCreate END / onStart / onResume / onPause / onStop / onDestroy
ui/theme/Theme.kt [Compose] XRStudyTheme
ui/XrStudyApp.kt [Compose] XrStudyApp / [Nav] 宛先が変わった → 宛先
ui/ThemeShowcase.kt、home/HomeScreen.kt、users/UserListScreen.kt、settings/SettingsScreen.kt [Compose] 画面名

⚠️ @Composable 関数の本体に、ログを書くときの注意

カウンターアプリの解説では、「Composable の本体にはログを書かない」と説明しました。 ボタンを押した、という操作の記録なら、押されたときの onClick に書くべきだからです。

今回の [Compose] のログは目的が違います。「この関数が実行された(組み立てられた)」こと自体を見るためのものです。 だから、関数の本体に書いています。

  • 再組み立てのたびに出ます。 回数は Compose が決めるので、操作の回数とは一致しません。
  • 学習用のログです。実務では、リリースのビルドから外すことが多いです。

iOS との比較

観点 iOS(SwiftUI) Android(Compose)
画面の入口 App の WindowGroup { ContentView() } Activity.onCreate の setContent { … }
画面を作る単位 View の body @Composable 関数
状態が変わったときの再実行 body の再評価 recomposition(再組み立て)
回転 ビューは破棄されない Activity が作り直され、組み立てからやり直し

← 前: Step 6:設定と仕上げ | 目次に戻る

今更だけどAndroidのCompose画面設計 Step 6:設定と仕上げ

← 前: Step 5:ユーザー一覧 | 目次に戻る | 次: 補足:起動から表示までの流れ →


Step 6:設定と仕上げ(ダイアログ・Snackbar・テーマ切り替え・アクセシビリティ)

Step 2 から「見た目だけ」だった設定画面を、押すと動くようにします。 ロードマップの「Scaffold、Top App Bar、Snackbar、ダイアログを使い、操作へのフィードバックを表示する」と、 「contentDescription、十分なタップ領域、文字が大きい場合の崩れを確認する」が、この Step の内容です。

6-1. 何を作ったのか

MainActivity.kt                ← テーマの選び方を持つ。バーのアイコンの色を、テーマに合わせる
ui/
├─ theme/
│   ├─ ThemeMode.kt            ← 新規:テーマの選び方(端末の設定/ライト/ダーク)
│   └─ Color.kt                ← Snackbar の色(inverseSurface など)を追加
├─ settings/SettingsScreen.kt  ← テーマの行とダイアログ、押せるスイッチ
├─ XrStudyApp.kt               ← Snackbar の置き場所(SnackbarHost)と、通知の設定を持つ
└─ users/UserListScreen.kt     ← アバターの1文字を、読み上げから外す
操作 何が起きるか
「テーマ」の行を押す ダイアログが開く。「端末の設定に従う/ライト/ダーク」から選び、「OK」で反映
「プッシュ通知」の行を押す スイッチが切り替わり、画面の下に「プッシュ通知をオフにしました [元に戻す]」
「元に戻す」を押す スイッチが、押す前に戻る

プッシュ通知は、スイッチの見た目と状態だけです。本当の通知は送りません。

6-2. 状態は「使う部品より、上」で持つ — どこに置いたか

この Step で増えた状態は4つです。置き場所が、それぞれ違います。

状態 置き場所 理由
テーマの選び方(themeMode) MainActivity(setContent の中) XRStudyTheme に渡すため、テーマより上で持つ必要がある
プッシュ通知のオン/オフ XrStudyApp Snackbar の「元に戻す」から、戻せる場所に置く(6-5)
Snackbar の状態(SnackbarHostState) XrStudyApp Scaffold と同じ場所。下部ナビのすぐ上に出してもらうため(6-5)
ダイアログを開いているか SettingsScreen 設定画面の中だけで使う、見た目の状態
MainActivity(setContent)
  themeMode ────────────┐
  XRStudyTheme(darkTheme)│
    XrStudyApp           ↓ 引数で渡す
      notificationsEnabled、snackbarHostState
      Scaffold
        SettingsScreen(themeMode, notificationsEnabled, onThemeModeChange, onNotificationsChange)
          showThemeDialog(ここだけで使う)

原則は「その状態を使う部品のうち、一番上の部品で持つ」です(Phase 1 の state hoisting と同じ)。 テーマは、アプリ全体の色を決めるので、一番上です。 ダイアログの開閉は、設定画面しか知らなくてよいので、設定画面の中です。

設定画面は、Step 5 の UserListScreen と同じく、値と処理を、引数で受け取るだけです。 だから @Preview で、「ダーク・通知オフ」のような組み合わせも、値を渡すだけで確認できます。

6-3. テーマの切り替え

テーマの選び方は、Boolean ではなく enum

enum class ThemeMode(val label: String) {
    System("端末の設定に従う"),
    Light("ライト"),
    Dark("ダーク"),
}

@Composable
fun ThemeMode.isDark(): Boolean = when (this) {
    ThemeMode.System -> isSystemInDarkTheme()   // 端末の設定を読む
    ThemeMode.Light -> false
    ThemeMode.Dark -> true
}

isDark: Boolean で持つと、「ライト」「ダーク」の2つしか表せず、「端末の設定に従う」が表せません。 Step 5 の UsersUiState と同じ考え方で、「ありうる選択肢」を、型で決めています。

MainActivity で持って、XRStudyTheme に渡す

setContent {
    var themeMode by rememberSaveable { mutableStateOf(ThemeMode.System) }
    val darkTheme = themeMode.isDark()

    XRStudyTheme(darkTheme = darkTheme) {
        XrStudyApp(
            themeMode = themeMode,
            onThemeModeChange = { themeMode = it },
        )
    }
}

Step 1 の XRStudyTheme は、はじめから darkTheme を引数で受け取れるように作ってありました(初期値が isSystemInDarkTheme())。 ここに、選んだ値を渡すだけです。

テーマを切り替えても、Activity は作り直されません。 実機のログでは、「OK」を押したあと、次の3行だけが出ました。

[Compose] XRStudyTheme
[Compose] XrStudyApp
[Compose] SettingsScreen

onCreate は出ていません。themeMode が変わり、それを読んでいる部品が、再組み立て(recomposition)されただけです。 端末の設定でダークモードを切り替えたときは、Activity が作り直されます(設定変更。回転と同じ)。 アプリの中で切り替えるほうが、軽い処理です。

⚠️ rememberSaveable なので、アプリを終了すると、元に戻る

操作 テーマ
回転 残る(rememberSaveable)
ほかのタブへ移って、戻る 残る(MainActivity で持っているので、そもそも消えない)
アプリを終了して、起動し直す 「端末の設定に従う」に戻る

実機で、adb shell am force-stop のあと起動し直すと、「端末の設定に従う」に戻りました。 終了しても設定を残すのは、DataStore の役目です(ロードマップの Phase 4)。今は、そこまでは作りません。

6-4. ダイアログ — AlertDialog

var showThemeDialog by rememberSaveable { mutableStateOf(false) }

ListItem(
    headlineContent = { Text("テーマ") },
    supportingContent = { Text(themeMode.label) },
    modifier = Modifier.clickable(onClickLabel = "テーマを選ぶ", role = Role.Button) {
        showThemeDialog = true                   // ← 開く
    },
)

if (showThemeDialog) {                           // ← true の間だけ、ダイアログが存在する
    ThemeDialog(
        current = themeMode,
        onConfirm = { selected ->
            onThemeModeChange(selected)
            showThemeDialog = false              // ← 閉じる
        },
        onDismiss = { showThemeDialog = false }, // ← 閉じる(何も変えない)
    )
}

「テーマ」の行を押して開いた、実機のダイアログです。

★ ダイアログは「状態」で出す。show() という命令はない

View の時代や iOS の UIKit では、「ダイアログを表示する」という命令を呼んでいました。 Compose では、「開いているか」という状態を持ち、true の間だけ、ダイアログを組み立てに含めます。 SwiftUI の .alert(isPresented: $showAlert) と同じ考え方です。

rememberSaveable にしているので、ダイアログを開いたまま回転しても、開いたままです(実機で確認)。

「OK」で反映し、それ以外は何も変えない

@Composable
private fun ThemeDialog(current: ThemeMode, onConfirm: (ThemeMode) -> Unit, onDismiss: () -> Unit) {
    var selected by rememberSaveable { mutableStateOf(current) }   // 選んでいる途中の値

    AlertDialog(
        onDismissRequest = onDismiss,            // ダイアログの外を押す・戻るボタン
        title = { Text("テーマ") },
        text = { /* ラジオボタン3つ。押すと selected が変わる */ },
        confirmButton = { TextButton(onClick = { onConfirm(selected) }) { Text("OK") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("キャンセル") } },
    )
}
閉じ方 呼ばれるもの テーマ
「OK」 onConfirm(selected) 選んだものに変わる
「キャンセル」 onDismiss 変わらない
ダイアログの外を押す・戻るボタン onDismissRequest(= onDismiss) 変わらない

選んでいる途中の値(selected)は、ダイアログの中で持ちます。 親の themeMode を直接書き換えると、 ラジオボタンを押しただけで、テーマが変わってしまい、「キャンセル」できません。 ダイアログが閉じると selected も消えるので、次に開いたときは、また「今の設定」から始まります。

選んだ時点で反映して閉じる作り(「OK」が無い)もあります。どちらにするかは、アプリの方針で決めます。 今回は、「キャンセル」の動きを学ぶため、「OK」で反映する作りにしました。

6-5. Snackbar — 操作の結果を、短く伝える

// XrStudyApp
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var notificationsEnabled by rememberSaveable { mutableStateOf(true) }

Scaffold(
    topBar = { … },
    bottomBar = { … },
    snackbarHost = { SnackbarHost(snackbarHostState) },   // ← Snackbar を出す場所
) { … }
// 設定画面で、通知のスイッチが押されたとき
onNotificationsChange = { enabled ->
    notificationsEnabled = enabled
    scope.launch {
        snackbarHostState.currentSnackbarData?.dismiss()     // 表示中のものを閉じる(下で説明)
        val result = snackbarHostState.showSnackbar(
            message = if (enabled) "プッシュ通知をオンにしました" else "プッシュ通知をオフにしました",
            actionLabel = "元に戻す",
            duration = SnackbarDuration.Short,
        )
        if (result == SnackbarResult.ActionPerformed) {      // 「元に戻す」が押された
            notificationsEnabled = !enabled
        }
    }
}
部品 役割
SnackbarHostState 「今、どの Snackbar を出しているか」を持つ
SnackbarHost Snackbar を実際に描く場所。Scaffold に渡すと、下部ナビのすぐ上に置いてくれる
showSnackbar(…) suspend 関数。Snackbar が消えるまで待って、結果(ActionPerformed か Dismissed)を返す
rememberCoroutineScope() suspend 関数を、ボタンを押したとき(イベント)から呼ぶための scope

スイッチをオフにしたときの、実機の Snackbar です。下部ナビのすぐ上に、周りと逆の明るさ(6-6)で出ています。

LaunchedEffect ではなく、rememberCoroutineScope

Step 5 の読み込みは、「画面が出たら、始める」ので、LaunchedEffect でした。 Snackbar は、「スイッチが押されたら、出す」ので、押されたときの処理(onClick など)の中から始めます。 onClick の中は @Composable ではないので、LaunchedEffect は書けません。そこで scope.launch { … } を使います。

始めるきっかけ 使うもの
画面が表示されたとき・キーが変わったとき LaunchedEffect(キー)
ボタンが押されたときなど(イベント) rememberCoroutineScope() + scope.launch

なぜ、Snackbar と通知の設定を XrStudyApp で持つのか

showSnackbar は、Snackbar が消えるまで(約4秒)待ちます。 もし、scope と通知の設定を設定画面の中で持つと、その4秒の間に別のタブへ移ったとき、設定画面が消え、

  • scope がキャンセルされ、「元に戻す」の結果を受け取れない
  • 通知の設定(remember の値)も、画面と一緒に消える

ことになります。XrStudyApp で持てば、タブを移っても、Snackbar も、その後の処理も、最後まで動きます。

⚠️ 続けて押すと、Snackbar は「順番待ち」になる

showSnackbar は、表示中の Snackbar があると、それが消えるまで待ってから、次を出します。 何もしないと、スイッチを3回すばやく押したとき、Snackbar が1つずつ、約4秒ごとに、合計12秒出続けます。 そこで、出す前に currentSnackbarData?.dismiss() で、表示中のものを閉じています。

実機で、3回すばやく押したときのログです。

01:19:40.012 [Snackbar] 結果 → Dismissed    ← 1つ目:2回目を押したときに閉じられた
01:19:40.455 [Snackbar] 結果 → Dismissed    ← 2つ目:3回目を押したときに閉じられた
01:19:44.514 [Snackbar] 結果 → Dismissed    ← 3つ目:約4秒表示されて、消えた

最後のスイッチの状態(3回押したので、元の逆)が、そのまま残りました。

ボタン付きの Snackbar は、何も指定しないと消えない

showSnackbar の duration の初期値は、ボタン(actionLabel)があると Indefinite(押されるまで消えない)です。 「元に戻す」くらいの軽い操作で、消えない Snackbar は邪魔なので、SnackbarDuration.Short を指定しています。

なお、TalkBack などが有効なときは、Snackbar が表示される時間が、自動で長くなります(読み上げに時間がかかるため)。

Snackbar と、ダイアログ・Toast の使い分け

部品 使うとき 例
Snackbar 操作の結果を、短く伝える。「元に戻す」を付けられる 「削除しました [元に戻す]」
ダイアログ 操作の前に、確認・選択してもらう。ほかの操作を止める 「削除しますか?」、テーマを選ぶ
Toast アプリの外(通知など)でも出せる、短い文字だけ。ボタンは付けられない Compose の画面の中では、Snackbar を使う

「元に戻す」があれば、「削除しますか?」の確認ダイアログを省けます。押す回数が減るので、よく使われる組み合わせです。

6-6. Snackbar の色 — inverseSurface を定義し忘れていた

最初に実機で出したとき、「元に戻す」の文字が、紫色でした。アプリの色(ティール)ではありません。

Snackbar は、周りと逆の明るさの色(inverse の役割)を使います。

役割 Snackbar のどこ
inverseSurface 背景
inverseOnSurface 文字
inversePrimary ボタン(「元に戻す」)

Color.kt で、この3つを定義していなかったため、Material の初期値(紫)が使われていました。 Step 1 の「surfaceContainer* を定義した理由」と、同じ原因です。 3つを、ライトとダークの両方に追加して、ティールになりました。

配色を手で置くときは、使っている部品が、どの役割を使うかを確認する必要があります。 Material Theme Builder の出力を丸ごと貼れば、全部の役割が入っているので、この問題は起きません。

6-7. ⚠️ ステータスバーのアイコンの色を、アプリのテーマに合わせる

アプリの中で「ダーク」を選んでも、端末がライトモードのままだと、ステータスバーのアイコンが黒いままになります。 実機で確認したところ、暗い背景に黒いアイコンが並んで、時計や電池がほとんど見えませんでした。 3ボタンのナビゲーションバーも、白い帯のまま残りました。

原因は、onCreate の enableEdgeToEdge() が、バーの色を端末の設定で決めているためです。 アプリのテーマが変わっても、それを知りません。

DisposableEffect(darkTheme) {                    // darkTheme が変わるたびに、実行し直す
    enableEdgeToEdge(
        statusBarStyle = SystemBarStyle.auto(
            AndroidColor.TRANSPARENT, AndroidColor.TRANSPARENT,
        ) { darkTheme },                         // ← 「ダークかどうか」を、アプリのテーマで答える
        navigationBarStyle = SystemBarStyle.auto(LightScrim, DarkScrim) { darkTheme },
    )
    onDispose {}
}

SystemBarStyle.auto の最後の { darkTheme } は、「今ダークかどうか」を答える関数です。 初期値は「端末の設定を読む」なので、ここで、アプリの darkTheme を返すように差し替えています。

DisposableEffect(キー) は、LaunchedEffect の仲間で、「キーが変わったら、中を実行し直す」ものです。 LaunchedEffect との違いは、コルーチンではないことと、後片付け(onDispose)を書けることです。 enableEdgeToEdge は suspend ではなく、すぐ終わる処理なので、こちらを使っています(片付けは不要なので空)。

6-8. アクセシビリティ — TalkBack と、押せる範囲

★ スイッチだけでなく、「行全体」を押せるようにする

ListItem(
    headlineContent = { Text("プッシュ通知") },
    supportingContent = { Text("お知らせが届いたときに通知します") },
    trailingContent = { Switch(checked = checked, onCheckedChange = null) },   // ← Switch は押せない
    modifier = Modifier.toggleable(                                             // ← 行が押せる
        value = checked,
        role = Role.Switch,
        onValueChange = onCheckedChange,
    ),
)
書き方 押せる範囲 TalkBack の読み上げ
❌ Switch(onCheckedChange = …) だけ スイッチの部分だけ 「プッシュ通知」「お知らせが…」「スイッチ、オン」が別々
✅ 行に toggleable、Switch は null 行全体 「プッシュ通知、お知らせが…、スイッチ、オン」が1回で

Switch にも処理を付けると、行とスイッチの2か所が「押せる部品」になり、読み上げが分かれます。 押す処理は、行だけに付けます。 ラジオボタンの行(selectable + RadioButton(onClick = null))も同じ形です。

実機の画面の情報(uiautomator dump)で、スイッチの行が、行全体(高さ 126px = 72dp)で1つの「押せる・チェックできる」部品になっていることを確認しました。 スイッチ単体は、押せる部品として出てきません。

role と onClickLabel — 何が起きるかを、読み上げで伝える

指定 TalkBack が読み上げる内容(目安)
role = Role.Switch 「スイッチ、オン」
role = Role.RadioButton 「ラジオボタン、選択済み」
role = Role.Button + onClickLabel = "テーマを選ぶ" 「ボタン、ダブルタップしてテーマを選ぶ」

selectableGroup() は、「この中から1つを選ぶ、ひとまとまり」だと伝える指定です。

読み上げの言い回しは、TalkBack のバージョンや端末で少し変わります。 この表は、実機の TalkBack で聞いて確認したものではありません(画面の情報を uiautomator dump で見た確認だけです)。

押せる範囲は、最低 48dp

Android の推奨は、押せる範囲を 48dp × 48dp 以上にすることです。 ダイアログのラジオボタンの行には、heightIn(min = 48.dp) を付けました。 実機(280dpi=1dp が 1.75px)では、行の高さが 84px、つまり 48dp でした。

ListItem、NavigationBarItem、IconButton などの Material の部品は、はじめから 48dp 以上あります。 自分で Row や Box に clickable を付けたときに、気を付けます。

飾りは、読み上げから外す

ユーザー一覧のアバター(名前の1文字目の丸)に、clearAndSetSemantics {} を付けました。

Surface(
    shape = CircleShape,
    modifier = Modifier.size(40.dp).clearAndSetSemantics {},   // 読み上げから外す
) { Text(initial) }

外さないと、「山、山田 太郎、taro.yamada@…」のように、1文字目が余分に読まれます。 名前は、隣の文字で読まれるので、丸は飾りです。Step 4 のバナー画像を contentDescription = null にしたのと、同じ考え方です。

部品の種類 読み上げ
意味を持つアイコン(戻る矢印、お気に入りの星) contentDescription で、意味を書く
隣の文字と同じ内容(下部ナビのアイコン、バナー画像) contentDescription = null
文字だが、飾り(アバターの1文字) clearAndSetSemantics {}

文字を最大にしても、崩れないか

@Preview に fontScale = 2f を付けると、端末の「フォントサイズ」を最大にしたときの見た目を確認できます。

@Preview(name = "文字 200%", showBackground = true, heightDp = 640, fontScale = 2f)

実機でも、フォントサイズを 200% にして確認しました。

場所 結果
設定画面 説明文が2行に折り返し、行が高くなった。スイッチとは重ならない
テーマのダイアログ 3つの選択肢と、ボタンが、すべて収まった
Snackbar 文字が2行に折り返し、「元に戻す」とは重ならない

Android 14 からは、文字が大きいほど、拡大の割合が小さくなります(非線形の拡大)。 200% でも、見出しのような大きな文字は、2倍にはなりません。本文のような小さな文字が、読みやすく大きくなります。

文字の大きさを sp で指定し、高さを固定しない(height ではなく、heightIn(min = …))ことで、崩れを防げます。 Step 1 の「文字の単位は sp、それ以外は dp」が、ここで効いています。

6-9. 実機で確認した結果

SH-51C(Android 14、3ボタンナビゲーション)で確認しました。

確認したこと 結果
「プッシュ通知」の説明文(スイッチではない所)を押す スイッチが切り替わり、Snackbar が下部ナビのすぐ上に出た
「元に戻す」 スイッチが戻った([Snackbar] 結果 → ActionPerformed)
何もしない 約4秒で消えた(Dismissed)。スイッチはそのまま
スイッチを3回すばやく押す 古い Snackbar はすぐ閉じられ、最後の1つだけが約4秒出た
ダイアログで「ダーク」を選んで、回転 ダイアログは開いたまま、「ダーク」の選択も残った
「キャンセル」/戻るボタン テーマは変わらなかった
「ダーク」→「OK」 アプリ全体がダークに。Activity は作り直されず、再組み立てだけ
ダークのまま回転 ダークのまま
アプリを終了して、起動し直す 「端末の設定に従う」に戻った(6-3)
DisposableEffect を一時的に外す ステータスバーのアイコンが黒いまま、ナビゲーションバーが白い帯になった
フォントサイズ 200% 崩れなし(6-8)

DisposableEffect を外す確認と、フォントサイズの変更は、確認後に元に戻してあります。

「ダーク」を選んだあとの、設定画面です。

ライト

ダーク

6-10. iOS との比較

観点 iOS(SwiftUI) Android(Compose)
ダイアログ .alert(isPresented:)、.confirmationDialog if (show) { AlertDialog(…) }
操作の結果を短く出す 標準の部品は無い(自作するか、ライブラリ) Snackbar(Scaffold の snackbarHost)
アプリだけダークにする .preferredColorScheme(.dark) XRStudyTheme(darkTheme = true)
ステータスバーの文字色 preferredColorScheme に合わせて、自動で変わる enableEdgeToEdge を、テーマに合わせて呼び直す
設定を、終了しても残す @AppStorage(UserDefaults) DataStore(Phase 4)
読み上げ VoiceOver TalkBack
部品をまとめて読ませる .accessibilityElement(children: .combine) toggleable / clickable / semantics(mergeDescendants = true)
読み上げから外す .accessibilityHidden(true) clearAndSetSemantics {}、contentDescription = null
押せる範囲の推奨 44pt × 44pt 48dp × 48dp
文字の拡大 Dynamic Type フォントサイズ(sp)、Android 14 から非線形

← 前: Step 5:ユーザー一覧 | 目次に戻る | 次: 補足:起動から表示までの流れ →

今更だけどAndroidのCompose画面設計 Step 5:ユーザー一覧

← 前: Step 4:横スクロールバナー | 目次に戻る | 次: Step 6:設定と仕上げ →


Step 5:ユーザー一覧(タブ・LazyColumn・状態の表示)

ユーザー一覧に、タブ(すべて/お気に入り)と、読み込み中・エラー・空の表示を作ります。 ロードマップの「静的な見た目 → ユーザー操作 → 状態に応じた表示」の、最後の段階です。

5-1. 何を作ったのか

ui/
├─ users/
│   ├─ UsersUiState.kt    ← 画面の状態(Loading / Success / Error)を表す型
│   ├─ UserListScreen.kt  ← タブ + LazyColumn + 状態ごとの表示(状態を受け取って表示するだけ)
│   ├─ UserListLoader.kt  ← 読み込みを実行し、状態を UserListScreen に渡す
│   └─ FakeUserApi.kt     ← 通信の代わりの、擬似的な読み込み(1.5秒待つ)
└─ model/Models.kt        ← User に isFavorite を追加

「状態を持つ部品」(UserListLoader)と、「表示だけの部品」(UserListScreen)を分けています。 表示だけの部品は、@Preview で、どんな状態でも、すぐに確認できます(5-6)。

5-2. 画面の状態を「型」で表す

sealed interface UsersUiState {
    data object Loading : UsersUiState                         // 読み込み中
    data class Success(val users: List<User>) : UsersUiState   // 読み込めた(0 件のこともある)
    data class Error(val message: String) : UsersUiState       // 失敗した
}

画面が取りうる姿を、3つの型で表します。

なぜ、Boolean(isLoading、isError)ではなく、型なのか

Boolean を並べる書き方だと、「読み込み中なのに、エラーでもある」のような、ありえない組み合わせが書けてしまいます。 型なら、常に、3つのうちの1つだけです。ありえない状態を、そもそも作れません。

sealed interface は、「種類は、これだけ」と決める

when で、状態ごとに表示を切り替えます。

when (state) {
    UsersUiState.Loading -> LoadingContent(…)
    is UsersUiState.Error -> ErrorContent(state.message, …)
    is UsersUiState.Success -> …
}

sealed にすると、when で、全部の種類を書かないと、コンパイルエラーになります。 実際に、UsersUiState に data object Refreshing(再読み込み中)を足すと、when を書いた2か所で、 次のエラーが出ました。

'when' expression must be exhaustive. Add the 'Refreshing' branch or an 'else' branch.

種類を足したとき、書き忘れている画面を、コンパイラが全部教えてくれます。

部分 意味
data object Loading 中身を持たない、1つだけの状態
is UsersUiState.Error -> 「Error 型かどうか」を調べる。Error 型だと分かった後は、state.message が使える(スマートキャスト)

5-3. LazyColumn — 見えている行だけを作る

LazyColumn(modifier = Modifier.fillMaxWidth()) {
    items(items = users, key = { it.id }) { user ->
        UserRow(user)
    }
}

Step 2 の「Column + verticalScroll + forEach」は、見えない行まで、全部作ってしまいます。 LazyColumn は、画面に見えている行(と、その少し先)だけを作ります。

実機で数えた結果(SH-51C・Android 14)

ユーザーを 1000件にして、行が作られた数を、ログで数えました(確認用に、一時的に足したログです)。

操作 作られた行の数
一覧を開いた直後 9行(1000件のうち)
5回スワイプした後(先頭の行が「ユーザー 84」) 新しく作られたのは 84行

Column なら、開いた時点で 1000 行が作られます。LazyColumn は、スクロールした分だけ、作ります。 SwiftUI の List が、見えている行だけを作るのと同じです。

  • key = { it.id } は、行を「位置」ではなく「id」で見分ける指定です。データの並びが変わっても(先頭に追加されるなど)、 行ごとの状態が混ざらず、動きも自然になります。
  • LazyColumn を、verticalScroll の中に入れてはいけません。 縦に動く部品を、縦に動く部品の中に入れると、 高さが決まらず、実行時にエラーになります(一般的な注意です。このプロジェクトでは試していません)。 1つの画面には、縦にスクロールする部品を、1つだけ置きます。

API のデータを出すときは — 「表示の遅延」と「取得」は別

実際のアプリでは、一覧のデータは、API から取ってきます(記事のタイトル、サブタイトル、サムネイル画像、記事ID など)。 そのとき、次の2つを分けて考えます。

LazyColumn が遅延させるのは、「表示」だけです。 見えている行だけを作る仕組みです。 データの取得は、別の話です。1000件を1回の通信で取っていたら、通信は、もう終わっています。

対策 何を減らすか 使うもの
見えている行だけを作る 画面に出す行(メモリ・描画) LazyColumn
ページ単位で取る 通信で取る件数(通信量・時間) ページネーション、Paging 3

実務では、両方をやります。

記事リストの API は、1回か、ページ単位か

API の仕様次第です。ページに対応していれば、件数が増えうる一覧は、ページ単位で取るのが一般的です。 (1回で全部取るのは、件数が少なく、増えないときだけです)

方式 リクエストの例 特徴(一般的な知識です)
一度に全部 GET /articles 簡単。件数が少なく、増えないときだけ
ページ番号 GET /articles?page=2&size=20 分かりやすい。新着が増えると、ずれて重複や抜けが出ることがある
カーソル GET /articles?after=最後のID 新着が増えても安定。記事のような一覧に向く

ユーザーが一番下の近くまでスクロールしたら、次のページを取るのが「無限スクロール」です。

Android での実現方法は、2つあります。

① 自前で作る。 ViewModel が、次のページの位置と、取得済みのリストを持ちます。 状態は、UsersUiState を広げます(「追加読み込み中」「追加の失敗」が増える)。 sealed interface なので、増やした種類を書き忘れると、コンパイルエラーで気づけます。

② Paging 3 ライブラリ(androidx.paging)を使う。 公式ドキュメントに、LazyColumn との組み合わせ方があります。

val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

LazyColumn {
    items(lazyPagingItems.itemCount, key = lazyPagingItems.itemKey { it.id }) { index ->
        val article = lazyPagingItems[index]
        if (article != null) ArticleRow(article) else ArticlePlaceholder()   // 読み込み前は、枠を表示
    }
}
  • 最後の方まで来たら、次のページの取得を、ライブラリが自動でやります。
  • 読み込み中・失敗・再試行の状態も、ライブラリが持っています。
  • paging-compose の最新の安定版は 3.5.1 です(2026-09 時点、Google Maven で確認)。

学習の順序としては、最初は「一度に取得」→ 慣れたら Paging 3 がおすすめです。

画像は、表示するときに取得する
  • API のレスポンスには、画像のバイナリではなく、URL("thumbnailUrl": "https://…")が入ります。
  • 画像は、その行が画面に出るときに、URL から読み込みます。LazyColumn は、見えている行だけを作る (5-3 の実測で9行)ので、見えている行の画像だけが、リクエストされます。相性が良い仕組みです。

Compose 標準では、URL の画像を表示できません。 Step 4 の painterResource は、アプリ内の画像用です。 URL の画像には、ライブラリの Coil を使うのが一般的です。

AsyncImage(
    model = article.thumbnailUrl,
    contentDescription = null,
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)),
)
項目 内容(公式ドキュメントで確認)
依存 coil-compose と、coil-network-okhttp(Coil 3 は、通信の部品が別。入れ忘れると URL の画像が読めない)
最新の安定版 3.6.3(Maven Central で確認)
キャッシュ メモリキャッシュ + ディスクキャッシュ。同じ画像を再び表示するときは、通信せずに、すぐ出る
リクエストの管理 自動(画面から消えた行の読み込みは、中止される)
縮小 表示するサイズに合わせて縮小して読み込む(メモリの節約)
設定例 公式の設定例では、メモリキャッシュが 25%、ディスクキャッシュが 2%
  • キャッシュは、自動で効きます。 自分で作る必要は、ありません。
  • Coil 3 は、サーバーの Cache-Control(有効期限の指定)を、標準では尊重しません。 サーバーの指定を効かせたいときは、設定が要ります。
  • 読み込み前と失敗時の表示(placeholder、error)も、指定できます。 画像にも、「読み込み中・成功・失敗」の状態がある、ということです(5-2 と同じ考え方)。
  • サムネイルは、小さな画像の URLを API に返してもらうのが、通信量の点で有利です(一般的な知識です)。
  • 画像の説明(contentDescription)は、記事のタイトルが、すぐ横に表示されていれば、null で構いません(Step 4 と同じ考え方)。

⚠️ このプロジェクトでは、Paging 3 も Coil も、まだ導入していません。 上のコードは、書き方のイメージです (ビルドも、実機での確認もしていません)。公式ドキュメントと、Maven の情報を確認して書きました。

このプロジェクトでの位置づけ:

Phase 内容
Phase 3 状態を ViewModel に移す(今の UserListLoader の中身)
Phase 5 Retrofit で GET /users を呼び、通信中・成功・失敗を表示する(Step 5 の UsersUiState を、そのまま使える)
発展 記事のような一覧(ページ単位、URL の画像)は、Phase 5 のあと

5-4. タブ — PrimaryTabRow

PrimaryTabRow(selectedTabIndex = selectedTab.ordinal) {
    UserTab.entries.forEach { tab ->
        Tab(
            selected = tab == selectedTab,
            onClick = { onTabSelected(tab) },
            text = { Text(tab.label) },
        )
    }
}
enum class UserTab(val label: String, val emptyMessage: String) {
    All("すべて", "ユーザーがいません"),
    Favorites("お気に入り", "お気に入りのユーザーは、まだいません"),
}
  • タブの名前と、0 件のときのメッセージを、enum にまとめています。 タブを足すときは、enum に1行足します。
  • 選んでいるタブは、引数で受け取ります(selectedTab、onTabSelected)。表示だけの部品は、状態を持ちません。
  • タブは、どの状態のときも表示します。 読み込み中でも、失敗しても、タブは動きません (タブが消えたり出たりすると、画面がガタつきます)。
  • タブが多いときは、横にスクロールできる ScrollableTabRow を使います(ロードマップ 5-1)。
  • タブを、横スワイプでも切り替えたいときは、Step 4 の HorizontalPager と組み合わせます(今回は作りません)。

「お気に入り」タブは、読み込んだユーザーを、絞り込むだけです。

val shown = when (selectedTab) {
    UserTab.All -> state.users
    UserTab.Favorites -> state.users.filter { it.isFavorite }
}

「お気に入り」タブに絞り込んだ、実機の表示です。星の付いた2人だけが残っています。

5-5. 状態ごとの表示

状態 表示 作りで気をつけたこと
Loading くるくる(CircularProgressIndicator)+ 「読み込み中…」 文字を付ける。何を待っているのか伝わる
Error 警告アイコン + メッセージ + 「もう一度試す」ボタン 失敗の表示には、必ず、次の行動を付ける。ボタンが無いと、画面を開き直すしかなくなる
Success(0 件) 「ユーザーがいません」 / 「お気に入りのユーザーは、まだいません」 タブごとに、メッセージを変える
Success(あり) 一覧。お気に入りには、星 —

実機の、それぞれの状態です。

Loading

Error

Success(あり)

Success(お気に入りが0件)

5-6. 画面は「状態を受け取って表示するだけ」— だから Preview で全部見える

@Composable
fun UserListScreen(
    state: UsersUiState,
    selectedTab: UserTab,
    onTabSelected: (UserTab) -> Unit,
    onRetry: () -> Unit,
    modifier: Modifier = Modifier,
)

UserListScreen は、状態・選んだタブ・イベントを、全部、引数で受け取るだけです(stateless)。 だから @Preview では、どんな状態でも、引数を渡すだけで、すぐに表示できます。

UserListScreen(state = UsersUiState.Loading, selectedTab = UserTab.All, onTabSelected = {}, onRetry = {})
UserListScreen(state = UsersUiState.Error("サーバーに接続できませんでした"), …)

UserListScreen.kt には、次の Preview を用意しています。

Preview 見ること
通常 一覧と、星
お気に入りタブ 絞り込み
長い文字列 名前とメールが「…」に省略される
空状態(お気に入りが 0 件) タブごとのメッセージ
空状態(ユーザーが 0 件) 全体が空のとき
読み込み中 くるくる
エラー メッセージと、ボタン
ダーク ダークモード

「読み込み中」と「エラー」は、実機では、待ったり、わざと失敗させたりしないと見られません。 Preview なら、すぐに、見た目を調整できます。これが、状態を引数で受け取る作りの利点です。

5-7. 擬似的な読み込み — LaunchedEffect と suspend

実機で、「読み込み中」と「エラー」を見るために、通信の代わりの、擬似的な読み込みを作りました。 本物の通信は Phase 5 で作ります。

object FakeUserApi {
    val simulateError: Boolean = false           // true にすると、必ず失敗する

    suspend fun fetchUsers(): List<User> {
        delay(1500)                              // 1.5秒待つ
        if (simulateError) throw IOException("サーバーに接続できませんでした")
        return SampleData.users
    }
}
@Composable
fun UserListLoader(modifier: Modifier = Modifier) {
    var reloadCount by remember { mutableIntStateOf(0) }
    var state by remember { mutableStateOf<UsersUiState>(UsersUiState.Loading) }
    var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) }

    LaunchedEffect(reloadCount) {                // ← reloadCount が変わるたびに、最初から実行
        state = UsersUiState.Loading
        state = try {
            UsersUiState.Success(FakeUserApi.fetchUsers())
        } catch (e: IOException) {
            UsersUiState.Error(e.message ?: "読み込みに失敗しました")
        }
    }

    UserListScreen(
        state = state,
        selectedTab = UserTab.entries[selectedTabIndex],
        onTabSelected = { selectedTabIndex = it.ordinal },
        onRetry = { reloadCount++ },             // 「もう一度試す」→ 数字を増やす → 読み込みをやり直す
        modifier = modifier,
    )
}
部品 役割
suspend fun 「時間がかかる処理」の印。コルーチン(下の LaunchedEffect の中など)からしか呼べない
delay(1500) スレッドを止めずに、1.5秒待つ
LaunchedEffect(キー) キーが変わるたびに、中の処理を最初から実行する。SwiftUI の .task(id:) に近い
reloadCount++ 「もう一度試す」が押されたら、数字を増やす。数字が変わると、LaunchedEffect がやり直される

画面を離れると、読み込みは、自動でキャンセルされる

LaunchedEffect の処理は、その画面が消えると、自動でキャンセルされます。 実機で、5秒かかる読み込みの途中で、別のタブに移ったところ、[Load] 結果 のログは、出ませんでした。 「読み込み中に、別の画面へ移っても、安全」です。

捕まえるのは IOException だけ

} catch (e: IOException) {          // ✅ 失敗(通信できなかった)だけを捕まえる

Exception を丸ごと捕まえると、コルーチンのキャンセル(CancellationException)まで捕まえてしまい、 キャンセルが効かなくなります。捕まえるのは、想定している失敗の種類だけにします。

5-8. 実機のログで見る、読み込みの流れ

一覧を開いたときの、実際のログです([Load] は、UserListLoader が出しています)。

15:45:42.050  [Compose] XrStudyApp
15:45:42.083  [Nav] 宛先が変わった → UsersRoute
15:45:42.112  [Compose] UserListScreen           ← ① 読み込み中の画面
15:45:42.188  [Load] 読み込み開始
15:45:43.696  [Load] 結果 → Success(5件)        ← 約1.5秒後
15:45:43.708  [Compose] UserListScreen           ← ② 成功の画面(状態が変わったので、作り直し)

状態が変わる(Loading → Success)と、それを読んでいる UserListScreen が、作り直されます。 Phase 1 の「状態が変わると、それを読む部分だけが再描画される」が、ここでも起きています。

エラーにしたときと、「もう一度試す」を押したときのログです。

15:48:19.151  [Load] 読み込み開始
15:48:20.658  [Load] 結果 → Error(サーバーに接続できませんでした)
15:48:25.446  [Load] 読み込み開始                   ← 「もう一度試す」を押した
15:48:26.957  [Load] 結果 → Error(サーバーに接続できませんでした)

5-9. ⚠️ remember の限界 — 読み込みが、何度もやり直される

UserListLoader は、state(読み込みの結果)を、remember で持っています。 Phase 1 で学んだとおり、remember は、回転や、画面を離れたときに、消えます。 実機で確認しました。

操作 読み込み 選んだタブ 一覧のスクロール位置
ホームに移って、一覧に戻る やり直し([Load] 読み込み開始 が、また出る) 残る(お気に入りのまま) 残る(1000件で「ユーザー 84」のまま)
端末を回転させる やり直し 残る (確認していない)
  • 選んだタブが残るのは、rememberSaveable で持っているためです(消えると困る、小さな値)。
  • 読み込みの結果が消えるのは、remember で持っているためです(Phase 1 の ① と同じ)。
  • スクロール位置が残るのは、LazyColumn の位置が、rememberSaveable で保存されるためです (Step 3 の saveState / restoreState で、タブごとに保存・復元されます)。

「読み込んだデータを、回転や画面移動をまたいで持つ」のは、ViewModel の役目です(Phase 1 の ③)。 Phase 3 で、UserListLoader の中身を、ViewModel に移します。今の remember は、それまでの、仮のものです。

5-10. 実機で確認した結果

SH-51C(Android 14)で確認しました。

確認したこと 結果
一覧を開く 「読み込み中…」→ 約1.5秒後に、5人が表示された。お気に入りの2人に、星
「お気に入り」タブ 2人に絞り込まれた
エラー(一時的に、失敗させた) 警告アイコン、メッセージ、「もう一度試す」が表示された
「もう一度試す」 読み込みが、やり直された
読み込み中に、別のタブへ移る 読み込みが、キャンセルされた([Load] 結果 が出ない)
1000件 最初に作られた行は 9 行。5回スワイプで、新しく作られたのは 84 行
タブ移動・回転 選んだタブは残り、読み込みは、やり直しになった
when の網羅性 種類を足すと、when の2か所が、コンパイルエラーになった

エラー、1000件、5秒の読み込みは、コードを一時的に書き換えて確認しました。書き換えは、元に戻してあります。

長い文字列

ユーザーが0件

5-11. iOS との比較

観点 iOS(SwiftUI) Android(Compose)
状態の型 関連値を持つ enum(case loaded([User]) など) sealed interface
状態ごとの切り替え switch(全ケースを書かないとエラー) when(全部の種類を書かないとエラー)
一覧 List(見えている行だけを作る) LazyColumn
読み込み中の表示 ProgressView CircularProgressIndicator
画面が出たときに読み込む .task { … } / .task(id:) LaunchedEffect(キー) { … }
待つ処理 async 関数 + Task.sleep suspend 関数 + delay
画面を離れたときの中断 .task が自動でキャンセルされる LaunchedEffect が自動でキャンセルされる
タブ Picker(.segmented)、TabView TabRow(PrimaryTabRow)

← 前: Step 4:横スクロールバナー | 目次に戻る | 次: Step 6:設定と仕上げ →

今更だけどAndroidのCompose画面設計 Step 4:横スクロールバナー

← 前: Step 3:下部ナビゲーションと画面遷移 | 目次に戻る | 次: Step 5:ユーザー一覧 →


Step 4:横スクロールバナー(HorizontalPager)

Step 2 では、バナーを「場所取り」の四角で置いていました。これを、横にスワイプして切り替えられる、画像のバナーに置き換えます。 ロードマップの方針は、「自動送りより、手でスワイプ、ページ表示、タップでの遷移を優先する」です。

4-1. 何を作ったのか

res/drawable/
├─ banner_green.xml        ← 自作のバナー画像(ベクター画像)3枚
├─ banner_indigo.xml
└─ banner_orange.xml
ui/
├─ home/
│   ├─ BannerPager.kt         ← 横スワイプのバナー + ページ表示
│   ├─ BannerDetailScreen.kt  ← バナーをタップしたときの詳細画面
│   └─ HomeScreen.kt          ← 場所取りを BannerPager に置き換え
├─ model/Models.kt            ← Banner を追加
├─ SampleData.kt              ← banners(3件)を追加
└─ navigation/Routes.kt       ← BannerDetailRoute(引数付きの宛先)を追加

4-2. HorizontalPager の基本

val pagerState = rememberPagerState(pageCount = { banners.size })

HorizontalPager(
    state = pagerState,
    contentPadding = PaddingValues(horizontal = 16.dp),
    pageSpacing = 12.dp,
    key = { page -> banners[page].id },
) { page ->
    BannerCard(banner = banners[page], onClick = { onBannerClick(banners[page]) })
}
部品 役割
HorizontalPager 横にスワイプして、1ページずつ切り替える入れ物。SwiftUI の TabView(.page スタイル)に近い
PagerState 「今どのページか」を持つ。rememberPagerState で作る
{ page -> … } ページの中身。page は 0 から始まる位置

pageCount は、値(3)ではなく、関数({ banners.size })で渡します。 あとからデータの件数が変わっても、最新の件数を読めるようにするためです。

contentPadding を付けると、隣のページの端が少し見えます。 「横にスクロールできる」ことが、見た目で伝わります。 0.dp にすると、隣のページは見えなくなります。

key は、ページを「位置」ではなく「id」で見分ける指定です。データの並び順が変わっても、ページごとの状態が混ざりません。

ページの位置は、回転しても残る

rememberPagerState は、内部で rememberSaveable を使っています。実機で、3ページ目まで進めてから、次の操作をしました。

操作 戻ったあとのページ
別のタブ(一覧)に移って、ホームに戻る 3ページ目のまま
バナーをタップして詳細を開き、戻る 3ページ目のまま
端末を回転させる 3ページ目のまま

タブの移動で残るのは、Step 3 の saveState / restoreState(3-7)が、この状態も保存してくれるためです。

4-3. 画像を表示する

Image(
    painter = painterResource(banner.imageRes),   // R.drawable.banner_green など
    contentDescription = null,
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxSize(),
)

画像は、res/drawable に置いて、R.drawable.ファイル名 で参照する

  • このプロジェクトのバナー画像は、自作のベクター画像(XML) です。点と線で描く画像で、拡大しても粗くならず、 ファイルも小さくなります。外部の素材を使っていないので、権利の心配がありません (ロードマップの「画像・アイコンを無断でアプリへ流用しない」に沿っています)。
  • PNG や JPG などの写真も、同じ書き方(painterResource)で読み込めます。ファイルを res/drawable に置くだけです。
  • Web 上の画像を表示するには、別のライブラリ(Coil など)が要ります(一般的な知識です。このプロジェクトでは使っていません)。
data class Banner(
    val id: Int,
    @DrawableRes val imageRes: Int,     // ← 画像リソースの ID
    val title: String,
    val description: String,
)

@DrawableRes は、「この Int は、画像リソースの ID です」という印です。ただの Int と区別できるので、 間違って別の数字(ユーザーの id など)を渡すと、Android Studio が警告してくれます。

contentScale — 画像を枠に合わせる方法

指定 動き
Crop 縦横の比率を保ったまま、枠いっぱいに広げ、はみ出た分を切る(バナーに向く)
Fit 縦横の比率を保ったまま、枠に収まるように縮める(余白ができることがある)
FillBounds 枠いっぱいに引き伸ばす(比率が崩れる)

contentDescription(画像の説明)

バナーの画像は、contentDescription = null(説明なし)にしています。 画像の意味は、上に重ねたタイトルと説明で、すでに伝わっているためです。 画像だけで意味を持つとき(文字が無いアイコンなど)は、必ず説明を付けます。

4-4. 画像の上に文字を重ねる

Box {
    Image(…)                                         // ① 画像
    Box(Modifier.fillMaxSize().background(           // ② 下が暗くなる膜
        Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.65f)))))
    Column(Modifier.align(Alignment.BottomStart)…) { // ③ 文字
        Text(banner.title, color = Color.White, …)
    }
}

Box は、中に置いた部品を重ねて表示します(SwiftUI の ZStack)。書いた順に、下から上へ重なります。

  • ② の膜が必要な理由: 画像の色は、どんな色にもなりえます。下を暗くしておくと、白い文字が、どの画像でも読めます。
  • 文字色を Color.White に固定している理由: 背景が「テーマ」ではなく「画像」で、ライトとダークで変わらないためです。 「色を直接書かない」原則(1-2)の、数少ない例外です。

4-5. ページ表示(● ○ ○)

PageIndicator(pageCount = banners.size, currentPage = pagerState.currentPage)
repeat(pageCount) { index ->
    val selected = index == currentPage
    Box(
        Modifier
            .size(if (selected) 10.dp else 8.dp)
            .clip(CircleShape)
            .background(if (selected) primary else outlineVariant)
    )
}
  • 点の数は、ページ数。今のページだけ、大きく、色を変えます。 このプロジェクトの material3(1.4.0)には、標準のページ表示の部品が 見当たらなかったので、自分で作ります。
  • PageIndicator は、currentPage(数字)を受け取るだけで、PagerState を知りません(state hoisting)。 見た目を確認するときは、数字を渡すだけで済みます。
  • currentPage は、スワイプ中に、一番近いページに切り替わります(半分を超えたあたり)。スワイプが終わって落ち着いたページは、settledPage で取れます (PagerState に、どちらもあることを、ライブラリ foundation 1.12.0 で確認しました)。
  • 読み上げ用の説明を付けています。 目で見るだけの部品なので、semantics で「3 / 3 ページ目」という説明を付けています (実機で、uiautomator を使って、この説明が読み取れることを確認しました)。

4-6. タップして、詳細画面へ移動する(引数付きの宛先)

バナーをタップすると、そのバナーの詳細画面が開きます。どのバナーかを、宛先の引数で渡します。

@Serializable
data class BannerDetailRoute(val bannerId: Int)          // 引数がある宛先は data class
// 渡す側(バナーが押されたとき)
onBannerClick = { banner -> navController.navigate(BannerDetailRoute(banner.id)) }

// 受け取る側
composable<BannerDetailRoute> { backStackEntry ->
    val route = backStackEntry.toRoute<BannerDetailRoute>()
    BannerDetailScreen(banner = SampleData.banners.firstOrNull { it.id == route.bannerId })
}
  • 引数は、型(Int)のまま、受け渡せます。文字列のルートで "banner/2" と書いて、あとで数字に直す必要がありません(3-4)。
  • HomeScreen は、移動のしかたを知りません。 「バナーが押された」と、親(XrStudyApp)に伝えるだけです (Phase 1 の CounterCard の onIncrement と同じ形)。どこへ移動するかは、親が決めます。
  • 見つからない場合に備えます。 firstOrNull は、見つからないと null を返します。 BannerDetailScreen は Banner? を受け取り、null のときは「バナーが見つかりません」と表示します。 first を使うと、見つからないときにアプリが落ちます(2-4 の first() と同じ理由)。

実機で、バナーをタップして、戻ったときの [Nav] のログです。

[Nav] 宛先が変わった → BannerDetailRoute/{bannerId}
[Nav] 宛先が変わった → HomeRoute

宛先の名前に {bannerId} と出るのは、引数の場所を示す、ルートの形です(値そのものではありません)。

4-7. 詳細画面の Top App Bar

テーマの確認画面と同じく、トップレベルではない画面です。戻る矢印を出し、下部ナビゲーションを隠します。 テーマの確認画面と詳細画面の、2つに同じ扱いをするため、まとめて判定しています。

val isSubScreen = isThemeScreen || isBannerDetailScreen

navigationIcon = { if (isSubScreen) { 戻る矢印 } }
val showTopLevelBars = !isSubScreen

4-8. 自動送りは、まだ作らない

ロードマップは、「自動送りより、手でスワイプ、ページ表示、タップ遷移を優先」としています。 自動送りは、一定時間ごとに次のページへ進める処理(LaunchedEffect と待ち時間)で作れますが、 ユーザーがスワイプしている最中に勝手に動くと、操作しにくくなります。必要になってから足します。

4-9. 実機で確認した結果

SH-51C(Android 14)で確認しました。

確認したこと 結果
バナーの表示 隣のページの端が見えた。ページ表示は「● ○ ○」
スワイプ 1ページずつ切り替わり、ページ表示の点が動いた
タップ 詳細画面が開いた(戻る矢印あり、下部ナビは隠れた)
戻る ホームに戻り、ページの位置が残った
タブ移動・回転 ページの位置が残った。横向きでも、バナーが表示された
長い文字列 タイトルは1行で「…」、説明は2行で「…」に省略された。ページ表示は4つになった
バナーが 0 件 バナーの枠が出ず、「お知らせ」が上に詰まった
innerPadding を外す バナーの上半分が、Top App Bar の裏に隠れて切れた

長い文字列と 0 件は、XrStudyApp.kt のデータを一時的に差し替えて、実機で確認しました。

1ページ目

スワイプして2ページ目

タップして詳細画面

長い文字列のバナーです。タイトルが1行、説明が2行で「…」に省略され、ページ表示が4つになっています。

ホーム(長いバナー)

詳細画面(長いバナー)

4-10. iOS との比較

観点 iOS(SwiftUI) Android(Compose)
ページ送り TabView + .tabViewStyle(.page) HorizontalPager
今のページ TabView の selection PagerState.currentPage
ページ表示(● ○ ○) .page スタイルが自動で付ける 自分で作る
重ねる ZStack Box
画像 Image("名前") Image(painterResource(R.drawable.名前), …)
画像の収め方 .scaledToFill() / .scaledToFit() ContentScale.Crop / Fit
画像の説明 .accessibilityLabel contentDescription

← 前: Step 3:下部ナビゲーションと画面遷移 | 目次に戻る | 次: Step 5:ユーザー一覧 →

今更だけどAndroidのCompose画面設計 Step 3:下部ナビゲーションと画面遷移

← 前: Step 2:静的な3画面 | 目次に戻る | 次: Step 4:横スクロールバナー →


Step 3:下部ナビゲーションと画面遷移

Step 2 の仮のスイッチを、本物の下部ナビゲーションと画面遷移(NavHost) に置き換えます。 主なテーマは、戻るボタンを押したときの動きと、選んでいるタブを正しく表示することです。

3-1. 何を作ったのか

gradle/libs.versions.toml、app/build.gradle.kts
    ← 依存を追加(下の表)
ui/
├─ XrStudyApp.kt              ← Scaffold + NavigationBar + NavHost(仮のスイッチを削除)
└─ navigation/
    ├─ Routes.kt              ← 画面の宛先(HomeRoute など)
    └─ TopLevelDestination.kt ← 下部ナビに並ぶ3画面(名前・アイコン・宛先)
追加した依存 役割
navigation-compose 2.10.1 NavHost / NavController(画面遷移)
material-icons-core アイコン(Icons.Filled.Home など)。material3 には含まれないので、別に必要(入れ忘れると Icons が見つからずコンパイルエラーになります)
Kotlin の serialization プラグイン 型安全なルート(@Serializable)に必要

3-2. なぜ NavHost(Navigation Compose)にしたのか

Compose の画面遷移には、2つのライブラリがあります。

書き方 安定版(2026-09 時点、Google Maven で確認)
Navigation Compose NavHost + NavController 2.10.1
Navigation 3 NavDisplay + バックスタックを自分で持つ 1.1.7

どちらも安定版があります。 このプロジェクトでは、次の理由で NavHost にしました。

  • ロードマップが NavHost を指定している
  • 下部ナビゲーションの「タブごとの状態の保存・復元」が、navigate の指定だけで書ける(3-7)
  • 公式ドキュメントの Compose ナビゲーションのページ(今回確認したもの)が、NavHost の書き方で書かれている

Navigation 3 は、将来の選択肢です。公式ドキュメントが、新規のアプリにどちらを推奨しているかは、 今回調べた範囲では読み取れませんでした。

3-3. NavHost の3つの部品

val navController = rememberNavController()          // ① 今どこにいるか、どの順で来たかを持つ

NavHost(                                             // ② 今の宛先の画面を表示する場所
    navController = navController,
    startDestination = HomeRoute,                    //    最初の宛先
) {
    composable<HomeRoute> { HomeScreen(…) }          // ③ 宛先ごとに、表示する画面を登録する
    composable<UsersRoute> { UserListScreen(…) }
}

navController.navigate(UsersRoute)                   // 画面を移動する
部品 役割
NavController バックスタック(今までに開いた画面の履歴)を持つ。navigate で積み、popBackStack で戻る
NavHost バックスタックの一番上の宛先を、画面に表示する
composable<宛先> 「この宛先のときは、この画面を表示する」という登録

rememberNavController は、回転しても、バックスタックを保ちます。 SwiftUI の NavigationStack と、その path(画面の履歴)に近い仕組みです。

3-4. 宛先は「型」で書く(型安全なルート)

@Serializable
object HomeRoute

@Serializable
object UsersRoute

画面の宛先を、"home" のような文字列ではなく、型(@Serializable の object)で表します。

navController.navigate(UsersRoute)          // ✅ 型で指定
navController.navigate("usres")             // ❌ 文字列だと、打ち間違いに実行時まで気づけない

型なら、打ち間違いはコンパイルエラーになります。 今回の宛先は、受け取る値(引数)が無いので object です。引数がある宛先は data class にします (例:data class UserDetailRoute(val id: Int))。

3-5. 下部ナビゲーション

enum class TopLevelDestination(val route: Any, val label: String, val icon: ImageVector) {
    Home(HomeRoute, "ホーム", Icons.Filled.Home),
    Users(UsersRoute, "一覧", Icons.Filled.Person),
    Settings(SettingsRoute, "設定", Icons.Filled.Settings),
}
NavigationBar {
    TopLevelDestination.entries.forEach { top ->
        NavigationBarItem(
            selected = top == selectedTopLevel,
            onClick = { navController.navigateToTopLevel(top) },
            icon = { Icon(top.icon, contentDescription = null) },
            label = { Text(top.label) },
        )
    }
}
  • 名前・アイコン・宛先を、enum に1か所にまとめています。 画面を足すときは、enum に1行足せば、下部ナビにも出ます。
  • アイコンの contentDescription は null です。 文字(label)が見えているので、説明を付けると、 読み上げで同じ内容が2回読まれるためです。逆に、文字が無いアイコンだけのボタン (Top App Bar の (i) ボタン、戻る矢印)には、必ず説明を付けます。
  • Material 3 の下部ナビゲーションは、3〜5個の主要な画面に向いています。

3-6. ★ 選択中のタブは、バックスタックから求める

val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination

val selectedTopLevel = TopLevelDestination.entries.firstOrNull { top ->
    currentDestination?.hierarchy?.any { it.hasRoute(top.route::class) } == true
}

「選択中のタブ」を、rememberSaveable などの別の変数で持ちません。 NavController が持つ「今の宛先」から、毎回求めます。

公式ドキュメントのサンプルには、選択中のタブを rememberSaveable の変数で別に持つ書き方があります。 この書き方だと、戻るボタンで前の画面に戻ったとき、画面は変わるのに、タブの表示が変わらない というずれが起きるおそれがあります(サンプルそのものは、実行して確かめていません)。

今の宛先から求めれば、タップでも、戻るボタンでも、回転でも、常に画面とタブが一致します。 「状態は、1か所だけで持つ」という原則です(同じ情報を2か所で持つと、ずれる)。

3-7. タブを押したときの移動

private fun NavController.navigateToTopLevel(destination: TopLevelDestination) {
    navigate(destination.route) {
        popUpTo(graph.findStartDestination().id) { saveState = true }
        launchSingleTop = true
        restoreState = true
    }
}

下部ナビゲーションの、決まった書き方です。3つの指定には、それぞれ役割があります。

指定 役割 外すと
popUpTo(最初の画面) { saveState = true } タブを移るたびに、バックスタックが積み上がらないようにする。抜ける画面の状態は保存する 戻るボタンで、押してきたタブを逆にたどる
launchSingleTop = true 表示中のタブをもう一度押しても、同じ画面を重ねて作らない 同じ画面が積み重なる
restoreState = true 前に開いたタブに戻ったとき、保存した状態を復元する スクロール位置などが先頭に戻る

実機で確認した動き(SH-51C・Android 14)

指定を全部付けたとき: ホーム → 一覧 → 設定 → 戻るボタン

[Nav] 宛先が変わった → UsersRoute
[Nav] 宛先が変わった → SettingsRoute
[Nav] 宛先が変わった → HomeRoute        ← 戻るボタン。一覧ではなく、ホームに戻る

バックスタックが積み上がらないので、戻るボタンは、最初の画面(ホーム)に戻ります。 これが、下部ナビゲーションの標準的な動きです。

popUpTo を外したとき: ホーム → 一覧 → 設定 → ホーム(タップ)→ 戻るボタンを3回

[Nav] 宛先が変わった → UsersRoute
[Nav] 宛先が変わった → SettingsRoute
[Nav] 宛先が変わった → HomeRoute        ← タップ
[Nav] 宛先が変わった → SettingsRoute    ← 戻る(押してきたタブを逆にたどる)
[Nav] 宛先が変わった → UsersRoute
[Nav] 宛先が変わった → HomeRoute

saveState と restoreState を外したとき: 一覧を下へスクロールし(先頭が「ユーザー 26」)、 ホームに移って一覧に戻る

一覧に戻ったときの先頭
指定あり(saveState + restoreState) ユーザー 27 のまま(スクロール位置が残る)
指定なし ユーザー 1(先頭に戻る)

(40件の一覧は、確認用に一時的に差し替えたものです。スクロール位置の保存には、 Step 1 で確認した rememberScrollState が、Saver で保存に対応していることが使われています)

3-8. 戻るボタンの動き

タブの画面で戻る

最初の画面(ホーム)で、もう一度戻るボタンを押すと、アプリを離れます。

  • adb(monkey)で起動した場合は、onPause → onStop → onDestroy まで進み、Activity が終了しました。
  • ホーム画面のアイコンから起動した場合は、Android 12 以降、バックグラウンドへ移るだけで onDestroy まで進みません(Hello World の解説で確認した動きです)。今回は、この端末のホーム画面のページに、 アプリのアイコンが見当たらず、Step 3 の構成では確認できていません。

起動方法で、戻るボタンの結果が変わることに注意してください (adb から起動したタスクは、アイコンから起動したタスクと、扱いが違うことがあります)。

テーマの確認画面で戻る(トップレベルではない画面)

Top App Bar の (i) ボタンは、navigate(ThemeRoute) で、バックスタックに画面を積みます。

IconButton(onClick = { navController.navigate(ThemeRoute) }) { … }        // (i) ボタン:積む
IconButton(onClick = { navController.popBackStack() }) { … }              // 戻る矢印:1つ戻る

実機で、(i) → 端末の戻るボタン → (i) → 画面の戻る矢印、と操作したログです。

[Nav] 宛先が変わった → ThemeRoute
[Nav] 宛先が変わった → HomeRoute        ← 端末の戻るボタン
[Nav] 宛先が変わった → ThemeRoute
[Nav] 宛先が変わった → HomeRoute        ← 画面の戻る矢印

端末の戻るボタンと、画面の戻る矢印は、同じ動き(1つ前の画面に戻る)です。

3-9. ⚠️ 起動・回転の直後は、「今の宛先」が null

[Compose] XrStudyApp
[Nav] 宛先が変わった → null            ← 最初の組み立て。宛先がまだ決まっていない
[Compose] XrStudyApp                   ← 少し後(コールドスタートで約0.5秒、回転で約0.2秒)に再実行
[Nav] 宛先が変わった → HomeRoute

currentBackStackEntryAsState() は、最初の組み立てでは null を返し、 そのあとで本当の宛先が入ります。

もし、下部ナビを「selectedTopLevel != null のときだけ」表示すると、 下部ナビが遅れて現れ、本文の余白が動いて、画面がガタつきます。

そのため、バーの表示は、「テーマの確認画面ではない」で決めています。

val showTopLevelBars = !isThemeScreen     // null の間も、true(バーが最初から出る)

(一瞬、選択中のタブの強調と、タイトルが後から入ります。余白が動くよりは、目立ちません。 この遅れそのものは、ログで確認したもので、目で見て確かめたものではありません)

3-10. 画面ごとに Top App Bar と下部ナビを変える

CenterAlignedTopAppBar(
    title = { Text(タブなら label、テーマの確認なら "テーマの確認") },
    navigationIcon = { if (isThemeScreen) { 戻る矢印 } },
    actions = { if (showTopLevelBars) { (i) ボタン } },
)
bottomBar = { if (showTopLevelBars) { NavigationBar { … } } }
画面 タイトル 左 右 下部ナビ
ホーム・一覧・設定 画面の名前 なし (i) ボタン あり
テーマの確認 テーマの確認 戻る矢印 なし なし

Scaffold は、アプリの一番外側に1つだけ置いて、バーの中身を、今の宛先で切り替えています。 (画面ごとに Scaffold を持つ作り方もありますが、その場合、下部ナビも画面ごとに別々に組み立てられる ことになります)

左は、下部ナビがある、トップレベルの画面(ホーム)。右は、下部ナビが隠れ、戻る矢印が出る、 サブ画面(テーマの確認)です。

トップレベル(ホーム)

サブ画面(テーマの確認)

3-11. 実機で確認した結果

確認したこと 結果
3つのタブの表示 ホーム・一覧・設定が表示され、選択中のタブが強調された
テーマの確認画面 戻る矢印が出て、下部ナビが隠れた(スクリーンショットと uiautomator で確認)
戻るボタン(タブ) ホーム → 一覧 → 設定 → 戻る → ホーム
戻るボタン(テーマの確認) 端末の戻るボタン・戻る矢印とも、1つ前の画面に戻った
スクロール位置の保存 ホームに移って一覧に戻っても、位置が残った
回転(設定タブ、横向き) 「設定」が選ばれたまま復元された。下部ナビも表示された
innerPadding を外す 場所取りのバナーの上半分が、Top App Bar の裏に隠れた(Step 3 の時点)

横向き(ダーク)にしたときの例です。右側のナビゲーションバーを避けて、下部ナビも表示されています。

3-12. iOS との比較

観点 iOS(SwiftUI) Android(Compose)
画面の履歴 NavigationStack の path NavController のバックスタック
宛先の登録 navigationDestination(for:) composable<宛先>
画面を移動する path.append(値) navController.navigate(宛先)
下部のタブ TabView NavigationBar + NavHost(自分で組み合わせる)
タブごとの状態 TabView が各タブを保持する saveState / restoreState の指定で保存・復元する
戻る 左上の戻るボタン、スワイプ 端末の戻るボタン(画面の戻る矢印は、自分で作る)

TabView は、各タブの状態を自動で保持します。 Android は、navigate の3つの指定を、 自分で書く必要があります(3-7)。


← 前: Step 2:静的な3画面 | 目次に戻る | 次: Step 4:横スクロールバナー →

今更だけどAndroidのCompose画面設計 Step 2:静的な3画面

← 前: Step 1:テーマと骨組み | 目次に戻る | 次: Step 3:下部ナビゲーションと画面遷移 →


Step 2:静的な3画面

「ホーム/ユーザー一覧/設定」の3画面を、データを固定値にして作ります。 この Step の時点では、画面の切り替えは仮のスイッチです(Step 3 で下部ナビゲーションに置き換えました)。 バナーの横スクロールは Step 4 で作りました。一覧のタブと読み込み中・エラー表示は Step 5、 スイッチを動かすのは Step 6 で作りました。

2-1. 何を作ったのか

ui/
├─ XrStudyApp.kt          ← 画面を切り替える仮のスイッチを追加(Step 3 で NavHost に置き換え済み)
├─ SampleData.kt          ← 固定のデータ(お知らせ・ユーザー)
├─ PreviewSupport.kt      ← @Preview 用の共通の枠(PreviewFrame)
├─ model/Models.kt        ← データの型(Notice, User)
├─ components/Components.kt  ← 複数の画面で使う部品(SectionHeader, EmptyState)
├─ home/HomeScreen.kt     ← バナー(この Step の時点では場所取り。Step 4 で HorizontalPager に置き換え済み)+ お知らせ
├─ users/UserListScreen.kt ← ユーザー一覧(Step 5 で、タブ・LazyColumn・状態ごとの表示に作り直し)
└─ settings/SettingsScreen.kt ← 設定

画面ごとにフォルダを分けています(home/、users/、settings/)。 ファイルの種類(画面だけ・部品だけ)ではなく、機能で分けると、 「ホームを直したい」ときに、home/ の中だけを見ればよくなります。

2-2. 画面は「データを引数で受け取る」だけ

@Composable
fun HomeScreen(
    notices: List<Notice>,        // ← データは引数で受け取る
    modifier: Modifier = Modifier,
)

HomeScreen は、データの出どころ(固定値なのか、通信なのか)を知りません。 渡されたものを表示するだけです。Phase 1 の CounterCard が、count を引数で受け取っていたのと同じ state hoisting(状態を上に持たせる) の考え方です。

この作りだと、同じ画面に、違うデータを渡して確認できます。

HomeScreen(notices = SampleData.notices)               // 通常
HomeScreen(notices = listOf(SampleData.longNotice) + …) // 長い文字列
HomeScreen(notices = emptyList())                       // 空状態

@Preview で「通常・長い文字列・空状態」を並べて確認できるのは、このためです。 画面の中で SampleData を直接読んでいたら、この確認はできません。

SampleData.kt は、「固定のデータを、1か所に集める」ためのファイルです。 Phase 4 以降で、通信やデータベースから取ってくるようになったとき、 画面のコードは変えずに、データの渡し方だけを変えれば済みます。

listOf と data class — データの作り方

SampleData.kt のデータは、次のように書いています。

val notices = listOf(
    Notice(1, "メンテナンスのお知らせ", "2026-09-20"),
    Notice(2, "新機能を追加しました", "2026-09-18"),
    Notice(3, "利用規約を更新しました", "2026-09-10"),
)

これは、「Notice を3つ作って、リストに入れ、notices という名前を付けた」という意味です。

Notice は、データの入れ物の設計図(型)です。 Models.kt で定義しています。

data class Notice(val id: Int, val title: String, val date: String)

Notice(1, "…", "…") は、その設計図から、実際の1件分のデータ(インスタンス)を1つ作る書き方です。

Notice(1, "メンテナンスのお知らせ", "2026-09-20")
       │   │                        └ date
       │   └ title
       └ id

引数は、id、title、date の定義の順番で渡します。名前を付けて書くこともできます。

Notice(id = 1, title = "メンテナンスのお知らせ", date = "2026-09-20")

listOf は、リスト(List)を作ります。 Swift の配列([Notice])に近いものです。

項目 内容
型 List<Notice>(「Notice のリスト」。Kotlin が自動で判断します)
順番 入れた順に並ぶ
取り出し notices[0](1つ目)、notices.size(個数)、notices.forEach { … }(順に処理)、notices.isEmpty()(空か)
変更 できません(add や remove が無い。読み取り専用)
  • 変更できるリストが必要なときは、mutableListOf(...) を使います。
  • 普通の配列(arrayOf)もありますが、あまり使いません。List を使うのが一般的です。
  • リストを足したいときは、+ で、新しいリストを作ります(元のリストは変わりません)。 Preview で listOf(SampleData.longNotice) + SampleData.notices と書いているのが、この使い方です。
  • data class の値も、val(変更不可)です。1項目だけ違うコピーが欲しいときは、 notice.copy(title = "新しいタイトル") と書くと、別のインスタンスができます。

Swift で書くと:

struct Notice { let id: Int; let title: String; let date: String }

let notices = [
    Notice(id: 1, title: "メンテナンスのお知らせ", date: "2026-09-20"),
    Notice(id: 2, title: "新機能を追加しました", date: "2026-09-18"),
    Notice(id: 3, title: "利用規約を更新しました", date: "2026-09-10"),
]
  • Swift の struct に、Kotlin の data class が対応します。
  • Swift は、引数名(id: など)を必ず書きますが、Kotlin は、書かなくても構いません(順番で決まります)。
  • 最後の項目の後ろの , は、あっても構いません。項目を足すときに、差分が小さくなるので、付けることが多いです。

2-3. 一覧の1行は ListItem

ListItem(
    leadingContent = { Avatar(…) },                  // 左(アイコン・画像)
    headlineContent = { Text(user.name) },           // 主題
    supportingContent = { Text(user.email) },        // 補足
    trailingContent = { Switch(…) },                 // 右(スイッチ・値)
)

ListItem は、Material 3 の「一覧の1行」の部品です。 4つの場所に中身を渡すだけで、余白・文字スタイル・色が自動で決まります (headline は bodyLarge、supporting は bodyMedium など)。使わない場所は省略できます。

SwiftUI の List の中の行(HStack を自分で組む代わりの、決まった形の行)に近い部品です。

丸いアバター(画像の代わり)

Surface(
    shape = CircleShape,
    color = MaterialTheme.colorScheme.secondaryContainer,
    contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
    modifier = Modifier.size(40.dp)
) {
    Box(contentAlignment = Alignment.Center) { Text(initial) }
}

Surface に「背景色(color)」と「中の文字色(contentColor)」を渡しています。 中の Text は色を書かなくても、contentColor で描かれます(1-2 の「背景と文字はペア」)。 背景が secondaryContainer、文字が onSecondaryContainer という、ペアの組み合わせです。

設定のスイッチ

Switch(checked = checked, onCheckedChange = null)

onCheckedChange = null は、「押されても何もしない(表示専用)」という指定です。 今の Step 2 では、見た目だけを作るので、これで足ります。 押したら切り替わるようにする(Step 6 で作りました)には、状態(checked)と、押されたときの処理を、 親から受け取る形にします。CounterCard の count と onIncrement と同じ形です。

2-4. 「長い文字列」と「空状態」を最初から作る

見た目を作るとき、固定のきれいなデータだけで確認すると、あとで崩れます。 実際のデータは、こちらの都合の長さでは来ません。

長い文字列:maxLines と overflow

Text(text = user.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
指定 動き
なし 長いと何行にも折り返す。行の高さがバラバラになる
maxLines = 1 + Ellipsis 1行に収まらない分を「…」で省略する

どこを1行にして、どこを折り返すかは、デザインの指定次第です。

  • ユーザー一覧の名前・メール:1行で省略(行の高さをそろえたい)
  • ホームのお知らせのタイトル:2行まで(意味が伝わるように、少し余裕を持たせる)

SwiftUI の .lineLimit(1) と .truncationMode(.tail) に相当します。

空状態:EmptyState

if (users.isEmpty()) {
    EmptyState("ユーザーがいません")
} else {
    users.forEach { user -> UserRow(user) }
}

データが 0 件のとき、何も出さないと画面が真っ白になり、壊れたように見えます。 「今は 0 件です」と伝える表示を、最初から用意します。

SwiftUI の ContentUnavailableView(iOS 17 以降)に相当します。

名前が空でも落ちないようにする

Text(text = initial.firstOrNull()?.toString() ?: "?")

first() は、文字列が空だと例外でアプリが落ちます。 firstOrNull() は、空なら null を返すので、?: "?" で代わりの文字を出せます。 外から来るデータは、空・長い・欠けている、が起こりうるという前提で書きます。

2-5. ⚠️ Column + verticalScroll + forEach は、件数が少ないときだけ

Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    users.forEach { user -> UserRow(user) }
}

この書き方は、画面に見えない行まで、全部作ってしまいます。 5件なら問題ありませんが、1000件になると、起動が遅くなり、メモリも使います。

Step 5 で、見えている行だけを作る LazyColumn に置き換えました(Step 5 の 5-3)。 (SwiftUI の List が、見えている行だけを作るのと同じです)

2-6. @Preview を「通常・長い文字列・空状態・ダーク」で並べる

@Preview(name = "通常", showBackground = true, heightDp = 640)
@Composable
private fun HomeScreenPreview() {
    PreviewFrame { HomeScreen(notices = SampleData.notices, …) }
}

@Preview(name = "空状態", showBackground = true, heightDp = 640)
@Composable
private fun HomeScreenEmptyPreview() {
    PreviewFrame { HomeScreen(notices = emptyList(), …) }
}

(Step 2 の時点では、ユーザー一覧の Preview を例にしていました。ユーザー一覧は、Step 5 で、状態を引数で渡す形に 変わったので、ここでは、ホーム画面の Preview に置き換えています。)

  • PreviewFrame は、テーマ(XRStudyTheme)と背景色を付ける共通の枠です(PreviewSupport.kt)。 テーマで包まないと、Preview では色や文字が Material の初期値になります。
  • heightDp は、Preview の高さです。指定しないと、中身の高さに縮みます。
  • Preview 関数は private にして、アプリ本体から呼ばれないようにします。

Android Studio では、1つのファイルの Preview が縦に並んで表示されます。 実機やエミュレーターを動かさずに、4つの状態を一度に見比べられます。 SwiftUI の #Preview を、複数並べるのと同じ使い方です。

「4パターン」に決まりはあるのか

決まりは、ありません。 公式ドキュメントで確認できたのは、「4パターンを作る」という決まりではなく、 Preview の便利な仕組みです。

大事なのは、その画面が取りうる状態を、Preview で見えるようにしておくという考え方です。 このプロジェクトの4パターンは、この考え方を、2つの画面に当てはめて選んだものです (ロードマップ 5-2 の「通常・長い文字列・空状態・エラー状態を個別に確認する」に沿っています)。

パターン 見ること
通常 基本の見た目
長い文字列 実際のデータは、こちらの都合の長さでは来ない(2-4)
空状態 データが 0 件のとき、画面が真っ白にならないか
ダーク ライトとダークで、読めなくならないか

画面によって、必要なパターンは変わります。

  • 設定画面には、「空状態」がありません
  • 通信する画面なら、「読み込み中」と「エラー」が要ります(エラー状態は、Step 5 で足します)

公式ドキュメントにある Preview の仕組み

仕組み 使いどころ
@PreviewLightDark ライトとダークを、1つの指定で並べる(今は、ダーク用の関数を別に書いている)
@PreviewFontScale 文字を大きくしたときの崩れを見る(ロードマップ 5-2 の「文字が大きい場合の崩れ」に合う)
@PreviewScreenSizes 画面の大きさ違いを並べる
@PreviewParameter 1つの Preview 関数に、データを何種類か渡して並べる(PreviewParameterProvider と組み合わせる)
自作のアノテーション 複数の @Preview をまとめて、1つの名前で使い回す(Multipreview)

上の5つのうち、@PreviewLightDark、@PreviewFontScale、@PreviewScreenSizes、@PreviewParameter は、 このプロジェクトのライブラリ(ui-tooling-preview 1.12.0)に入っていることを確認しました。

今の書き方との違い: 今の PreviewFrame は、ダークかどうかを引数(darkTheme)で渡しています。 @PreviewLightDark は、端末のダークモード設定(uiMode)で切り替わる仕組みなので、使うには、 PreviewFrame を、端末の設定に従う形(isSystemInDarkTheme())に変える必要があります。

画面の作りとの関係: 公式ドキュメントにも、ViewModel の代わりに、状態を引数で渡す形にすると Preview しやすい、という説明があります。2-2 の「画面はデータを引数で受け取るだけ」と同じ考え方です。

名前の付け方

公式のサンプルは、関数名の末尾に Preview を付ける例が多く(UserProfilePreview など)、 先頭に付ける例もあります。どちらかに統一すれば十分です。このプロジェクトは末尾に付けています。

Preview と実機の使い分け

デザインの確認は Preview、動きの確認は実機、と分けるのが基本です。

Preview 実機
得意なこと 見た目の確認。変更してすぐ見られる 動きの確認
向いている確認 色、余白、文字、ダーク、長い文字列、空状態、文字を大きくしたとき 画面遷移、戻るボタン、回転、ライフサイクル、スクロール、アニメーション、キーボード、速さ

おすすめの流れ:

① Preview で、デザインの状態を確認(通常・長い文字列・空状態・ダーク)
      ↓
② 問題なければ、実機で「動き」を確認(画面遷移、戻るボタン、回転)
      ↓
③ デザイナーさんの Figma と、実機の見た目を見比べる

Preview で見た目を固めておくと、実機での確認が、動きの確認に集中できます。

このプロジェクトで、実機やログで初めて分かったこと:

分かったこと どこで
戻るボタンの動き(ホーム → 一覧 → 設定 → 戻る → ホーム。popUpTo を外すと、逆にたどる) Step 3
起動・回転の直後、「今の宛先」が一瞬 null になる(下部ナビが遅れて現れかける) Step 3(ログで確認)
innerPadding を渡さないと、本文がバーの裏に潜り込む Step 1(実機で確認)
回転しても、選んだタブが残る。ホームボタンで離れて戻っても、Compose は組み立て直されない 起動の流れ(ログで確認)
戻るボタンで Activity が終了するかどうかは、起動方法(adb かアイコンか)で変わる Step 3
ダークモードでの起動直後の背景(values-night/themes.xml)。Compose の外にある XML の設定なので、Preview には出ない Step 1
  • innerPadding の抜けは、Preview でも、システムバーを含めて表示する設定(showSystemUi = true)を付ければ確認できます。 付けないと、見逃しやすいです。

Preview の限界(一般的な知識です。このプロジェクトでは確認していません):

  • Preview は、Android の一部の機能(権限、カメラ、通信、システムのサービスなど)を動かせません。 Phase 7 のカメラは、実機が必須です。
  • フォントや影の描画が、実機と少し違うことがあります。最終の見た目は、実機で確かめるのが安全です。

2-7. 画面を切り替える仮のスイッチ(Step 3 で置き換え済み)

Step 2 の時点では、実機で3画面を見るために、画面上部に仮のスイッチ(SegmentedButton)を付け、 選んだ画面を rememberSaveable で持ち、when で画面を切り替えていました。

このスイッチは、Step 3 で NavigationBar と NavHost に置き換えました(後半の Step 3 を参照)。 このときの書き方のうち、次の2つは、今も使える知識です。

  • when は、enum の全ての値を書かないとコンパイルエラーになります。 値を足したときに、書き忘れに気づけます。
  • Modifier.weight(1f) は、「残りの高さを使い切る」指定です。

Step 2 の時点の書き方は、git show 4ac1cb2:app/src/main/kotlin/com/example/xrstudy/ui/XrStudyApp.kt で確認できます。

2-8. 実機で確認した結果

SH-51C(Android 14)で確認しました。長い文字列と空状態は、XrStudyApp.kt のデータを一時的に 差し替えて、実機の画面で確認しました(@Preview ではなく、実機の表示です)。

確認したこと 結果
3画面の表示 ホーム・一覧・設定とも表示された。下端もナビゲーションバーと重ならない
長い文字列(ホーム) お知らせのタイトルが2行に折り返した
長い文字列(一覧) 名前とメールが1行で「…」に省略された
空状態 「お知らせはありません」「ユーザーがいません」が表示された
回転(横向き) 選んだ「一覧」が残った。右側のナビゲーションバーを避けて表示された

3画面(通常時)と、ホームの「長い文字列」「空状態」です。

ホーム

一覧

設定

ダークにすると、こうなります。

ホーム

一覧

ホーム(長い文字列)

ホーム(空状態)

2-9. iOS との比較

観点 iOS(SwiftUI) Android(Compose)
一覧の1行 List の中の行(HStack など) ListItem
長い文字の省略 .lineLimit(1) + .truncationMode(.tail) maxLines = 1 + overflow = Ellipsis
0 件の表示 ContentUnavailableView 自分で作る(EmptyState)
スイッチ Toggle Switch
複数の状態のプレビュー #Preview を複数並べる @Preview を複数並べる
画面にデータを渡す イニシャライザの引数 関数の引数(state hoisting)

← 前: Step 1:テーマと骨組み | 目次に戻る | 次: Step 3:下部ナビゲーションと画面遷移 →