프래그먼트 간 공유되는 ViewModel

1. 시작하기 전에

활동, 프래그먼트, 인텐트, 데이터 결합, 탐색 구성요소, 아키텍처 구성요소의 기본사항을 사용하는 방법에 관해 알아보았습니다. 이 Codelab에서는 학습한 내용을 종합하여 컵케이크 주문 앱인 고급 샘플을 완성해 보겠습니다.

공유 ViewModel을 사용하여 동일한 활동의 프래그먼트 간에 데이터를 공유하는 방법 및 LiveData 변환과 같은 새로운 개념을 알아봅니다.

기본 요건

  • XML의 Android 레이아웃을 읽고 이해하는 데 익숙함
  • Jetpack Navigation 구성요소의 기본사항을 숙지하고 있음
  • 앱에서 프래그먼트 대상이 있는 탐색 그래프를 생성할 수 있음
  • 이전에 활동 내에서 프래그먼트를 사용한 적이 있음
  • 앱 데이터를 저장할 ViewModel을 생성할 수 있음
  • LiveData와 함께 데이터 결합을 사용하여 ViewModel의 앱 데이터로 UI를 최신 상태로 유지할 수 있음

학습할 내용

  • 고급 사용 사례 내에서 권장 앱 아키텍처 사례를 구현하는 방법
  • 활동의 프래그먼트 간에 공유 ViewModel을 사용하는 방법
  • LiveData 변환을 적용하는 방법

빌드할 프로그램

  • 컵케이크의 주문 흐름을 표시하는 Cupcake 앱: 사용자가 컵케이크 맛, 수량, 수령 날짜를 선택할 수 있습니다.

필요한 항목

  • Android 스튜디오가 설치된 컴퓨터
  • Cupcake 앱의 시작 코드

2. 시작 앱 개요

Cupcake 앱 개요

Cupcake 앱은 온라인 주문 앱을 디자인하고 구현하는 방법을 보여줍니다. 이 학습 과정이 끝날 때 다음 화면과 같이 Cupcake 앱을 완성하게 됩니다. 사용자는 컵케이크 주문 시 수량, 맛, 기타 옵션을 선택할 수 있습니다.

732881cfc463695d.png

이 Codelab용 시작 코드 다운로드

이 Codelab은 시작 코드를 제공합니다. 이 Codelab에서 학습한 기능을 사용하여 시작 코드를 확장할 수 있습니다. 시작 코드에는 이전의 Codelab에서 살펴본 코드가 포함되어 있습니다.

GitHub에서 시작 코드를 다운로드하는 경우 프로젝트의 폴더 이름은 android-basics-kotlin-cupcake-app-starter입니다. 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 도구 창에서 프로젝트 파일을 둘러보고 앱이 설정된 방식을 확인합니다.

시작 코드 둘러보기

  1. Android 스튜디오에서 다운로드한 프로젝트를 엽니다. 프로젝트의 폴더 이름은 android-basics-kotlin-cupcake-app-starter입니다. 그런 다음, 앱을 실행합니다.
  2. 파일을 둘러보면서 시작 코드를 이해합니다. 레이아웃 파일의 경우 오른쪽 상단에 있는 Split 옵션을 사용하여 레이아웃과 XML의 미리보기를 동시에 확인할 수 있습니다.
  3. 앱을 컴파일하고 실행해 보면 앱이 불완전한 것을 알게 됩니다. 버튼으로는 그다지 많은 작업을 수행할 수 없으며(Toast 메시지 표시 제외), 다른 프래그먼트로 이동할 수 없습니다.

이제 프로젝트에서 중요한 파일을 둘러보겠습니다.

MainActivity:

MainActivity에는 활동의 콘텐츠 뷰를 activity_main.xml로 설정하는 기본 생성 코드와 유사한 코드가 있습니다. 이 코드는 super.onCreate(savedInstanceState)의 일부로 확장될 레이아웃을 포함하는 매개변수화된 생성자 AppCompatActivity(@LayoutRes int contentLayoutId)를 사용합니다.

MainActivity 클래스의 코드

class MainActivity : AppCompatActivity(R.layout.activity_main)

기본 AppCompatActivity 생성자를 사용하는 다음 코드와 동일합니다.

class MainActivity : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
   }
}

레이아웃(res/layout 폴더):

layout 리소스 폴더에는 활동 및 프래그먼트 레이아웃 파일이 있습니다. 이러한 파일은 간단한 레이아웃 파일이며 XML은 이전 Codelab을 통해 익숙해진 파일입니다.

  • fragment_start.xml은 앱에 표시되는 첫 번째 화면입니다. 이 화면에는 컵케이크 이미지와 주문할 컵케이크 수를 선택할 수 있는 버튼 3개(1개, 6개, 12개)가 있습니다.
  • fragment_flavor.xml에는 컵케이크 맛 목록이 라디오 버튼 옵션으로 표시되며 Next 버튼이 있습니다.
  • fragment_pickup.xml은 수령일을 선택하는 옵션과 요약 화면으로 이동할 수 있는 Next 버튼을 제공합니다.
  • fragment_summary.xml에는 수량, 맛과 같은 주문 세부정보의 요약이 표시되며 주문을 다른 앱으로 전송하는 버튼이 있습니다.

프래그먼트 클래스:

  • StartFragment.kt는 앱에 표시되는 첫 번째 화면입니다. 이 클래스에는 3개의 버튼을 위한 클릭 핸들러 및 뷰 결합 코드가 있습니다.
  • FlavorFragment.kt, PickupFragment.kt, SummaryFragment.kt 클래스에는 대부분 상용구 코드와 토스트 메시지를 표시하는 Next 또는 Send Order to Another App 버튼의 클릭 핸들러가 있습니다.

리소스(res 폴더):

  • drawable 폴더에는 첫 번째 화면의 컵케이크 애셋뿐 아니라 런처 아이콘 파일이 있습니다.
  • navigation/nav_graph.xml에는 작업이 없는 4개의 프래그먼트 대상(startFragment, flavorFragment, pickupFragment, summaryFragment)이 있으며, 이러한 대상은 Codelab에서 나중에 정의합니다.
  • values 폴더에는 앱 테마를 맞춤설정하는 데 사용되는 색상, 크기, 문자열, 스타일, 테마가 있습니다. 이러한 리소스 유형은 이전 Codelab에서 이미 살펴본 바 있습니다.

3. 탐색 그래프 완성

이 작업에서는 Cupcake 앱의 화면을 함께 연결하고 앱 내에서 적절한 탐색 구현을 완료합니다.

탐색 구성요소를 사용하는 데 필요한 사항을 기억하시나요? 다음과 같이 프로젝트와 앱을 설정하는 방법에 관해 다시 알아보려면 이 가이드를 참고하세요.

탐색 그래프에서 대상 연결

  1. Android 스튜디오의 Project 창에서 res > navigation > nav_graph.xml 파일을 엽니다. 아직 선택하지 않았다면 Design 탭으로 전환합니다.

28c2c94eb97e2f0.png

  1. 그러면 Navigation Editor가 열리고 앱의 탐색 그래프가 표시됩니다. 앱에 이미 있는 4개의 프래그먼트가 표시됩니다.

fdce89b318218ea6.png

  1. 탐색 그래프에서 프래그먼트 대상을 연결합니다. startFragment에서 flavorFragment로의 작업, flavorFragment에서 pickupFragment로의 연결, pickupFragment에서 summaryFragment로의 연결을 생성합니다. 더 자세한 안내가 필요하다면 다음과 같은 몇 단계를 따릅니다.
  2. startFragment 위로 마우스를 가져가 프래그먼트 주위에 회색 테두리가 표시되고 회색 원이 프래그먼트 오른쪽 가장자리 가운데 위에 나타날 때까지 기다립니다. 원을 클릭하고 flavorFragment로 드래그한 후 마우스 버튼을 놓습니다.

d014c1b710c1088d.png

  1. 두 프래그먼트 간의 화살표는 성공적인 연결을 나타내며, startFragment에서 flavorFragment로 이동할 수 있음을 의미합니다. 이를 탐색 작업이라고 하며, 이전 Codelab에서 알아보았습니다.

