5 minute read

Jetpack Compose Navigation 공부하기

공식 튜토리얼 링크

1. 프로젝트 만들기

Empty Activity 만들기로 새 프로젝트를 만들어 주고 기본적인 gradle 설정을 해 준다.

android {
...
    buildFeatures {
        compose true
    }
...
    viewBinding {
        enabled = true
    }
}
...
dependencies {
...
    // Jetpack Compose
    implementation("androidx.compose.ui:ui:1.1.1")
    implementation("androidx.compose.foundation:foundation:1.1.1")
    implementation("androidx.compose.material:material:1.1.1")
    implementation("androidx.compose.ui:ui-tooling:1.1.1")
    implementation("androidx.navigation:navigation-compose:2.5.0")
}

그리고 RootActivity에 초기 셋팅을 해 준다. (기본으로 생성되는 MainActivity의 이름을 RootActivity로 바꿨다)

class RootActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.composeView.setContent {
            // TODO
        }
    }
}

xml에도 기본값으로 존재하는 뷰를 지우고 composeView를 추가해 준다.

<androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

2. NavController, NavHost 만들기

        binding.composeView.setContent {
            val navController = rememberNavController()

            NavHost(navController = navController, startDestination = "main") {
                composable("main") { MainPage() }
                // TODO
            }
        }

NavController는 Navigation 구성요소의 중심 API로, 스테이트풀(Stateful)이며 앱의 화면과 각 화면 상태를 구성하는 컴포저블의 백 스택을 추적합니다.

컴포저블 계층 구조에서 NavController를 만드는 위치는 이를 참조해야 하는 모든 컴포저블이 액세스할 수 있는 곳이어야 합니다.

아직 공식 문서의 설명을 읽어도 무슨 소린지는 잘 모르겠지만 일단 따라해본다.

rememberNavController()로 navController를 만들고, NavHost를 만들어서 navController과 연결시켜 준다. startDestination 자리에는 String이 들어가는데, 컴포저블의 경로 라고 한다.

주요 용어: 경로는 컴포저블의 경로를 정의하는 String입니다. 특정 대상으로 연결되는 암시적 딥 링크라고 생각하면 됩니다. 각 대상에는 고유 경로가 있어야 합니다.
“main”이라는 경로에 해당하는 컴포저블 함수도 배정해 줘야 한다. MainPage.kt를 만들고 람다에 넣어 준다.

// MainPage.kt
@Composable
fun MainPage() {
    Text("Main page!")
}

빌드하고 실행해 보면 Main page! 라는 텍스트 하나가 뜬다. (빌드할 때 compose compiler가 호환되지 않는다 어쩌구 뜰 수 있다. 시키는 대로 버전 조정하기)

image

3. 본격적으로 composable 간 이동하기

MainPage에서 4가지 composable로 각각 이동할 수 있는 버튼을 만들어 본다. 이를 위해 상위의 NavController에 4개 composable을 추가하고, MainPage.kt에는 4개의 버튼을 만들어서 각각의 콜백에 컴포저블 함수를 등록해 준다.

이때, MainPage의 각 버튼의 onClick에서 각 composable로 이동하기 위해서 .navigate()를 사용한다. 이를 위해서 파라미터로 RootActivity의 navController를 받아 와야 한다.

// RootActivity.kt
            ...
            NavHost(navController = navController, startDestination = "main") {
                composable("main") { MainPage(navController) }

                composable("page1") { Page1() }
                composable("page2") { Page2() }
                composable("page3") { Page3() }
                composable("page4") { Page4() }
            }
            
// MainPage.kt
@Composable
fun MainPage(navController: NavController) {
    Column {
        Button(onClick = { navController.navigate("page1") }) {
            Text("MOVE TO PAGE 1")
        }
        Button(onClick = { navController.navigate("page2") }) {
            Text("MOVE TO PAGE 2")
        }
        Button(onClick = { navController.navigate("page3") }) {
            Text("MOVE TO PAGE 3")
        }
        Button(onClick = { navController.navigate("page4") }) {
            Text("MOVE TO PAGE 4")
        }
    }
}

// 각 Page{$n}.kt
@Composable
fun Page1() {
    // TODO
}

4. NavOptions 설정하기

