Android Compose Navigation 을 다루기 위해 좋은 라이브러리 도 있다. 하지만 내부 동작을 이해하기 위해 직접 구현해서 사용 !
sealed class Destination(
protected val route: String,
vararg params: String
)
먼저, 특정 UI을 가기 위한 Destination sealed class 를 만든다.
private fun String.appendParams(vararg params: Pair<String, Any?>): String {
val builder = StringBuilder(this)
params.forEach {
it.second?.toString()?.let { arg ->
builder.append("/$arg")
}
}
return builder.toString()
}
Android compose navigation 에서는 다음 UI로 이동할 때 "/"를 사용하여 값을 보내기 때문에 위와 같은 함수를 구현해 준다.
sealed class Destination(
protected val route: String,
vararg params: String
) {
val fullRoute: String = if (params.isEmpty()) route else {
val builder = StringBuilder(route)
params.forEach { builder.append("/{${it}}") }
builder.toString()
}
}
params로 넘어온 값을 "/" 로 구분하여 fullRoute에 추가하는 함수를 구현해준다.
sealed class NoArgumentsDestination(route: String) : Destination(route) {
operator fun invoke(): String = route
}
만약 params가 없을 경우에 사용할 sealed class도 구현해 준다.
object Dynasty : NoArgumentsDestination(DestinationType.DYNASTY.value)
object MyStudy : NoArgumentsDestination(DestinationType.MY_STUDY.value)
object StudyType : Destination(DestinationType.STUDY_TYPE.value, "dynastyType") {
const val DYNASTY_TYPE_KEY = "dynastyType"
operator fun invoke(dynastyType: String): String = route.appendParams(
DYNASTY_TYPE_KEY to dynastyType
)
}
object TypeCheck : Destination(DestinationType.TYPE_CHECK.value, "dynastyType") {
const val DYNASTY_TYPE_KEY = "dynastyType"
operator fun invoke(dynastyType: String): String = route.appendParams(
DYNASTY_TYPE_KEY to dynastyType
)
}
object StudyPage : Destination(DestinationType.STUDY_PAGE.value, "dynastyType", "studyType") {
const val DYNASTY_TYPE_KEY = "dynastyType"
const val STUDY_TYPE_KEY = "studyType"
operator fun invoke(dynastyType: String, studyType: String): String = route.appendParams(
DYNASTY_TYPE_KEY to dynastyType,
STUDY_TYPE_KEY to studyType,
)
}
위와 같이 params 가 필요 없다면 NoArgumentsDestination, 필요있다면 Destination로 해당 UI를 정의해 준다.
interface Navigator {
val navigationChannel : Channel<NavigationIntent>
suspend fun navigateBack(
route: String? = null,
inclusive: Boolean = false,
)
suspend fun navigateTo(
route: String,
popUpToRoute: String? = null,
inclusive: Boolean = false,
isSingleTop: Boolean = false,
)
}
sealed class NavigationIntent {
data class NavigateBack(
val route: String? = null,
val inclusive: Boolean = false,
) : NavigationIntent()
data class NavigateTo(
val route: String,
val popUpToRoute: String? = null,
val inclusive: Boolean = false,
val isSingleTop: Boolean = false,
) : NavigationIntent()
}
Channel을 통해서 Intent를 관리하고 특정 UI로 가기위한 navigationTo, 이전 UI로 돌아가기 위한 navigationBack 함수를 정의해 준다.
class NavigatorImpl : KoreanHistoryNavigator {
override val navigationChannel = Channel<NavigationIntent>(
capacity = Int.MAX_VALUE,
onBufferOverflow = BufferOverflow.DROP_LATEST,
)
override suspend fun navigateBack(
route: String?,
inclusive: Boolean,
) {
navigationChannel.send(
NavigationIntent.NavigateBack(
route = route,
inclusive = inclusive
)
)
}
override suspend fun navigateTo(
route: String,
popUpToRoute: String?,
inclusive: Boolean,
isSingleTop: Boolean,
) {
navigationChannel.send(
NavigationIntent.NavigateTo(
route = route,
popUpToRoute = popUpToRoute,
inclusive = inclusive,
isSingleTop = isSingleTop,
)
)
}
}
받아온 params 값으로 NavigationIntent로 만들어서 Channel 로 보낸다.
@Composable
fun NavHost(
navController: NavHostController,
startDestination: Destination,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) {
androidx.navigation.compose.NavHost(
navController = navController,
startDestination = startDestination.fullRoute,
modifier = modifier,
route = route,
builder = builder
)
}
fun NavGraphBuilder.composable(
destination: Destination,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: @Composable (NavBackStackEntry) -> Unit
) {
composable(
route = destination.fullRoute,
arguments = arguments,
deepLinks = deepLinks,
content = content
)
}
직접 만든 Destination 을 사용하기위해 NavHost의 재정의가 필요하다.
Composable
fun NavigationEffects(
navigationChannel: Channel<NavigationIntent>,
navHostController: NavHostController
) {
val activity = (LocalContext.current as? Activity)
LaunchedEffect(activity, navHostController, navigationChannel) {
navigationChannel.receiveAsFlow().collect { intent ->
if (activity?.isFinishing == true) {
return@collect
}
when (intent) {
is NavigationIntent.NavigateBack -> {
if (intent.route != null) {
navHostController.popBackStack(intent.route, intent.inclusive)
} else {
navHostController.popBackStack()
}
}
is NavigationIntent.NavigateTo -> {
navHostController.navigate(intent.route) {
launchSingleTop = intent.isSingleTop
intent.popUpToRoute?.let { popUpToRoute ->
popUpTo(popUpToRoute) { inclusive = intent.inclusive }
}
}
}
}
}
}
}
navigationChannel 에 있는 Intent 값을 확인해서 NavigateBack 이면 popBackStack()을 하고 NavigateTo 이면 navHostController 의 navigate를 통해 특정 UI로 이동해 준다.
val navController = rememberNavController()
NavigationEffects(
navigationChannel = homeViewModel.navigationChannel,
navHostController = navController
)
AppTheme {
NavHost(
navController = navController,
startDestination = Destination.Dynasty) {
composable(Destination.Dynasty) {
DynastyScreen()
}
composable(Destination.MyStudy) {
MyStudyScreen()
}
composable(Destination.StudyType) {
StudyTypeScreen()
}
composable(Destination.StudyPage) {
StudyPageScreen()
}
composable(Destination.TypeCheck) {
TypeCheckScreen()
}
}
}
MainScreen에서 NavigationEffects 와 NavHost를 정의해준다.
(이때, NavHost는 내가 재정의한 NavHost를 사용한다.