【JetpackCompose】BottomNavigation の使い方

アプリの基本的な画面です。

【JetpackCompose】BottomNavigation の使い方

シンプルなもので眺めてみます。

 

■ 構成

「Column」を使って2つのパーツを縦に並べます。

【JetpackCompose】BottomNavigation の使い方

メイン画面を表示する部分は

「NavHost」

それを表示切り替えする下側に並んだ操作部分は

「BottomNavigation」

です。

コードは以下のように構成できます。


@Composable
fun MainScreen() {

  val navController = rememberNavController()

  Column {

    NavHost(
      navController = navController,
    ) {
      composable(NavigationItem.HOME.route) {
        HomeScreen()
      }
      composable(NavigationItem.CRYPT.route) {
        CryptScreen()
      }
      // ...
    }

    BottomNavigation {
      val backStack by navController.currentBackStackEntryAsState()
      val current = backStack?.destination?.route
      NavigationItem.values().forEach { item ->
        BottomNavigationItem(
          // ...
        )
      }
    }

  }

}


enum class NavigationItem(
  // ...
)

 

■ NavController

NavHost を BottomNavigation を紐付けるコントローラーです。


val navController = rememberNavController()

両方からアクセスしやすい場所に置くことになります。

 

■ NavigationItem

BottomNavigation に並べるアイテムの情報をまとめておきます。

以下、3つの組み合わせになります。


- 画面を表す内部文字列(遷移先)

- アイコン画像

- 表示する画面名称文字列

すべて定数なので enum クラスで作成します。


enum class NavigationItem(
  val route: String,
  val icon: ImageVector,
  @StringRes val label: Int,
) {

  HOME("home", Icons.Outlined.Home, R.string.nav_label_home),
  CRYPT("crypt", Icons.Outlined.MonetizationOn, R.string.nav_label_crypt),
  // ...

}

ここでは多言語化を考慮して、

遷移を表す内部で使用する文字列と、画面名称を表示する文字列を区別しています。

必要なければまとめてもいいと思います。

 

■ BottomNavigation

NavigationItem を BottomNavigation に配置します。


BottomNavigation {

  val backStack by navController.currentBackStackEntryAsState()
  val current = backStack?.destination?.route

  NavigationItem.values().forEach { item ->
    BottomNavigationItem(
      selected = current == item.route,
      onClick = {
        navController.navigate(item.route) {

          // no back stacks. 0 is the root navigation
          popUpTo(id = 0) {  
            saveState = true
          }
          launchSingleTop = true
          restoreState = true
        }
      },
      icon = { Icon(item.icon, null) },
      label = {
        Text(
          text = stringResource(item.label),
          maxLines = 1
        )
      },
      alwaysShowLabel = false
    )
  }
}

onClick のラムダブロックでは、


- バックスタックなし。

- 画面の state は保存/リストアする。

としています。

それぞれの画面内での遷移がない場合は、これがシンプルで自然だと思います。

画面のスクロールポジションもこれで保持できます。

 

■ NavHost

画面のメイン表示部分です。

NavigationItem に紐づけて各画面の Screen-level Composable を記述しておきます。


NavHost( 
) {
  composable(NavigationItem.HOME.route) {
    HomeScreen()
  }
  composable(NavigationItem.CRYPT.route) {
    CryptScreen()
  }
  // ...
}

 

■ まとめ


@Composable
fun MainScreen() {

  val navController = rememberNavController()

  Column(
    modifier = Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.Bottom
  ) {

    NavHost(
      navController = navController,
      startDestination = NavigationItem.HOME.route,
      modifier = Modifier
        .weight(1f)
    ) {
      composable(NavigationItem.HOME.route) {
        HomeScreen()
      }
      composable(NavigationItem.CRYPT.route) {
        CryptScreen()
      }
      // ...
    }

    BottomNavigation {

      val backStack by navController.currentBackStackEntryAsState()
      val current = backStack?.destination?.route

      NavigationItem.values().forEach { item ->
        BottomNavigationItem(
          selected = current == item.route,
          onClick = {
            navController.navigate(item.route) {
              // no back stacks. 0 is the root navigation
              popUpTo(id = 0) { 
                saveState = true
              }
              launchSingleTop = true
              restoreState = true
            }
          },
          icon = { Icon(item.icon, null) },
          label = {
            Text(
              text = stringResource(item.label),
              maxLines = 1
            )
          },
          alwaysShowLabel = false
        )
      }

    }

  }

}


enum class NavigationItem(
  val route: String,
  val icon: ImageVector,
  @StringRes val label: Int,
) {

  HOME("home", Icons.Outlined.Home, R.string.nav_label_home),
  CRYPT("crypt", Icons.Outlined.MonetizationOn, R.string.nav_label_crypt),
  // ...

}

