🚚

NavigationComposeで「次」の画面に値を渡すのを楽にしてみた

2024/01/04に公開

はじめに

新しく他の画面に遷移するときに値を渡したいことがあります。
NavigationComposeでは、新しく他の画面に遷移するときに引数を渡そうとすると、navigationのパスとして文字列で値を渡す必要があります。
特に、カスタムクラスを渡したい場合は、別途実装が必要で、カスタムのNavTypeを定義するか、SharedViewModelを使うかという形になります。
カスタムNavTypeを使えば、ComposeNavigationの仕組みに則ることが出来ますが、普通に実装すると記述量が多くなります。
FragmentのNavigationの場合、SafeArgsPluginが提供されていて簡単だったので、Composeでももう少し実装が楽にならないかと思っていました。
そこで今回は、カスタムNavTypeを使う場合にボイラプレートを減らし楽に運用出来る方法を考えました。

前提

  • JsonのパースはKotlinx.serializationを用います
  • 画面引数をViewModelで受け取る場合を考えます
  • Bundleに入れるため、カスタムクラスはParcelableを実装します

結論から見たい方は、こちら

通常

そのままやる場合

公式で説明されていますが、おさらいです。
https://developer.android.com/jetpack/compose/navigation?hl=ja#nav-with-args

composableにキーを指定し、NavTypeを使って型を決めます。

NavHost(startDestination = "profile/{userId}") {
    ...
    composable(
        "profile/{userId}",
        arguments = listOf(navArgument("userId") { type = NavType.StringType })
    ) {...}
}

ViewModelで受け取る場合このようになります。

class UserViewModel(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["userId"])

カスタムクラスを受け渡す場合

NavTypeクラスを使うことになります。
https://developer.android.com/guide/navigation/design/kotlin-dsl#custom-types

こんな感じで、NavTypeの各メソッドをオーバーライドし、カスタムなNavTypeを作る必要があります。

val SearchParametersType = object : NavType<SearchParameters>(
  isNullableAllowed = false
) {
  override fun put(bundle: Bundle, key: String, value: SearchParameters) {
    bundle.putParcelable(key, value)
  }
  override fun get(bundle: Bundle, key: String): SearchParameters {
    return bundle.getParcelable(key) as SearchParameters
  }

  override fun parseValue(value: String): SearchParameters {
    return Json.decodeFromString<SearchParameters>(value)
  }
}

グラフの定義

composable(
    "profile/{searchParameters}",
    arguments = listOf(
        // typeに作成したカスタムNavTypeを指定する
        navArgument("searchParameters") { type = SearchParameters }
    )
)

画面遷移時

val arg = SearchParameters(...)
val argUri = Uri.encode(Json.encodeToString(arg))
navController.navigate("profile/$argUri")

ViewModelで取得時

