게으른 컴공생
article thumbnail

Cupcake App : Navigation

위의 페이지를 참고하여 여러 화면을 이동해갈 수 있는 navigation을 활용해보자

 

 

개요

우리는 지금까지 화면이 하나인 앱만 다뤄봤었는데

평소에 쓰는 앱을 생각해보면 화면은 보통 여러개이다

이런 앱에서 화면 이동 기능은 필수적인데 이를 가능하게 해주는 navigation을 이용해보자

 

우선 다음 url에서 프로젝트를 clone 해오자

https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git

그리고 starter branch로 체크아웃해주자

 

Cupcake App 살펴보기

이 App은 Cupcake를 주문하는 기능을 가지고 있다

 

첫 화면에서는 몇 개의 Cupcake를 주문할지 선택하고

두번째 화면에서는 어떤 맛의 Cupcake를 주문할지 선택하고

세번째 화면에서는 언제 배달을 받을 지 선택하고

마지막 화면에서는 주문내용을 보낼 수 있다

 

위의 4가지 화면의 UI는 이미 starter branch에 구성이 되어있고

심지어 ViewModel까지 구성이 끝나있다

 

ViewModel의 구성을 잠깐 짚고 넘어가자면

주문의 상세내역 데이터를 가지는 OrderUiState를 관리하는 역할로

기본적으로 수량, 맛, 배달날짜를 설정하고

추가로 주문 초기화, 가격 계산, 날짜 목록 계산을 할 수 있다

 

 

Navigation 추가하기

개요

Navigation이라는 개념은 기본적으로 3개의 요소로 구성된다

각각 간단히 설명하자면

NavGraph : 화면을 노드로 하고 그 화면에서 넘어갈 수 있는 다른 화면 노드를 연결지은 그래프

NavController : 다른 화면으로 이동하는 (그래프를 탐색하는) 행위를 실행할 수 있는 컨트롤러

NavHost : 일종의 container로서 화면 목록을 가지고 있고, 현재 선택된 화면을 출력해주는 composable

라고 볼 수 있다

 

필자도 react에서 router 깔짝 대본게 다라서 이해도가 낮아 이 정도로 밖에 설명을 못하는 점

양해바란다 ㅜㅜ

 

경로 추가

우선 우리가 출력하고 넘어다닐 4가지 화면의 이름을 붙여줘야 한다

이 이름은 일종의 url과 같은 역할을 해서

navController가 '이 이름의 화면으로 이동해줘'라는 기능을 할 수 있게 해준다

 

CupcakeScreen.kt파일의 CupcakeAppBar 위에 다음과 같은 CupcakeScreen enum class를 만들어주자

enum class CupcakeScreen {
    Start,
    Flavor,
    Pickup,
    Summary
}

 

이들은 화면의 이름이자 일종의 url이 될 것이다

각각 시작화면, 맛선택화면, 픽업날짜선택화면, 주문결과요약화면이라는 뜻을 가지는 것이다다

지금 당장은 이해가 안되더라도 넘어가도록 하자

 

NavHost 추가

이제 CupcakeApp 컴포저블 안에 NavHost를 호출하고 화면 목록을 넣어줄 것이다

NavHost는 NavController, startDestination, modifier라는 3가지 인자를 가진다

modifier야 많이 봐와서 익숙하겠지만 NavController는 방금 들은 개념이고 startDestination은 뭔지도 모를 것이다

 

startDestination은 사실 되게 쉬운데 가장 초기화면의 url을 넣어주면 된다

그러니까 아까 선언한 CupcakeScreen.Start가 우리가 만드는 NavHost의 startDestination이 될 것이다

NavController는 아까 설명한 개념이 전부긴 하다

화면 목록이 들어있는 NavHost에 붙어서 '저 화면으로 이동해줘', '처음 화면으로 돌아가줘'등의 조작을 가능하게 해준다

rememberNavController()라는 함수를 이용해서 NavController 인수를 얻을 수 있다

 

결과적으로 다음과 같은 코드를 작성하면 된다

val navController = rememberNavController()
Scaffold(
    //codes
)
{ innerPadding ->
    val uiState by androidx.lifecycle.viewmodel.compose.viewModel.uiState.collectAsState()

    NavHost(
        navController = navController,
        startDestination = CupcakeScreen.Start.name,
        modifier = modifier.padding(innerPadding)
    ) {
    }
}

