탐색 및 백 스택

1. 시작하기 전에

이 Codelab에서는 이전 Codelab에서 시작한 Cupcake 앱의 나머지 부분을 구현합니다. Cupcake 앱에는 여러 화면이 있으며 컵케이크 주문 흐름이 표시됩니다. 완성된 앱에서는 사용자가 앱을 탐색하여 다음과 같이 할 수 있어야 합니다.

  • 컵케이크 주문 생성
  • Up 또는 Back 버튼을 사용하여 주문 흐름의 이전 단계로 이동
  • 주문 취소
  • 주문을 이메일 앱과 같은 다른 앱으로 전송

Codelab을 진행하면서 Android에서 앱의 작업과 백 스택이 처리되는 방법을 배우게 됩니다. 예를 들어 주문 취소와 같은 시나리오에서 백 스택 조작을 통해 사용자가 앱의 첫 화면으로(주문 흐름의 이전 화면이 아님) 돌아가도록 만들 수 있습니다.

기본 요건

  • 활동의 프래그먼트 간에 공유 뷰 모델을 만들고 사용할 수 있음
  • Jetpack 탐색 구성요소를 사용하는 데 익숙함
  • LiveData와 데이터 결합을 사용하여 UI와 뷰 모델 간의 동기화를 유지한 경험이 있음
  • 새로운 활동을 시작하도록 인텐트를 빌드할 수 있음

학습할 내용

  • 탐색이 앱의 백 스택에 미치는 영향
  • 맞춤 백 스택 동작을 구현하는 방법

빌드할 프로그램

  • 사용자가 주문을 다른 앱에 전송할 수 있고 주문 취소가 가능한 컵케이크 주문 앱

준비물

  • Android 스튜디오가 설치된 컴퓨터
  • 이전 Codelab을 완료하여 얻은 Cupcake 앱 코드

2. 시작 앱 개요

이 Codelab은 이전 Codelab의 Cupcake 앱을 사용합니다. 이전 Codelab을 완료하여 얻은 코드를 사용하거나 GitHub에서 시작 코드를 다운로드할 수 있습니다.

이 Codelab의 시작 코드 다운로드

GitHub에서 시작 코드를 다운로드하는 경우 프로젝트의 폴더 이름은 android-basics-kotlin-cupcake-app-viewmodel입니다. Android 스튜디오에서 프로젝트를 열 때 이 폴더를 선택하세요.

이 Codelab의 코드를 가져와서 Android 스튜디오에서 열려면 다음을 실행합니다.

코드 가져오기

  1. 제공된 URL을 클릭합니다. 브라우저에서 프로젝트의 GitHub 페이지가 열립니다.
  2. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 대화상자를 엽니다.

5b0a76c50478a73f.png

  1. 대화상자에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
  2. 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
  3. ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.

Android 스튜디오에서 프로젝트 열기

  1. Android 스튜디오를 시작합니다.
  2. Welcome to Android Studio 창에서 Open an existing Android Studio project를 클릭합니다.

36cc44fcf0f89a1d.png

참고: Android 스튜디오가 이미 열려 있는 경우 File > New > Import Project 메뉴 옵션을 대신 선택합니다.

21f3eec988dcfbe9.png

  1. Import Project 대화상자에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 11c34fc5e516fb1c.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.
  5. Project 도구 창에서 프로젝트 파일을 살펴보고 앱이 설정된 방식을 확인합니다.

이제 앱을 실행하면 다음과 같이 표시됩니다.

45844688c0dc69a2.png

이 Codelab에서는 먼저 앱에서 Up 버튼 구현을 완료합니다. 사용자가 이 버튼을 탭하면 주문 흐름의 이전 단계로 이동하게 됩니다.

fbdc1793f9fea6da.png

그런 다음 사용자가 주문 처리 과정에서 마음이 바뀌는 경우 주문을 취소할 수 있도록 Cancel 버튼을 추가합니다.

d66fdafeac1b0dcf.gif

그런 다음 Send Order to Another App을 탭하여 주문을 다른 앱과 공유하도록 앱을 확장합니다. 그러면 예를 들어 이메일을 통해 주문을 컵케이크 상점에 전송할 수 있습니다.

170d76b64ce78f56.png

본격적으로 Cupcake 앱을 완료해보겠습니다.

3. Up 버튼 동작 구현

Cupcake 앱의 앱 바에는 이전 화면으로 돌아가는 화살표가 표시됩니다. 이를 Up 버튼이라고 하며 이전 Codelab에서 배웠습니다. Up 버튼은 현재 작동되지 않고 있으니 먼저 앱에서 이 탐색 버그를 수정합니다.

fbdc1793f9fea6da.png

  1. MainActivity에는 탐색 컨트롤러를 사용하여 앱 바(작업 모음이라고도 함)를 설정하는 코드가 이미 있습니다. navController를 클래스 변수로 하여 다른 메서드에서 사용할 수 있습니다.
class MainActivity : AppCompatActivity(R.layout.activity_main) {

    private lateinit var navController: NavController

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

        val navHostFragment = supportFragmentManager
                .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        navController = navHostFragment.navController

        setupActionBarWithNavController(navController)
    }
}
  1. 동일한 클래스 내에 onSupportNavigateUp() 함수를 재정의하는 코드를 추가합니다. 이 코드는 앱에서 위로 이동을 처리하도록 navController에 요청합니다. 그러지 않으면 Up 버튼을 처리하는 슈퍼클래스 구현(AppCompatActivity)으로 대체됩니다.
