Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android Navigation Compose 導入

こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。

今回私が担当している「auサービスToday」で Navigation Compose を導入したため、その内容をお伝えします。また、導入時に遭遇した問題点やその対処方法についてもお話しします。

 

背景

「auサービスToday」では UI の開発方法を Jetpack Compose へ移行する作業を進めてきました。新規作成の画面はもちろん、既存の画面も徐々に Jetpack Compose へ置き換えています。

tech.gunosy.io

約 2 年かけて、一部置き換えられない xml の実装を除いた全ての画面で、 Jetpack Compose への置き換えが完了しました。

現状はまだ Fragment を実装した上に ComposeView が乗っている状態です。次のステップとして Navigation Compose を導入して、Fragment から脱却して Compose だけで完結する画面遷移を実現していきたいと思います。

そもそも Navigation とは、Android アプリケーション内での画面遷移やバックスタックの管理を簡単に行うライブラリやフレームワークです。Navigation Compose は Jetpack Compose 用の Navigation ライブラリであり、Compose から Compose へ画面遷移ができるようになります。

コールバック等の実装が不要になるため、よりシンプルで分かりやすい実装ができるようになります。実際に導入する前は 44 個の引数を持った画面が存在していましたが、 Navigation Compose を導入することによって引数を 2 個までに減らすことができました。本当に感激しました。


導入方法

Navigation Compose の基本的な導入方法は下記となります。

依存関係の追加

Navigation Compose を使用するため、build.gradle ファイルに下記を追加します。

dependencies {
    implementation("androidx.navigation:navigation-compose:$nav_version")
}

NavController とは Jetpack Compose で Navigation を管理するためのクラスです。この NavController で画面遷移の処理を実行したり、画面の表示やバックスタックの管理をします。rememberNavController メソッドを使用することで、NavController のオブジェクトを生成することができます。

val navController = rememberNavController()

NavHost は現在の画面情報を表示する Composable です。NavHost メソッドの引数として、navController には先ほど作成した NavController を指定します。 Navigation Compose では destination と呼ばれる画面個々で一意の文字列を定義します。この destination を使用して画面遷移を行い、NavHost の引数である startDestination には最初に表示する画面の destination を設定します。

Scaffold {
  NavHost(
    navController = navController,
    startDestination = "first_screen"
  ) {
    ~省略~
  }
}

NavGraph に遷移する画面群を定義します。NavHost のカッコ内(正確には NavHost の引数 builder )に NavGraphBuilder.composable で作成した画面の Composable を設定します。ここに定義した画面を画面遷移することができます。composable の引数の route には、画面個々の destination を指定します。

NavHost(
  navController = navController,
  startDestination = "first_screen"
) {
  composable(route = "first_screen") {
    FirstScreen() // 個々で作成した画面の Composable
  }
  composable(route = "second_screen") {
    SecondScreen()
  }
  ~省略~
}

基本定義は以上で、後は NavController から navigate メソッドを呼び出して画面遷移をします。引数は先ほど NavGraph に定義した destination の文字列を route に設定します。

onClick = {
 navController.navigate(route = "second_screen")
},


以上が基本的な Navigation Compose の導入になります。

が、これだけで導入が完了すれば良いのですが・・やはり問題はつきものでした。

ここからは発生した問題点とその対処法について話していきます。


問題点と対処法

画面を戻すと、元の画面は破棄されて recompose が走る

「auサービスToday」のメイン画面は記事のリスト画面になります。今回の Navigation Compose 導入によって、下のタブを押下してメニュー画面等に遷移できるように対応しました。しかし、バックキー等で元の記事のリスト画面に戻ってきた時、recompose が走ってレイアウトの再生成が行われてしまいました。

記事のリスト画面では記事や広告表示の判定をしているため、recompose が走ると前回と同じ記事や広告が表示されていたかどうかの判定ができなくなりました。また描画する情報が多い関係で表示に時間がかかり、ユーザビリティも落ちてしまいます。何とか recompose されない方法はないかと模索しましたが実現はできませんでした。

そこでメイン画面の記事リスト画面は他の NavHost とは分離して、独立で存在するようにしてみました。実装例は下記です。

Box(
  modifier = Modifier
    .fillMaxSize()
  ) {
    ArticleList(~省略~) // メイン画面の記事リスト画面
    NavHost( // 記事リストの上に表示する画面
      navController = navController,
      startDestination = "empty",
      modifier = modifier
    ) {
      composable(route = "empty") {
        // 空の Compose を表示する
      }
      composable(route = "menu") {
        Menu(~省略~)
      }
      ~省略~
}}

メイン画面の記事リスト画面は NavHost の後ろに表示されるように配置します。NavHost の startDestination に空の composable を設定することで、初期状態では透けることによって、後ろにある記事リスト画面が表示されます。画面遷移でメニュー画面等に遷移して戻ってきても、記事リスト画面は Navigation の影響を受けないため、 recompose されない作りにできました。

Dialog の非表示イベントが全て onDismissRequest で行われてしまう

DialogFragment から単体の Dialog の Composable として独立させた時の話です。ダイアログの背景タップかバックキーが押されたかのイベントを判別したかったのですが、Compose の Dialog を使用すると全て onDismissRequest のコールバックでしか受け取れず、判別ができませんでした。

そこで下記のような Dialog を別途自作しました。

@Composable
fun DialogContent(
  dialogProperties: DialogProperties,
  onClickBackGround: (() -> Unit)? = null,
  onBackPressed: (() -> Unit)? = null,
  content: @Composable () -> Unit
) {
  val currentContent by rememberUpdatedState(content)
  Dialog(
    onDismissRequest = {
      // ここで通知されるのは背景タップイベントのみ
      onClickBackGround?.invoke()
    },
    properties = DialogProperties(
      dismissOnBackPress = false, // BackHandler でイベントを拾うため false に設定
      dismissOnClickOutside = dialogProperties.dismissOnClickOutside,
      ~省略~
    ),
  ) {
    currentContent()
    // バックキーイベントを取得
    BackHandler {
      if (dialogProperties.dismissOnBackPress) {
        onBackPressed?.invoke()
      }
    }
  }
}

DialogProperties の dismissOnBackPress を false に設定して、BackHandler で自前でバックキーイベントを取得するようにしました。これにより onDismissRequest で通知されるのがダイアログの背景タップのみとなるため、判別ができるようになりました。


またこれ以外にも「Navigation Compose で画面遷移時の引数指定方法をシンプルにする」の対応を行いましたが、また別の機会でお話しさせていただきます。

まとめ

Navigation Compose の導入方法と導入の際に遭遇した問題とその対処方法についてお伝えしました。画面で使用する引数を大量に削減でき、コードの可読性を大幅に改善することができました。従来の画面遷移とは仕組みや動きが違っている場面も多々ありますが、Compose は非常に実装の柔軟性があるため対応策を一つ一つ見つけることができています。Navigation Compose の導入がまだの方がいましたら、ぜひこちらの内容を参考にしていただけると幸いです。

最後に

Gunosy では Android エンジニアを募集しております。ご興味をお持ちの方はぜひご連絡ください。

gunosy.co.jp