navController.navigate("route")는 navController가 가지고 있는 스택에 새 대상을 추가한다. 그리고 navigate()에 여러가지 탐색 옵션을 설정할 수 있다.

  1. launchSingleTop
    launchSinglTop을 true로 설정하면 스택의 가장 위(=현재 위치)가 두 개 이상 쌓이지 못하도록 한다. 즉, 현재 위치와 똑같은 곳을 도착지로 설정해서 navigate()하지 못하게 해서 스택에 중복 원소가 연속해서 쌓이지 않도록 해 준다.
  2. restoreState
    restoreState는 BottomNavigation()을 사용할 때 필요하다. 나중에…
  3. popUpTo
navController.navigate("pageA") {
    popUpTo("pageB") {
        inclusive = true
    }
}

navigate()의 목적지는 pageA이다. 이때 popUpTo(“pageB”)를 설정해 주면 navController의 스택에서 pageB 까지 전부 pop하고 navigate한다. inclusive가 true이면 pageB도 포함해서 pop하고, false라면 pageB는 빼고 pop한다.

5. 종합해서 연습 애플리케이션 만들기

스택의 push와 pop, 그리고 각종 옵션들의 기능을 알아보기 위해 다음과 같은 앱을 만들고자 한다.

MainPage와 Page 1~4는 서로서로 naviagte할 수 있고, navigate시의 옵션을 UI에서 선택할 수 있도록 만들려고 한다. 또한 navController의 스택을 시각화하여 화면 하단에 나타내고자 한다. 최종 목표는 아래와 같다.

image

체크박스 그룹

@Composable
fun GroupCheckBox(
    list: List<String> = emptyList(),
    states: MutableList<Boolean> = mutableListOf(),
    onlyOneOption: Boolean = false
) {
    Column {
        list.forEachIndexed { index, item ->
            Row(
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier
                    .toggleable(
                        value = states[index],
                        role = Role.Checkbox,
                        onValueChange = {
                            states[index] = states[index].not()
                            if (onlyOneOption && states[index]) {
                                repeat(list.size) {
                                    if (it != index) states[it] = false
                                }
                            }
                        },
                        interactionSource = MutableInteractionSource(),
                        indication = null
                    )
                    .padding(5.dp)
            ) {
                Checkbox(
                    checked = states[index],
                    onCheckedChange = null
                )
                Spacer(modifier = Modifier.width(10.dp))
                Text(text = item)
            }
        }
    }
}

원래는 Checkbox()onCheckedChange에 람다를 넣어서, 체크박스를 클릭했을 때의 동작을 제어한다. 하지만 자연스러운 조작을 위해 checkbox와 Row()로 묶이는 텍스트를 포함한 전체 영역을 터치했을 때 체크박스가 클릭되는 것과 동일한 효과를 내기 위해 Row()의 modifier에 .toggleable을 넣어 주었다.
이 컴포저블을 사용하는 각 Page.kt에서는 다음 state를 만들어서 체크박스들이 눌릴 때마다 recompose될 수 있도록 해 준다.

val destinationCheckBoxStates = remember { mutableStateListOf(false, false, false, false, false) }
val optionCheckBoxStates = remember { mutableStateListOf(false, false, false) }

onlyOneOption이 true라면, 해당 체크박스 그룹에서 체크 상태인 것이 하나까지만 존재할 수 있도록 한다.

스택 시각화

@Composable
fun ShowStack(navController: NavController) {
    val scrollState = rememberScrollState()

    Column(modifier = Modifier.fillMaxSize()) {
        Spacer(modifier = Modifier.weight(1.0f, true))
        Divider(thickness = 2.dp)
        Text("NavController Stack", fontSize = 25.sp)
        Spacer(modifier = Modifier.height(5.dp))
        Row(
            Modifier
                .padding(5.dp)
                .horizontalScroll(scrollState), verticalAlignment = Alignment.CenterVertically) {
            navController.backQueue
                .filter {
                    it.destination.route != null
                }
                .forEach {
                    Text(text = it.destination.route!!, fontSize = 18.sp)
                    Text(" -> ")
                }
        }
    }
}

