[Android] unit3_pathway4

  • Cupcake app introduction
  • Shared ViewModel
  • Navigation and the backstack
  • Project: Lunch Tray app

Cupcake app introduction

온라인 주문 앱!
컵케이크의 수량, 맛, 기타 옵션을 선택할 수 있다.

Shared ViewModel

학습할 내용:

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

빌드할 프로그램

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

시작앱 개요

크게 MainActivity, res/layout, 프래그먼트 클래스, 리소스(res 폴더)가 있다.

MainActivity:

class MainActivity : AppCompatActivity(R.layout.activity_main)// 생성자 사용

++콘텐츠 뷰를 activity_main.xml로 설정하는 기본 생성 코드

class MainActivity : AppCompatActivity() {

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

레이아웃 res/layout:

  • 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을 통해 이러한 리소스 유형을 이해하고 있어야 합니다.

탐색 그래프 완성

목표:
Cupcake 앱의 화면을 함께 연결하고 앱 내에서 적절한 탐색 구현을 완료한다.

  1. 탐색 그래프에서 대상 연결
    res > navigation > nav_graph.xml 파일을 열면 4개의 프래그먼트가 뜬다.
    탐색 그래프에서 프래그먼트 대상을 연결한다. startFragment에서 flavorFragment로의 작업, flavorFragment에서 pickupFragment로의 연결, pickupFragment에서 summaryFragment로의 연결을 한다. 처음부터 오른쪽으로 순차적이게 연결한다.
    프래그먼트 주위에 회색 테두리가 표시되고 회색 원이 프래그먼트 오른쪽 가장자리 가운데 위에 나타날 때까지 startFragment 위로 마우스를 가져간다. 원을 클릭하고 flavorFragment로 드래그한 후 마우스 버튼을 놓는다.

두 프래그먼트 간의 화살표는 성공적인 연결을 나타내며, startFragment에서 flavorFragment로 이동할 수 있음을 의미한다. 이를 탐색 작업이라고한다.
생성한 작업은 Component Tree 창에도 반영된다.
프래그먼트의 시작 대상 옆에는 집 아이콘이 생기며 이 프래그먼트가 NavHost에 표시될 첫 번째 프래그먼트임을 나타낸다. 시작 대상은 언제든지 변경 가능하다.

start 프래그먼트에서 flavor 프래그먼트로 이동

목표:

첫 번째 프래그먼트의 버튼을 탭하면 startFragment에서 flavorFragment로 이동하는 코드를 추가

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

orderOneCupcake.setOnClickListener { orderCupcake(1) }
orderSixCupcakes.setOnClickListener { orderCupcake(6) }
orderTwelveCupcakes.setOnClickListener { orderCupcake(12) }

현재 이 코드는 토스트 메세지를 표시하는 것이라는 것을 알 수 있다. 이제 이 코드를 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)
}

로 바꾼다. toast를 없애고 flavorFragment로 이동하게 하는 것을 볼 수 있다.
각각의 프래그먼트 클래스의 함수도 마찬가지로 수정한다.
이 때, import androidx.navigation.fragment.findNavController 를 추가한다.

앱 바에서 제목 업데이트

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

  1. MainActivity.kt에서 onCreate() 메서드를 재정의하여 탐색 컨트롤러 를 설정한다. NavHostFragment에서 NavController의 인스턴스를 가져옵니다.

  2. setupActionBarWithNavController(navController)를 호출하여 NavController의 인스턴스를 전달한다. 이렇게 하면 대상의 라벨을 기반으로 앱 바에 제목이 표시되고, 최상위 대상에 있지 않은 경우 항상 위로 버튼이 표시된다.

class MainActivity : AppCompatActivity(R.layout.activity_main) {

    override fun onCreate(savedInstanceState: Bundle?) {// 1번 작업
        super.onCreate(savedInstanceState)

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

        setupActionBarWithNavController(navController)//2번 작업 
    }
}

이제 프래그먼트의 앱 바 제목을 설정하자.

  1. navigataion/nav_graph.xml을 열고 Code 탭으로 전환한다.
  2. nav_graph.xml에서 각 프래그먼트 대상의 android:label 속성을 수정한다. 시작 앱에 이미 선언되어 있는 다음 문자열 리소스를 사용한다.
<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" ... />

위 코드로 바꾼다. android:label= "@string/표시 될 이름" 줄에 집중하자.

앱 바의 제목이 바뀌고 뒤로 버튼이 생긴다는 것 또한 확인할 수 있다. 하지만 버튼 눌렀을 때 아무것도 실행되지 않으므로 뒤로 버튼 동작은 다음에 살펴보기로 한다.