65c7d993b98c9dea.png

  1. 마찬가지로 flavorFragment에서 pickupFragment로, pickupFragment에서 summaryFragment로의 탐색 작업을 추가합니다. 탐색 작업 만들기를 완료하면 완성된 탐색 그래프는 다음과 같습니다.

724eb8992a1a9381.png

  1. 생성한 새로운 세 가지 작업은 Component Tree 창에도 반영됩니다.

e4ee54469f5ff1a4.png

  1. 탐색 그래프를 정의할 때 시작 대상을 지정할 수도 있습니다. 현재 startFragment 옆에 작은 집 아이콘이 있는 것을 볼 수 있습니다.

739d4ddac561c478.png

이는 startFragmentNavHost에 표시될 첫 번째 프래그먼트임을 나타냅니다. 앱의 원하는 동작으로 이대로 유지합니다. 언제든지 프래그먼트를 마우스 오른쪽 버튼으로 클릭하고 Set as Start Destination 메뉴 옵션을 선택하여 시작 대상을 변경할 수 있습니다.

bf3cfa7841476892.png

이제 Toast 메시지를 표시하는 대신 첫 번째 프래그먼트의 버튼을 탭하면 startFragment에서 flavorFragment로 이동하는 코드를 추가합니다. 다음은 start 프래그먼트 레이아웃의 참조입니다. 이후 작업에서 컵케이크의 수량을 flavor 프래그먼트에 전달합니다.

867d8e4c72078f76.png

  1. Project 창에서 app > java > com.example.cupcake > StartFragment Kotlin 파일을 엽니다.
  2. onViewCreated() 메서드에서 클릭 리스너가 3개의 버튼에 설정되어 있는 것을 확인할 수 있습니다. 각 버튼을 탭하면 컵케이크의 수량(컵케이크 1개, 6개 또는 12개)을 매개변수로 사용하여 orderCupcake() 메서드가 호출됩니다.

참조 코드:

orderOneCupcake.setOnClickListener { orderCupcake(1) }
orderSixCupcakes.setOnClickListener { orderCupcake(6) }
orderTwelveCupcakes.setOnClickListener { orderCupcake(12) }
  1. orderCupcake() 메서드에서 토스트 메시지를 표시하는 코드를 flavor 프래그먼트로 이동하는 코드로 바꿉니다. findNavController() 메서드를 사용하여 NavController를 가져오고 거기에서 navigate()를 호출하여 작업 ID인 R.id.action_startFragment_to_flavorFragment를 전달합니다. 이 작업 ID가 nav_graph.xml.에 선언된 작업과 일치하는지 확인합니다.

다음을

fun orderCupcake(quantity: Int) {
    Toast.makeText(activity, "Ordered $quantity cupcake(s)", Toast.LENGTH_SHORT).show()
}

다음 코드로 바꿉니다.

fun orderCupcake(quantity: Int) {
   findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
  1. import androidx.navigation.fragment.findNavController 가져오기를 추가하거나 Android 스튜디오에서 제공하는 옵션 중에서 선택할 수 있습니다.

2a087f53a77765a6.png

flavor 및 pickup 프래그먼트에 탐색 추가

이전 작업과 마찬가지로 이 작업에서는 다른 프래그먼트(flavor 및 pickup 프래그먼트)에 탐색 기능을 추가합니다.

3b351067bf4926b7.png

  1. app > java > com.example.cupcake > FlavorFragment.kt를 엽니다. Next 버튼 클릭 리스너 내에서 호출되는 메서드는 goToNextScreen() 메서드인 것을 확인할 수 있습니다.
  2. FlavorFragment.ktgoToNextScreen() 메서드 내에서 pickup 프래그먼트로 이동하는 토스트 메시지를 표시하는 코드를 바꿉니다. 작업 ID, R.id.action_flavorFragment_to_pickupFragment를 사용합니다. 그리고 이 ID가 nav_graph.xml.에 선언된 작업과 일치하는지 확인합니다.
fun goToNextScreen() {
    findNavController().navigate(R.id.action_flavorFragment_to_pickupFragment)
}

import androidx.navigation.fragment.findNavController를 기억해 보세요.

  1. PickupFragment.ktgoToNextScreen() 메서드 내에서 summary 프래그먼트로 이동하도록 기존 코드를 바꿉니다.
fun goToNextScreen() {
    findNavController().navigate(R.id.action_pickupFragment_to_summaryFragment)
}

androidx.navigation.fragment.findNavController를 가져옵니다.

  1. 앱을 실행합니다. 버튼을 사용하여 화면에서 다른 화면으로 이동할 수 있는지 확인합니다. 각 프래그먼트에 표시되는 정보가 불완전할 수도 있지만 걱정하지 않아도 됩니다. 이후 단계에서 올바른 데이터로 프래그먼트를 채웁니다.

96b33bf7a5bd8050.png

앱 바에서 제목 업데이트

앱을 탐색하는 동안 앱 바의 제목을 확인합니다. 항상 Cupcake로 표시됩니다.

현재 프래그먼트의 기능에 따라 더욱 관련성이 높은 제목을 제공하는 것이 더 나은 사용자 환경입니다.

NavController를 사용하여 각 프래그먼트의 앱 바(작업 모음이라고도 함)에 있는 제목을 변경하고 위로(←) 버튼을 표시합니다.

b7657cdc50cfeab0.png

  1. MainActivity.kt에서 onCreate() 메서드를 재정의하여 탐색 컨트롤러를 설정합니다. NavHostFragment에서 NavController의 인스턴스를 가져옵니다.
  2. setupActionBarWithNavController(navController)를 호출하여 NavController의 인스턴스를 전달합니다. 이렇게 하면 대상의 라벨을 기반으로 앱 바에 제목이 표시되고, 최상위 대상에 있지 않은 경우 항상 위로 버튼이 표시됩니다.
class MainActivity : AppCompatActivity(R.layout.activity_main) {

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

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

        setupActionBarWithNavController(navController)
    }
}
  1. Android 스튜디오에서 메시지가 표시되면 필요한 가져오기를 추가합니다.
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupActionBarWithNavController
  1. 각 프래그먼트의 앱 바 제목을 설정합니다. navigation/nav_graph.xml을 열고 Code 탭으로 전환합니다.
  2. nav_graph.xml에서 각 프래그먼트 대상의 android:label 속성을 수정합니다. 시작 앱에 이미 선언되어 있는 다음 문자열 리소스를 사용합니다.

start 프래그먼트에는 값이 Cupcake@string/app_name을 사용합니다.

flavor 프래그먼트에는 값이 Choose Flavor@string/choose_flavor를 사용합니다.

pickup 프래그먼트에는 값이 Choose Pickup Date@string/choose_pickup_date를 사용합니다.

summary 프래그먼트에는 값이 Order Summary@string/order_summary를 사용합니다.

<navigation ...>
    <fragment
        android:id="@+id/startFragment"
        ...
        android:label="@string/app_name" ... >
        <action ... />
    </fragment>
    <fragment
        android:id="@+id/flavorFragment"
        ...
        android:label="@string/choose_flavor" ... >
        <action ... />
    </fragment>
    <fragment
        android:id="@+id/pickupFragment"
        ...
        android:label="@string/choose_pickup_date" ... >
        <action ... />
    </fragment>
    <fragment
        android:id="@+id/summaryFragment"
        ...
        android:label="@string/order_summary" ... />
</navigation>
  1. 앱을 실행합니다. 각 프래그먼트 대상으로 이동하면 앱 바의 제목이 변경되는 것을 확인할 수 있습니다. 또한 이제 뒤로 버튼(← 화살표)이 앱 바에 표시되는 것도 확인할 수 있습니다. 버튼을 탭해도 아무 작업도 실행되지 않습니다. 위로 버튼 동작은 다음 Codelab에서 구현합니다.

89e0ea37d4146271.png

4. 공유 ViewModel 만들기

이제 각 프래그먼트에 올바른 데이터를 채우는 단계를 진행해 보겠습니다. 공유 ViewModel을 사용하여 앱의 데이터를 단일 ViewModel에 저장합니다. 앱의 여러 프래그먼트는 활동 범위를 사용하여 공유 ViewModel에 액세스합니다.

