Activity와 Fragment로 Android 화면 구조를 만드는 것은 많이 익숙할 것이다.
Jetpack Compose로 UI를 구현하는 것은 공식문서를 참고한다면 어렵지 않다. 그런데 화면 구조는 어떻게 설계해야할까?
나는 NowInAndroid를 참고해서 텃텃에 필요한 화면 구조로 수정해 사용했다.
텃텃의 간단한 화면 구조이다. 원래라면,
LoginActivity
, MainActivity
를 두고 각 Activity 내에서 필요한 Fragment를 만들어 사용했을 것이다. 그러나 Jetpack Compose에서는 ComponentActivity의 setContent내에서 Composable을 선언해 사용한다. 기존의 방법처럼 여러 Activity로 나눠 복잡하게 만들고 싶지 않았다.
그래서 나는 중첩 그래프
를 사용해 Activity 역할을 해줄 수 있게 만들었다.
ScreenGraph
와 Screen
클래스를 when절에서 깔끔하게 나타내기 위해 sealed class로 만들어준다.
ScreenGraph
sealed class ScreenGraph(val route: String) {
data object LoginGraph : ScreenGraph("loginGraph")
data object MainGraph : ScreenGraph("mainGraph")
}
Screen
sealed class Screen(val route: String) {
data object Login : Screen("login")
data object Participate : Screen("participate")
data object Welcome : Screen("welcome")
data object Main : Screen("main")
..
}
route는 화면 전환할 때, id로 사용되므로 모두 다르게 설정해준다.
AppState
@Composable
fun rememberTutTutAppState(
navController: NavHostController = rememberNavController(),
coroutineScope: CoroutineScope = rememberCoroutineScope(),
) : TutTutAppState {
return remember(
navController,
coroutineScope,
) {
TutTutAppState(
navController = navController,
coroutineScope = coroutineScope
)
}
}
@Stable
class TutTutAppState(
val navController: NavHostController,
val coroutineScope: CoroutineScope
) {
fun navigateTopLevelScreen(destination: ScreenGraph) {
when (destination) {
ScreenGraph.LoginGraph -> navController.navigateToLoginGraph()
ScreenGraph.MainGraph -> navController.navigateToMainGraph()
}
}
fun navigate(screen: Screen) {
navController.navigate(screen.route)
}
fun navigateWithOptions(screen: Screen, builder: (NavOptionsBuilder.() -> Unit)) {
navController.navigate(screen.route, builder)
}
fun popBackStack() {
navController.popBackStack()
}
}
AppState
에는 앱에 전역적으로 사용될 NavHostController
와 CoroutineScope
를 선언한다. 텃텃엔 BottomSheetModal에서 CoroutineScope을 사용하지만 만약 사용하지 않는다면 선언하지 않아도 좋다.
그리고 rememberAppState
메서드를 통해 AppState을 메모리에 저장하여, 항상 같은 appState이 반환될 수 있게 만든다.
NavHost
@Composable
fun TutTutNavHost(
modifier: Modifier = Modifier,
appState: TutTutAppState,
startDestination: ScreenGraph,
onShowSnackBar: suspend (String, String?) -> Boolean,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = startDestination.route,
modifier = modifier
) {
addNestedLoginGraph(appState, onShowSnackBar)
addNestedMainGraph(appState, onShowSnackBar)
}
}
NavHost
를 통해 탐색 그래프를 만들어준다. 중첩 그래프
를 사용하기 때문에 NavHost의 startDestination은 ScreenGraph의 route로 설정해준다.
onShowSnackBar는 SnackBar을 사용하기 위한 것이나 필요없다면 생략해도 좋다.
이제 모든 준비가 끝났으니, App Composable을 만들어보자
App
@Composable
fun TutTutApp(
appState: TutTutAppState,
startDestination: ScreenGraph
) {
val snackBarHostState = remember { SnackbarHostState() }
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
snackbarHost = { SnackbarHost(snackBarHostState) }
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
) {
TutTutNavHost(
appState = appState,
startDestination = startDestination,
onShowSnackBar = { message, action ->
snackBarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = SnackbarDuration.Short,
) == SnackbarResult.ActionPerformed
},
)
}
}
}
Scaffold
내에 Column을 포함한 NavHost
가 위치한다. 따라서 화면이 바뀔 땐, NavHost부분의 화면만 바뀌게 된다.
만일 Scaffold의 topBar, bottomBar 같은 속성에 Composable을 선언하면, NavHost 내의 Composable은 그 사이에 위치하게 되는 것이다.
MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val appState = rememberTutTutAppState()
TutTutTheme {
TutTutApp(appState, viewModel.getStartDestination())
}
}
}
}
이제 MainActivity
에 App을 넣어주자.
ComponentActivity의 setContent
내에선 Composable을 사용할 수 있다.
텃텃에선 MainViewModel
에서 로그인 여부를 판단하여 startDestination을 반환하는 getStartDestination( ) 메서드로 시작 화면을 판단하고 있다.
만일, 시작 화면이 항상 동일하다면, NavHost의 startDestination에 시작할 ScreenGraph의 route를 넣어주면 된다.
LoginNavigation
fun NavController.navigateToLoginGraph() = navigate(Screen.Login.route) {
popUpTo(ScreenGraph.MainGraph.route) { inclusive = true }
}
fun NavGraphBuilder.addNestedLoginGraph(
appState: TutTutAppState,
onShowSnackBar: suspend (String, String?) -> Boolean
) {
navigation(
startDestination = Screen.Login.route,
route = ScreenGraph.LoginGraph.route
) {
composable(
route = Screen.Login.route,
popEnterTransition = popEnterAnimation()
) {
LoginRoute(
onNext = { appState.navigate(Screen.Participate) },
moveMain = { appState.navigateTopLevelScreen(ScreenGraph.MainGraph) },
onShowSnackBar = onShowSnackBar
)
}
composable(
route = Screen.Participate.route,
enterTransition = enterAnimation(),
popEnterTransition = popEnterAnimation()
) {
ParticipateRoute(
onNext = { appState.navigate(Screen.Welcome) },
onBack = { appState.popBackStack() },
onShowSnackBar = onShowSnackBar
)
}
composable(
route = Screen.Welcome.route,
enterTransition = enterAnimation(),
) {
WelcomeRoute { appState.navigateTopLevelScreen(ScreenGraph.MainGraph) }
}
}
}
Navcontroller.navigateToLoginGraph( )
: MainGraph의 모든 Screen을 제거하며 LoginGraph로 이동한다. 주의할 점은 이동할 navigate( )내에는 이동하는 Screen의 route를, popUpTo( )내에는 현재 ScreenGraph의 route를 사용해야한다는 것이다.
NavGraphBuilder.addNestedLoginGraph( )내의 navigation
: 중첩 화면 그래프를 만든다. startDestination
에는 중첩 그래프 중 시작할 Screen의 route를, route에는 중첩 그래프 ScreenGraph의 route를 사용해야한다.
navigation내의 composable( )
: 해당 ScreenGraph에서 사용할 화면을 넣어준다. 그래서 route 속성에는 Screen의 route를 사용한다.
결론적으로 Compose에서 중첩 그래프
를 사용하면,
ScreenGraph: Activity 역할
Screen: Fragment 역할
같이 생각할 수 있다.
LoginNavigation
fun NavGraphBuilder.addNestedLoginGraph(
appState: TutTutAppState,
onShowSnackBar: suspend (String, String?) -> Boolean
) {
navigation(
startDestination = Screen.Login.route,
route = ScreenGraph.LoginGraph.route
) {
composable(
route = Screen.Login.route,
popEnterTransition = popEnterAnimation()
) {
LoginRoute(
onNext = { appState.navigate(Screen.Participate) },
moveMain = { appState.navigateTopLevelScreen(ScreenGraph.MainGraph) },
onShowSnackBar = onShowSnackBar
)
}
..
중첩 그래프 내
composable( )
에는 ~Route는 화면을 의미한다. 그러면 각 화면은 어떤 식으로 설계하면 좋을까?
LoginScreen
@Composable
fun LoginRoute(
modifier: Modifier = Modifier,
onNext: () -> Unit,
moveMain: () -> Unit,
onShowSnackBar: suspend (String, String?) -> Boolean,
viewModel: LoginViewModel = hiltViewModel()
) {
..
val uiState by viewModel.uiState
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult(),
onResult = { viewModel.handleLoginResult(it, onNext, moveMain, onShowSnackBar) }
)
LoginScreen(
modifier = modifier,
isLoading = uiState == LoginUiState.Loading,
onLogin = { viewModel.onLogin(launcher) }
)
..
}
@Composable
internal fun LoginScreen(
modifier: Modifier,
isLoading: Boolean,
onLogin: () -> Unit
) {
..
텃텃에선 한 화면을 ~Route
과 ~Screen
으로 분리했다.
~Route: 중첩 그래프에 노출되는 Composable로 appState의 화면 이동, CoroutineScope 등을 받아온다.
~Screen: 화면 UI에 해당하는 Composable로 State의 원형 (String, Boolean, User..)을 받아 UI를 리컴포지션 시킨다. 외부로 노출되지 않게 internal fun
으로 선언한다.
이를 통해 Compose에 단방향 데이터 흐름
을 만들 수 있다.
~Route
는 화면 이동, ViewModel 생성 등을 담당하고, ~Screen
은 State을 받아 리컴포지션 되고 event를 전달한다. 그리고 ViewModel
에서만 State을 내려주고 변하는 화면 구조를 가지게 된다.
텃텃은 UI 구현에 100% Compose를 사용했기 때문에 처음에 적합한 화면 구조에 대해 고민하며 적용해봤다. 서비스마다 필요한 화면 구조가 다를 것이고, 내가 적용한 화면 구조가 최선일 순 없지만 도움이 됐으면 좋겠다.
결론적으로, 기존의 Activity-Fragment 처럼 화면을 설계하고 싶다면 중첩 그래프
를 이용해 보자!