【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


KMM や マルチプラットフォーム を見据えて SQLDelight で Repository

👉 Getting Started - Multiplatform - SQLDelight hatena-bookmark
👉 SQLDelight 1.x Quick Start Guide for Android – Handstand Sam hatena-bookmark

マルチプラットフォームに対応していますが、

まずは、Android のみで使ってみると良いです。

👉 Getting Started - Android - SQLDelight hatena-bookmark

Todo アプリ向けの Repository を作ります。

Hilt と Flow を使っています。

 

■ インストール

Gradleまわりは書き方いろいろですけども適宜書き換えてください。


buildscript {
  repositories {
    google()
    mavenCentral()
  }
  dependencies {
    classpath 'com.squareup.sqldelight:gradle-plugin:1.5.3'
  }
}

apply plugin: 'com.squareup.sqldelight'

// android driver
implementation "com.squareup.sqldelight:android-driver:1.5.3"

// flow-coroutine extension 
implementation "com.squareup.sqldelight:coroutines-extensions:1.5.3"

 

■ スキーマ / クエリー

パッケージ名を、com.your.package としています。


-- app/src/main/sqldelight/com/your/package/data/Todo.sq

CREATE TABLE todo (
  id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
  text TEXT NOT NULL,
  updated INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

INSERT INTO todo(text) VALUES ('宿題をする');
INSERT INTO todo(text) VALUES ('マンガを読む');
INSERT INTO todo(text) VALUES ('プールに行く');

selectAll:
SELECT * FROM todo ORDER BY updated DESC;

deleteAll:
DELETE FROM todo;

insert:
INSERT INTO todo (text) VALUES (:text);

update:
UPDATE todo SET text = :text, updated = (strftime('%s', 'now')) WHERE id = :entryId;

delete:
DELETE FROM todo
WHERE id = :entryId;

count:
SELECT COUNT(id) FROM todo;

Todo.sq ファイルをテキストで作成して、テーブル定義、実行クエリーとメソッド名を箇条書きします。

配置位置は、上記コメントの位置が自然で分かりやすいと思います。

この場合、SQLDelight によって以下に実装に利用するクラス群が生成されます。


app/build/generated/sqldelight/code/Database/debug/com/your/package/

 

■ Database Module

SQLDelightによって生成された Database クラスを利用して書きます。

今回は Android向けなので、AndroidSqliteDriver を使っています。


@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

  @Provides
  @Singleton
  fun provideDatabase(@ApplicationContext context: Context): Database {
    val driver = AndroidSqliteDriver(Database.Schema, context, DB_NAME)
    return Database(driver)
  }

  private const val DB_NAME = "database.db"
}

JetpackCompose の UI である Screen-level の Composable はマルチに稼働しているので、 @Singleton としておくことを忘れてはなりません。

 

■ Repository

時間のかかる処理は Flow を使っておきます。


class TodoRepository @Inject constructor(
  private val database: Database
) {

  fun load(): Flow<List<Todo>> {
    return database.todoQueries.selectAll().asFlow().mapToList(Dispatchers.IO)
  }

  fun count(): Flow<Long> {
    return database.todoQueries.count().asFlow().mapToOne(Dispatchers.IO)
  }

  fun insert(text: String) {
    database.todoQueries.insert(text)
  }

  fun update(id: Long, text: String) {
    database.todoQueries.update(text, id)
  }

  fun delete(id: Long) {
    database.todoQueries.delete(id)
  }

}

Flow - Couroutine Extension は非常に便利です。


fun load(): Flow<List<Todo>> {
  return database.todoQueries.selectAll().asFlow().mapToList(Dispatchers.IO)
}

Cold な Flow で単発の List<Todo> の emit ではなく、データベースが変更されるたびに、新しい List<Todo> を emit してくれます。


@JvmName("toFlow")
fun <T : Any> Query<T>.asFlow(): Flow<Query<T>> = flow {
  val channel = Channel<Unit>(CONFLATED)
  channel.trySend(Unit)

  val listener = object : Query.Listener {
    override fun queryResultsChanged() {
      channel.trySend(Unit)
    }
  }

  addListener(listener)
  try {
    for (item in channel) {
      emit(this@asFlow)
    }
  } finally {
    removeListener(listener)
  }
}

👉 sqldelight/FlowExtensions.kt at master · cashapp/sqldelight hatena-bookmark

(更新中...)

👉 Jetpack Compose で Todo アプリを作ってみた - Qiita hatena-bookmark


Jetpack Compose Compiler and Kotlin Versions Compatibility

Jetpack Compose コンパイラー と Kotlin バージョンの互換性、バージョンアップ時に確認が必要。

毎回、ググってるので貼っておく。


  repositories {
    google()
    mavenCentral()
  }

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


  buildFeatures {
    compose true
  }

  composeOptions {
    kotlinCompilerExtensionVersion versions.composeCompiler
  }

 