대부분의 프로덕션 앱에서 프래그먼트 간에 데이터를 공유하는 것은 일반적인 사용 사례입니다. 예를 들어 이 Codelab의 Cupcake 앱 최종 버전(아래 스크린샷 참고)에서 사용자는 첫 번째 화면에서 컵케이크의 수량을 선택합니다. 그러면 두 번째 화면에 가격이 컵케이크의 수량에 따라 계산되어 표시됩니다. 마찬가지로 맛 및 수령 날짜와 같은 다른 앱 데이터는 요약 화면에서도 사용됩니다.

3b6a68cab0b9ee2.png

앱 기능을 살펴보면 이 활동의 프래그먼트 간에 공유할 수 있는 단일 ViewModel에 이 주문 정보를 저장하는 것이 유용하다고 판단할 수 있습니다. ViewModelAndroid 아키텍처 구성요소의 일부임을 상기하시기 바랍니다. ViewModel 내에 저장된 앱 데이터는 구성 변경 중에도 유지됩니다. 앱에 ViewModel을 추가하려면 ViewModel 클래스에서 확장되는 새 클래스를 만듭니다.

OrderViewModel 만들기

이 작업에서는 OrderViewModel이라는 Cupcake 앱용 공유 ViewModel을 만듭니다. 또한 앱 데이터를 ViewModel 내의 속성으로 추가하며 데이터를 업데이트하고 수정하는 메서드도 추가합니다. 클래스의 속성은 다음과 같습니다.

  • 주문 수량(Integer)
  • 컵케이크 맛(String)
  • 수령 날짜(String)
  • 가격(Double)

ViewModel 권장사항 준수

ViewModel에서는 뷰 모델 데이터를 public 변수로 노출하지 않는 것이 좋습니다. 공개 변수로 노출하게 되면 앱 데이터가 외부 클래스에 의해 예상치 못한 방식으로 수정될 수 있으며, 앱에서 처리할 것으로 예상하지 못한 극단적인 케이스가 발생할 수 있습니다. 대신, 이러한 변경 가능한 속성을 private으로 만들고, 지원 속성을 구현하며, 필요한 경우 각 속성의 변경 불가능한 public 버전을 노출합니다. 이름 지정 규칙은 변경 가능한 private 속성의 이름 앞에 밑줄(_)을 붙이는 것입니다.

다음은 사용자의 선택에 따라 위의 속성을 업데이트하는 메서드입니다.

  • setQuantity(numberCupcakes: Int)
  • setFlavor(desiredFlavor: String)
  • setDate(pickupDate: String)

가격에 관한 setter 메서드는 필요하지 않습니다. 다른 속성을 사용하여 OrderViewModel 내에서 가격이 계산되기 때문입니다. 아래 단계에서는 공유 ViewModel을 구현하는 방법을 안내합니다.

프로젝트에서 model이라는 새 패키지를 만들고 OrderViewModel 클래스를 추가합니다. 그러면 나머지 UI 코드(프래그먼트 및 활동)에서 뷰 모델 코드가 분리됩니다. 기능에 따라 코드를 패키지로 분리하는 것이 코딩 권장사항입니다.

  1. Android 스튜디오의 Project 창에서 com.example.cupcake > New > Package를 마우스 오른쪽 버튼으로 클릭합니다.
  2. New Package 대화상자가 열리면 패키지 이름을 com.example.cupcake.model로 지정합니다.

d958ee5f3d2aef5a.png

  1. model 패키지에서 OrderViewModel Kotlin 클래스를 생성합니다. Project 창에서 model 패키지를 마우스 오른쪽 버튼으로 클릭하고 New > Kotlin File/Class를 선택합니다. 새 대화상자에서 파일 이름을 OrderViewModel로 지정합니다.

fc68c1d3861f1cca.png

  1. OrderViewModel.kt에서 클래스 서명을 변경하여 ViewModel에서 확장합니다.
import androidx.lifecycle.ViewModel

class OrderViewModel : ViewModel() {

}
  1. OrderViewModel 클래스 내에서 위에서 설명한 속성을 private val로 추가합니다.
  2. 속성 유형을 LiveData로 변경하고 지원 필드를 속성에 추가합니다. 그러면 이러한 속성을 관찰할 수 있으며 뷰 모델의 소스 데이터가 변경될 때 UI를 업데이트할 수 있습니다.
private val _quantity = MutableLiveData<Int>(0)
val quantity: LiveData<Int> = _quantity

private val _flavor = MutableLiveData<String>("")
val flavor: LiveData<String> = _flavor

private val _date = MutableLiveData<String>("")
val date: LiveData<String> = _date

private val _price = MutableLiveData<Double>(0.0)
val price: LiveData<Double> = _price

다음 클래스를 가져와야 합니다.

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
  1. OrderViewModel 클래스에서 위에서 설명한 메서드를 추가합니다. 메서드 내에서 변경 가능한 속성에 전달된 인수를 할당합니다.
  2. 이러한 setter 메서드는 뷰 모델 외부에서 호출되어야 하므로 public 메서드로 그대로 둡니다(즉, fun 키워드 앞에 private 또는 기타 공개 상태 한정자가 필요하지 않음). Kotlin의 기본 공개 상태 한정자는 public입니다.
fun setQuantity(numberCupcakes: Int) {
    _quantity.value = numberCupcakes
}

fun setFlavor(desiredFlavor: String) {
    _flavor.value = desiredFlavor
}

fun setDate(pickupDate: String) {
    _date.value = pickupDate
}
  1. 앱을 빌드하고 실행하여 컴파일 오류가 없는지 확인합니다. 아직은 UI에 눈에 띄는 변화가 없습니다.

훌륭합니다. 이제 뷰 모델을 시작했습니다. 앱에 더 많은 기능을 빌드함에 따라 그리고 클래스에 더 많은 속성 및 메서드가 필요하다고 생각될 때 이 클래스에 조금씩 더 많은 것을 추가하게 됩니다.

Android 스튜디오에서 클래스 이름, 속성 이름 또는 메서드 이름이 회색 글꼴로 표시되더라도 문제가 있는 게 아닙니다. 이는 클래스, 속성 또는 메서드가 현재 사용되고 있지 않음을 의미합니다. 하지만 앞으로 사용될 것입니다. 다음 단계로 진행하겠습니다.

5. ViewModel을 사용하여 UI 업데이트

이 작업에서는 직접 만든 공유 뷰 모델을 사용하여 앱의 UI를 업데이트합니다. 공유 뷰 모델 구현의 주요 차이점은 UI 컨트롤러에서 뷰 모델에 액세스하는 방식입니다. 프래그먼트 인스턴스 대신 활동 인스턴스를 사용하며, 이후 섹션에서 이를 수행하는 방법을 확인할 수 있습니다.

즉, 뷰 모델을 여러 프래그먼트 간에 공유할 수 있습니다. 각 프래그먼트는 뷰 모델에 액세스하여 주문의 일부 세부정보를 확인하거나 뷰 모델의 일부 데이터를 업데이트할 수 있습니다.

StartFragment를 업데이트하여 뷰 모델 사용

StartFragment에서 공유 뷰 모델을 사용하려면 viewModels() 대리자 클래스 대신 activityViewModels()를 사용하여 OrderViewModel을 초기화합니다.

  • viewModels()는 현재 프래그먼트로 범위가 지정된 ViewModel 인스턴스를 제공합니다. 따라서 인스턴스는 프래그먼트마다 다릅니다.
  • activityViewModels()는 현재 활동으로 범위가 지정된 ViewModel 인스턴스를 제공합니다. 따라서 인스턴스는 동일한 활동의 여러 프래그먼트 간에 동일하게 유지됩니다.

Kotlin 속성 위임 사용

Kotlin에는 각 변경 가능한(var) 속성에 자동으로 생성되는 기본 getter 및 setter 함수가 있습니다. 값을 할당하거나 속성의 값을 읽을 때 setter 및 getter 함수가 호출됩니다. (읽기 전용 속성(val)의 경우 기본적으로 getter 함수만 생성됩니다. 읽기 전용 속성의 값을 읽을 때 이 getter 함수가 호출됩니다.)

Kotlin에서 속성 위임을 사용하면 getter-setter 책임을 다른 클래스에 넘길 수 있습니다.