startDestination에는 String을 넣어줘야 하고 enum constant는 기본적으로

name이라는 프로퍼티를 가지기 때문에 저렇게 넘겨주면 된다

innerPadding은 Scaffold에서 기본으로 튀어나오는 것이라는 걸 예전에 봤었다

 

 

NavHost에 화면목록 채우기

이제 NavHost에 화면 목록을 채워넣을 준비가 끝났다

 

화면을 채우려면

composable(route = $화면이름) {

    $해당화면의UI를담당할컴포저블호출

}

과 같이 작성하면 된다

 

아래 코드를 보면 쉽게 이해할 수 있을 것이다

composable(route = CupcakeScreen.Start.name) {
    StartOrderScreen(
        quantityOptions = quantityOptions
    )
}
composable(route = CupcakeScreen.Flavor.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        options = flavors.map { id -> stringResource(id) },
        onSelectionChanged = { viewModel.setFlavor(it) }
    )
}
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) }
    )
}
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState
    )
}

각각의 컴포저블을 호출할 때 넘겨주는 인자들을 하나하나 설명해보겠다

 

1. quantityOptions & flavor

quantityOptions과 flavors는 그냥 데이터이다

data 패키지의 DataSource라는 object가 가진 변수가 favors와 quantityOptions라는 list이다

 

2. map

map이라는 함수는 예전에 collection의 유용한 메소드들을 소개할 때 나왔었다

여타 다른 언어에서도 자주 쓰이니 익숙할 것이라 생각한다

위 코드에서는 String list인 flavors를 stringResource 값으로 이루어진 Int list로 바꿔주고 있다

 

3. 기타

uiState는 그냥 Data Class라는 건 모두들 잘 알 것 같고

viewModel의 setDate, setFlavor도 크게 설명할 건 없는 것 같다

 

 

각 화면의 버튼에 Navigation 기능 달아주기

StartOrderScreen에서 SelectQuantityButton 그러니까 수량 버튼을 눌렀을 때 다음 화면으로 넘어가는 기능,

SelectOptionScreen에서 Next 버튼을 눌렀을 때 다음 화면으로 넘어가는 기능,

SelectOptionScreen에서 Cancel 버튼을 눌렀을 때 처음 화면으로 돌아가는 기능,

SummaryScreen에서 Cancel 버튼을 눌렀을 때 처음화면으로 돌아가는 기능을 추가해줘야 한다

 

 

우선 각 Composable에 인자를 추가해주자

StartOrderScreen과 SelectOptionScreen에는 () -> Unit 타입의 onNextButtonClicked 인자를

SelectOptionScreen과 SummaryScreen에는 () -> Unit 타입의 onCancelButtonClicked 인자를 추가해주자

 

 

인자를 추가했으니 이제 각 컴포저블에서 onClick에 연결해주면 된다

StartOrderScreen에서는 SelectQuantityButton의 onclick에 onNextButtonClicked를 연결해주자

그런데 이때 Button에서 onNextButtonClicked에 quantity 값을 전달해주는데

우리는 CupcakeApp 컴포저블로 이 값을 받아와서 viewModel.setQuantity로 설정을 해줘야 하니까

onNextButtonClicked의 타입을 (Int) -> Unit으로 바꿔주자

fun StartOrderScreen(
    quantityOptions: List<Pair<Int, Int>>,
    onNextButtonClicked: (Int) -> Unit,
    modifier: Modifier = Modifier
)

그리고 SelectQuantityButton이 들어있는 forEach 구문을 보면 item이 Pair라는 자료형인 것을 볼 수 있다

얘는 이름 그대로 두 개의 값이 묶여있는 것이다

DataSource.kt 파일을 확인해보면 quantityOptions라는 list에는

첫번째 값이 String이고 두번째 값이 Int인 Pair가 들어있는 것을 알 수 있다

우리가 올려줄 '수량'은 Int 값이니까 Pair의 두번째 값이다

 

그러니 onClick에 onNextButtonClicked을 붙여주고 인자로 item.second를 넘겨준다

quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = { onNextButtonClicked(item.second) }
    )
}

 

SelectOptionScreen에서는 onNextButtonClicked는 Next Button에(코드에서는 Button 컴포저블),

onCancelButtonClicked는 Cancel Button에(코드에서는 OutlinedButton 컴포저블) 붙여주면 된다

OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
Button(
    modifier = Modifier.weight(1f),
    // the button is enabled when the user makes a selection
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

 

SummaryScreen에서는 onCancelButtonClicked를 

Cancel Button(코드에서는 OutlinedButton 컴포저블)에 붙여주면 된다

OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}

 

 

이렇게 on~ButtonClicked 인자를 만들어주고 각 버튼에 붙여줬기 때문에

이제 CupcakeScreen의 CupcakeApp 컴포저블에서 저 인자로 버튼의 기능을 붙여주기만 하면 된다

 

onCancelButton에 붙여줄 기능은

UI 상태값을 초기화하고 첫번째 화면으로 돌아가는 것이다

 

CupcakeScreen에 따로 private 함수를 선언한다

이름은 cancelOrderAndNavigateToStart로 하자

이 친구는 viewModel과 navController를 인자로 받아서

viewModel로 UI 상태값을 초기화하고

navController로 첫번째 화면으로 돌아가준다

 

코드는 다음과 같다

private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, false)
}

여기서 navController의 popBackStack은 특정화면으로 돌아가게 해준다

 

navigation에서 화면을 이동할 때마다

그동안 출력했던 이전 화면들의 기록을 stack에 쌓아두는데

이 popBackStack은 첫번째 인자로 들어온 화면이 나올 때까지

stack에서 화면을 꺼낸다고 생각해주면 된다

 

두번째 인자는 첫번째 인자로 넣은 화면'까지' 뺄 것인지를 결정하는 boolean 값인데

우리는 첫번째 화면을 빼버리면 아무것도 안남으니까 당연히 false로 해준다

 

이제 이 cancelOrderAndNavigationToStart 함수를 이용해서 onCancelButtonClicked에 인자를 넘겨주고

onNextButtonClicked에도 viewModel의 set 메소드들과 navController를 이용해 인자를 넘겨줘보자

코드는 다음과 같다

composable(route = CupcakeScreen.Start.name) {
    StartOrderScreen(
        quantityOptions = quantityOptions,
        onNextButtonClicked = {
            viewModel.setQuantity(it)
            navController.navigate(CupcakeScreen.Flavor.name)
        }
    )
}
composable(route = CupcakeScreen.Flavor.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        options = flavors.map { id -> stringResource(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        onNextButtonClicked = {
            navController.navigate(CupcakeScreen.Pickup.name)
        },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        }
    )
}
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        onNextButtonClicked = {
            navController.navigate(CupcakeScreen.Summary.name)
        },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        }
    )
}
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        }
    )
}

navController.navigate()는 해당하는 화면으로 이동해주는 메소드라는 걸 바로 이해할 수 있을 것이다

StartOrderScreen은 onNextButtonClicked에서 상태값(quantity) 설정과 다음 화면으로 이동을 한 번에 처리하지만

SelectOptionScreen은 onSelectionChanged에서 상태값(flavor, date) 설정을 하고

onNextButtonClicked에서 다음 화면으로 이동을 처리한다

 

이제 앱을 실행시켜보고

next button과 cancel 버튼들을 눌러보자

next button을 누를 때는 다음 화면으로 넘어가다가

cancel 버튼을 누르면 첫 화면으로 돌아오는 것을 볼 수 있다

 

onSendButtonClicked 기능 추가하기

개요

안드로이드 어플은 다음 4가지의 컴포넌트로 구성된다

1. 액티비티

2. 서비스

3. 브로드캐스트 리시버

4. 컨텐트 프로바이더

이 컴포넌트 간의 통신 역할을 해주는 일종의 메시지인 intent라는 녀석이 있다

 

액티비티는 웹에서 html 정도의 역할로 화면을 구성해주고

서비스는 프로세스와 같은 의미이며 백그라운드에서 돌아가면서 여러 일을 처리해준다

브로드캐스트 리시버는 안드로이드 단말기의 이벤트 (배터리 부족 등)을 수신하는 역할을 하고

컨텐트 프로바이더는 sql lite로 앱의 데이터를 관리하거나 다른 앱과 데이터를 공유하는 등의 역할을 한다

 

intent는 명시적과 암시적으로 나뉘는데

명시적은 어떤 컴포넌트에게 메시지를 보낼지 특정하고

암시적은 어떤 컴포넌트에게 보낼지 안드로이드 운영체제가 알아서 선택하거나

적합한 목록을 제시해 사용자가 선택하게 만든다

