Compose's Navigation is difficult to use? I will pack a super easy-to-use package for you!

The Navigation component supports Jetpack Compose applications. We can navigate between composables by leveraging the infrastructure and capabilities of the Navigation component. However, after using it in the project, I found that this component is really not easy to use:

  • Coupling: Navigation needs to hold NavHostController . In composable functions, NavHostController must be passed to navigate, so all composable functions that need to navigate must hold a reference to NavHostController . Passing callbackis the same problem.
  • Refactoring and encapsulation becomes difficult: some projects are not a completely new Compose project, but partial functional rewrites, in which case it is difficult to provide NavHostController to these composes.
  • The jump function is troublesome. In many cases, it is not simply to navigate to the next page. It may be accompanied by replace, pop, and clearing the navigation stack, which requires a lot of code to implement.
  • ViewModeletc. Non-composable functions cannot get NavHostController .
  • Splicing route names is troublesome: if the route of the navigation component passes parameters, it needs to be spliced ​​according to the rules.

I read a lot of discussions on how to implement navigation, and found some great libraries, appyx , compose-router , Decompose , compose-backstack and compose-destinations with the most users , but none of them satisfied me, after all, navigation is the most important It is very important, so I am going to transform the Navigation component and encapsulate a convenient component library.

Jetpack Compose Clean Navigation

If you use a singleton or Hiltprovide a custom navigator for a singleton, you can use it directly in each ViewModelcontext Compose, and navigate to different screens by calling the functions of the navigator. All navigation events can be collected together so that there is no need to pass callbacks or pass navControllerto other screens. When you reach the concise usage of the following sentence, just ask you if it smells good or not?

            AppNav.to(ThreeDestination("来自Two"))
            AppNav.replace(ThreeDestination("replace来自Two"))
            AppNav.back()
复制代码

To implement a custom navigator, first use the interface to declare the required functions. Generally speaking, the first two stack and navigation functions can meet the needs of the application, and the functions of the latter two functions can also use the first two functions. It is implemented, but there are a little more parameters, and there are many actual use scenarios. For simplicity, use the following two functions to expand:

interface INav {


    /**
     * 出栈
     * @param route String
     * @param inclusive Boolean
     */
    fun back(
        route: String? = null,
        inclusive: Boolean = false,
    )

    /**
     * 导航
     * @param route 目的地路由
     * @param popUpToRoute 弹出路由?
     * @param inclusive 是否也弹出popUpToRoute
     * @param isSingleTop Boolean
     */
    fun to(
        route: String,
        popUpToRoute: String? = null,
        inclusive: Boolean = false,
        isSingleTop: Boolean = false,
    )


    /**
     * 弹出当前栈并导航到
     * @param route String
     * @param isSingleTop Boolean
     */
    fun replace(
        route: String,
        isSingleTop: Boolean = false,
    )

    /**
     * 清空导航栈然后导航到route
     * @param route String
     */
    fun offAllTo(
        route: String,
    )


}

复制代码

AppNavThe above four navigation functions are realized. It's very simple, because you need to use a singleton, which is used here object, which only adds a private function to send navigation intentions:

object AppNav : INav {

    private fun navigate(destination: NavIntent) {
        NavChannel.navigate(destination)
    }

    override fun back(route: String?, inclusive: Boolean) {
        navigate(NavIntent.Back(
            route = route,
            inclusive = inclusive,
        ))
    }


    override fun to(
        route: String,
        popUpToRoute: String?,
        inclusive: Boolean,
        isSingleTop: Boolean,
    ) {
        navigate(NavIntent.To(
            route = route,
            popUpToRoute = popUpToRoute,
            inclusive = inclusive,
            isSingleTop = isSingleTop,
        ))
    }

    override fun replace(route: String, isSingleTop: Boolean) {
        navigate(NavIntent.Replace(
            route = route,
            isSingleTop = isSingleTop,
        ))

    }

