(3) navigation-compose 적용하기

HEYDAY7·2022년 10월 13일
1

compose에서 제공하는 navigation 방식으로 composable들 사이를 이동할 때 사용할 수 있다. 공식문서는 이 링크에서 확인할 수 있다.

두 번째 작업으로 navigation을 택한 이유는 이 navigation까지 로직을 짜 두면 이제는 정말 "만들고 싶은 것을 만들 수 있는 상태"가 된다고 생각했기 때문이다.

실제로 navigation-compose 자체를 적용하는 것은 매우 간단하다. 앞서 언급한 공식문서만 참고해도 바로 적용이 가능하다. 작업에서도 적용 자체는 간단했다. 관련 commit을 남긴다.

마주한 문제점

위 commit과 같은 상태까지만 작업을 한다해도 동작상의 문제는 없다. 하지만 Screen 구조가 복잡해지고, project가 커질 경우(이 프로젝트엔 해당하지 않을 수 있겠지만) 큰 문제점이 하나 생긴다.

  • Composable간의 이동의 경우 navController.navigate()텍스트를 통해서 진행된다. 즉 달리 말하면 모든 ViewModel에서 navController를 알아야 한다는 말이 된다. 이 말은 구조가 복잡해질 경우 불필요하게 navController를 parameter로 가져야만 하게 된다.

이를 해결할 방법을 찾다 Navigator Class를 Custom하게 만드는 방식을 찾았다. 이 방식의 경우 Stream-slack-clone을 참고했으며, 최소한으로 필요한 부분만 추려서 작성했다.

간단히 말해 Navigation에 대한 정보를 담는 class를 만들었다~ 정도이다. 여기서는 Navigation에는 NavigateUp과 NavigateToRoute 두 종류가 있겠구나~ 하면 된다.

sealed class NavigationCommand {
    object NavigateUp : NavigationCommand()
}

sealed class ComposeNavigationCommand : NavigationCommand() {
    data class NavigateToRoute(val route: String, val options: NavOptions? = null) :
        ComposeNavigationCommand()
}

이 부분은 나도 아직은 Flow에 대한 추가적인 이해가 있어야 100% 이해할 수 있을 것 같은 부분으로, 지금은 절반정도 이해했다고 생각한다. 각 요소들을 간략하게 나마 순서대로 설명해보자면

  1. navigationCommands : command들이 흐르는(?) sharedFlow
  2. navControllerFlow : navController를 담는 stateFlow
  3. fun naviagateUp : 해당 function이 불리면 NavigateUp command를 1번 sharedFlow로 emit 해라!
  4. fun navigate : 추후 주입시 구현될 navigate 함수
  5. handleNavigationCommands : navController를 받아 stateFlow에 등록, 해제를 시켜주며, navController가 command를 처리할 수 있게 연결해준다.
  6. NavController.handleComposeNavigationCommand : navigationCommand의 종류에 따라 다른 동작을 연결시켜준다.
abstract class Navigator {
    val navigationCommands = MutableSharedFlow<NavigationCommand>(extraBufferCapacity = Int.MAX_VALUE)

    val navControllerFlow = MutableStateFlow<NavController?>(null)

    fun navigateUp() {
        navigationCommands.tryEmit(NavigationCommand.NavigateUp)
    }
}

abstract class ComposeNavigator : Navigator() {
    abstract fun navigate(route: String, optionsBuilder: (NavOptionsBuilder.() -> Unit)? = null)

    suspend fun handleNavigationCommands(navController: NavController) {
        navigationCommands
            .onSubscription { this@ComposeNavigator.navControllerFlow.value = navController }
            .onCompletion { this@ComposeNavigator.navControllerFlow.value = null }
            .collect { navController.handleComposeNavigationCommand(it)}
    }

    private fun NavController.handleComposeNavigationCommand(navigationCommand: NavigationCommand) {
        when (navigationCommand) {
            is ComposeNavigationCommand.NavigateToRoute -> {
                navigate(navigationCommand.route, navigationCommand.options)
            }
            is NavigationCommand.NavigateUp -> navigateUp()
        }
    }
}

MovieAppNavigator.kt

ComposeNavigator의 구현부가 되는 부분으로 위에서 미처 구현되지 않았던 fun navigate의 구현을 볼 수 있다.

class MovieAppNavigator @Inject constructor() : ComposeNavigator() {
    override fun navigate(route: String, optionsBuilder: (NavOptionsBuilder.() -> Unit)?) {
        val options = optionsBuilder?.let { navOptions(it) }
        navigationCommands.tryEmit(ComposeNavigationCommand.NavigateToRoute(route, options))
    }
}

지난 글에도 나왔던 hilt를 통한 의존성 주입 과정이다. "provideComposeNavigator"는 어디선가 ComposeNavigator를 필요로하면 이 코드를 통해서 만들어가십쇼~ 하는 의미이며, 구현부를 파라미터로 넘겨주고 있는 것을 볼 수 있다.
따라서 해당 코드는 "ComposeNavigator의 구현은 MovieAppNavigator입니다."라고 말해주는 코드다.

@Module
@InstallIn(SingletonComponent::class)
abstract class NavigationModule {

    @Binds
    @Singleton
    abstract fun provideComposeNavigator(movieAppNavigator: MovieAppNavigator): ComposeNavigator
}

MainActivity와의 연결

navigation 관련 파트를 AppContent로 분리했고, MainActivity에서는 ComposeNavigator를 주입받아 AppContent에 넘겨준다.

## MainActivity.kt

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var composeNavigator: ComposeNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MovieAppTheme {
                AppContent(composeNavigator)
            }
        }
    }
    
## AppContent.kt

@Composable
fun AppContent(
    composeNavigator: ComposeNavigator
) {
    val navController = rememberNavController()

    LaunchedEffect(Unit) {
        composeNavigator.handleNavigationCommands(navController) // navController 등록
    }

    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable(
            route = "home"
        ) {
            CompositionLocalProvider(
                provideHomeViewModelFactory { hiltViewModel<RealHomeViewModel>() }
            ) {
                HomeScreen()
            }
        }
    }
}

마무리

이로써 navigation 구조도 작업이 마무리되었고. 이번 글과 관련된 코드는 이 branch에서 확인 가능하다.
이제는 원하는 것을 그려나가기 위한 기초 정도는 갖춰졌다고 생각한다. 따라서 사용하려 했던 API를 좀 탐구해보고, 어떤 걸 만들 수 있을지 고민해보려고 한다.

추가적으로 이번 작업에 많이 참고했던 stream-slack-clone-android github을 다시 한번 적어둔다.

profile
(전) Junior Android Developer (현) Backend 이직 준비생

1개의 댓글

comment-user-thumbnail
2023년 11월 15일

참고가 되었습니다! 감사합니다!

답글 달기