공유 ViewModel 만들기

목표:
이제 각 프래그먼트에 올바른 데이터를 채우는 단계를 진행한다.
공유 ViewModel을 사용하여 앱의 데이터를 단일 ViewModel에 저장한다.

OrderViewModel 만들기

이 작업에서는 OrderViewModel이라는 Cupcake 앱용 공유 ViewModel을 만든다.
또한 앱 데이터를 ViewModel 내의 속성으로 추가하며 데이터를 업데이트하고 수정하는 메서드도 추가한다.

클래스의 속성

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

ViewModel 에서는 데이터를 public 변수로 노출하지 않는 것이 좋다. 외부의 수정과 같은 극단적인 케이스가 발생할 수 있기 때문이다. 따라서 속성을 private으로 만들고, 필요한 경우 각 속성의 변경 불가능한 public 버전을 노출한다.
이름 지정 규칙은 변경 가능한 private 속성의 이름 앞에 밑줄(_)을 붙이는 것이다.

사용자의 선택에 따라 속성을 업데이트하는 메서드

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

가격에 관한 setter는 필요없다. 다른 속성을 사용하여 OrderViewModel 내에서 가격이 계산도기 때문이다.

이제 ViewModel을 구현해보자.

  1. 프로젝트에서 model이라는 새 패키지를 만들고 OrderViewModel 클래스를 추가한다. com.example.cupcake.model.OrderViewModel

기능에 따라 코드를 패키지로 분리하는 것이 코딩 권장사항!

import androidx.lifecycle.ViewModel

class OrderViewModel : ViewModel() {//ViewModel 에서 확장

}
  1. OrderViewModel.kt에서 클래스 서명을 변경하여 ViewModel에서 확장한다.

  2. OrderViewModel 클래스 내에서 위에서 설명한 속성을 private val로 추가한다.

  3. 속성 유형을 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
  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
}

아직은 눈에 띄는 UI변호가 없다.

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

목표:
이 작업에서는 직접 만든 공유 뷰 모델을 사용하여 앱의 UI를 업데이트한다.

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

뷰 모델을 사용하도록 StartFragment 업데이트

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

activityViewModels() 를 쓰는 이유

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

대리자 클래스란? 코틀린 속성 위임에 대하여
Kotlin에는 각 변경 가능한(var) 속성에 자동으로 생성되는 기본 getter 및 setter 함수가 있습니다. 값을 할당하거나 속성의 값을 읽을 때 setter 및 getter 함수가 호출됩니다. (읽기 전용 속성(val)의 경우 기본적으로 getter 함수만 생성됩니다. 읽기 전용 속성의 값을 읽을 때 이 getter 함수가 호출됩니다.)
Kotlin에서 속성 위임을 사용하면 getter-setter 책임을 다른 클래스에 넘길 수 있습니다.
이 클래스(대리자 클래스라고 함)는 속성의 getter 및 setter 함수를 제공하고 변경사항을 처리합니다.
대리자 속성은 다음과 같이 by 절 및 대리자 클래스 인스턴스를 사용하여 정의됩니다.
예시코드:

var <property-name> : <property-type> by <delegate-class>()
  1. StartFragment 클래스에서 공유 뷰 모델의 참조를 클래스 변수로 가져온다. fragment-ktx 라이브러리의 by activityViewModels() Kotlin 속성 위임을 사용한다.
private val sharedViewModel: OrderViewModel by activityViewModels()
  1. 모든 프래그먼트 클래스에 위 코드를 추가한다. 이후 섹션에서 이 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로 설정한다. 완성된 메서드는 다음과 같다.4
fun orderCupcake(quantity: Int) {
    sharedViewModel.setQuantity(quantity)
    if (sharedViewModel.hasNoFlavorSet()) {
        sharedViewModel.setFlavor(getString(R.string.vanilla))
    }
    findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}

데이터 결합과 함께 ViewModel 사용

목표:
데이터 결합을 사용하여 뷰 모델 데이터를 UI에 결합한다.
사용자가 UI에서 선택한 사항에 따라 공유 뷰 모델을 업데이트한다.