이 클래스(대리자 클래스라고 함)는 속성의 getter 및 setter 함수를 제공하고 변경사항을 처리합니다.

대리자 속성은 다음과 같이 by 절 및 대리자 클래스 인스턴스를 사용하여 정의됩니다.

// Syntax for property delegation
var <property-name> : <property-type> by <delegate-class>()
  1. StartFragment 클래스에서 공유 뷰 모델의 참조를 클래스 변수로 가져옵니다. fragment-ktx 라이브러리의 by activityViewModels() Kotlin 속성 위임을 사용합니다.
private val sharedViewModel: OrderViewModel by activityViewModels()

다음과 같은 새로운 가져오기가 필요할 수 있습니다.

import androidx.fragment.app.activityViewModels
import com.example.cupcake.model.OrderViewModel
  1. FlavorFragment, PickupFragment, SummaryFragment 클래스에 대해 위의 단계를 반복합니다. Codelab의 이후 섹션에서 이 sharedViewModel 인스턴스를 사용합니다.
  2. StartFragment 클래스로 돌아가면 이제 뷰 모델을 사용할 수 있습니다. orderCupcake() 메서드 시작 부분에서 flavor 프래그먼트로 이동하기 전에 공유 뷰 모델의 setQuantity() 메서드를 호출하여 수량을 업데이트합니다.