override fun onSupportNavigateUp(): Boolean {
   return navController.navigateUp() || super.onSupportNavigateUp()
}
  1. 앱을 실행합니다. 이제 FlavorFragment, PickupFragment, SummaryFragment에서 Up 버튼이 작동합니다. 주문 흐름의 이전 단계로 이동하면 프래그먼트가 뷰 모델에서 올바른 맛과 수령 날짜를 표시해야 합니다.

4. 작업 및 백 스택 알아보기

이제 앱의 주문 흐름 안에 Cancel 버튼을 도입합니다. 주문 절차에서 언제든지 주문을 취소하면 사용자가 StartFragment 단계로 이동합니다. 이 동작을 처리하기 위해서 Android의 작업과 백 스택에 관해 학습할 예정입니다.

작업

Android에서 활동은 작업 내에 존재합니다. 런처 아이콘으로 앱을 처음 열면 Android는 기본 활동이 포함된 새로운 작업을 생성합니다. 작업은 사용자가 이메일 확인, 컵케이크 주문 생성, 사진 촬영 등의 특정한 일을 할 때 상호작용하는 활동의 모음입니다.

활동은 백 스택이라는 스택으로 배열되며, 사용자가 방문하는 각각의 새 활동은 작업의 백 스택으로 푸시됩니다. 층층이 쌓이는 팬케이크처럼 스택 위에 새로운 스택이 추가적으로 쌓인다고 보면 됩니다. 맨 위 스택 활동은 현재 사용자가 상호작용하고 있는 활동을 나타내고 그 아래에 스택은 백그라운드로 전환되었다가 중지된 활동을 의미합니다.

517054e483795b46.png

백 스택은 사용자가 뒤로 이동하고자 하는 경우 유용합니다. Android는 스택 맨 위에 있는 현재 활동을 삭제하고 폐기한 후 그 아래에 있는 활동을 다시 시작할 수 있습니다. 즉, 스택에서 활동을 없애고 사용자가 상호작용할 수 있게 이전 활동이 포그라운드로 이동합니다. 사용자가 여러 번 뒤로 이동하고 싶어하는 경우 Android는 맨 마지막 스택에 가까워질 때까지 계속 상단의 활동을 없앱니다. 백 스택에 더 이상 활동이 남아 있지 않으면 사용자는 기기의 런처 화면이나 이 활동을 실행한 앱으로 돌아가게 됩니다.

MainActivityDetailActivity의 두 활동으로 구현한 Words 앱 버전을 살펴보겠습니다.

앱을 처음 실행하면 MainActivity가 열리고 작업의 백 스택에 추가됩니다.

4bc8f5aff4d5ee7f.png

글자를 클릭하면 DetailActivity가 실행되어 백 스택으로 푸시됩니다. 이는 사용자가 상호작용할 수 있도록 DetailActivity가 생성, 시작 및 재개되었음을 의미합니다. MainActivity는 백그라운드로 전환됩니다(다이어그램에서 회색 배경 색상으로 표시됨).

80f7c594ae844b84.png

Back 버튼을 탭하면 DetailActivity가 백 스택에서 없어지고 DetailActivity 인스턴스가 폐기되며 완료됩니다.

80f532af817191a4.png

그런 다음 백 스택 상단에 있는 다음 항목(MainActivity)이 포그라운드로 이동합니다.

85004712d2fbcdc1.png

백 스택은 사용자가 열어본 활동을 추적할 수 있는 것과 같은 방법으로 Jetpack 탐색 구성요소의 도움으로 사용자가 방문한 프래그먼트 대상도 추적할 수 있습니다.

fe417ac5cbca4ce7.png

탐색 라이브러리를 사용하면 사용자가 Back 버튼을 누를 때마다 백 스택에서 프래그먼트 대상을 없앨 수 있습니다. 이 기본 동작은 무료로 제공되며 직접 구현할 필요가 없습니다. 맞춤 백 스택 동작이 필요한 경우에만 코드를 작성하면 됩니다. Cupcake 앱에서 이러한 코드를 작성할 것입니다.

Cupcake 앱의 기본 동작

Cupcake 앱에서 백 스택이 작동하는 방식을 살펴보겠습니다. 이 앱에는 활동이 하나만 있지만 사용자가 탐색하는 프래그먼트 대상은 여러 개가 있습니다. 따라서 Back 버튼을 탭할 때마다 이전 프래그먼트 대상으로 돌아가야 합니다.

앱을 처음 열면 StartFragment 대상이 표시됩니다. 이 대상은 스택 상단으로 푸시됩니다.

cf0e80b4907d80dd.png

주문할 컵케이크 수량을 선택하면 FlavorFragment로 이동하고 이 프래그먼트는 백 스택으로 푸시됩니다.

39081dcc3e537e1e.png

맛을 선택하고 Next를 탭하면 PickupFragment로 이동하고 이 프래그먼트는 백 스택으로 푸시됩니다.

37dca487200f8f73.png

마지막으로 수령 날짜를 선택하고 Next를 탭하면 SummaryFragment로 이동하고 이 프래그먼트는 백 스택의 맨 위에 추가됩니다.

d67689affdfae0dd.png

SummaryFragment에서 Back 또는 Up 버튼을 탭하는 경우 SummaryFragment가 스택에서 없어지고 폐기됩니다.

215b93fd65754017.png