class UserViewModel(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val parameters: SearchParameters = checkNotNull(savedStateHandle["searchParameters"])

利用例と仕組み

利用例

まずは利用例から。

Navigationの定義ファイル

private const val SEARCH_RESULT = "search_result"

fun NavController.toSearchResult(args: SearchResultArgs) {
    navigate(route = "$SEARCH_RESULT/${args.toNavigationUri()}")
}

fun NavGraphBuilder.searchResultScreen() {
    composable(
        route = "$SEARCH_RESULT/{${ScreenArgs.KEY}}",
        arguments = navScreenArgument<SearchResultArgs>(),
    ) {
        SearchResultScreen()
    }
}

引数を受け取るViewModel

@HiltViewModel
class SearchResultViewModel @Inject constructor(savedStateHandle: SavedStateHandle) : ViewModel() {
    val screenArgs: SearchResultArgs = savedStateHandle.getScreenArg()
}

argumentsのNavTypeの実装のボイラプレートが無く、受け取り側では引数名を使わずメソッドをgetScreenArg()を使うだけで引数が取得できるようになっています。

仕組み

ルール

まず、以下のルールを決めました。

  • 画面引数にはカスタムクラスを使う
  • カスタムクラスにするので、引数はそれ一つとする
  • 引数はNonNullとする
  • 引数のカスタムクラスは、ScreenArgsというinterface作成し、それを実装する

画面引数にはカスタムクラスを使うルールにすることで、複数の引数があってもカスタムクラスの中に入れれば良くなるので、渡す引数クラス自体は一つで良くなります。
それにより、routeに指定する引数名も一つに固定できることになります。
引数名を間違えるという不具合も起こらなくなります。
引数はNonNullですが、仮にnullを表現したい要望があれば、sealed interfaceで相当するdata objectを作って処理を分ければ表現できます。

ScreenArgs

キーを持つマーカーinterfaceです。
Bundle(SavedStateHandle)に入れることになるので、Parcelableの実装を強制しています。

interface ScreenArgs : Parcelable {
    companion object {

        const val KEY = "key_screen_args"
    }
}

受け渡すカスタムクラスはこのようになります。

@Parcelize
@Serializable
data class SearchResultArgs(val parameter: SearchParameter?, val float: Float) : ScreenArgs

ScreenArgsを実装し、Parcelableのための@Parcelizeと、kotlinx.serializationの@Serializableを付けています。

カスタムnavType

NavTypeの実装を共通化することで、ジェネリクスに希望の型を入れるだけで実装が完成するようにしました。

private inline fun <reified T : ScreenArgs> navType(isNullableAllowed: Boolean = false): NavType<T> {
    return object : NavType<T>(isNullableAllowed = isNullableAllowed) {
        override fun put(bundle: Bundle, key: String, value: T) {
            bundle.putParcelable(key, value)
        }

        override fun get(bundle: Bundle, key: String): T? {
            return bundle.getParcelable(key) as? T
        }

        override fun parseValue(value: String): T {
            return Json.decodeFromString<T>(value)
        }
    }
}

また、画面引数は一つにしたので、listOf(navArgument(...))も共通化出来ました。
これをinternalで公開します。

internal inline fun <reified T : ScreenArgs> navScreenArgument(): List<NamedNavArgument> {
    return listOf(navArgument(ScreenArgs.KEY) {
        this.type = navType<T>(isNullableAllowed = false)
    })
}

internal inline fun <reified T : ScreenArgs> navScreenArgument(defaultValue: T): List<NamedNavArgument> {
    return listOf(navArgument(ScreenArgs.KEY) {
        this.type = navType<T>(isNullableAllowed = false)
        this.defaultValue = defaultValue
    })
}

defaultValueの有無は、defaultValue:T? = nullとすることで共通化できそうですが、内部にdefaultValuePresentというBooleanがいて、nullであってもセットするとtrueになります。
navArgumentの初期化時にチェックが走ってnullを入れると落ちるため、また、ifnullを意図して入れられないようにするためにメソッドを分けました。

NavArgument.kt
require(!(!isNullable && defaultValuePresent && defaultValue == null)) {
            "Argument with type ${type.name} has null value but is not nullable."
        }

Uriにする拡張

引数クラスが一律ScreenArgsを継承することで、toNavigationUri()という拡張を作ることが出来ます。

inline fun <reified T : ScreenArgs> T.toNavigationUri(): String {
    return Uri.encode(Json.encodeToString(this))
}

わざわざTの拡張にしているのは、ScreenArgsの拡張にした場合、kotlinx.serializationで具象クラスの型にserialize出来ないからです。

ViewModelでの引数取得

キーが一つなことにより、取得処理も共通化出来ます。

context(ViewModel)
fun <T> SavedStateHandle.getScreenArg(): T {
    return get<T>(ScreenArgs.KEY) as T
}

最後に

今回は、ComposeNavigationの画面引数を楽にしてみました。
サンプルコードは下記にあります。
https://github.com/morayl/compose-navigation-args-sample
こちらでは、マルチモジュール(app/feature/core)において、appモジュールでNavigationを行う想定でファイルを配置しています。

お読みいただきありがとうございました!

Discussion