    override fun offAllTo(route: String) {
        navigate(NavIntent.OffAllTo(route))
    }


}
复制代码

NavIntentIt is the intention of navigation, which corresponds to each function of the navigator. Like the navigator, two functions are enough, and two more functions are also for simplicity:

sealed class NavIntent {

    /**
     * 返回堆栈弹出到指定目标
     * @property route 指定目标
     * @property inclusive 是否弹出指定目标
     * @constructor
     * 【"4"、"3"、"2"、"1"】 Back("2",true)->【"4"、"3"】
     * 【"4"、"3"、"2"、"1"】 Back("2",false)->【"4"、"3"、"2"】
     */
    data class Back(
        val route: String? = null,
        val inclusive: Boolean = false,
    ) : NavIntent()


    /**
     * 导航到指定目标
     * @property route 指定目标
     * @property popUpToRoute 返回堆栈弹出到指定目标
     * @property inclusive 是否弹出指定popUpToRoute目标
     * @property isSingleTop 是否是栈中单实例模式
     * @constructor
     */
    data class To(
        val route: String,
        val popUpToRoute: String? = null,
        val inclusive: Boolean = false,
        val isSingleTop: Boolean = false,
    ) : NavIntent()

    /**
     * 替换当前导航/弹出当前导航并导航到指定目的地
     * @property route 当前导航
     * @property isSingleTop 是否是栈中单实例模式
     * @constructor
     */
    data class Replace(
        val route: String,
        val isSingleTop: Boolean = false,
    ) : NavIntent()

    /**
     * 清空导航栈并导航到指定目的地
     * @property route 指定目的地
     * @constructor
     */
    data class OffAllTo(
        val route: String,
    ) : NavIntent()

}

复制代码

To achieve ViewMdeolsending in multiple places (, composable functions) and receiving and processing navigation commands in one place, you need to use Flow or Channelimplement it here Channel. Also object, if you use Hiltit, you can provide a singleton:

internal object NavChannel {

    private val channel = Channel<NavIntent>(
        capacity = Int.MAX_VALUE,
        onBufferOverflow = BufferOverflow.DROP_LATEST,
    )

    internal var navChannel = channel.receiveAsFlow()

    internal fun navigate(destination: NavIntent) {
        channel.trySend(destination)
    }
}
复制代码

实现接收并执行对应功能:

fun NavController.handleComposeNavigationIntent(intent: NavIntent) {
    when (intent) {
        is NavIntent.Back -> {
            if (intent.route != null) {
                popBackStack(intent.route, intent.inclusive)
            } else {
                currentBackStackEntry?.destination?.route?.let {
                    popBackStack()
                }
            }
        }
        is NavIntent.To -> {
            navigate(intent.route) {
                launchSingleTop = intent.isSingleTop
                intent.popUpToRoute?.let { popUpToRoute ->
                    popUpTo(popUpToRoute) { inclusive = intent.inclusive }
                }
            }
        }
        is NavIntent.Replace -> {
            navigate(intent.route) {
                launchSingleTop = intent.isSingleTop
                currentBackStackEntry?.destination?.route?.let {
                    popBackStack()
                }
            }
        }

        is NavIntent.OffAllTo -> navigate(intent.route) {
            popUpTo(0)
        }
    }
}
复制代码

自定义NavHostcomposable. NavigationEffects只需收集navigationChannel并导航到所需的屏幕。这里可以看到,它很干净干净,我们不必传递任何回调或navController.

@Composable
fun NavigationEffect(
    startDestination: String, builder: NavGraphBuilder.() -> Unit,
) {
    val navController = rememberNavController()
    val activity = (LocalContext.current as? Activity)
    val flow = NavChannel.navChannel
    LaunchedEffect(activity, navController, flow) {
        flow.collect {
            if (activity?.isFinishing == true) {
                return@collect
            }
            navController.handleComposeNavigationIntent(it)
            navController.backQueue.forEachIndexed { index, navBackStackEntry ->
                Log.e(
                    "NavigationEffects",
                    "index:$index=NavigationEffects: ${navBackStackEntry.destination.route}",
                )
            }
        }
    }
    NavHost(
        navController = navController,
        startDestination = startDestination,
        builder = builder
    )
}
复制代码

