[Android/Compose] Jetpack Navigation 사용해보기 (기초)

곽의진·2024년 5월 7일
1

Android

목록 보기
14/15
post-thumbnail

Jetpack Navigaiton

Jetpack Navigation 이란?

Navigation은 공식문서를 직역해보자면 사용자가 앱 내 다양한 콘텐츠를 탐색하고 진입하며 돌아갈 수 있게 하는 상호작용을 말합니다.

이를 쉽게 얘기해보자면 Navigation이란 화면을 탐색하여 사용자가 앱 안에서 다른 화면으로 갈 수 있게 해주는 기능을 말합니다. 예를 들어, 메뉴에서 하나의 버튼을 눌러 다른 페이지로 이동하거나, 설정 화면으로 가서 이전 화면으로 돌아오는 것들 모두 포함되는 개념입니다.

Android Jetpack의 네비게이션 구성 요소 다음과 같습니다.
1. Navigation 라이브러리
2. Safe Args Gradle 플러그인
3. 그리고 앱 Navigation을 구현하는데 도움을 주는 도구

핵심 요소

Host

Host는 사용자가 앱을 사용하는 동안 현재 방문하고 있는 Navigation Destination(목적지)을 담고 있는 UI 요소입니다. 이는 NavHost라고도 불리며, 앱에서 화면이 전환될 때마다 현재 화면을 Host UI 요소 안에서 교체하여 보여줍니다.

예를 들어, 사용자가 A 화면에서 B 화면으로 이동할 때, 네비게이션 호스트는 A 화면을 제거하고 B 화면을 새로 표시합니다. (stack에서 제거의 경우 특정한 옵션이 필요)

Compose의 경우 NavHost를 활용합니다.

그래프(Graph)

그래프는 앱 내의 모든 네비게이션 목적지들과 이 목적지들이 어떻게 서로 연결되는지를 정의하는 데이터 구조입니다. 이 네비게이션 그래프는 각 화면(목적지)을 노드로 표현하며, 사용자가 한 화면에서 다른 화면으로 넘어갈 수 있는 경로(엣지)들을 정의합니다. 이를 통해 개발자는 앱의 전체 네비게이션 구조를 쉽게 설정하고 관리할 수 있습니다.

NavGraph를 사용하여 이 구조를 설정합니다.

컨트롤러(Controller)

컨트롤러는 목적지 간의 네비게이션을 총괄하는 역할을 하는 컴포넌트입니다.

NavController라는 컴포넌트가 이 역할을 맡아, 사용자가 한 화면에서 다른 화면으로 넘어갈 수 있도록 도와주며, 딥 링크 처리, 뒤로 가기 스택의 관리 등의 기능을 제공합니다.

즉, NavController는 앱 내에서 사용자의 위치를 파악하고, 필요에 따라 적절한 화면(목적지)으로 이동시키는 역할을 합니다.

이 세 가지 주요 요소를 통해 Jetpack Navigation은 사용자가 앱 내에서 원활하고 직관적으로 다른 화면으로 이동할 수 있도록 지원하며, 복잡한 네비게이션 요구 사항을 간소화하여 개발자가 더 효율적으로 작업할 수 있게 도와줍니다.

장점 및 특징

Navigation 다음을 포함하여 다양한 이점과 기능을 제공합니다.

  1. Animations과 화면 전환: 애니메이션 및 화면 전환에 대한 표준화된 리소스를 제공합니다.
  2. Deep linking: 사용자를 특정 화면으로 직접 연결하는 딥링크를 구현하고 처리합니다.
  3. UI 패턴: Navigation Drawables, BottomNavigation과의 통합을 간편하게 지원합니다.
  4. Safe Args: 대상 간에 데이터를 탐색하고 전달할 때 유형 안전성을 제공하는 Safe Args Gradle 플러그인이 포함되어 있습니다.
  5. ViewModel 지원: ViewModel을 특정 Navigation 경로(그래프)와 연결하여, 그 경로에 있는 여러 화면(목적지)에서 같은 데이터를 공유하고 접근할 수 있게 합니다.

Jepack Navigation 구현해보기

초기 셋팅

Jetpack Navigation Releas Note
app 수준의 build.gradle 파일에 아래 종속성을 추가해줍니다.