fun orderCupcake(quantity: Int) {
    sharedViewModel.setQuantity(quantity)
    findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
  1. OrderViewModel 클래스 내에서 주문의 맛이 설정되었는지 여부를 확인하는 다음 메서드를 추가합니다. 이후 단계의 StartFragment 클래스에서 이 메서드를 사용합니다.
fun hasNoFlavorSet(): Boolean {
    return _flavor.value.isNullOrEmpty()
}
  1. StartFragment 클래스의 orderCupcake() 메서드 내에서 flavor 프래그먼트로 이동하기 전에 수량을 설정한 후에 맛이 설정되지 않았다면 기본 맛을 Vanilla로 설정합니다. 완성된 메서드는 다음과 같습니다.
fun orderCupcake(quantity: Int) {
    sharedViewModel.setQuantity(quantity)
    if (sharedViewModel.hasNoFlavorSet()) {
        sharedViewModel.setFlavor(getString(R.string.vanilla))
    }
    findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
  1. 앱을 빌드하여 컴파일 오류가 없는지 확인합니다. 하지만 UI에 눈에 띄는 변화가 없습니다.

6. 데이터 결합과 함께 ViewModel 사용

다음으로, 데이터 결합을 사용하여 뷰 모델 데이터를 UI에 결합합니다. 또한 사용자가 UI에서 선택한 사항에 따라 공유 뷰 모델을 업데이트합니다.

데이터 결합에 관한 복습

아시다시피 데이터 결합 라이브러리Android Jetpack의 구성요소입니다. 데이터 결합은 선언적 형식을 사용하여 레이아웃의 UI 구성요소를 앱의 데이터 소스에 결합합니다. 간단히 말해서 데이터 결합은 코드에서 데이터를 뷰 + 뷰 결합에 결합(뷰를 코드에 결합)하는 것입니다. 이러한 결합을 설정하고 업데이트를 자동으로 설정하면 코드에서 UI를 직접 업데이트하는 것을 잊은 경우 오류 발생 가능성을 줄일 수 있습니다.

사용자 선택으로 맛 업데이트

  1. layout/fragment_flavor.xml에서 <data> 태그를 루트 <layout> 태그 내에 추가합니다. com.example.cupcake.model.OrderViewModel 유형의 viewModel이라는 레이아웃 변수를 추가합니다. type 속성의 패키지 이름이 앱의 공유 뷰 모델 클래스, OrderViewModel의 패키지 이름과 일치하는지 확인합니다.
<layout ...>

    <data>
        <variable
            name="viewModel"
            type="com.example.cupcake.model.OrderViewModel" />
    </data>

    <ScrollView ...>

    ...
  1. 마찬가지로 fragment_pickup.xmlfragment_summary.xml에 대해 위의 단계를 반복하여 viewModel 레이아웃 변수를 추가합니다. 이후 섹션에서 이 변수를 사용합니다. fragment_start.xml에서는 이 코드를 추가할 필요가 없습니다. 이 레이아웃에서는 공유 뷰 모델을 사용하지 않기 때문입니다.
  2. FlavorFragment 클래스의 onViewCreated() 내에서 뷰 모델 인스턴스를 레이아웃의 공유 뷰 모델 인스턴스와 결합합니다. binding?.apply 블록 내에 다음 코드를 추가합니다.
binding?.apply {
    viewModel = sharedViewModel
    ...
}

범위 함수 적용

Kotlin에서 apply 함수를 보는 것이 이번이 처음일 수 있습니다. apply는 Kotlin 표준 라이브러리의 범위 함수입니다. 이 함수는 객체의 컨텍스트 내에서 코드 블록을 실행하며, 임시 범위를 형성합니다. 그러면 이 범위에서 이름을 사용하지 않고 객체에 액세스할 수 있습니다. apply의 일반적인 사용 사례는 객체를 구성하는 것입니다. 이 함수 호출은 '객체에 다음 할당 적용'으로 읽을 수 있습니다.

예:

clark.apply {
    firstName = "Clark"
    lastName = "James"
    age = 18
}

// The equivalent code without apply scope function would look like the following.

clark.firstName = "Clark"
clark.lastName = "James"
clark.age = 18
  1. PickupFragmentSummaryFragment 클래스 내의 onViewCreated() 메서드에 대해 동일한 단계를 반복합니다.
binding?.apply {
    viewModel = sharedViewModel
    ...
}
  1. fragment_flavor.xml에서 새 레이아웃 변수인 viewModel을 사용하여 뷰 모델의 flavor 값에 따라 라디오 버튼의 checked 속성을 설정합니다. 라디오 버튼이 나타내는 맛이 뷰 모델에 저장된 맛과 동일하면 라디오 버튼을 선택된 상태로 표시합니다(checked = true). Vanilla RadioButton의 선택 상태에 대한 결합 표현식은 다음과 같습니다.

@{viewModel.flavor.equals(@string/vanilla)}

기본적으로 equals 함수를 사용해 viewModel.flavor 속성을 상응하는 문자열 리소스와 비교하여 선택 상태가 true인지 false인지 확인합니다.

<RadioGroup
   ...>

   <RadioButton
       android:id="@+id/vanilla"
       ...
       android:checked="@{viewModel.flavor.equals(@string/vanilla)}"
       .../>

   <RadioButton
       android:id="@+id/chocolate"
       ...
       android:checked="@{viewModel.flavor.equals(@string/chocolate)}"
       .../>

   <RadioButton
       android:id="@+id/red_velvet"
       ...
       android:checked="@{viewModel.flavor.equals(@string/red_velvet)}"
       .../>

   <RadioButton
       android:id="@+id/salted_caramel"
       ...
       android:checked="@{viewModel.flavor.equals(@string/salted_caramel)}"
       .../>

   <RadioButton
       android:id="@+id/coffee"
       ...
       android:checked="@{viewModel.flavor.equals(@string/coffee)}"
       .../>
</RadioGroup>

리스너 결합

리스너 결합은 onClick 이벤트와 같은 이벤트가 발생할 때 실행되는 람다 표현식입니다. 리스너 결합은 textview.setOnClickListener(clickListener)와 같은 메서드 참조와 비슷하지만, 리스너 결합을 사용하면 임의의 데이터 결합 표현식을 실행할 수 있습니다.

  1. fragment_flavor.xml에서 리스너 결합을 사용하여 이벤트 리스너를 라디오 버튼에 추가합니다. 매개변수 없이 람다 표현식을 사용하고 viewModel을 호출합니다.상응하는 flavor 문자열 리소스를 전달하여 setFlavor() 메서드를 호출합니다.
<RadioGroup
   ...>

   <RadioButton
       android:id="@+id/vanilla"
       ...
       android:onClick="@{() -> viewModel.setFlavor(@string/vanilla)}"
       .../>

   <RadioButton
       android:id="@+id/chocolate"
       ...
       android:onClick="@{() -> viewModel.setFlavor(@string/chocolate)}"
       .../>

   <RadioButton
       android:id="@+id/red_velvet"
       ...
       android:onClick="@{() -> viewModel.setFlavor(@string/red_velvet)}"
       .../>

   <RadioButton
       android:id="@+id/salted_caramel"
       ...
       android:onClick="@{() -> viewModel.setFlavor(@string/salted_caramel)}"
       .../>

   <RadioButton
       android:id="@+id/coffee"
       ...
       android:onClick="@{() -> viewModel.setFlavor(@string/coffee)}"
       .../>
</RadioGroup>
  1. 앱을 실행하고 flavor 프래그먼트에서 Vanilla 옵션이 기본적으로 선택되는 방식을 확인합니다.

3095e824b4817b98.png

좋습니다. 이제 다음 프래그먼트로 이동할 수 있습니다.

7. Pickup 및 summary 프래그먼트를 업데이트하여 뷰 모델 사용

앱을 탐색하면 pickup 프래그먼트에서 라디오 버튼 옵션 라벨이 비어 있는 것을 확인할 수 있습니다. 이 작업에서는 이용 가능한 4개의 수령 날짜를 계산하여 pickup 프래그먼트에 표시합니다. 형식이 지정된 날짜를 표시할 수 있는 방법으로는 여러 가지가 있으며, 다음은 이를 지원하기 위해 Android에서 제공하는 몇 가지 유용한 유틸리티입니다.

수령 옵션 목록 만들기

날짜 형식 지정

Android 프레임워크는 SimpleDateFormat이라는 클래스를 제공합니다. 이 클래스는 언어에 민감한 방식으로 날짜 형식을 지정하고 파싱하는 클래스입니다. 이 클래스를 통해 날짜의 형식 지정(날짜 → 텍스트) 및 파싱(텍스트 → 날짜)이 가능합니다.

다음과 같이 패턴 문자열과 언어를 전달하여 SimpleDateFormat의 인스턴스를 만들 수 있습니다.

SimpleDateFormat("E MMM d", Locale.getDefault())

"E MMM d"와 같은 패턴 문자열은 날짜 및 시간 형식의 표현입니다. 'A'부터 'Z'까지, 'a'부터 'z'까지의 문자는 날짜 또는 시간 문자열의 구성요소를 나타내는 패턴 문자로 해석됩니다. 예를 들어 d는 월의 일, y는 연도, M은 월을 나타냅니다. 날짜가 2018년 1월 4일이면 패턴 문자열 "EEE, MMM d""Wed, Jul 4"로 파싱됩니다. 패턴 문자의 전체 목록은 문서를 참고하세요.

Locale 객체는 특정한 지리적, 정치적 또는 문화적 지역을 나타냅니다. 또한 언어/국가/변형 조합을 나타냅니다. 언어 객체는 지역의 규칙에 맞게 숫자 또는 날짜와 같은 정보의 표시를 변경하는 데 사용됩니다. 날짜 및 시간은 세계 각지에서 서로 다르게 작성되기 때문에 언어에 민감합니다. Locale.getDefault() 메서드를 사용하여 사용자의 기기에 설정된 언어 정보를 가져와서 SimpleDateFormat 생성자에 전달합니다.

Android의 언어는 언어와 국가 코드의 조합입니다. 언어 코드는 2자로 된 소문자 ISO 언어 코드입니다(예: 영어의 경우 'en'). 국가 코드는 2자로 된 대문자 ISO 국가 코드입니다(예: 미국의 경우 'US').

이제 SimpleDateFormatLocale을 사용하여 Cupcake 앱에서 이용 가능한 수령 날짜를 결정합니다.

  1. OrderViewModel 클래스에서 다음과 같이 getPickupOptions()라는 함수를 추가하여 수령 날짜 목록을 만들고 반환합니다. 메서드 내에서 options라는 val 변수를 만들어 mutableListOf<String>()으로 초기화합니다.
private fun getPickupOptions(): List<String> {
   val options = mutableListOf<String>()
}
  1. SimpleDateFormat을 사용하여 형식 지정 문자열을 만들어 "E MMM d" 패턴 문자열 및 언어를 전달합니다. 패턴 문자열에서 E는 요일 이름을 나타내며 'Tue Dec 10'으로 파싱됩니다.
val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())

Android 스튜디오에서 메시지가 표시되면 java.text.SimpleDateFormatjava.util.Locale을 가져옵니다.

  1. Calendar 인스턴스를 가져와서 새 변수에 할당합니다. 그리고 변수를 val로 설정합니다. 이 변수에는 현재 날짜 및 시간이 포함됩니다. 또한 java.util.Calendar도 가져옵니다.
val calendar = Calendar.getInstance()
  1. 현재 날짜 및 다음 세 날짜로 시작하는 날짜 목록을 만듭니다. 4개의 날짜 옵션이 필요하므로 이 코드 블록을 4번 반복합니다. 이 repeat 블록은 날짜 형식을 지정하여 날짜 옵션 목록에 추가한 후 캘린더를 1일씩 증가시킵니다.
repeat(4) {
    options.add(formatter.format(calendar.time))
    calendar.add(Calendar.DATE, 1)
}
  1. 메서드의 끝부분에서 업데이트된 options를 반환합니다. 완성된 메서드는 다음과 같습니다.
private fun getPickupOptions(): List<String> {
   val options = mutableListOf<String>()
   val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
   val calendar = Calendar.getInstance()
   // Create a list of dates starting with the current date and the following 3 dates
   repeat(4) {
       options.add(formatter.format(calendar.time))
       calendar.add(Calendar.DATE, 1)
   }
   return options
}
  1. OrderViewModel 클래스에서 valdateOptions라는 클래스 속성을 추가합니다. 방금 만든 getPickupOptions() 메서드를 사용하여 이 속성을 초기화합니다.
val dateOptions = getPickupOptions()

수령 옵션을 표시하도록 레이아웃 업데이트

이제 뷰 모델에 4개의 이용 가능한 수령 날짜가 있으므로 fragment_pickup.xml 레이아웃을 업데이트하여 이러한 날짜를 표시합니다. 또한 데이터 결합을 사용하여 각 라디오 버튼의 선택 상태를 표시하고 다른 라디오 버튼이 선택된 경우 뷰 모델의 날짜를 업데이트합니다. 이 구현은 flavor 프래그먼트의 데이터 결합과 비슷합니다.

fragment_pickup.xml에서:

option0 라디오 버튼은 viewModeldateOptions[0](오늘)을 나타냅니다.

option1 라디오 버튼은 viewModeldateOptions[1](내일)을 나타냅니다.

option2 라디오 버튼은 viewModeldateOptions[2](모레)를 나타냅니다.

option3 라디오 버튼은 viewModeldateOptions[3](글피)를 나타냅니다.

  1. fragment_pickup.xml에서 option0 라디오 버튼에 대해 새 레이아웃 변수인 viewModel을 사용하여 뷰 모델의 date 값에 따라 checked 속성을 설정합니다. viewModel.date 속성을 dateOptions 목록의 첫 번째 문자열(즉, 현재 날짜)과 비교합니다. 이때 equals 함수를 사용하여 비교합니다. 최종 결합 표현식은 다음과 같습니다.

@{viewModel.date.equals(viewModel.dateOptions[0])}

  1. 동일한 라디오 버튼에 대해 리스너 결합을 사용하여 이벤트 리스너를 onClick 속성에 추가합니다. 이 라디오 버튼 옵션을 클릭하면 viewModel에서 setDate()를 호출하여 dateOptions[0]을 전달합니다.
  2. 동일한 라디오 버튼에 대해 text 속성 값을 dateOptions 목록의 첫 번째 문자열로 설정합니다.
<RadioButton
   android:id="@+id/option0"
   ...
   android:checked="@{viewModel.date.equals(viewModel.dateOptions[0])}"
   android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[0])}"
   android:text="@{viewModel.dateOptions[0]}"
   ...
   />
  1. 다른 라디오 버튼에 대해 위 단계를 반복하여 dateOptions의 색인을 적절하게 변경합니다.
<RadioButton
   android:id="@+id/option1"
   ...
   android:checked="@{viewModel.date.equals(viewModel.dateOptions[1])}"
   android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[1])}"
   android:text="@{viewModel.dateOptions[1]}"
   ... />