데이터 결합에 관한 복습
데이터 결합은 선언적 형식을 사용하여 레이아웃의 UI 구성요소를 앱의 데이터 소스에 결합합니다. 간단히 말해서 데이터 결합은 코드에서 데이터를 뷰 + 뷰 결합에 결합(뷰를 코드에 결합)하는 것입니다.

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

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

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

    <ScrollView ...>

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

  2. fragment_flavor.xml에서 새 레이아웃 변수인 viewModel을 사용하여 뷰 모델의 flavor 값에 따라 라디오 버튼의 checked 속성을 설정한다. 라디오 버튼이 나타내는 맛이 뷰 모델에 저장된 맛과 동일하면 라디오 버튼을 선택된 상태로 표시한다(checked = true). Vanilla RadioButton의 선택 상태에 대한 결합 표현식은 다음과 같다.

<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 이벤트와 같은 이벤트가 발생할 때 실행되는 람다 표현식이다.

  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>

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

목표:
앱을 탐색하면 pickup 프래그먼트에서 라디오 버튼 옵션 라벨이 비어 있는 것을 확인할 수 있다. 이 작업에서는 이용 가능한 4개의 수령 날짜를 계산하여 pickup 프래그먼트에 표시한다.

  1. OrderViewModel 클래스에서 다음과 같이 getPickupOptions()라는 함수를 추가하여 수령 날짜 목록을 만들고 반환한다. 메서드 내에서 options라는 val 변수를 만들어 mutableListOf\()으로 초기화한다.
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())
  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 클래스에서 val인 dateOptions라는 클래스 속성을 추가한다. 방금 만든 getPickupOptions() 메서드를 사용하여 이 속성을 초기화한다.
val dateOptions = getPickupOptions()

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

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

  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의 색인을 적절하게 변경한다.

  2. 앱을 실행하면 이용 가능한 수령 옵션으로 '앞으로 며칠'이 표시된다. 스크린샷은 당일 날짜에 따라 달라진다. 기본적으로 선택된 옵션이 없는 것을 확인할 수 있다. 다음 단계에서 이를 구현한다.

  3. OrderViewModel 클래스 내에서 resetOrder()라는 함수를 만들어 뷰 모델의 MutableLiveData 속성을 재설정한다. dateOptions 목록의 현재 날짜 값을 _date.value.에 할당한다.

fun resetOrder() {
   _quantity.value = 0
   _flavor.value = ""
   _date.value = dateOptions[0]
   _price.value = 0.0
}
  1. 클래스에 init 블록을 추가하고 여기에서 새로운 resetOrder() 메서드를 호출한다.

  2. 클래스의 속성 선언에서 초깃값을 삭제한다. 이제 OrderViewModel 인스턴스를 만들 때 init 블록을 사용하여 속성을 초기화한다.
    괄호 안의 값을 지운다.

  3. 앱을 다시 실행한다. 오늘 날짜가 기본적으로 선택되어 있다.

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

마지막 프래그먼트로 이동하겠다.

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

  2. SummaryFragment의 onViewCreated()에서 binding.viewModel이 초기화되었는지 확인한다.

  3. 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}"
   ... />

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

매장 규칙:

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

뷰모델에서 가격 업데이트

당일 수령 비용을 무시하고 가격을 처리하는 코드를 작성한다.

  1. 컵케이크 하나당 가격 설정
private const val PRICE_PER_CUPCAKE = 2.00
  1. 컵케이크당 가격을 정의했으므로 이제 도우미 메서드를 생성하여 가격을 계산한다. 이 메서드는 이 클래스 내에서만 사용되므로 private일 수 있다. 다음 작업에서 당일 수령 요금을 포함하도록 가격 로직을 변경한다.
private fun updatePrice() {
    _price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE //컵케이크당 가격*주문 수량 null임을 방지해서 '?:' 을 쓴다. null 값이 들어오면 0이 되고 null 이 아니면 quantity.value를 쓴다.
}
  1. 동일한 OrderViewModel 클래스에서 수량이 설정된 경우 가격 변수를 업데이트한다. setQuantity() 함수에서 새 함수를 호출한다.

UI에 가격 속성 결합

  1. fragment_flavor.xml, fragment_pickup.xml 및 fragment_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
    ...
}

밑에  
private val sharedViewModel: OrderViewModel by activityViewModels()
추가해야됨
  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)}"
    ... /> //// 추가
  1. 이제 pickup 및 summary 프래그먼트에서도 이와 비슷하게 변경한다. fragment_pickup.xml 및 fragment_summary.xml 레이아웃에서도 viewModel price 속성을 사용하도록 텍스트 뷰를 수정한다.
<TextView
    android:id="@+id/subtotal"
    ...
    android:text="@{@string/subtotal_price(viewModel.price)}"
    ... />

