깨알 개념/Android

[Android] Jetpack Compose - Navigation 기초

interfacer_han 2024. 9. 12. 16:06

#1 개요

#1-1 전통적인 안드로이드 프로젝트에서의 Navigation

 

[Android] Navigation - 기초

#1 이전 글 [Android] Navigation - 환경 설정 #1 Navigation#1-1 액티비티 및 프래그먼트 구성의 트렌드요즘 안드로이드 개발의 트렌드는 하나의 액티비티, 여러 개의 프래그먼트다. 이는 구글의 권장사항

kenel.tistory.com

위 게시글은 Jetpack Compose가 쓰이지 않은 전통적인 구조의 안드로이드 프로젝트에서의 Navigation에 대해 다룬 게시글이다.

 

#1-2 Jetpack Compose 구조의 안드로이드 프로젝트에서의 Navigation

 

Compose를 사용한 탐색  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose를 사용한 탐색 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 탐색 구성요소는 Jetpack 지원을 제

developer.android.com

본 게시글은 Jetpack Compose 구조에서 사용 가능한 Navigation에 대해 다뤄본다. #1-1과 하는 동작은 동일하지만, 구현 방법은 꽤 다르다.

 

#2 기제

#2-1 개요

NavHost 객체가 화면 전환을 주관한다. 화면 전환을 트리거(발생)시키는 객체는 NavHostController로, 마치 TV 리모콘과 같은 기능을 한다고 보면 된다. NavHost에 의해 관리될 각각의 화면 컴포저블을 Destination(목적지)이라 하며, 그 Destination은 route라는 String형 인수를 가진다. 이 route는 각 Destination을 식별(구분)하는데 사용된다. 따라서, 같은 NavHost 내에서 route의 이름은 중복됨 없이 고유(unique)해야 한다.

 

#2-2 도식도

자유 형식으로 그린 도식도다. #3에서 실제 코드로 구현한다.

 

#2-3 NavHost

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

Navigation 동작을 주관하는 객체는 NavHost라는 @Composable 함수다. 이전 게시글(#1-1)에서는 Activity 또는 Fragment의 전환을 위해 Navigation graph라는 일종의 지도를 사용했었다. Jetpack Compose의 Navigation에서 Navigation graph의 역할을 수행하는 것이 바로 NavHost다.

 

각 인수에 대한 설명은 아래와 같다.

 

더보기

navController: NavHostController
NavHost가 지도라면, NavHostController는 순간이동 장치다. 지도 위에서 가고싶은 목적지(route)를 선택하여 이동할 수 있다. 화면 간의 전환을 트리거(발생)시키기 위해서는 NavHostController를 사용한다. TV 리모콘하고 비슷하다.

startDestination: String
내비게이션 그래프에서 기본적으로 표시될 하위 컴포저블이다. NavHost는 Composable 함수지만, 눈에 보이지 않는다. 자기 자신 대신 startDestination으로 지정된 하위 컴포저블을 표시한다.

route: String?
NavHost의 식별자. 하나의 NavHost가 가지고 있는 하위 컴포저블인 목적지(Destination)의 식별자(route)하고는 다른 개념이다. 앱 내에서 NavHost가 여러 개 사용되는 경우에 사용되는 인수다. 기본값은 null로 지정되어있다.

 

@JvmSuppressWildcards()

자바와의 호환성을 위해 사용되는 어노테이션. 이 어노테이션을 생략하면 NavHost를 자바 환경에서 사용할 때, 인수 데이터형에 "? super" 및 "? extends" 등의 와일드카드("?") 구문이 추가되어 코드가 더러워진다고 한다.

enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition
다른 Destination이 종료되며 현재 Destination로 전환했을 때 나오는 애니메이션.

(참조: 전통적인 구조에서의 Navigation 애니메이션의 #2-2)

exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition
현재 Destination이 종료될 때 다른 Destination으로 전환되며 나오는 애니메이션.

(참조: 전통적인 구조에서의 Navigation 애니메이션의 #2-3)

popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition
(사용자의 뒤로가기 버튼 클릭으로 인해) 이전 Destination이 종료되며 현재 Destination로 전환했을 때 나오는 애니메이션.

(참조: 전통적인 구조에서의 Navigation 애니메이션의 #2-4)

popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition

(사용자의 뒤로가기 버튼 클릭으로 인해) 현재 Destination이 종료될 때 다른 Destination으로 전환되며 나오는 애니메이션.

(참조: 전통적인 구조에서의 Navigation 애니메이션의 #2-5)

sizeTransform: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> SizeTransform?)?
화면 전환 시 크기가 변경되는 애니메이션을 추가한다. 기본값은 null이다.

builder: NavGraphBuilder.() -> Unit
NavHost가 가질 Destination들을 정의.

 

#2-4 builder: NavGraphBuilder.() -> Unit

public fun NavGraphBuilder.composable(
    route: String,
    arguments: List<NamedNavArgument> = emptyList(),
    deepLinks: List<NavDeepLink> = emptyList(),
    content: @Composable (NavBackStackEntry) -> Unit
): Unit

NavHost에 정의된 마지막 인수이며, NavHost가 보유하는 Destination의 정체이기도 하다.

 

각 인수에 대한 설명은 아래와 같다.

 

route: String
Destination끼리 식별하기 위한 이름.

arguments: List<NamedNavArgument>
Destination 간 전달할 데이터의 리스트. 화면 전환 시의 데이터 전달이 목적이다. 마치 웹 프로그래밍에서 URL에 데이터를 실어보내는 것(GET 요청)과 비슷하게, route에 이 arguments를 넣어 실어보낸다. 이어지는 글(#8)에서 자세히 다룬다.

deepLinks: List<NavDeepLink>
해당 Destination에 진입할 수 있는 딥 링크들의 리스트. 딥 링크는 앱 외부(웹 브라우저, QR 코드, 타 앱)에서 특정 Destination으로 바로 접근할 수 있는 링크를 말한다고 한다. 본 게시글에선 다루지 않는다.

content: @Composable (NavBackStackEntry) -> Unit
Destination에 해당하는 컴포저블 함수. NavBackStackEntry는 Destination의 정보를 담고 있는 객체로, arguments에 저장한 데이터에 접근하기 위한 통로로 사용된다 (이어지는 글(#8) 참조).

 

#3 구현

#3-1 모듈 수준 build.gradle.kt

plugins {
    ...
}

android {
    ...
}

dependencies {

    ...
    
    // Navigation
    implementation("androidx.navigation:navigation-runtime-ktx:2.8.0")
    implementation("androidx.navigation:navigation-compose:2.8.0")
}

 

#3-2 NavHost

// package com.example.navigationbasics

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController

@Composable
fun MyNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = "firstScreen"
    ) {
        // (1) FirstScreen
        composable(route = "firstScreen") {
            FirstScreen(navController)
        }

        // (2) SecondScreen
        composable(route = "secondScreen") {
            SecondScreen(navController)
        }
    }
}

MyNavHost는 Activity의 setContent  { ... }에 넣을 컴포저블이다 (#3-5 참조). 위 코드에서 MyNavHost 및 NavHost의 modifier를 하위 컴포저블인 FirstScreen 및 SecondScreen에 전달하고 있지 않으므로, 그 존재가 무의미하다. 삭제하는 편이 더 깔끔한 코드겠지만, Activity로부터 Modifier를 이어받아 NavHost를 거쳐 Destination까지 전달이 가능하다는 걸 보이고 싶기에 그냥 냅두겠다.

 

#3-3 첫번째 Destination (FirstScreen)

// package com.example.navigationbasics

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController

@Composable
fun FirstScreen(navController: NavHostController) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text = "First Screen")
        Button(
            onClick = { navController.navigate("secondScreen") }
        ) {
            Text(text = "Go to Second Screen")
        }
    }
}

NavHostController가 어떻게 동작하는지 확인할 수 있다.

 

#3-4 두번째 Destination (SecondScreen)

// package com.example.navigationbasics

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController

@Composable
fun SecondScreen(navController: NavHostController) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text = "Second Screen")
        Button(
            onClick = { navController.navigate("firstScreen") }
        ) {
            Text(text = "Go to First Screen")
        }
    }
}