<RadioButton
   android:id="@+id/option2"
   ...
   android:checked="@{viewModel.date.equals(viewModel.dateOptions[2])}"
   android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[2])}"
   android:text="@{viewModel.dateOptions[2]}"
   ... />

<RadioButton
   android:id="@+id/option3"
   ...
   android:checked="@{viewModel.date.equals(viewModel.dateOptions[3])}"
   android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[3])}"
   android:text="@{viewModel.dateOptions[3]}"
   ... />
  1. 앱을 실행하면 이용 가능한 수령 옵션으로 '앞으로 며칠'이 표시됩니다. 스크린샷은 당일 날짜에 따라 달라집니다. 기본적으로 선택된 옵션이 없는 것을 확인할 수 있습니다. 다음 단계에서 이를 구현합니다.

b55b3a36e2aa7be6.png

  1. OrderViewModel 클래스 내에서 resetOrder()라는 함수를 만들어 뷰 모델의 MutableLiveData 속성을 재설정합니다. dateOptions 목록의 현재 날짜 값을 _date.value.에 할당합니다.
fun resetOrder() {
   _quantity.value = 0
   _flavor.value = ""
   _date.value = dateOptions[0]
   _price.value = 0.0
}
  1. 클래스에 init 블록을 추가하고 여기에서 새로운 resetOrder() 메서드를 호출합니다.
init {
   resetOrder()
}
  1. 클래스의 속성 선언에서 초깃값을 삭제합니다. 이제 OrderViewModel 인스턴스를 만들 때 init 블록을 사용하여 속성을 초기화합니다.
private val _quantity = MutableLiveData<Int>()
val quantity: LiveData<Int> = _quantity

private val _flavor = MutableLiveData<String>()
val flavor: LiveData<String> = _flavor

private val _date = MutableLiveData<String>()
val date: LiveData<String> = _date

private val _price = MutableLiveData<Double>()
val price: LiveData<Double> = _price
  1. 앱을 다시 실행합니다. 오늘 날짜가 기본적으로 선택되어 있습니다.

bfe4f1b82977b4bc.png

뷰 모델을 사용하도록 Summary 프래그먼트 업데이트

이제 마지막 프래그먼트로 이동하겠습니다. order summary 프래그먼트는 주문 세부정보의 요약을 표시하기 위한 것입니다. 이 작업에서는 공유 뷰 모델의 모든 주문 정보를 활용하고 데이터 결합을 사용하여 화면의 주문 세부정보를 업데이트합니다.

78f510e10d848dd2.png

  1. fragment_summary.xml에서 뷰 모델 데이터 변수인 viewModel이 선언되어 있는지 확인합니다.
<layout ...>

    <data>
        <variable
            name="viewModel"
            type="com.example.cupcake.model.OrderViewModel" />
    </data>

    <ScrollView ...>

    ...
  1. SummaryFragmentonViewCreated()에서 binding.viewModel이 초기화되었는지 확인합니다.
  2. fragment_summary.xml에서는, 뷰 모델에서 데이터를 읽어서 주문 요약 세부정보로 화면을 업데이트합니다. 다음 텍스트 속성을 추가하여 수량, 맛, 날짜 TextViews를 업데이트합니다. 수량은 Int 유형이므로 문자열로 변환해야 합니다.
<TextView
   android:id="@+id/quantity"
   ...
   android:text="@{viewModel.quantity.toString()}"
   ... />
<TextView
   android:id="@+id/flavor"
   ...
   android:text="@{viewModel.flavor}"
   ... />
<TextView
   android:id="@+id/date"
   ...
   android:text="@{viewModel.date}"
   ... />
  1. 앱을 실행하고 테스트하여 선택한 주문 옵션이 주문 요약에 표시되는지 확인합니다.

7091453fa817b55.png

8. 주문 세부정보에서 가격 계산

이 Codelab의 최종적인 앱 스크린샷을 보면 StartFragment를 제외한 각 프래그먼트에 가격이 실제로 표시된 것을 확인할 수 있습니다. 그렇게 하면 사용자가 주문 생성 당시의 가격을 알 수 있습니다.

3b6a68cab0b9ee2.png

다음은 가격 계산 방법에 관한 컵케이크 매장의 규칙입니다.

  • 각 컵케이크의 가격은 $2.00입니다.
  • 당일 수령 시 주문에 $3.00의 금액이 추가됩니다.

따라서 6개의 컵케이크 주문 시 가격은 컵케이크 6개 x $2 = $12입니다. 사용자가 당일 수령을 원하는 경우 추가로 $3의 비용이 부가되어 총 주문 가격은 $15가 됩니다.

뷰 모델에서 가격 업데이트

앱에서 이 기능을 지원하도록 하려면 먼저 컵케이크당 가격을 처리하고 지금 당장은 당일 수령 비용을 무시합니다.

  1. OrderViewModel.kt를 열고 변수에 컵케이크당 가격을 저장합니다. 즉, 파일 맨 위, 클래스 정의 외부에서(하지만 import 문보다 뒤에) 최상위 private 상수로 선언합니다. const 한정자를 사용하고, 읽기 전용으로 만들려면 val을 사용합니다.
package ...

import ...

private const val PRICE_PER_CUPCAKE = 2.00

class OrderViewModel : ViewModel() {
    ...

상수의 값(Kotlin에서 const 키워드로 표시됨)은 변경되지 않으며 컴파일 시간에 값이 알려진다는 점을 상기하시기 바랍니다. 상수에 관해 자세히 알아보려면 문서를 참고하세요.

  1. 컵케이크당 가격을 정의했으므로 이제 도우미 메서드를 생성하여 가격을 계산합니다. 이 메서드는 이 클래스 내에서만 사용되므로 private일 수 있습니다. 다음 작업에서 당일 수령 요금을 포함하도록 가격 로직을 변경합니다.
private fun updatePrice() {
    _price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
}

이 코드 줄은 컵케이크당 가격과 컵케이크 주문 수량을 곱합니다. 괄호 안에 있는 코드의 경우 quantity.value의 값이 null일 수 있으므로 elvis 연산자(?:)를 사용합니다. elvis 연산자(?:)는 왼쪽의 표현식이 null이 아니면 이 값을 사용한다는 것을 의미합니다. 이와는 달리 왼쪽의 표현식이 null이면 elvis 연산자의 오른쪽에 있는 표현식(이 경우에는 0)을 사용합니다.

  1. 동일한 OrderViewModel 클래스에서 수량이 설정된 경우 가격 변수를 업데이트합니다. setQuantity() 함수에서 새 함수를 호출합니다.
fun setQuantity(numberCupcakes: Int) {
    _quantity.value = numberCupcakes
    updatePrice()
}

UI에 가격 속성 결합

  1. fragment_flavor.xml, fragment_pickup.xmlfragment_summary.xml의 레이아웃에서 com.example.cupcake.model.OrderViewModel 유형의 데이터 변수 viewModel이 정의되어 있는지 확인합니다.
<layout ...>

    <data>
        <variable
            name="viewModel"
            type="com.example.cupcake.model.OrderViewModel" />
    </data>

    <ScrollView ...>

    ...
  1. 각 프래그먼트 클래스의 onViewCreated() 메서드에서 프래그먼트의 뷰 모델 객체 인스턴스를 레이아웃의 뷰 모델 데이터 변수에 결합해야 합니다.
binding?.apply {
    viewModel = sharedViewModel
    ...
}
  1. 각 프래그먼트 레이아웃 내에서 viewModel 변수를 사용하여 레이아웃에 표시되는 가격을 설정합니다. 먼저, fragment_flavor.xml 파일을 수정합니다. subtotal 텍스트 뷰에서 android:text 속성의 값을 "@{@string/subtotal_price(viewModel.price)}".로 설정합니다. 이 데이터 결합 레이아웃 표현식은 문자열 리소스 @string/subtotal_price를 사용하고 뷰 모델의 가격인 매개변수를 전달합니다. 따라서 출력에는 예를 들면 Subtotal 12.0이 표시됩니다.
...

<TextView
    android:id="@+id/subtotal"
    android:text="@{@string/subtotal_price(viewModel.price)}"
    ... />

...

strings.xml 파일에서 이미 선언한 다음과 같은 문자열 리소스를 사용합니다.

<string name="subtotal_price">Subtotal %s</string>
  1. 앱을 실행합니다. start 프래그먼트에서 컵케이크 1개를 선택하면 flavor 프래그먼트에 Subtotal 2.0이 표시됩니다. 컵케이크 6개를 선택하면 flavor 프래그먼트에 Subtotal 12.0이 표시됩니다. 나중에 가격을 적절한 통화 형식으로 지정하겠지만, 지금은 이렇게 표시될 것으로 예상됩니다.