이제 PickupFragment가 백 스택 맨 위에 놓이고 사용자에게 표시됩니다.

37dca487200f8f73.png

Back 또는 Up 버튼을 다시 탭합니다. PickupFragment가 스택에서 없어진 후 FlavorFragment가 표시됩니다.

Back 또는 Up 버튼을 다시 탭합니다. FlavorFragment가 스택에서 없어진 후 StartFragment가 표시됩니다.

주문 흐름에서 이전 단계로(뒤로) 이동하면 대상이 한 번에 하나씩만 없어집니다. 하지만 다음 작업에서는 앱에 주문 취소 기능을 추가합니다. 이렇게 하려면 사용자가 StartFragment로 돌아와 새 주문을 시작할 수 있도록 백 스택의 여러 대상을 한꺼번에 없애야 할 수도 있습니다.

e3dae0f492450207.png

Cupcake 앱에서 백 스택 수정하기

사용자에게 주문 취소 버튼을 제공할 수 있도록 FlavorFragment, PickupFragment, SummaryFragment 클래스와 레이아웃 파일을 수정합니다.

탐색 작업 추가하기

먼저 앱의 탐색 그래프에 탐색 작업을 추가하여 사용자가 후속 대상에서 StartFragment로 다시 이동할 수 있도록 합니다.

  1. res > navigation > nav_graph.xml 파일로 이동하고 Design 뷰를 선택하여 Navigation Editor를 엽니다.
  2. 현재 startFragment에서 flavorFragment로, flavorFragment에서 pickupFragment로, pickupFragment에서 summaryFragment로 흐르는 작업이 있습니다.
  3. 클릭하고 드래그하여 summaryFragment에서 startFragment로 이어지는 새 탐색 작업을 만듭니다. 탐색 그래프에서 대상을 연결하는 방법을 다시 확인하려면 이 안내를 참고하세요.
  4. pickupFragment에서 클릭하고 드래그하여 startFragment로 이어지는 새 작업을 만듭니다.
  5. flavorFragment에서 클릭하고 드래그하여 startFragment로 이어지는 새 작업을 만듭니다.
  6. 완료하면 탐색 그래프는 다음과 같이 표시됩니다.

dcbd27a08d24cfa0.png

이러한 변경을 통해 사용자는 주문 흐름 내의 후반 프래그먼트 중 하나에서 주문 흐름의 시작으로 이동할 수 있습니다. 이제 이러한 작업으로 실제로 이동하는 코드가 필요합니다. 적합한 위치는 Cancel 버튼을 탭하는 시점입니다.

레이아웃에 Cancel 버튼 추가하기

먼저 StartFragment를 제외한 모든 프래그먼트에 해당하는 Cancel 버튼을 레이아웃 파일에 추가합니다. 주문 흐름의 첫 번째 화면에 이미 있는 경우에는 주문을 취소할 필요가 없습니다.

  1. fragment_flavor.xml 레이아웃 파일을 엽니다.
  2. Split 뷰를 사용하여 XML을 직접 수정하고 이와 나란히 미리보기를 확인합니다.
  3. 소계 텍스트 뷰와 Next 버튼 사이에 Cancel 버튼을 추가합니다. 이 버튼에 @string/cancel로 표시할 텍스트와 함께 리소스 ID @+id/cancel_button을 할당합니다.

이 버튼을 Next 버튼 옆에 가로로 배치하여 버튼 행에 속하도록 표시합니다. 수직 제약 조건의 경우 Cancel 버튼 상단을 Next 버튼 상단으로 제한합니다. 가로 제약 조건의 경우 Cancel 버튼 시작 부분을 상위 컨테이너로 제한하고 버튼 끝부분을 Next 버튼의 시작 부분으로 제한합니다.

또한 Cancel 버튼의 높이를 wrap_content로, 너비를 0dp로 지정하면 다른 버튼과 동일하게 화면 너비를 분할할 수 있습니다. 다음 단계 전까지는 이 버튼이 Preview 창에 표시되지 않습니다.

...

<TextView
    android:id="@+id/subtotal" ... />

<Button
    android:id="@+id/cancel_button"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:text="@string/cancel"
    app:layout_constraintEnd_toStartOf="@id/next_button"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@id/next_button" />

<Button
    android:id="@+id/next_button" ... />

...
  1. fragment_flavor.xml에서 Next 버튼의 시작 제약 조건을 app:layout_constraintStart_toStartOf="parent에서 app:layout_constraintStart_toEndOf="@id/cancel_button"으로 변경해야 합니다. Cancel 버튼에 끝 여백을 추가하여 두 버튼 사이에 공백이 위치하도록 합니다. 이제 Android 스튜디오의 Preview 창에 Cancel 버튼이 나타납니다.
...

<Button
    android:id="@+id/cancel_button"
    android:layout_marginEnd="@dimen/side_margin" ... />

<Button
    android:id="@+id/next_button"
    app:layout_constraintStart_toEndOf="@id/cancel_button"... />

...
  1. 시각적 스타일 측면에서 볼 때 사용자의 초점을 유도할 기본 작업인 Next 버튼에 비해 Cancel 버튼이 두드러지지 않도록 Material Outlined Button 스타일(style="?attr/materialButtonOutlinedStyle" 속성)을 적용합니다.
<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle" ... />

버튼과 위치가 이제 멋지게 표시됩니다.