당일 수령 시 추가 요금 청구

  1. OrderViewModel 클래스에서 당일 수령 비용에 관한 새로운 최상위 private 상수를 정의한다.

  2. 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()
}

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

  1. FlavorFragment, PickupFragment 및 SummaryFragment 클래스의 onViewCreated() 메서드 내에서 binding?.apply 블록에 다음을 추가한다. 이렇게 하면 결합 객체에 수명 주기 소유자가 설정된다. 수명 주기 소유자를 설정하면 앱이 LiveData 객체를 관찰할 수 있다.
binding?.apply {
    lifecycleOwner = viewLifecycleOwner
    ...
}
  1. 앱을 다시 실행한다. 수령 화면에서 수령 날짜를 변경하고 가격이 자동으로 변경되는 방식의 차이를 확인한다. 또한 수령 요금이 요약 화면에 올바르게 반영된다.

  2. 수령일로 오늘 날짜를 선택하면 주문 가격이 $3.00만큼 증가되는 것을 확인할 수 있다. 미래의 날짜를 선택하는 경우 가격은 여전히 컵케이크 수량 x $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.kt의 onViewCreated() 메서드에서 새 데이터 변수를 프래그먼트 인스턴스에 결합한다. 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. 마찬가지로 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 ...>
  1. 나머지 프래그먼트 클래스의 onViewCreated() 메서드에서 버튼에 클릭 리스너를 직접 설정하는 코드를 삭제한다.

  2. onViewCreated() 메서드에서 프래그먼트 데이터 변수를 프래그먼트 인스턴스와 결합한다. 여기서는 this 키워드를 다르게 사용한다. binding?.apply 블록 내에서 this 키워드가 프래그먼트 인스턴스가 아닌 결합 인스턴스를 참조하기 때문이다.

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

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

FlavorFragment 클래스의 onViewCreated() 메서드

  1. 다른 레이아웃 파일에서도 버튼의 onClick 속성에 리스너 결합 표현식을 추가한다.

예시1:

fragment_flavor.xml

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

탐색 및 백 스택

목표:
Cupcake 앱의 나머지 부분 구현을 완료한다.

3. Up 버튼 동작 구현

up button: 이전 화면으로 돌아가는 화살표. 현재는 이 버튼 누르면 아무것도 작동하지 않는다.

MainActivity 클래스의 oncreate() 함수에 집중

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에 요청한다.
override fun onSupportNavigateUp(): Boolean {
   return navController.navigateUp() || super.onSupportNavigateUp()
}

이제 up 버튼이 작동한다.

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

의 주문 흐름 안에 Cancel 버튼을 도입한다. 주문 절차에서 언제든지 주문을 취소하면 사용자가 StartFragment로 이동하게 된다. 이 동작을 처리하기 위해 Android의 작업과 백 스택에 관해 배운다.

탐색 작업 추가하기

res > navigation > nav_graph.xml 파일로 이동하고 Design 뷰를 선택하여 Navigation Editor를 열어서 나머지 세 프래그먼트가 startFragment 로 이동하는 화살표를 만든다.

cancle 버튼 추가하기

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

  1. fragment_flavor.xml 레이아웃 파일을 연다.
<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"으로 변경해야 한다.
<Button
    android:id="@+id/cancel_button"
    android:layout_marginEnd="@dimen/side_margin"
    style="?attr/materialButtonOutlinedStyle" ... /> // 스타일 속성 적용
    

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

fragment_pickup.xml 에도 똑같이 취소버튼을 만든다.
버튼이 생기긴 했는데 눌렀을 때 아무 반응이 나타나지 않는다.

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

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

  1. FlavorFragment 클래스에 cancelOrder() 함수 추가한다.
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_flavorFragment_to_startFragment)
}
  1. 리스너 결합을 사용하여 fragment_flavor.xml 레이아웃의 Cancel 버튼에 클릭 리스너를 설정한다. 이 버튼을 클릭하면 FragmentFlavor 클래스에서 방금 생성한 cancelOrder() 메서드가 호출된다.
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> flavorFragment.cancelOrder()}" ... />
  1. PickupFragment에 반복한다.
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로 이동하게 한다.
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_summaryFragment_to_startFragment)
}
  1. fragment_summary.xml에서 Cancel 버튼 클릭 시 SummaryFragment의 cancelOrder() 메서드를 호출한다.
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> summaryFragment.cancelOrder()}" ... />

5. 주문 전송

Send Order 버튼 구현

  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. 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)
    }
}

좋은 웹페이지 즐겨찾기