■ Compose to Kotlin Compatibility Map

Jetpack Compose Compiler and Kotlin versions Compatibility

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

 

■ JetBrains/kotlin/releases/latest

Releases Latest - JetBrains/kotlin
👉 Releases Latest - JetBrains/kotlin hatena-bookmark

 

■ compose - Google's Maven Repository

compose - Google's Maven Repository
👉 compose - Google's Maven Repository hatena-bookmark

 

■ Compose Compiler & Kotlin versions

Compose Compiler & Kotlin versions
👉 Compose Compiler Maven Index hatena-bookmark

 

■ 参考

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


3つの画像読み込みライブラリ Glide / Picasso / Coil - JetpackCompose 対応の状況

どれが、今、旬なのか。

まずはリンクを列挙しておきます。

全部使ってみようと思います。

giide vs picasso vs coil

👉 android glide, android picasso, android coil - 調べる - Google トレンド hatena-bookmark

 

Glide


32.8k stars
Watchers 1.1k watching
Forks 6k forks

👉 bumptech/glide: An image loading and caching library for Android focused on smooth scrolling hatena-bookmark

Glide

👉 Glide v4 : Fast and efficient image loading for Android hatena-bookmark

@sjudd Hey, I believe you're one of the maintainers of Glide. Is it a planned feature? Does Glide's team want help from the community on this? Jetpack Compose is going to stable soon, I believe later this month or next month

👉 Jetpack Compose Support · Issue #4459 · bumptech/glide hatena-bookmark

 

Picasso


18.3k stars
Watchers 867 watching
Forks 4k forks

👉 square/picasso: A powerful image downloading and caching library for Android hatena-bookmark

Picasso

👉 Picasso hatena-bookmark

Nobody works on Picasso. If you want something in the next N years definitely use Coil. Or Glide. Or whatever. They're all fine.

Image loading is a terrible, horrible business to be in. It's been really nice not being in that business for the last few years. I don't see a reason to resume. I certainly have no intent to support it anymore. Picasso accomplished its goal of moving the ecosystem out of the painful image loaders of 2011/2012 to the fluent and extensible ones we know today. But it's filled with technical debt and the legacy of poor design (at least, in hindsight) and is currently very, very stuck between a major refactor and redesign with no end in sight.

👉 Consider providing Jetpack Compose support · Issue #2203 · square/picasso hatena-bookmark

 

Coil


8.4k stars
Watchers 98 watching
Forks 512 forks

👉 coil-kt/coil: Image loading for Android backed by Kotlin Coroutines. hatena-bookmark

Coil

👉 Coil hatena-bookmark

To add support for Jetpack Compose, import the extension library:

implementation("io.coil-kt:coil-compose:2.1.0")

👉 Jetpack Compose - Coil hatena-bookmark

 

まとめ

JetpackCompose への対応ライブラリ群も見逃せません。

👉 skydoves/landscapist: 🍂 Jetpack Compose image loading library that fetches and displays network images with Glide, Coil, and Fresco hatena-bookmark

👉 wasabeef/composable-images: The Composable Images is a library providing Jetpack Compose wrapper for Glide, Picasso, and Coil. hatena-bookmark

アーキテクチャー、フレームワーク、ライブラリの選定はそのプロダクトの安定感に直結します。

機能確認を中心に検証しながら、将来の本筋を外してはなりません。

👉 画像読み込みライブラリ「COIL」 hatena-bookmark


「Compose Compiler Reports」 recompose される条件とタイミングと範囲を知りたい

サンプル として、

Screen-level と その中に Button 3つの composable と ViewModel でカウンターを作ります。


@HiltViewModel
class HomeViewModel @Inject constructor() : ViewModel() {

  private val _countState = mutableStateOf(0)
  val countState: State<Int> = _countState

  private val _countStateFlow = MutableStateFlow(0)
  val countStateFlow = _countStateFlow.asStateFlow()

  fun incState() {
    _countState.value += 1
  }

  fun incStateFlow() {
    _countStateFlow.value += 1
  }

}


@Composable
fun HomeScreen(
  viewModel: HomeViewModel = hiltViewModel()
) {

  Timber.d("### HomeScreen composed.")

  var countA by remember { mutableStateOf(0) }
  val countB by viewModel.countState
  val countC by viewModel.countStateFlow.collectAsStateWithLifecycle()

  val onClickA = { countA += 1 }
  val onClickB = { viewModel.incState() }
  val onClickC = { viewModel.incStateFlow() }


  Text("${System.currentTimeMillis()}")

  ButtonA(countA, onClickA)

  Divider()

  ButtonB(countB, onClickB)

  Divider()

  ButtonC(countC, onClickC)

}