1fb41763cc255c05.png

  1. 같은 방법으로 fragment_pickup.xml 레이아웃 파일에 Cancel 버튼을 추가합니다.
...

<TextView
    android:id="@+id/subtotal" ... />

<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginEnd="@dimen/side_margin"
    android:text="@string/cancel"
    app:layout_constraintEnd_toStartOf="@id/next_button"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@id/next_button" />

<Button
    android:id="@+id/next_button" ... />

...
  1. Next 버튼의 시작 제약 조건도 업데이트합니다. 그러면 Cancel 버튼이 미리보기에 표시됩니다.
<Button
    android:id="@+id/next_button"
    app:layout_constraintStart_toEndOf="@id/cancel_button" ... />
  1. fragment_summary.xml 파일에도 비슷한 변경을 적용합니다. 단, 이 프래그먼트의 레이아웃은 약간 다릅니다. 상위 세로 LinearLayout에서 Send 버튼 아래에 Cancel 버튼을 추가하고 둘 사이에 여백을 약간 둡니다.

741c0f034397795c.png

...

    <Button
        android:id="@+id/send_button" ... />

    <Button
        android:id="@+id/cancel_button"
        style="?attr/materialButtonOutlinedStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin_between_elements"
        android:text="@string/cancel" />

</LinearLayout>
  1. 앱을 실행하고 테스트합니다. 이제 레이아웃에서 FlavorFragment, PickupFragment, SummaryFragment에 해당하는 Cancel 버튼이 표시됩니다. 하지만 버튼을 탭해도 아직 아무 작업도 발생하지 않습니다. 다음 단계에서 이 버튼의 클릭 리스너를 설정합니다.

Cancel 버튼의 클릭 리스너 추가하기

StartFragment를 제외한 각 프래그먼트 클래스 내부에 Cancel 버튼 클릭 시 처리하는 도우미 메서드를 추가합니다.

  1. FlavorFragment에 다음 cancelOrder() 메서드를 추가합니다. 맛 옵션이 제시되었을 때 사용자가 주문을 취소하기로 결정하는 경우 sharedViewModel.resetOrder().를 호출하여 뷰 모델을 지웁니다. 그런 다음 ID가 R.id.action_flavorFragment_to_startFragment.인 탐색 작업을 사용하여 StartFragment로 다시 이동합니다.
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_flavorFragment_to_startFragment)
}

작업 리소스 ID와 관련된 오류가 표시되는 경우 nav_graph.xml 파일로 돌아가 탐색 작업도 동일한 이름(action_flavorFragment_to_startFragment)으로 불리는지 확인해야 할 수도 있습니다.

  1. 리스너 결합을 사용하여 fragment_flavor.xml 레이아웃의 Cancel 버튼에 클릭 리스너를 설정합니다. 이 버튼을 클릭하면 FragmentFlavor 클래스에서 방금 생성한 cancelOrder() 메서드가 호출됩니다.
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> flavorFragment.cancelOrder()}" ... />
  1. 동일한 프로세스를 PickupFragment에 반복합니다. 주문을 재설정하고 PickupFragment에서 StartFragment로 이동하는 cancelOrder() 메서드를 프래그먼트 클래스에 추가합니다.
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_pickupFragment_to_startFragment)
}
  1. fragment_pickup.xml에서 클릭 시 cancelOrder() 메서드를 호출하도록 Cancel 버튼에 클릭 리스너를 설정합니다.
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> pickupFragment.cancelOrder()}" ... />
  1. SummaryFragment에서 Cancel 버튼에 관해 유사한 코드를 추가하여 사용자를 StartFragment로 이동합니다. 자동으로 가져오기가 처리되지 않는 경우 androidx.navigation.fragment.findNavController를 가져와야 할 수도 있습니다..
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_summaryFragment_to_startFragment)
}
  1. fragment_summary.xml에서 Cancel 버튼 클릭 시 SummaryFragmentcancelOrder() 메서드를 호출합니다.
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> summaryFragment.cancelOrder()}" ... />
  1. 앱을 실행하고 테스트하여 방금 각 프래그먼트에 추가한 로직을 확인합니다. 컵케이크 주문을 생성할 때 FlavorFragment, PickupFragment, SummaryFragment에서 Cancel 버튼을 탭하면 StartFragment로 돌아갑니다. 새 주문을 생성하는 과정에서 이전 주문의 정보가 삭제된 것을 확인할 수 있습니다.

제대로 작동하는 것처럼 보이지만 StartFragment로 돌아가면 실제로는 뒤로 이동하기에 버그가 있습니다. 다음 몇 단계에 따라 버그를 재현합니다.

  1. 요약 화면이 나타날 때까지 새 컵케이크 주문을 생성하는 주문 흐름을 진행합니다. 예를 들어 초콜릿 맛 컵케이크 12개를 주문하고 수령할 미래 날짜를 선택할 수 있습니다.
  2. 그런 다음 Cancel을 탭합니다. StartFragment로 돌아와야 합니다.
  3. 제대로 작동하는 것처럼 보입니다. 하지만 시스템 Back 버튼을 탭하면 주문 요약 화면으로 돌아가고 이 화면에 컵케이크가 0개이고 맛이 지정되지 않은 주문 요약이 표시됩니다. 올바르지 않은 동작이며 사용자에게 표시되어서는 안 됩니다.

1a9024cd58a0e643.png