#3-3과 같은 맥락의 코드다.

 

#3-5 Activity

// package com.example.navigationbasics

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import com.example.navigationbasics.ui.theme.NavigationBasicsTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            NavigationBasicsTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    MyNavHost(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

MyNavHost는 컴포저블이지만 형태가 없다. 대신, NavHost()의 인수 중 하나인 startDestination에 적힌 Destination을 대신 보여준다.

 

#4 작동 확인

 

#5 코드 리팩토링: 단일 진실 공급원(single source of truth, SSOT) 원칙

#5-1 SSOT 원칙?

 

단일 진실 공급원 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전.

ko.wikipedia.org

위 원칙을 한 마디로 정의하면, "데이터 조작 동작을 한 곳으로 몽땅 모음"이다. 이 원칙에 기반하여 #3의 코드를 리팩토링해본다.

 

#5-2 NavHost

// package com.example.navigationbasics

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable

import androidx.navigation.compose.rememberNavController

@Composable
fun MyNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = "firstScreen"
    ) {
        // (1) FirstScreen
        composable(route = "firstScreen") {
            FirstScreen(navigateToSecondScreen = { navController.navigate("secondScreen") })
        }

        // (2) SecondScreen
        composable(route = "secondScreen") {
            SecondScreen(navigateToFirstScreen = { navController.navigate("firstScreen") })
        }
    }
}

FirstScreen 및 SecondScreen에 NavHostController를 주는 대신, NavHostController의 한 동작(이벤트)를 주게끔 바꾼다. 즉, FirstScreen에서 조작할 NavHostController의 동작을 미리 NavHost단에서 정의하여 내려준다. 이를 SSOT 패턴이라고 하며, 이 패턴을 구현함으로써 (기존 대비 변수를 보내주지 않는다는 점에서) 객체 간 결합도가 낮아졌다.

 

#5-3 FirstScreen

// package com.example.navigationbasics

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

@Composable
fun FirstScreen(navigateToSecondScreen: () -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text = "First Screen")
        Button(
            onClick = { navigateToSecondScreen() }
        ) {
            Text(text = "Go to Second Screen")
        }
    }
}

Button의 onClick 부분을 수정한다. SecondScreen도 이와 같은 맥락으로 고칠 수 있으므로 생략한다.

 

#6 요약

NavHost는 TV 채널 목록, NavController는 TV 리모콘이다.

 

#7 완성된 앱

 

android-practice/jetpack-compose/NavigationBasics at master · Kanmanemone/android-practice

Contribute to Kanmanemone/android-practice development by creating an account on GitHub.

github.com

 

#8 이어지는 글

#8-1 Destination 간 데이터 전달 (NavBackStackEntry.arguments)

 

[Android] Jetpack Compose - Navigation의 Destination 간 데이터 전달 (NavBackStackEntry.arguments)

#1 이전 글 [Android] Jetpack Compose - Navigation 기초#1 개요#1-1 전통적인 안드로이드 프로젝트에서의 Navigation [Android] Navigation - 기초#1 이전 글 [Android] Navigation - 환경 설정 #1 Navigation#1-1 액티비티 및 프

kenel.tistory.com

 

#8-2 NavigationBar

[게시글 작성 및 링크 추가 예정]