@Composable
private fun ButtonA(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonA(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("A: $count")
  }

}

@Composable
private fun ButtonB(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonB(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("B: $count")
  }

}

@Composable
private fun ButtonC(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonC(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("C: $count")
  }

}

ボタンを順番に押していきます。

挙動とログを予想できますか。

 

■ 結果

クリックのたびに、

Screen が recompose されています。

クリックしてないボタンも recompose されていますね!

Recompose Highlighter でもやってみました。



そうなるんですか。

なんでかな。

 

■ 「Compose Compiler Reports」 を使う

ツールを使って書き出してみます。

The Compose Compiler plugin can generate reports / metrics around certain compose-specific concepts that can be useful to understand what is happening with some of your compose code at a fine-grained level.

👉 androidx/compiler-metrics.md at androidx-main · androidx/androidx hatena-bookmark


// build.gradle

kotlinOptions {
  if (project.findProperty("composeCompilerReports") == "true") {
    freeCompilerArgs += [
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
    ]
  }
  if (project.findProperty("composeCompilerMetrics") == "true") {
    freeCompilerArgs += [
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
    ]
  }
}


./gradlew assembleRelease -PcomposeCompilerReports=true


./gradlew assembleRelease -PcomposeCompilerMetrics=true

👉 Jetpack Compose Stability Explained | by Ben Trengrove | Android Developers | Jul, 2022 | Medium hatena-bookmark

Compose Compiler Reports


// app_release-composables.txt

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HomeScreen(
  unstable viewModel: HomeViewModel? = @dynamic hiltViewModel(null, $composer, 0, 0b0001)
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonA(
  stable count: Int
  stable onClick: Function0<Unit>
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonB(
  stable count: Int
  stable onClick: Function0<Unit>
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonC(
  stable count: Int
  stable onClick: Function0<Unit>
)

ViewModel が untable で @dynamic です。


// app_release-classes.txt

unstable class HomeViewModel {
  stable val _countState: MutableState<Int>
  stable val countState: State<Int>
  unstable val _countStateFlow: MutableStateFlow<Int>
  unstable val countStateFlow: StateFlow<Int>
  <runtime stability> = Unstable
}

Flow が unstable なので ViewModel として unstable となっているように見えます。


// app_release-composables.csv

name,composable,skippable,restartable,readonly,inline,isLambda,hasDefaults,defaultsGroup,groups,calls,
HomeScreen,1,1,1,0,0,0,1,0,2,2,
ButtonA,1,1,1,0,0,0,0,0,1,1,
ButtonB,1,1,1,0,0,0,0,0,1,1,
ButtonC,1,1,1,0,0,0,0,0,1,1,

Button たちは、すべて同じ数字。

Screen はそれらと違う。

なんとく、ViewModel が原因のような雰囲気。

しかし、気安く ViewModel に @Stable とかつけたくないし。

 

■ remember を使う

onClick* のラムダが毎回初期化されているというので、


// val onClickB = { viewModel.incState() }
// val onClickC = { viewModel.incStateFlow() }

val onClickB = remember { { viewModel.incState() } }
val onClickC = remember { { viewModel.incStateFlow() } }

とすべて remember 扱いにします。

イメージしていたログ出力になりました。

 

■ 「::」を使う

こんな記述はどう? というので書き換えてみます。


// val onClickB = { viewModel.incState() }
// val onClickC = { viewModel.incStateFlow() }

// val onClickB = remember { { viewModel.incState() } }
// val onClickC = remember { { viewModel.incStateFlow() } }

val onClickB = viewModel::incState
val onClickC = viewModel::incStateFlow

これもイメージしていたログ出力になりました。

少し驚きました。

 

■ State 取得部分はどうなの?

Screen 自体が recompose されてるのなら、こうしたほうがいいのでは?


// val countB by viewModel.countState
// val countC by viewModel.countStateFlow.collectAsStateWithLifecycle()

val countB by remember { viewModel.countState }
val countC by remember { viewModel.countStateFlow }.collectAsStateWithLifecycle()

...

もう、知らん...

(ここは後日検証します。)

 

■ まとめ

そもそも recompose の条件とタイミングをコード目視で把握できますかね。


「考えないほうがいい、きちんと行儀よくコードを書け」的な記事もよく見かけます。

しかし、それで SideEffect系 きちんと使える気がしません。

では、だれか decompose をお願いします。

👉 パフォーマンスに任せてコードの見通しを優先するほうが「recompose沼」にはまらなくていいのではという逆説。 - Qiita hatena-bookmark

👉 takahirom/decomposer: Gradle Plugin that allows you to decompile bytecode compiled with Jetpack Compose Compiler Plugin into Java and check it hatena-bookmark

👉 【Jetpack Compose】「Layout Inspector Recomposition counts」で re-compose 回数を確認する hatena-bookmark