사용자는 주문 흐름에서 뒤로 이동하려고 하지 않을 것입니다. 또한 뷰 모델의 모든 주문 데이터가 지워졌으므로 이 정보는 유용하지 않습니다. 대신 StartFragment에서 Back 버튼을 탭하면 Cupcake 앱이 종료됩니다.

현재 백 스택의 모양과 버그 수정 방법을 살펴보겠습니다. 주문 요약 화면을 통해 주문을 생성하면 각 대상이 백 스택으로 푸시됩니다.

fc88100cdf1bdd1.png

SummaryFragment에서 주문을 취소했습니다. SummaryFragment에서 StartFragment로 작업을 이동할 때 Android는 다른 StartFragment 인스턴스를 새 대상으로 백 스택에 추가했습니다.

5616cb0028b63602.png

이런 이유로 인해 StartFragment에서 Back 버튼을 탭하면 앱이 다시 SummaryFragment를 표시(주문 정보가 비어 있음)했습니다.

이 탐색 버그를 수정하려면 작업을 사용해 탐색할 때 탐색 구성요소가 추가 대상을 백 스택에서 없애는 방법을 알아보세요.

백 스택에서 추가 대상 없애기

탐색 그래프의 탐색 작업에 app:popUpTo 속성을 포함하면 지정된 대상에 도달할 때까지 대상 두 개 이상이 백 스택에서 없어질 수 있습니다. app:popUpTo="@id/startFragment"를 지정하는 경우 스택에 남게 될 StartFragment에 도달할 때까지 백 스택에 있는 대상이 없어집니다.

이 변경사항을 코드에 추가하고 앱을 실행하면 주문 취소 시 StartFragment로 돌아가는 것을 확인하게 됩니다. 하지만 이번에는 StartFragment에서 Back 버튼을 탭하면 앱이 종료되는 대신 StartFragment가 다시 표시됩니다. 이 또한 의도한 동작이 아닙니다. 앞서 언급했듯이 StartFragment로 이동하면 Android는 실제로 StartFragment를 새 대상으로 백 스택에 추가하므로, 이제 백 스택에 StartFragment 인스턴스가 두 개 있습니다. 따라서 앱을 종료하려면 Back 버튼을 두 번 탭해야 합니다.

dd0fedc6e231e595.png

이 새로운 버그를 수정하려면 StartFragment에 이르기까지(StartFragment까지 포함) 모든 대상을 백 스택에서 없애도록 요청합니다. 적절한 탐색 작업에 app:popUpTo="@id/startFragment"

app:popUpToInclusive="true"를 지정하면 됩니다. 이렇게 하면 백 스택에 새 StartFragment 인스턴스가 하나만 생성됩니다. 그런 다음 StartFragment에서 Back 버튼을 한 번 탭하면 앱이 종료됩니다. 지금 이렇게 변경하겠습니다.

cf0e80b4907d80dd.png

탐색 작업 수정하기

  1. res > navigation > nav_graph.xml 파일을 열어 Navigation Editor로 이동합니다.
  2. summaryFragment에서 startFragment로 이어지는 작업을 선택합니다. 이 작업이 파란색으로 강조표시됩니다.
  3. 오른쪽의 Attributes를 펼칩니다(아직 열려 있지 않은 경우). 수정할 수 있는 속성 목록에서 Pop Behavior를 찾습니다.

8c87589f9cc4d176.png

  1. 드롭다운 옵션에서 popUpTostartFragment로 설정합니다. 즉, startFragment에 이르기까지 백 스택에 있는 모든 대상이 없어집니다(스택 상단부터 시작해서 아래로 이동).

a9a17493ed6bc27f.png

  1. 그런 다음 체크표시와 true 라벨이 나타날 때까지 popUpToInclusive의 체크박스를 클릭합니다. 이 동작은 백 스택에 이미 있는 startFragment 인스턴스에 이르기까지(이 인스턴스 포함) 대상을 없애려고 함을 나타냅니다. 그러면 백 스택에 있는 startFragment 인스턴스가 두 개가 아닙니다.

4a403838a62ff487.png

  1. pickupFragmentstartFragment에 연결하는 작업에 이러한 변경을 반복합니다.

4a403838a62ff487.png

  1. flavorFragmentstartFragment에 연결하는 작업에 반복합니다.
  2. 완료하면 탐색 그래프 파일의 Code 뷰를 검토하여 앱을 올바르게 변경했는지 확인합니다.
<navigation
    android:id="@+id/nav_graph" ...>
    <fragment
        android:id="@+id/startFragment" ...>
        ...
    </fragment>
    <fragment
        android:id="@+id/flavorFragment" ...>
        ...
        <action
            android:id="@+id/action_flavorFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
    <fragment
        android:id="@+id/pickupFragment" ...>
        ...
        <action
            android:id="@+id/action_pickupFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
    <fragment
        android:id="@+id/summaryFragment" ...>
        <action
            android:id="@+id/action_summaryFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
</navigation>

세 작업(action_flavorFragment_to_startFragment, action_pickupFragment_to_startFragment, action_summaryFragment_to_startFragment) 각각에 관해 새로 추가된 속성 app:popUpTo="@id/startFragment"app:popUpToInclusive="true"가 있어야 합니다.

  1. 이제 앱을 실행합니다. 주문 절차를 진행하고 Cancel을 탭합니다. StartFragment로 돌아가 Back 버튼을 (한 번만) 탭하면 앱이 종료됩니다.