사용자가 선택하게 만드는 경우는 pdf 문서를 열때 어떤 뷰어로 열지 선택하는

화면을 생각하면 이해하기 쉬울 것이다

오늘 우리는 암시적인 intent를 사용할 것이다

 

intent와 context를 이용해 공유선택기능 만들기

우리는 intent를 이용하여 마지막 Summary Screen에서 주문전송버튼으로

다른 어플에 공유할 수 있는 기능을 추가할 것이다

 

우선 이 기능을 구현할 함수를 하나 만들어주자

이름은 shareOrder라고 지어주고 인자로는 context와 String 2개를 받아준다

여기서 context는 또 뭐냐하면

어플리케이션의 속성 정도로 이해하면 된다

그러니까 어플리케이션의 갖가지 기본정보 정도로 받아들이면 되겠다

이 친구를 추가하는 이유는 startActivity라는 메소드를 이용하기 위해서이다

startActivity는 intent를 인자로 받아서 하나의 Activity를 시작해주는 기능을 한다

 

intent는 Action과 Data로 나뉘는데

우리는 send(공유)라는 action과 제목, 주문내용상세라는 data를 가지는 intent를 만들것이다

apply 메소드를 이용해서 코드의 각 줄이 intent. 로 시작할 필요 없게 만들어주면 코드를 줄일 수 있다

putExtra를 이용해서 데이터를 넣어준다

intent.createChooser는 대상 intent와 제목 string을 받아서 ACTION_CHOOSE라는 행동을 가지는 intent로 포장해준다

여기서 ACTION_CHOOSE에서 pdf 뷰어 선택하는 화면을 생각하면 된다

 

그러니까 정리하자면 ((제목과 주문내용상세라는 data와 공유라는 action을 가진) intent를

실행할 컴포넌트를 고르는 action을 가진) intent를 새로운 액티비티 컴포넌트에서 실행시킨다고 보면 되겠다

 

코드는 다음과 같다

private fun shareOrder(context: Context, subject:String, summary: String) {
    val intent = Intent(Intent.ACTION_SEND).apply {
        type = "text/plain"
        putExtra(Intent.EXTRA_SUBJECT, subject)
        putExtra(Intent.EXTRA_TEXT, summary)
    }

    context.startActivity(
        Intent.createChooser(
            intent,
            context.getString(R.string.new_cupcake_order)
        )
    )
}

 

공유선택기능 버튼에 연결하기

안드로이드에는 UI tree라는 게 있는데

간단하게 설명하면 화면 상의 composable들을 노드로 한 tree이다

근데 보통 이 트리에서 우리는 더 깊은 composable에게 데이터를 넘겨줄 때 인자로 일일이 명시해서 넘겨주는데

theme 설정할 때 썼던 color와 같은 데이터는 모든 컴포저블에게 넘겨주기 매우 번거롭고 힘들어진다

그런 애들을 모아놓고 모든 UI tree에서 접근할 수 있게 해준 친구들이 있는데 그 친구들이 바로

compositionLocal이라는 친구들이다

 

그 중 지금 실행중인 액티비티의 context를 담고 있는 LocalContext라는 친구가 있다

그리고 compositionLocal이기 때문에 current를 통해 데이터를 가져올 수 있다

즉 LocalContext.current를 통해서 우리가 shareOrder에 넘겨줄 context 인자를 가져올 수 있다

 

그리고 그 전에 SummaryScreen.kt에서 onSendButtonClicked: (String, String) -> Unit 인자를 추가해주고

Send Button(Button 컴포저블)의 onClick에 연결해줘야 한다

 

그리고 최종적으로 CupcakeScreen.kt에서 이 기능을 연결해주면 다음과 같은 코드가 나온다

composable(route = CupcakeScreen.Summary.name) {
    val context = LocalContext.current
    LocalContext.
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        onSendButtonClicked = { subject: String, summary: String ->
            shareOrder(context, subject = subject, summary = summary)
        }
    )

 

 

상황에 따라 다른 AppBar

현재 Cupcake 어플의 상단 AppBar의 제목은 모든 화면에서 같지만

우리는 각 화면마다 상단 AppBar의 제목이 다르게 만들어줄 것이다

또한 상단 AppBar에 뒤로가기 버튼을 표시하고 누르면 이전화면으로 돌아가는 기능을 추가할 것이다

 

뒤로가기 버튼 추가하기