  1. 이제 pickup 및 summary 프래그먼트에서도 이와 비슷하게 변경합니다. fragment_pickup.xmlfragment_summary.xml 레이아웃에서도 viewModel price 속성을 사용하도록 텍스트 뷰를 수정합니다.

fragment_pickup.xml

...

<TextView
    android:id="@+id/subtotal"
    ...
    android:text="@{@string/subtotal_price(viewModel.price)}"
    ... />

...

fragment_summary.xml

...

<TextView
   android:id="@+id/total"
   ...
   android:text="@{@string/total_price(viewModel.price)}"
   ... />

...

  1. 앱을 실행합니다. 주문 요약에 표시된 가격이 컵케이크 1개, 6개, 12개의 주문 수량에 맞게 정확하게 계산되었는지 확인합니다. 앞서 언급했듯이 현재 가격 형식이 올바르지 않을 것으로 예상됩니다($2의 경우 2.0으로, $12의 경우 12.0으로 표시됨).

당일 수령 시 추가 요금 청구

이 작업에서는 당일 수령 시 주문에 추가로 $3.00를 부가하는 두 번째 규칙을 구현합니다.

  1. OrderViewModel 클래스에서 당일 수령 비용에 관한 새로운 최상위 private 상수를 정의합니다.
private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00
  1. updatePrice()에서는 사용자가 당일 수령을 선택했는지 확인합니다. 뷰 모델의 날짜(_date.value)가 dateOptions 목록의 첫 번째 항목(항상 당일 날짜)과 동일한지 확인합니다.
private fun updatePrice() {
    _price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
    if (dateOptions[0] == _date.value) {

    }
}
  1. 이러한 계산을 더 간단하게 하려면 임시 변수인 calculatedPrice를 사용합니다. 업데이트된 가격을 계산하여 _price.value에 다시 할당합니다.
private fun updatePrice() {
    var calculatedPrice = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
    // If the user selected the first option (today) for pickup, add the surcharge
    if (dateOptions[0] == _date.value) {
        calculatedPrice += PRICE_FOR_SAME_DAY_PICKUP
    }
    _price.value = calculatedPrice
}
  1. setDate() 메서드에서 updatePrice() 도우미 메서드를 호출하여 당일 수령 요금을 추가합니다.
fun setDate(pickupDate: String) {
    _date.value = pickupDate
    updatePrice()
}
  1. 앱을 실행하고 앱을 탐색합니다. 수령 날짜를 변경해도 총가격에서 당일 수령 요금이 삭제되지 않는 것을 확인할 수 있습니다. 이는 뷰 모델에서 가격이 변경되었지만 이 정보가 결합 레이아웃에 알려지지 않았기 때문입니다.

2ea8e000fb4e6ec8.png

LiveData를 관찰하도록 수명 주기 소유자 설정

LifecycleOwner는 활동이나 프래그먼트와 같이 Android 수명 주기를 보유한 클래스입니다. LiveData 관찰자는 수명 주기 소유자가 활성 상태(STARTED 또는 RESUMED)인 경우에만 앱 데이터의 변경사항을 관찰합니다.

이 앱에서 LiveData 객체 또는 관찰 가능한 데이터는 뷰 모델의 price 속성입니다. 수명 주기 소유자는 flavor, pickup, summary 프래그먼트입니다. LiveData 관찰자는 가격과 같은 관찰 가능한 데이터가 있는 레이아웃 파일의 결합 표현식입니다. 데이터 결합을 사용하면 관찰 가능한 값이 변경되는 경우 결합된 UI 요소가 자동으로 업데이트됩니다.

결합 표현식의 예: android:text="@{@string/subtotal_price(viewModel.price)}"

UI 요소가 자동으로 업데이트되도록 하려면 binding.lifecycleOwner

앱의 수명 주기 소유자와 연결해야 합니다. 이는 다음에 구현해 보겠습니다.

  1. FlavorFragment, PickupFragmentSummaryFragment 클래스의 onViewCreated() 메서드 내에서 binding?.apply 블록에 다음을 추가합니다. 이렇게 하면 결합 객체에 수명 주기 소유자가 설정됩니다. 수명 주기 소유자를 설정하면 앱이 LiveData 객체를 관찰할 수 있습니다.
binding?.apply {
    lifecycleOwner = viewLifecycleOwner
    ...
}
  1. 앱을 다시 실행합니다. 수령 화면에서 수령 날짜를 변경하고 가격이 자동으로 변경되는 방식의 차이를 확인합니다. 또한 수령 요금이 요약 화면에 올바르게 반영됩니다.
  2. 수령일로 오늘 날짜를 선택하면 주문 가격이 $3.00만큼 증가되는 것을 확인할 수 있습니다. 미래의 날짜를 선택하는 경우 가격은 여전히 컵케이크 수량 x $2.00여야 합니다.

  1. 다양한 컵케이크 수량, 맛, 수령 날짜를 사용하여 다양한 케이스를 테스트합니다. 이제 각 프래그먼트의 뷰 모델에서 가격이 업데이트되는 것을 확인할 수 있습니다. 가장 좋은 점은 매번 바뀐 가격으로 UI를 계속 업데이트하기 위해 추가 Kotlin 코드를 작성할 필요가 없다는 점입니다.

f4c0a3c5ea916d03.png

가격 기능 구현을 완료하려면 가격 형식을 현지 통화로 지정해야 합니다.

LiveData 변환을 사용하여 가격 형식 지정

LiveData 변환 메서드는 LiveData 소스에서 데이터 조작을 실행하고 결과 LiveData 객체를 반환하는 방법을 제공합니다. 간단히 말해 LiveData 값을 다른 값으로 변환합니다. 관찰자가 LiveData 객체를 관찰하고 있지 않다면 이러한 변환은 계산되지 않습니다.

Transformations.map()은 변환 함수 중 하나이며, 이 메서드는 소스 LiveData 및 함수를 매개변수로 사용합니다. 이 함수는 LiveData 소스를 조작하고, 관찰할 수도 있는 업데이트된 값을 반환합니다.

LiveData 변환을 사용할 수 있는 몇 가지 실시간 예는 다음과 같습니다.

  • 표시할 날짜 및 시간 문자열 형식 지정
  • 항목 목록 정렬
  • 항목 필터링 또는 그룹화
  • 모든 항목 합계, 항목 수, 마지막 항목 반환 등과 같이 목록의 결과 계산

이 작업에서는 Transformations.map() 메서드를 사용하여 가격에 현지 통화를 사용하도록 가격 형식을 지정합니다. 십진수 값(LiveData<Double>)의 원래 가격을 문자열 값(LiveData<String>)으로 변환합니다.

  1. OrderViewModel 클래스에서 지원 속성 유형을 LiveData<Double>. 대신 LiveData<String>으로 변경합니다. 형식이 지정된 가격은 '$'와 같은 통화 기호가 있는 문자열입니다. 다음 단계에서 초기화 오류를 수정합니다.
private val _price = MutableLiveData<Double>()
val price: LiveData<String>
  1. Transformations.map()을 사용하여 새로운 변수를 초기화하고 _price 및 람다 함수를 전달합니다. NumberFormat 클래스의 getCurrencyInstance() 메서드를 사용하여 가격을 현지 통화 형식으로 변환합니다. 변환 코드는 다음과 같습니다.
private val _price = MutableLiveData<Double>()
val price: LiveData<String> = Transformations.map(_price) {
   NumberFormat.getCurrencyInstance().format(it)
}

androidx.lifecycle.Transformationsjava.text.NumberFormat을 가져와야 합니다.