상황을 요약하자면, 주문을 취소하고 앱의 첫 화면으로 다시 이동할 때 백 스택 내의 모든 프래그먼트 대상(첫 번째 StartFragment 인스턴스 포함)이 스택에서 없어졌습니다. 탐색 작업을 완료하자 StartFragment가 새 대상으로 백 스택에 추가되었습니다. 여기서 Back을 탭하면 스택에서 StartFragment가 없어지고 백 스택에 더 이상 프래그먼트가 남지 않습니다. 따라서 Android가 활동을 종료하고 앱이 종료됩니다.

앱은 다음과 같은 모습입니다. 2e0599d9b55401f1.png

5. 주문 전송하기

앱이 지금까지 잘 작동합니다! 단, 한 부분이 누락되었습니다. SummaryFragment에서 Send Order 버튼을 탭하면 여전히 Toast 메시지가 표시됩니다.

90ed727c7b812fd6.png

앱에서 주문을 전송할 수 있다면 더 유용할 것입니다. 암시적 인텐트를 사용하여 앱에서 다른 앱으로 정보를 공유하는 것에 관해 이전 Codelab에서 배운 내용을 활용해보세요. 이렇게 하면 사용자가 기기의 이메일 앱을 통해 컵케이크 상점에 주문을 이메일로 보내 컵케이크 주문 정보를 공유할 수 있습니다.

170d76b64ce78f56.png

이 기능을 구현하려면 위의 스크린샷에서 이메일 제목과 이메일 본문이 어떻게 구성되어 있는지 살펴보세요.

strings.xml 파일에서 이미 제공되는 문자열을 사용합니다.

<string name="new_cupcake_order">New Cupcake Order</string>
<string name="order_details">Quantity: %1$s cupcakes \n Flavor: %2$s \nPickup date: %3$s \n Total: %4$s \n\n Thank you!</string>

order_details는 서로 다른 서식 인수 4개(컵케이크 실제 수량, 원하는 맛, 원하는 수령 날짜, 총가격의 자리표시자)가 포함된 문자열 리소스입니다. 인수는 1부터 4까지 번호가 매겨지며 %1부터 %4까지 구문으로 사용합니다. 인수의 유형도 지정됩니다($s는 이 위치에 문자열이 필요하다는 것을 의미함).

Kotlin 코드에서는 R.string.order_details에서 getString() 다음에 4개 인수(순서가 중요함)를 사용해 호출할 수 있습니다. 예를 들어 getString(R.string.order_details, "12", "Chocolate", "Sat Dec 12", "$24.00")을 호출하면 원하는 이메일 본문인 다음과 같은 문자열이 생성됩니다.

Quantity: 12 cupcakes
Flavor: Chocolate
Pickup date: Sat Dec 12
Total: $24.00

Thank you!
  1. SummaryFragment.kt에서 sendOrder() 메서드를 수정합니다. 기존 Toast 메시지를 삭제합니다.
fun sendOrder() {

}
  1. sendOrder() 메서드 내에 주문 요약 텍스트를 작성합니다. 공유 뷰 모델에서 주문 수량, 맛, 날짜, 가격을 가져와서 형식이 지정된 order_details 문자열을 만듭니다.
val orderSummary = getString(
    R.string.order_details,
    sharedViewModel.quantity.value.toString(),
    sharedViewModel.flavor.value.toString(),
    sharedViewModel.date.value.toString(),
    sharedViewModel.price.value.toString()
)
  1. sendOrder() 메서드 내에서 주문을 다른 앱에 공유하는 암시적 인텐트를 만듭니다. 이메일 인텐트를 만드는 방법은 문서를 참고하세요. 인텐트 작업에 Intent.ACTION_SEND를 지정하고, 유형을 "text/plain"으로 설정하고, 이메일 제목(Intent.EXTRA_SUBJECT)과 이메일 본문(Intent.EXTRA_TEXT)을 위한 인텐트 추가항목을 포함합니다. 필요한 경우 android.content.Intent를 가져옵니다.
val intent = Intent(Intent.ACTION_SEND)
    .setType("text/plain")
    .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
    .putExtra(Intent.EXTRA_TEXT, orderSummary)

보너스 팁으로, 이 앱을 고유한 사용 사례에 맞춰 조정하는 경우 이메일 수신자에 컵케이크 상점의 이메일 주소를 자동 입력할 수 있습니다. 인텐트에서 인텐트 추가항목 Intent.EXTRA_EMAIL을 사용하여 이메일 수신자를 지정합니다.

  1. 암시적 인텐트이므로, 이 인텐트를 처리할 특정 구성요소나 앱을 사전에 알지 않아도 됩니다. 인텐트를 처리하는 데 사용할 앱을 사용자가 결정합니다. 하지만 이 인텐트로 활동을 실행하기 전에 이 인텐트를 처리할 수 있는 앱이 있는지 확인하세요. 이렇게 확인하면 인텐트를 처리할 앱이 없는 경우 Cupcake 앱이 비정상 종료되지 않습니다. 즉, 코드가 더 안전해집니다.
if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
    startActivity(intent)
}