일단 상단 AppBar에 뒤로가기 버튼을 추가해보자

이미 TopAppBar에 기초적인 코드가 짜여져있다

코드를 유심히보면 우리가 수정할 점을 찾을 수 있다

첫째는 canNavigateBack의 값이 상황에 맞게 변해야한다는 점이고

둘째는 navigateUp에 알맞은 람다를 넘겨줘야 한다는 점이다

 

사실 둘째는 쉽다

navController에 있는 navigateUp 메소드를 쓰면되는데

이 친구의 역할은 진짜 그냥 뒤로가기이다

그러니까 TopAppBar를 Scaffold안에서 호출할 때 navigateUp이라는 인자에는

{ navController.navigateUp() }

를 넘겨주면 된다

 

그럼 이제 canNavigateBack의 값을 상황에 맞게 변화시켜주기만 하면 된다

깊게 생각할 거 없이 그냥 뒤로 갈 화면이 있으면 true이고 아니면 false이다

그렇다면 뒤로 갈 화면이 있는지를 알려면 뭘 봐야할까?

바로 back stack이다

위에서 popBackStack에서 처음봤던 이 용어는

페이지를 방문할 때마다 Stack 자료구조에 그 페이지를 넣다보면 생기는 Stack을 의미한다

이 back stack이 비어있으면 canNavigateBack이 false이고 아니면 true인 것이다

 

따라서 canNavigateBack 인자에

navController.previousBackStackEntry != null

값을 넣어주면 완성이다

 

제목을 화면에 맞게 변화시키기

제목을 화면에 맞게 변화시키려면 일단 현재화면이 무엇인지를 알아야 한다

그 전에 현재 화면에서 표시할 제목의 문자열을 추가해주자

enum class의 상수들은 enum class의 instance이기 때문에 다음과 같이도 쓸 수 있다고 한다

enum class CupcakeScreen(@StringRes val title: Int) {
    Start(title = R.string.app_name),
    Flavor(title = R.string.choose_flavor),
    Pickup(title = R.string.choose_pickup_date),
    Summary(title = R.string.order_summary)
}

 

현재화면이 무엇인지를 알아보려면

backStackEntrydestinationroute을 보면 된다

여기서 현재화면의 이름을 알 수 있는데

backStackEntry를 state로 가져오자

그냥 backStack만 가져와서 확인해도 될 거 같은데 왜 이렇게 state로 가져오는지는 모르겠다

val backStackEntry by navController.currentBackStackEntryAsState()

 

enum class에는 valueOf라는 메소드가 있는데 얘는 name 값을 받아서 이거에 해당하는 enum class 객체를 찾아온다

val currentScreen = CupcakeScreen.valueOf(
    backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)

위와 같이 작성하면 현재화면의 name을 가지고 CupcakeScreen 안에서 해당하는 상수를 찾아서 반환한다

 

이제 이 currentScreen을 TopAppBar에 넘겨줘서 다음과 같이 작성하면 된다

title = { Text(stringResource(currentScreen.title)) },

 

결과적으로 CupcakeAppBar는 다음과 같은 코드가 된다

@Composable
fun CupcakeAppBar(
    currentScreen: CupcakeScreen,
    canNavigateBack: Boolean,
    navigateUp: () -> Unit,
    modifier: Modifier = Modifier
) {
    TopAppBar(
        title = { Text(stringResource(currentScreen.title)) },
        modifier = modifier,
        navigationIcon = {
            if (canNavigateBack) {
                IconButton(onClick = navigateUp) {
                    Icon(
                        imageVector = Icons.Filled.ArrowBack,
                        contentDescription = stringResource(R.string.back_button)
                    )
                }
            }
        }
    )
}

 

이제 Cupcake 어플을 실행해보면 화면마다 상단바의 텍스트가 바뀌고

뒤로 갈 화면이 있으면 상단바에 뒤로가기 버튼이 나타나고 클릭했을 때 뒤로갈 수 있는 걸 확인할 수 있다

 

 

이번 navigation 공부하면서 enum이나 state, component lifecycle과 안드로이드 구성요소 등

android의 중요 개념과 kotlin 각 문법의 세부사항들을 깊이 있게 배워야할 필요를 느꼈다

일단은 하던대로 진행을 하다가 정말 막힌다 싶으면 한번 돌아보는 시간이 필요할 것 같다

profile

게으른 컴공생

@노나니노나

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

검색 태그