  1. 앱을 실행합니다. 이제 소계 및 합계에 대해 형식이 지정된 가격 문자열이 표시됩니다. 이러한 표시는 훨씬 더 사용자 친화적입니다!

1853bd13a07f1bc7.png

  1. 앱이 예상대로 작동하는지 테스트합니다. 예를 들어 컵케이크 1개 주문, 컵케이크 6개 주문 또는 컵케이크 12개 주문과 같은 사례를 테스트합니다. 가격이 각 화면에서 올바르게 업데이트되는지 확인합니다. Flavor 및 Pickup 프래그먼트에서는 Subtotal $2.00, Order Summary 프래그먼트에서는 Total $2.00로 표시되어야 합니다. 또한 주문 요약에 정확한 주문 세부정보가 표시되는지 확인합니다.

9. 리스너 결합을 사용하여 클릭 리스너 설정

이 작업에서는 리스너 결합을 사용하여 프래그먼트 클래스의 버튼 클릭 리스너를 레이아웃에 결합합니다.

  1. 레이아웃 파일 fragment_start.xml에서 com.example.cupcake.StartFragment 유형의 startFragment라는 데이터 변수를 추가합니다. 프래그먼트의 패키지 이름이 앱의 패키지 이름과 일치하는지 확인합니다.
<layout ...>

    <data>
        <variable
            name="startFragment"
            type="com.example.cupcake.StartFragment" />
    </data>
    ...
    <ScrollView ...>
  1. StartFragment.ktonViewCreated() 메서드에서 새 데이터 변수를 프래그먼트 인스턴스에 결합합니다. this 키워드를 사용하여 프래그먼트 내에서 프래그먼트 인스턴스에 액세스할 수 있습니다. binding?.apply 블록과 그 안에 있는 코드를 함께 삭제합니다. 완성된 메서드는 다음과 같습니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    binding?.startFragment = this
}
  1. fragment_start.xml에서 리스너 결합을 사용하여 이벤트 리스너를 버튼의 onClick 속성에 추가하고, startFragment에서 orderCupcake()를 호출하여 컵케이크 수를 전달합니다.
<Button
    android:id="@+id/order_one_cupcake"
    android:onClick="@{() -> startFragment.orderCupcake(1)}"
    ... />

<Button
    android:id="@+id/order_six_cupcakes"
    android:onClick="@{() -> startFragment.orderCupcake(6)}"
    ... />

<Button
    android:id="@+id/order_twelve_cupcakes"
    android:onClick="@{() -> startFragment.orderCupcake(12)}"
    ... />
  1. 앱을 실행합니다. start 프래그먼트에서 버튼 클릭 핸들러가 예상대로 작동하는지 확인합니다.
  2. 마찬가지로 fragment_flavor.xml, fragment_pickup.xml, fragment_summary.xml의 다른 레이아웃에서도 위의 데이터 변수를 추가하여 프래그먼트 인스턴스에 결합니다.

fragment_flavor.xml에서:

<layout ...>

    <data>
        <variable
            ... />

        <variable
            name="flavorFragment"
            type="com.example.cupcake.FlavorFragment" />
    </data>

    <ScrollView ...>

fragment_pickup.xml에서:

<layout ...>

    <data>
        <variable
            ... />

        <variable
            name="pickupFragment"
            type="com.example.cupcake.PickupFragment" />
    </data>

    <ScrollView ...>

fragment_summary.xml에서:

<layout ...>

    <data>
        <variable
            ... />

        <variable
            name="summaryFragment"
            type="com.example.cupcake.SummaryFragment" />
    </data>

    <ScrollView ...>
  1. 나머지 프래그먼트 클래스의 onViewCreated() 메서드에서 버튼에 클릭 리스너를 직접 설정하는 코드를 삭제합니다.
  2. onViewCreated() 메서드에서 프래그먼트 데이터 변수를 프래그먼트 인스턴스와 결합합니다. 여기서는 this 키워드를 다르게 사용합니다. binding?.apply 블록 내에서 this 키워드가 프래그먼트 인스턴스가 아닌 결합 인스턴스를 참조하기 때문입니다. @을 사용하여 프래그먼트 클래스 이름을 명시적으로 지정합니다(예: this@FlavorFragment). 완성된 onViewCreated() 메서드는 다음과 같습니다.

FlavorFragment 클래스의 onViewCreated() 메서드는 다음과 같습니다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    binding?.apply {
        lifecycleOwner = viewLifecycleOwner
        viewModel = sharedViewModel
        flavorFragment = this@FlavorFragment
    }
}

PickupFragment 클래스의 onViewCreated() 메서드는 다음과 같습니다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)

   binding?.apply {
       lifecycleOwner = viewLifecycleOwner
       viewModel = sharedViewModel
       pickupFragment = this@PickupFragment
   }
}

SummaryFragment 클래스 메서드의 결과 onViewCreated() 메서드는 다음과 같습니다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)

   binding?.apply {
       lifecycleOwner = viewLifecycleOwner
       viewModel = sharedViewModel
       summaryFragment = this@SummaryFragment
   }
}
  1. 다른 레이아웃 파일에서도 버튼의 onClick 속성에 리스너 결합 표현식을 추가합니다.

fragment_flavor.xml에서:

<Button
    android:id="@+id/next_button"
    android:onClick="@{() -> flavorFragment.goToNextScreen()}"
    ... />

fragment_pickup.xml에서:

<Button
    android:id="@+id/next_button"
    android:onClick="@{() -> pickupFragment.goToNextScreen()}"
    ... />

fragment_summary.xml에서:

<Button
    android:id="@+id/send_button"
    android:onClick="@{() -> summaryFragment.sendOrder()}"
    ...>
  1. 앱을 실행하여 버튼이 여전히 예상대로 작동하는지 확인합니다. 동작에는 눈에 띄는 변화가 없지만, 이제 리스너 결합을 사용하여 클릭 리스너를 설정했습니다!

이 Codelab을 완료하고 Cupcake 앱을 빌드한 것을 축하합니다! 그러나 앱이 아직 완전히 완성되지는 않았습니다. 다음 Codelab에서는 Cancel 버튼을 추가하고 백 스택을 수정합니다. 또한 백 스택이 무엇인지와 다른 새로운 주제도 학습합니다. 다음 Codelab에서 뵙겠습니다!

10. 솔루션 코드

이 Codelab의 솔루션 코드는 아래 표시된 프로젝트에 있습니다. ViewModel 분기를 사용하여 코드를 가져오거나 다운로드하세요.

이 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 도구 창에서 프로젝트 파일을 둘러보고 앱이 설정된 방식을 확인합니다.

11. 요약

  • ViewModelAndroid 아키텍처 구성요소의 일부이며, ViewModel 내에 저장된 앱 데이터는 구성 변경 중에도 유지됩니다. 앱에 ViewModel을 추가하려면 새 클래스를 만들어 ViewModel 클래스에서 확장합니다.
  • 공유 ViewModel은 여러 프래그먼트의 앱 데이터를 단일 ViewModel에 저장하는 데 사용됩니다. 앱의 여러 프래그먼트는 활동 범위를 사용하여 공유 ViewModel에 액세스합니다.
  • LifecycleOwner는 활동이나 프래그먼트와 같이 Android 수명 주기를 보유한 클래스입니다.
  • LiveData 관찰자는 수명 주기 소유자가 활성 상태(STARTED 또는 RESUMED)인 경우에만 앱 데이터의 변경사항을 관찰합니다.
  • 리스너 결합은 onClick 이벤트와 같은 이벤트가 발생할 때 실행되는 람다 표현식입니다. 리스너 결합은 textview.setOnClickListener(clickListener)와 같은 메서드 참조와 비슷하지만, 리스너 결합을 사용하면 임의의 데이터 결합 표현식을 실행할 수 있습니다.
  • LiveData 변환 메서드는 LiveData 소스에서 데이터 조작을 실행하고 결과 LiveData 객체를 반환하는 방법을 제공합니다.
  • Android 프레임워크는 언어에 민감한 방식으로 날짜 형식을 지정하고 파싱하는 클래스인 SimpleDateFormat이라는 클래스를 제공합니다. 이 클래스를 통해 날짜의 형식 지정(날짜 → 텍스트) 및 파싱(텍스트 → 날짜)이 가능합니다.

12. 자세히 알아보기