각 Page.kt의 navigator를 인수로 받는다. 내부에 있는 backQueue에 들어 있는 각 entry에서 정보를 뽑아 scrollable Row로 표시해 준다.
일반적인 Row()를 scrollable하게 하려면 위와 같이 val scrollState = rememberScrollState()로 state를 하나 만들고, Modifier에서 .horizontalScroll() 을 사용하면 된다.

이제 popUpTo를 구현해 본다.
popUpTo 체크박스를 누르면 route를 선택하는 드롭다운 박스와 옵션 2가지(inclusive, saveState)를 설정하는 체크박스가 나타나도록 하고 싶다.

ExposedDropdownMenuBox

                ExposedDropdownMenuBox(
                    expanded = dropDownExpanded,
                    onExpandedChange = { dropDownExpanded = dropDownExpanded.not() }
                ) {
                    TextField(
                        readOnly = true,
                        value = "popUpToRoute: $chosenPopupTo",
                        onValueChange = {},
                        trailingIcon = {
                            ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropDownExpanded)
                        },
                        colors = ExposedDropdownMenuDefaults.textFieldColors()
                    )
                    ExposedDropdownMenu(
                        expanded = dropDownExpanded,
                        onDismissRequest = {
                            dropDownExpanded = false
                        }
                    ) {
                        navController.backQueue
                            .filter {
                                it.destination.route != null
                            }
                            .map {
                                it.destination.route!!
                            }
                            .distinct()
                            .forEach {
                                DropdownMenuItem(
                                    onClick = {
                                        chosenPopupTo = it
                                        dropDownExpanded = false
                                    }
                                ) {
                                    Text(it)
                                }
                            }
                    }
                }

먼저 드롭다운의 확장 상태를 나타내는 var dropDownExpanded by remember { mutableStateOf(false) }를 만들어 준다. ExposedDropdownMenuBox 생성자의 파라미터 중 expanded는 앞의 var을 넣어 주고 onExpandChange는 이 Boolean이 뒤집히도록 해 준다. 그리고 content에는 TextField와 드롭다운 메뉴가 필요하다.

TextField는 적당히 예쁘게 꾸며 주고, 중요한 건 ExposedDropdownMenu이다. popUpTo의 대상은 스택에 있는 것만 돼야 하기 때문에 navController.backQueue에서 null이 아니고, 중복을 제외해서 DropdownMenuItem로 항목을 만들어 준다.

popUpTo() 부분 완성

var chosenPopupTo by remember { mutableStateOf("") }
        if (optionCheckBoxStates[2]) {
            var dropDownExpanded by remember { mutableStateOf(false) }

            Column(Modifier.padding(start = 15.dp)) {
                ExposedDropdownMenuBox(
                   // 생략 (바로 위의 코드)
                )
                GroupCheckBox(
                    list = popUpToOptionList,
                    states = popUpToOptionBoxStates
                )
            }

이 모든 것은 popUpTo 체크박스가 체크돼 있을 떄만 나타나게 하고 싶으니 state를 조건으로 if를 걸어 준다. 이렇게 하면 값이 바뀔 때마다 recompose되어 UI를 새로 그려준다.
마지막으로 inclusive, saveState 옵션을 위한 체크박스까지 만들어 준다.

최종 navigate() 버튼

체크박스로 설정한 목적지와 옵션을 가지고 최종적으로 navigate 하는 버튼은 다음과 같이 만들어 준다.

        Button(
            onClick = {
                destinationCheckBoxStates.forEachIndexed { index, state ->
                    if (state) navController.navigate(destinationList[index]) {
                        launchSingleTop = optionCheckBoxStates[0]
                        restoreState = optionCheckBoxStates[1]
                        if (chosenPopupTo.isEmpty().not()) {
                            popUpTo(chosenPopupTo) {
                                inclusive = popUpToOptionBoxStates[0]
                                saveState = popUpToOptionBoxStates[1]
                            }
                        }
                    }
                }
            }
        ) {
            Text("Navigate!", fontSize = 25.sp)
        }

이때 목적지의 경로 와 옵션의 이름은 깔끔하게 아래 리스트에 담아 보관한다.

    val destinationList = listOf("page1", "page2", "page3", "page4", "main")
    val optionList = listOf("singleTop", "restoreState", "popUpTo")
    val popUpToOptionList = listOf("inclusive", "saveState")

여기까지의 코드 링크: 체크포인트 1