导航封装完成,还有一步就是路由间的参数拼接,最初的实现是使用者自己实现:

sealed class Screen(
    path: String,
    val arguments: List<NamedNavArgument> = emptyList(),
) {
    val route: String = path.appendArguments(arguments)

    object One : Screen("one")
    object Two : Screen("two")
    object Four : Screen("four", listOf(
        navArgument("user") {
            type = NavUserType()
            nullable = false
        }
    )) {
        const val ARG = "user"
        fun createRoute(user: User): String {
            return route.replace("{${arguments.first().name}}", user.toString())
        }
    }

    object Three : Screen("three",
        listOf(navArgument("channelId") { type = NavType.StringType })) {
        const val ARG = "channelId"
        fun createRoute(str: String): String {
            return route.replace("{${arguments.first().name}}", str)
        }
    }
}
复制代码

优点是使用密封类实现路由声明,具有约束作用。后来考虑到减少客户端样板代码,就声明了一个接口,appendArguments是拼接参数的扩展方法,无需自己手动拼接:

abstract class Destination(
    path: String,
    val arguments: List<NamedNavArgument> = emptyList(),
) {
    val route: String = if (arguments.isEmpty()) path else path.appendArguments(arguments)
}

private fun String.appendArguments(navArguments: List<NamedNavArgument>): String {
    val mandatoryArguments = navArguments.filter { it.argument.defaultValue == null }
        .takeIf { it.isNotEmpty() }
        ?.joinToString(separator = "/", prefix = "/") { "{${it.name}}" }
        .orEmpty()
    val optionalArguments = navArguments.filter { it.argument.defaultValue != null }
        .takeIf { it.isNotEmpty() }
        ?.joinToString(separator = "&", prefix = "?") { "${it.name}={${it.name}}" }
        .orEmpty()
    return "$this$mandatoryArguments$optionalArguments"
}
复制代码

使用

首先声明路由,继承Destination,命名采用page+Destination

object OneDestination : Destination("one")
object TwoDestination : Destination("two")

object ThreeDestination : Destination("three",
    listOf(navArgument("channelId") { type = NavType.StringType })) {
    const val ARG = "channelId"
    operator fun invoke(str: String): String = route.replace("{${arguments.first().name}}", str)
}


object FourDestination : Destination("four", listOf(
    navArgument("user") {
        type = NavUserType()
        nullable = false
    }
)) {
    const val ARG = "user"
    operator fun invoke(user: User): String =
        route.replace("{${arguments.first().name}}", user.toString())
}

object FiveDestination : Destination("five",
    listOf(navArgument("age") { type = NavType.IntType },
        navArgument("name") { type = NavType.StringType })) {
    const val ARG_AGE = "age"
    const val ARG_NAME = "name"
    operator fun invoke(age: Int, name: String): String =
        route.replace("{${arguments.first().name}}", "$age")
            .replace("{${arguments.last().name}}", name)
}
复制代码

传递普通参数,String、Int

使用navArgument生命参数名和类型,然后用传参替换对应的参数名,这里使用invoke简化写法:

object ThreeDestination : Destination("three",
    listOf(navArgument("channelId") { type = NavType.StringType })) {
    const val ARG = "channelId"
    operator fun invoke(str: String): String = route.replace("{${arguments.first().name}}", str)
}
复制代码

传递多个参数

用传参去去替换路由里面对应的参数名。

object FiveDestination : Destination("five",
    listOf(navArgument("age") { type = NavType.IntType },
        navArgument("name") { type = NavType.StringType })) {
    const val ARG_AGE = "age"
    const val ARG_NAME = "name"
    operator fun invoke(age: Int, name: String): String =
        route.replace("{${arguments.first().name}}", "$age")
            .replace("{${arguments.last().name}}", name)
}
复制代码