기기에 설치된 앱 패키지에 관한 정보가 포함된 PackageManager에 액세스하여 확인합니다. activitypackageManager가 null이 아닌 경우 프래그먼트의 activity를 통해 PackageManager에 액세스할 수 있습니다. 생성한 인텐트로 PackageManagerresolveActivity() 메서드를 호출합니다. 결과가 null이 아니면 인텐트로 startActivity()를 호출하는 것이 안전합니다.

  1. 앱을 실행하여 코드를 테스트합니다. 컵케이크 주문을 생성하고 Send Order to Another App을 탭합니다. 공유 대화상자가 표시되면 Gmail 앱을 선택할 수 있지만 원하는 경우 다른 앱을 선택할 수도 있습니다. Gmail 앱을 선택하는 경우 아직 설정하지 않았다면(예: 에뮬레이터 사용 중) 기기에서 계정을 설정해야 할 수도 있습니다. 이메일 본문에 최근 컵케이크 주문이 표시되지 않는 경우에는 먼저 현재 이메일 초안을 삭제해야 할 수도 있습니다.

170d76b64ce78f56.png

다른 시나리오를 테스트할 때 컵케이크 한 개만 있는 경우 버그를 발견할 수도 있습니다. 주문 요약에 1 cupcakes라고 표시되지만 이 문구는 영문법상 잘못되었습니다.

ef046a100381bb07.png

대신 1 cupcake(단수형)여야 합니다. 수량 값에 따라 단수형 cupcake나 복수형 cupcakes를 사용할지 여부를 선택하려면 Android에서 수량 문자열을 사용할 수 있습니다. plurals 리소스를 선언하면 수량에 따라 사용할 다른 문자열 리소스(예: 단수형 또는 복수형)를 지정할 수 있습니다.

  1. strings.xml 파일에 cupcakes 복수형 리소스를 추가합니다.
<plurals name="cupcakes">
    <item quantity="one">%d cupcake</item>
    <item quantity="other">%d cupcakes</item>
</plurals>

단수(quantity="one")인 경우 단수형 문자열이 사용됩니다. 다른 모든 경우에는(quantity="other") 복수형 문자열이 사용됩니다. 문자열 인수가 필요한 %s 대신 %d에는 정수 인수가 필요하며, 인수는 문자열의 형식을 지정할 때 전달됩니다.

Kotlin 코드에서 호출과 결과는 다음과 같습니다.

getQuantityString(R.plurals.cupcakes, 1, 1) 호출 시 문자열 1 cupcake 반환

getQuantityString(R.plurals.cupcakes, 6, 6) 호출 시 문자열 6 cupcakes 반환

getQuantityString(R.plurals.cupcakes, 0, 0) 호출 시 문자열 0 cupcakes 반환

  1. Kotlin 코드로 이동하기 전에 strings.xmlorder_details 문자열 리소스를 업데이트하여 복수형 cupcakes가 더 이상 하드코딩되지 않도록 합니다.
<string name="order_details">Quantity: %1$s \n Flavor: %2$s \nPickup date: %3$s \n
        Total: %4$s \n\n Thank you!</string>
  1. SummaryFragment 클래스에서 새 수량 문자열을 사용하도록 sendOrder() 메서드를 업데이트합니다. 먼저 뷰 모델에서 수량을 파악하고 이 값을 변수에 저장하는 것이 가장 쉽습니다. 뷰 모델의 quantityLiveData<Int> 유형이므로 sharedViewModel.quantity.value가 null일 수 있습니다. null이면 0numberOfCupcakes의 기본값으로 사용합니다.

sendOrder() 메서드의 첫 번째 코드 줄에 다음을 추가합니다.

val numberOfCupcakes = sharedViewModel.quantity.value ?: 0

elvis 연산자(?:)는 왼쪽의 표현식이 null이 아니면 이 값을 사용한다는 것을 의미합니다. 이와 달리 왼쪽의 표현식이 null이면 elvis 연산자의 오른쪽에 있는 표현식(이 경우에는 0)을 사용합니다.

  1. 그런 다음 이전과 같이 order_details 문자열의 형식을 지정합니다. 수량 인수로 numberOfCupcakes를 직접 전달하는 대신 resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes)을 사용하여 형식이 지정된 컵케이크 문자열을 만듭니다.

전체 sendOrder() 메서드는 다음과 같습니다.

fun sendOrder() {
    val numberOfCupcakes = sharedViewModel.quantity.value ?: 0
    val orderSummary = getString(
        R.string.order_details,
        resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes),
        sharedViewModel.flavor.value.toString(),
        sharedViewModel.date.value.toString(),
        sharedViewModel.price.value.toString()
    )

    val intent = Intent(Intent.ACTION_SEND)
        .setType("text/plain")
        .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
        .putExtra(Intent.EXTRA_TEXT, orderSummary)

    if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
        startActivity(intent)
    }
}
  1. 코드를 실행하고 테스트합니다. 이메일 본문의 주문 요약에 단수형인 1 cupcake 및 복수형인 6 cupcakes나 12 cupcakes 등이 표시되는지 확인합니다.

이와 같이 Cupcake 앱의 모든 기능을 완료했습니다. 축하합니다. 확실히 까다로운 앱이었으며 여러분은 Android 개발자가 되기 위한 여정에서 크게 발전했습니다. 지금까지 학습한 모든 개념을 잘 결합했으며 그 과정에서 새로운 문제 해결 팁도 얻었습니다.

마지막 단계

이제 잠시 시간을 내서 코드를 정리하세요. 이전 Codelab에서 배운 좋은 코딩 방법입니다.

  • 가져오기 최적화하기
  • 파일 형식 다시 지정하기
  • 미사용 코드 또는 주석 처리된 코드 삭제하기
  • 필요한 경우 코드에 주석 추가하기