dependencies {
  def nav_version = "2.7.7" // 최신 버전은 릴리즈 노트를 확인해주세요

  // Jetpack Compose Integration
  implementation "androidx.navigation:navigation-compose:$nav_version"
}

Compose에서는 NavController 만들기가 매우 간단합니다, 아래와 같이 rememberNavController() 를 사용하여 만들면 됩니다.

val navController = rememberNavController()

주의할 점으로는 NavController의 경우 Composable 구조의 높은 계층에서 생성되어야한다는 점입니다. 왜냐하면 모든 컴포저블이 화면 이동을 위해 해당 controller를 참조해야하기 때문이죠!

또한 NavController는 Composable 외부에서 상태를 업데이트하기 위해 사용될 수 있으며 이는 상태 호이스팅의 원칙을 따릅니다.

상태 호이스팅이란?

Navigation Graph를 만들기 위해서는 NavHost Composable을 사용해야합니다.

NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
        ...
        }
        composable(Screen.Detail.route) {
        ...
        }
        composable(Screen.PlayGround.route) {
        ...
        }
    }
    
 sealed class Screen(val route: String) {
    data object Home : Screen("home")
    data object Detail : Screen("detail")
    data object PlayGround : Screen("playground")
}

NavHost 컴포저블은 NavController와 시작 목적지를 인자로 받습니다. 예를 들어, startDestination에 "home"이라는 route 문자열을 설정하면, 앱이 시작할 때 "home"이라는 목적지의 화면이 먼저 보여집니다.

네비게이션 그래프 구성
NavHost의 내부 구성은 다음과 같습니다.

@Composable
public fun NavHost(
    navController: NavHostController,
    startDestination: String,
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.Center,
    route: String? = null,
    enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
        { fadeIn(animationSpec = tween(700)) },
    exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
        { fadeOut(animationSpec = tween(700)) },
    popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
        enterTransition,
    popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
        exitTransition,
    builder: NavGraphBuilder.() -> Unit
) 

마지막 파라메터인 builder 블럭을 활용해 NavGraphBuilder.composable()을 호출하여 네비게이션 그래프에 목적지를 추가합니다.

즉, 위 코드에서는 sealed class로 구성한 Screen.Home.route 를 통해 "home" 문자열에 접근하고 HomeScreen Composable을 보여주도록 처리합니다.

NavController와 NavHost의 상호작용
정리해보자면 NavController는 하나의 NavHost 컴포저블과 연관되어 있습니다.
NavController를 사용하여 목적지로 이동할 때, 해당 NavController는 연결된 NavHost와 상호작용하여 화면을 전환하는 것 입니다.

Navigation을 통해 목적지로 이동하는 방법은 navController를 사용하는 것 입니다.

@Composable
fun Navigation() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                navigateToDetail = { navController.navigate(Screen.Detail.route) },
                navigateToPlayGround = { navController.navigate(Screen.PlayGround.route) }
            )
        }
        composable(Screen.Detail.route) {
            DetailScreen(
                navigateToHome = { navController.navigate(Screen.Home.route) },
                navigateToPlayGround = { navController.navigate(Screen.PlayGround.route) }
            )
        }
        composable(Screen.PlayGround.route) {
            PlayGroundScreen(
                navigateToHome = { navController.navigate(Screen.Home.route) {
                    popUpTo(Screen.Home.route) {
                        inclusive = true
                    }
                }},
                navigateToDetail = { navController.navigate(Screen.Detail.route) }
            )
        }
    }
}

위 코드와 같이 NavController.navigate(route) 함수를 통해 특정 화면으로 이동하는 것이 가능합니다, 다만 조금 의아하신 부분이 있을텐데요.

왜 navController를 넘기는 방식이 아닌 람다를 통해 Composable 외부에서 navigate 을 호출하는지 궁금하실 겁니다, Navigate 동작 상태 호이스팅을 해야하는 이유는 다음과 같은데요!

  1. 리컴포지션 중복 호출 문제: NavController의 메소드가 리컴포지션 때마다 실행될 가능성이 있습니다. 예를 들어, 상태 변경에 따라 HomeScreen, DetailScreen, PlayGroundScreen 등이 재구성될 때마다 NavController의 메소드 호출이 발생할 수 있습니다. 이는 특히 네비게이션 명령이 자주 사용되는 상황에서 성능 저하를 일으킬 수 있습니다.
  2. 네비게이션 로직의 분산: 각 화면이 네비게이션 로직을 갖고 있어야 하기 때문에, 네비게이션 로직이 여러 곳에 분산되어 있습니다. 이는 유지보수를 어렵게 만들고, 네비게이션 로직의 일관성을 해칠 수 있습니다.