传递序列化参数

DataBean 要序列化,这里用了两个注解,Serializable是因为使用了kotlinx.serialization,如果使用 Gson 则不需要,重写toString是因为拼接参数的时候可以直接用。

@Parcelize
@kotlinx.serialization.Serializable
data class User(
    val name: String,
    val phone: String,
) : Parcelable{
    override fun toString(): String {
        return Uri.encode(Json.encodeToString(this))
    }
}
复制代码

然后自定义NavType

class NavUserType : NavType<User>(isNullableAllowed = false) {

    override fun get(bundle: Bundle, key: String): User? =
        bundle.getParcelable(key)

    override fun put(bundle: Bundle, key: String, value: User) =
        bundle.putParcelable(key, value)

    override fun parseValue(value: String): User {
        return Json.decodeFromString(value)
    }

    override fun toString(): String {
        return Uri.encode(Json.encodeToString(this))
    }

}
复制代码

传递自定义的NavType

object FourDestination : Destination("four", listOf(
    navArgument("user") {
        type = NavUserType()
        nullable = false
    }
)) {
    const val ARG = "user"
    operator fun invoke(user: User): String =
        route.replace("{${arguments.first().name}}", user.toString())
}
复制代码

注册

使用NavigationEffect替换原生的NavHost

                    NavigationEffect(OneDestination.route) {
                        composable(OneDestination.route) { OneScreen() }
                        composable(TwoDestination.route) { TwoScreen() }
                        composable(FourDestination.route, arguments = FourDestination.arguments) {
                            val user = it.arguments?.getParcelable<User>(FourDestination.ARG)
                                ?: return@composable
                            FourScreen(user)
                        }
                        composable(ThreeDestination.route, arguments = ThreeDestination.arguments) {
                            val channelId =
                                it.arguments?.getString(ThreeDestination.ARG) ?: return@composable
                            ThreeScreen(channelId)
                        }

                        composable(FiveDestination.route, arguments = FiveDestination.arguments) {
                            val age =
                                it.arguments?.getInt(FiveDestination.ARG_AGE) ?: return@composable
                            val name =
                                it.arguments?.getString(FiveDestination.ARG_NAME)
                                    ?: return@composable
                            FiveScreen(age, name)
                        }
                    }
复制代码

导航

看下现在的导航是有多简单:

   Button(onClick = {
            AppNav.to(TwoDestination.route)
        }) {
            Text(text = "去TwoScreen")
        }
        Button(onClick = {
            AppNav.to(ThreeDestination("来自首页"))
        }) {
            Text(text = "去ThreeScreen")
        }
        Button(onClick = {
            AppNav.to(FourDestination(User("来着首页", "110")))
        }) {
            Text(text = "去FourScreen")
        }
        Button(onClick = {
            AppNav.to(FiveDestination(20, "来自首页"))
        }) {
            Text(text = "去FiveScreen")
        }
复制代码

untitled.gif

完成上述操作后,我们已经能够在模块化应用程序中实现 Jetpack Compose 导航。并且使我们能够集中导航逻辑,在这样做的同时,我们可以看到一系列优势:

  • 我们不再需要将 NavHostController 传递给我们的可组合函数,消除了我们的功能模块依赖于 Compose Navigation 依赖项的需要,同时还简化了我们的构造函数以进行测试。
  • We've added ViewModelsupport for navigating within normal functions.
  • It simplifies operations such as replacement and popping, and it is simple to implement in one sentence.

Navigation in Compose is still in its early stages, and with official improvements, maybe we won't need encapsulation, but for now I'm happy with the way I've implemented it.

I have published this warehouse to Maven Central, and you can directly rely on it:

implementation 'io.github.yuexunshi:Nav:1.0.1'
复制代码

Attach the source code

Guess you like

Origin juejin.im/post/7155289564775448607