앱의 접근성을 더 높이려면 TalkBack을 사용 설정한 채로 앱을 테스트하여 원활한 사용자 경험을 보장합니다. 음성 피드백은 화면에 있는 각 요소의 목적을 전달하는 데 도움이 됩니다(해당되는 경우). 또한 스와이프 동작을 사용하여 앱의 모든 요소로 이동할 수 있도록 합니다.

구현한 사용 사례가 모두 최종 앱에서 예상대로 작동하는지 다시 확인합니다. 예를 들어 다음과 같습니다.

  • 기기 회전 시 데이터가 보존되어야 합니다(뷰 모델의 영향).
  • Up 버튼이나 Back 버튼을 탭할 때 주문 정보가 FlavorFragmentPickupFragment에 계속 올바르게 표시되어야 합니다.
  • 다른 앱에 주문을 전송하면 올바른 주문 세부정보가 공유되어야 합니다.
  • 주문을 취소하면 주문의 모든 정보가 지워져야 합니다.

버그를 발견하면 바로 수정합니다.

다시 확인하느라 수고하셨습니다.

6. 솔루션 코드

이 Codelab의 솔루션 코드는 아래 표시된 프로젝트에 있습니다.

이 Codelab의 코드를 가져와서 Android 스튜디오에서 열려면 다음을 실행합니다.

코드 가져오기

  1. 제공된 URL을 클릭합니다. 브라우저에서 프로젝트의 GitHub 페이지가 열립니다.
  2. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 대화상자를 엽니다.

5b0a76c50478a73f.png

  1. 대화상자에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
  2. 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
  3. ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.

Android 스튜디오에서 프로젝트 열기

  1. Android 스튜디오를 시작합니다.
  2. Welcome to Android Studio 창에서 Open an existing Android Studio project를 클릭합니다.

36cc44fcf0f89a1d.png

참고: Android 스튜디오가 이미 열려 있는 경우 File > New > Import Project 메뉴 옵션을 대신 선택합니다.

21f3eec988dcfbe9.png

  1. Import Project 대화상자에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 11c34fc5e516fb1c.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.
  5. Project 도구 창에서 프로젝트 파일을 살펴보고 앱이 설정된 방식을 확인합니다.

7. 요약

  • Android에서는 방문한 모든 대상의 백 스택이 유지되며 각각의 새 대상이 스택으로 푸시됩니다.
  • Up 버튼이나 Back 버튼을 탭하면 대상을 백 스택에서 없앨 수 있습니다.
  • Jetpack 탐색 구성요소를 사용하면 프래그먼트 대상을 백 스택으로 푸시하고 백 스택에서 없앨 수 있으므로 기본 Back 버튼 동작을 직접 구현할 필요가 없습니다.
  • 속성 값에 지정된 대상에 이를 때까지 대상을 백 스택에서 없애기 위해 탐색 그래프에서 작업에 app:popUpTo 속성을 지정합니다.
  • app:popUpTo에 지정된 대상도 백 스택에서 없애야 하는 경우 작업에 app:popUpToInclusive="true"를 지정합니다.
  • Intent.ACTION_SEND를 사용하고 Intent.EXTRA_EMAIL, Intent.EXTRA_SUBJECT, Intent.EXTRA_TEXT 등의 인텐트 추가항목을 채워 이메일 앱에 콘텐츠를 공유하도록 암시적 인텐트를 만들 수 있습니다.
  • 수량에 따라 서로 다른 문자열 리소스(예: 단수형 또는 복수형)를 사용하려면 plurals 리소스를 사용합니다.

8. 자세히 알아보기

9. 연습하기

컵케이크 주문 흐름을 고유하게 변형하여 Cupcake 앱을 확장합니다. 예를 들면 다음과 같습니다.

  • 당일 수령을 이용할 수 없는 등 특수한 조건이 따르는 특별한 맛을 제공합니다.
  • 컵케이크 주문 시 사용자에게 이름을 요청합니다.
  • 컵케이크 수량이 한 개 이상인 경우 사용자가 컵케이크 맛을 여러 개 선택하여 주문할 수 있게 허용합니다.

이러한 새 기능을 수용하려면 앱의 어느 영역을 업데이트해야 할까요?

학습 내용 확인:

완성된 앱은 오류 없이 실행되어야 합니다.

10. 작업에 도전해보기

Cupcake 앱을 빌드하면서 배운 내용을 활용하여 사용 사례에 맞는 앱을 빌드하세요. 피자나 샌드위치 또는 다른 것을 주문하는 앱일 수 있습니다. 구현을 시작하기 전에 앱의 여러 대상을 스케치하는 것이 좋습니다.

다른 디자인 아이디어를 얻으려면 Shrine 앱을 확인하세요. 고유한 브랜드에 맞는 Material 테마 설정과 구성요소를 채택할 수 있는 방법을 보여주는 Material 연구입니다. Shrine 앱은 빌드해본 Cupcake 앱보다 훨씬 더 복잡하므로, 매우 까다로운 앱을 사전에 빌드하는 것을 목표로 하지 말고 먼저 해결할 수 있는 작은 기능에 관해 생각해보세요. 시간이 지남에 따라 하나씩 점진적으로 승리해내며 자신감을 키울 수 있습니다.

앱 만들기를 완료하면 빌드한 결과물을 소셜 미디어에 공유합니다. 모두가 볼 수 있도록 해시태그 #LearningKotlin을 사용하세요.