Jetpack Navigation에서는 route에 argument를 추가하는 방식을 활용하여 Composable간의 데이터를 전달할 수 있습니다.

# NavHost 블럭 내부 코드

composable(Screen.Home.route) {
    HomeScreen(
        onSubmitUserName = { userName ->
            navController.navigate(Screen.Detail.route + "/$userName")
        }
    )
}
composable(
    route = Screen.Detail.route + "/{userName}",
    arguments = listOf(
        navArgument("userName") {
            type = NavType.StringType
            nullable = true
            defaultValue = null
        }
    )
) { entry ->
    val userName = entry.arguments?.getString("userName")
    DetailScreen(
        name = userName.orEmpty()
    )
}

@Composable
fun HomeScreen(
    onSubmitUserName: (String) -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 30.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        var userName by remember { mutableStateOf("") }
        Text(text = "Input User Name")
        Spacer(modifier = Modifier.height(8.dp))
        TextField(
            value = userName,
            onValueChange = {
                userName = it
            })
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = { onSubmitUserName(userName) }) {
            Text(text = "Submit")
        }
    }
}

위 코드를 예시로 들자면 userName을 route의 path로 추가하였으며, composable 함수의 argument 파라메터로 navArgument("userName")을 통해 type, nullable, default value를 설정하여 DetailScreen Composable이 userName을 전달받을 수 있습니다.

직접적으로 데이터를 전달하는 부분은 navController.navigate() 함수를 통해 route를 Screen.Detail.route + "/$userName"로 전달하는 부분인데요, 쉽게 보자면 HomeScreen에서 TextField의 문자열 값을 Button Click시 navigate를 통해 전달하는 것 입니다.

NavController.navigate() 메소드를 통해 BackStack을 효율적으로 관리한다면 보다 세밀한 네비게이션 제어가 가능합니다!

아래 에시들을 하나씩 살펴보시죠!

특정 목적지까지의 모든 목적지를 백 스택에서 제거한 후 다른 목적지로 이동 예제

// "destination_a"까지의 모든 목적지를 백 스택에서 제거하고 "destination_c"로 이동
// a -> b -> c 스택이 쌓여야하지만
// a -> c popUpTo를 활용하여 a까지의 모든 스택(b)를 제거하고 c로 이동
navController.navigate("destination_c") {
    popUpTo("destination_a")
}

이 호출은 "destination_a" 이전까지의 모든 목적지를 백 스택에서 제거한 다음, "destination_c"로 이동합니다. "destination_a"는 백 스택에 남아있습니다.

특정 목적지를 포함하여 그 이전까지의 모든 목적지를 백 스택에서 제거한 후 다른 목적지로 이동 예제

// "destination_a"를 포함해 그 이전까지의 모든 목적지를 백 스택에서 제거
하고 "destination_c"로 이동
// a -> b -> c
// c
navController.navigate("destination_c") {
    popUpTo("destination_a") { inclusive = true }
}

이 설정은 "destination_a"를 포함하여 그 이전의 모든 목적지를 백 스택에서 제거한 후, "destination_c"로 이동합니다. 여기서 inclusive = true는 "destination_a"도 백 스택에서 제거한다는 것을 의미합니다.

목적지가 이미 최상위에 있지 않은 경우에만 해당 목적지로 이동하여 중복 이동을 방지 예제

// "search" 목적지로 이동하지만, 이미 "search"가 최상위에 있으면 중복 추가하지 않음
navController.navigate("search") {
    launchSingleTop = true
}

launchSingleTop = true 설정은 이미 "search" 목적지가 최상위에 있을 경우, 새로운 "search"를 스택에 추가하지 않고 기존의 것을 재사용합니다. 이는 같은 목적지가 중복으로 쌓이는 것을 방지할 수 있습니다.

오늘은 간단하게 기초적으로 Navigation을 구축하고 활용하는 방법을 알아보았는데요, 다음에는 NesteadNavigation, Navigation Bar, Share ViewModel 등의 개념을 살펴보도록 하겠습니다.

profile
Android Developer

0개의 댓글