Scaffold って使い勝手悪くね?

👉 JetpackCompose Coil で GIF - Qiita hatena-bookmark


【Jetpack Compose】Compose (androidx.compose.*) のバージョンが分かれている件

元は、こんな感じで問題ありませんでした。


ext.versions = [
  'kotlin'  : '1.6.21'
  'compose' : '1.2.0-rc02'
]

dependencies {
  classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
}

composeOptions {
  kotlinCompilerExtensionVersion versions.compose
}


implementation "androidx.compose.material:material:${versions.compose}"
implementation "androidx.compose.material:material-icons-extended:${versions.compose}"
implementation "androidx.compose.ui:ui:${versions.compose}"
implementation "androidx.compose.ui:ui-tooling-preview:${versions.compose}"
debugImplementation "androidx.compose.ui:ui-tooling:${versions.compose}"
debugImplementation "androidx.compose.ui:ui-test-manifest:${versions.compose}"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:${versions.compose}"

アップデート通知が出たので、いつものように


1.3.0-alpha01

に上げました。


ext.versions = [
  'kotlin'  : '1.6.21'
  'compose': '1.3.0-alpha01'
]

...

ビルドできなくなりました。

なんでや。

 

■ kotlin と compose の関係

kotlin と compose には、お互いに対応するバージョンが決まっていましたね!

Compose to Kotlin Compatibility Map

👉 Compose to Kotlin Compatibility Map  |  Android Developers hatena-bookmark

あれ、compose「1.3.0-alpha01」がないよ!

 

■ 「Compose」 は1つではない

Jetpack Compose is multiple things under one name:

- A compiler plugin that helps efficiently calculate the difference between two in-memory tree data structures
- A new UI toolkit for Android
- A new UI toolkit for desktop apps (Compose Desktop)

👉 Drop "androidx" from Jetpack Compose package name, for multiplatform, before 1​.​0 release. · Change.org hatena-bookmark

どうやら、Android でいうと compose は

- compose compiler
- compose ui toolkit

の2つに分かれているようです

👉 This version (1.2.0-alpha08) of the Compose Compiler requires Kotlin version 1.6.20 but you appear to be using Kotlin version 1.6.21 which is not known to be compatible. hatena-bookmark

 

■ まとめ

上記2つのことを考慮して書き換えます。


ext.versions = [
  'kotlin'    : '1.7.0'
  'compose'   : '1.2.0',         // compose-compiler
  'composeUi' : '1.3.0-alpha01'  // compose-ui
]

dependencies {
  classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
}

composeOptions {
  kotlinCompilerExtensionVersion versions.compose // compose-compiler
}

// compose-ui
implementation "androidx.compose.material:material:${versions.composeUi}"
implementation "androidx.compose.material:material-icons-extended:${versions.composeUi}"
implementation "androidx.compose.ui:ui:${versions.composeUi}"
implementation "androidx.compose.ui:ui-tooling-preview:${versions.composeUi}"
debugImplementation "androidx.compose.ui:ui-tooling:${versions.composeUi}"
debugImplementation "androidx.compose.ui:ui-test-manifest:${versions.composeUi}"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:${versions.composeUi}"

👉 This version (1.2.0-alpha08) of the Compose Compiler requires Kotlin version 1.6.20 but you appear to be using Kotlin version 1.6.21 which is not known to be compatible. hatena-bookmark


【Jetpack Compose】よくあるボタンの有効化/無効化

読み込み待ち、SwipeRefresh などで見かけるやつ。

ボタン2度押し対策にも使えます。


// @Composable

val sending by viewModel.sending

Button(
  onClick = { 
    viewModel.send() 
  },
  enabled = !sending
) {
  Text("SEND")
}


// ViewModel

private val _sending = mutableStateOf(false)
val sending: State<Boolean> = _sending // *

fun send() {
  viewModelScope.launch(Dispatchers.IO) {
    _sending.value = true
    delay(5000) // heavy
    _sending.value = false
  }
}

【Jetpack Compose】 よくあるボタンの有効化/無効化

ViewModel から露出している sendingState<Boolean> として @Composable 側からの書き換えは許しません。

👉 【Jetpack Compose】rememberCoroutineScope() vs LaunchedEffect hatena-bookmark

👉 【MVVM】 ViewModel の_プロパティ記述 hatena-bookmark
👉 StateFlow の View への公開 hatena-bookmark

👉 Jetpack Compose 二度押しを避けるボタン | Zenn hatena-bookmark