1. 시작하기 전에
활동, 프래그먼트, 인텐트, 데이터 결합, 탐색 구성요소, 아키텍처 구성요소의 기본사항을 사용하는 방법에 관해 알아보았습니다. 이 Codelab에서는 학습한 내용을 종합하여 컵케이크 주문 앱인 고급 샘플을 완성해 보겠습니다.
공유 ViewModel
을 사용하여 동일한 활동의 프래그먼트 간에 데이터를 공유하는 방법 및 LiveData
변환과 같은 새로운 개념을 알아봅니다.
기본 요건
- XML의 Android 레이아웃을 읽고 이해하는 데 익숙함
- Jetpack Navigation 구성요소의 기본사항을 숙지하고 있음
- 앱에서 프래그먼트 대상이 있는 탐색 그래프를 생성할 수 있음
- 이전에 활동 내에서 프래그먼트를 사용한 적이 있음
- 앱 데이터를 저장할
ViewModel
을 생성할 수 있음 LiveData
와 함께 데이터 결합을 사용하여ViewModel
의 앱 데이터로 UI를 최신 상태로 유지할 수 있음
학습할 내용
- 고급 사용 사례 내에서 권장 앱 아키텍처 사례를 구현하는 방법
- 활동의 프래그먼트 간에 공유
ViewModel
을 사용하는 방법 LiveData
변환을 적용하는 방법
빌드할 프로그램
- 컵케이크의 주문 흐름을 표시하는 Cupcake 앱: 사용자가 컵케이크 맛, 수량, 수령 날짜를 선택할 수 있습니다.
필요한 항목
- Android 스튜디오가 설치된 컴퓨터
- Cupcake 앱의 시작 코드
2. 시작 앱 개요
Cupcake 앱 개요
Cupcake 앱은 온라인 주문 앱을 디자인하고 구현하는 방법을 보여줍니다. 이 학습 과정이 끝날 때 다음 화면과 같이 Cupcake 앱을 완성하게 됩니다. 사용자는 컵케이크 주문 시 수량, 맛, 기타 옵션을 선택할 수 있습니다.
이 Codelab용 시작 코드 다운로드
이 Codelab은 시작 코드를 제공합니다. 이 Codelab에서 학습한 기능을 사용하여 시작 코드를 확장할 수 있습니다. 시작 코드에는 이전의 Codelab에서 살펴본 코드가 포함되어 있습니다.
GitHub에서 시작 코드를 다운로드하는 경우 프로젝트의 폴더 이름은 android-basics-kotlin-cupcake-app-starter
입니다. Android 스튜디오에서 프로젝트를 열 때 이 폴더를 선택하세요.
이 Codelab의 코드를 가져와서 Android 스튜디오에서 열려면 다음을 실행합니다.
코드 가져오기
- 제공된 URL을 클릭합니다. 브라우저에서 프로젝트의 GitHub 페이지가 열립니다.
- 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 대화상자를 엽니다.
- 대화상자에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
- 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
- ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.
Android 스튜디오에서 프로젝트 열기
- Android 스튜디오를 시작합니다.
- Welcome to Android Studio 창에서 Open an existing Android Studio project를 클릭합니다.
참고: Android 스튜디오가 이미 열려 있는 경우 File > New > Import Project 메뉴 옵션을 대신 선택합니다.
- Import Project 대화상자에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
- 프로젝트 폴더를 더블클릭합니다.
- Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
- Run 버튼 을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.
- Project 도구 창에서 프로젝트 파일을 둘러보고 앱이 설정된 방식을 확인합니다.
시작 코드 둘러보기
- Android 스튜디오에서 다운로드한 프로젝트를 엽니다. 프로젝트의 폴더 이름은
android-basics-kotlin-cupcake-app-starter
입니다. 그런 다음, 앱을 실행합니다. - 파일을 둘러보면서 시작 코드를 이해합니다. 레이아웃 파일의 경우 오른쪽 상단에 있는 Split 옵션을 사용하여 레이아웃과 XML의 미리보기를 동시에 확인할 수 있습니다.
- 앱을 컴파일하고 실행해 보면 앱이 불완전한 것을 알게 됩니다. 버튼으로는 그다지 많은 작업을 수행할 수 없으며(
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 앱의 화면을 함께 연결하고 앱 내에서 적절한 탐색 구현을 완료합니다.
탐색 구성요소를 사용하는 데 필요한 사항을 기억하시나요? 다음과 같이 프로젝트와 앱을 설정하는 방법에 관해 다시 알아보려면 이 가이드를 참고하세요.
- Jetpack Navigation 라이브러리 포함
- 활동에
NavHost
추가 - 탐색 그래프 만들기
- 탐색 그래프에 프래그먼트 대상 추가
탐색 그래프에서 대상 연결
- Android 스튜디오의 Project 창에서 res > navigation > nav_graph.xml 파일을 엽니다. 아직 선택하지 않았다면 Design 탭으로 전환합니다.
- 그러면 Navigation Editor가 열리고 앱의 탐색 그래프가 표시됩니다. 앱에 이미 있는 4개의 프래그먼트가 표시됩니다.
- 탐색 그래프에서 프래그먼트 대상을 연결합니다.
startFragment
에서flavorFragment
로의 작업,flavorFragment
에서pickupFragment
로의 연결,pickupFragment
에서summaryFragment
로의 연결을 생성합니다. 더 자세한 안내가 필요하다면 다음과 같은 몇 단계를 따릅니다. - startFragment 위로 마우스를 가져가 프래그먼트 주위에 회색 테두리가 표시되고 회색 원이 프래그먼트 오른쪽 가장자리 가운데 위에 나타날 때까지 기다립니다. 원을 클릭하고 flavorFragment로 드래그한 후 마우스 버튼을 놓습니다.
- 두 프래그먼트 간의 화살표는 성공적인 연결을 나타내며, startFragment에서 flavorFragment로 이동할 수 있음을 의미합니다. 이를 탐색 작업이라고 하며, 이전 Codelab에서 알아보았습니다.
- 마찬가지로 flavorFragment에서 pickupFragment로, pickupFragment에서 summaryFragment로의 탐색 작업을 추가합니다. 탐색 작업 만들기를 완료하면 완성된 탐색 그래프는 다음과 같습니다.
- 생성한 새로운 세 가지 작업은 Component Tree 창에도 반영됩니다.
- 탐색 그래프를 정의할 때 시작 대상을 지정할 수도 있습니다. 현재 startFragment 옆에 작은 집 아이콘이 있는 것을 볼 수 있습니다.
이는 startFragment가 NavHost
에 표시될 첫 번째 프래그먼트임을 나타냅니다. 앱의 원하는 동작으로 이대로 유지합니다. 언제든지 프래그먼트를 마우스 오른쪽 버튼으로 클릭하고 Set as Start Destination 메뉴 옵션을 선택하여 시작 대상을 변경할 수 있습니다.
start 프래그먼트에서 flavor 프래그먼트로 이동
이제 Toast
메시지를 표시하는 대신 첫 번째 프래그먼트의 버튼을 탭하면 startFragment에서 flavorFragment로 이동하는 코드를 추가합니다. 다음은 start 프래그먼트 레이아웃의 참조입니다. 이후 작업에서 컵케이크의 수량을 flavor 프래그먼트에 전달합니다.
- Project 창에서 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) }
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)
}
import
androidx.navigation.fragment.findNavController
가져오기를 추가하거나 Android 스튜디오에서 제공하는 옵션 중에서 선택할 수 있습니다.
flavor 및 pickup 프래그먼트에 탐색 추가
이전 작업과 마찬가지로 이 작업에서는 다른 프래그먼트(flavor 및 pickup 프래그먼트)에 탐색 기능을 추가합니다.
- app > java > com.example.cupcake > FlavorFragment.kt를 엽니다. Next 버튼 클릭 리스너 내에서 호출되는 메서드는
goToNextScreen()
메서드인 것을 확인할 수 있습니다. FlavorFragment.kt
의goToNextScreen()
메서드 내에서 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
를 기억해 보세요.
PickupFragment.kt
의goToNextScreen()
메서드 내에서 summary 프래그먼트로 이동하도록 기존 코드를 바꿉니다.
fun goToNextScreen() {
findNavController().navigate(R.id.action_pickupFragment_to_summaryFragment)
}
androidx.navigation.fragment.findNavController
를 가져옵니다.
- 앱을 실행합니다. 버튼을 사용하여 화면에서 다른 화면으로 이동할 수 있는지 확인합니다. 각 프래그먼트에 표시되는 정보가 불완전할 수도 있지만 걱정하지 않아도 됩니다. 이후 단계에서 올바른 데이터로 프래그먼트를 채웁니다.
앱 바에서 제목 업데이트
앱을 탐색하는 동안 앱 바의 제목을 확인합니다. 항상 Cupcake로 표시됩니다.
현재 프래그먼트의 기능에 따라 더욱 관련성이 높은 제목을 제공하는 것이 더 나은 사용자 환경입니다.
NavController
를 사용하여 각 프래그먼트의 앱 바(작업 모음이라고도 함)에 있는 제목을 변경하고 위로(←) 버튼을 표시합니다.
MainActivity.kt
에서onCreate()
메서드를 재정의하여 탐색 컨트롤러를 설정합니다.NavHostFragment
에서NavController
의 인스턴스를 가져옵니다.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)
}
}
- Android 스튜디오에서 메시지가 표시되면 필요한 가져오기를 추가합니다.
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupActionBarWithNavController
- 각 프래그먼트의 앱 바 제목을 설정합니다.
navigation/nav_graph.xml
을 열고 Code 탭으로 전환합니다. 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>
- 앱을 실행합니다. 각 프래그먼트 대상으로 이동하면 앱 바의 제목이 변경되는 것을 확인할 수 있습니다. 또한 이제 뒤로 버튼(← 화살표)이 앱 바에 표시되는 것도 확인할 수 있습니다. 버튼을 탭해도 아무 작업도 실행되지 않습니다. 위로 버튼 동작은 다음 Codelab에서 구현합니다.
4. 공유 ViewModel 만들기
이제 각 프래그먼트에 올바른 데이터를 채우는 단계를 진행해 보겠습니다. 공유 ViewModel
을 사용하여 앱의 데이터를 단일 ViewModel
에 저장합니다. 앱의 여러 프래그먼트는 활동 범위를 사용하여 공유 ViewModel
에 액세스합니다.
대부분의 프로덕션 앱에서 프래그먼트 간에 데이터를 공유하는 것은 일반적인 사용 사례입니다. 예를 들어 이 Codelab의 Cupcake 앱 최종 버전(아래 스크린샷 참고)에서 사용자는 첫 번째 화면에서 컵케이크의 수량을 선택합니다. 그러면 두 번째 화면에 가격이 컵케이크의 수량에 따라 계산되어 표시됩니다. 마찬가지로 맛 및 수령 날짜와 같은 다른 앱 데이터는 요약 화면에서도 사용됩니다.
앱 기능을 살펴보면 이 활동의 프래그먼트 간에 공유할 수 있는 단일 ViewModel
에 이 주문 정보를 저장하는 것이 유용하다고 판단할 수 있습니다. ViewModel
이 Android 아키텍처 구성요소의 일부임을 상기하시기 바랍니다. 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 코드(프래그먼트 및 활동)에서 뷰 모델 코드가 분리됩니다. 기능에 따라 코드를 패키지로 분리하는 것이 코딩 권장사항입니다.
- Android 스튜디오의 Project 창에서 com.example.cupcake > New > Package를 마우스 오른쪽 버튼으로 클릭합니다.
- New Package 대화상자가 열리면 패키지 이름을
com.example.cupcake.model
로 지정합니다.
model
패키지에서OrderViewModel
Kotlin 클래스를 생성합니다. Project 창에서model
패키지를 마우스 오른쪽 버튼으로 클릭하고 New > Kotlin File/Class를 선택합니다. 새 대화상자에서 파일 이름을OrderViewModel
로 지정합니다.
OrderViewModel.kt
에서 클래스 서명을 변경하여ViewModel
에서 확장합니다.
import androidx.lifecycle.ViewModel
class OrderViewModel : ViewModel() {
}
OrderViewModel
클래스 내에서 위에서 설명한 속성을private
val
로 추가합니다.- 속성 유형을
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
OrderViewModel
클래스에서 위에서 설명한 메서드를 추가합니다. 메서드 내에서 변경 가능한 속성에 전달된 인수를 할당합니다.- 이러한 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에 눈에 띄는 변화가 없습니다.
훌륭합니다. 이제 뷰 모델을 시작했습니다. 앱에 더 많은 기능을 빌드함에 따라 그리고 클래스에 더 많은 속성 및 메서드가 필요하다고 생각될 때 이 클래스에 조금씩 더 많은 것을 추가하게 됩니다.
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>()
StartFragment
클래스에서 공유 뷰 모델의 참조를 클래스 변수로 가져옵니다.fragment-ktx
라이브러리의by activityViewModels()
Kotlin 속성 위임을 사용합니다.
private val sharedViewModel: OrderViewModel by activityViewModels()
다음과 같은 새로운 가져오기가 필요할 수 있습니다.
import androidx.fragment.app.activityViewModels
import com.example.cupcake.model.OrderViewModel
FlavorFragment
,PickupFragment
,SummaryFragment
클래스에 대해 위의 단계를 반복합니다. Codelab의 이후 섹션에서 이sharedViewModel
인스턴스를 사용합니다.StartFragment
클래스로 돌아가면 이제 뷰 모델을 사용할 수 있습니다.orderCupcake()
메서드 시작 부분에서 flavor 프래그먼트로 이동하기 전에 공유 뷰 모델의setQuantity()
메서드를 호출하여 수량을 업데이트합니다.
fun orderCupcake(quantity: Int) {
sharedViewModel.setQuantity(quantity)
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
OrderViewModel
클래스 내에서 주문의 맛이 설정되었는지 여부를 확인하는 다음 메서드를 추가합니다. 이후 단계의StartFragment
클래스에서 이 메서드를 사용합니다.
fun hasNoFlavorSet(): Boolean {
return _flavor.value.isNullOrEmpty()
}
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)
}
- 앱을 빌드하여 컴파일 오류가 없는지 확인합니다. 하지만 UI에 눈에 띄는 변화가 없습니다.
6. 데이터 결합과 함께 ViewModel 사용
다음으로, 데이터 결합을 사용하여 뷰 모델 데이터를 UI에 결합합니다. 또한 사용자가 UI에서 선택한 사항에 따라 공유 뷰 모델을 업데이트합니다.
데이터 결합에 관한 복습
아시다시피 데이터 결합 라이브러리는 Android Jetpack의 구성요소입니다. 데이터 결합은 선언적 형식을 사용하여 레이아웃의 UI 구성요소를 앱의 데이터 소스에 결합합니다. 간단히 말해서 데이터 결합은 코드에서 데이터를 뷰 + 뷰 결합에 결합(뷰를 코드에 결합)하는 것입니다. 이러한 결합을 설정하고 업데이트를 자동으로 설정하면 코드에서 UI를 직접 업데이트하는 것을 잊은 경우 오류 발생 가능성을 줄일 수 있습니다.
사용자 선택으로 맛 업데이트
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 ...>
...
- 마찬가지로
fragment_pickup.xml
및fragment_summary.xml
에 대해 위의 단계를 반복하여viewModel
레이아웃 변수를 추가합니다. 이후 섹션에서 이 변수를 사용합니다.fragment_start.xml
에서는 이 코드를 추가할 필요가 없습니다. 이 레이아웃에서는 공유 뷰 모델을 사용하지 않기 때문입니다. 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
PickupFragment
및SummaryFragment
클래스 내의onViewCreated()
메서드에 대해 동일한 단계를 반복합니다.
binding?.apply {
viewModel = sharedViewModel
...
}
fragment_flavor.xml
에서 새 레이아웃 변수인viewModel
을 사용하여 뷰 모델의flavor
값에 따라 라디오 버튼의checked
속성을 설정합니다. 라디오 버튼이 나타내는 맛이 뷰 모델에 저장된 맛과 동일하면 라디오 버튼을 선택된 상태로 표시합니다(checked
= true). VanillaRadioButton
의 선택 상태에 대한 결합 표현식은 다음과 같습니다.
@{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)
와 같은 메서드 참조와 비슷하지만, 리스너 결합을 사용하면 임의의 데이터 결합 표현식을 실행할 수 있습니다.
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>
- 앱을 실행하고 flavor 프래그먼트에서 Vanilla 옵션이 기본적으로 선택되는 방식을 확인합니다.
좋습니다. 이제 다음 프래그먼트로 이동할 수 있습니다.
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').
이제 SimpleDateFormat
및 Locale
을 사용하여 Cupcake 앱에서 이용 가능한 수령 날짜를 결정합니다.
OrderViewModel
클래스에서 다음과 같이getPickupOptions()
라는 함수를 추가하여 수령 날짜 목록을 만들고 반환합니다. 메서드 내에서options
라는val
변수를 만들어mutableListOf
<String>()
으로 초기화합니다.
private fun getPickupOptions(): List<String> {
val options = mutableListOf<String>()
}
SimpleDateFormat
을 사용하여 형식 지정 문자열을 만들어"E MMM d"
패턴 문자열 및 언어를 전달합니다. 패턴 문자열에서E
는 요일 이름을 나타내며 'Tue Dec 10'으로 파싱됩니다.
val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
Android 스튜디오에서 메시지가 표시되면 java.text.SimpleDateFormat
및 java.util.Locale
을 가져옵니다.
Calendar
인스턴스를 가져와서 새 변수에 할당합니다. 그리고 변수를val
로 설정합니다. 이 변수에는 현재 날짜 및 시간이 포함됩니다. 또한java.util.Calendar
도 가져옵니다.
val calendar = Calendar.getInstance()
- 현재 날짜 및 다음 세 날짜로 시작하는 날짜 목록을 만듭니다. 4개의 날짜 옵션이 필요하므로 이 코드 블록을 4번 반복합니다. 이
repeat
블록은 날짜 형식을 지정하여 날짜 옵션 목록에 추가한 후 캘린더를 1일씩 증가시킵니다.
repeat(4) {
options.add(formatter.format(calendar.time))
calendar.add(Calendar.DATE, 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
}
OrderViewModel
클래스에서val
인dateOptions
라는 클래스 속성을 추가합니다. 방금 만든getPickupOptions()
메서드를 사용하여 이 속성을 초기화합니다.
val dateOptions = getPickupOptions()
수령 옵션을 표시하도록 레이아웃 업데이트
이제 뷰 모델에 4개의 이용 가능한 수령 날짜가 있으므로 fragment_pickup.xml
레이아웃을 업데이트하여 이러한 날짜를 표시합니다. 또한 데이터 결합을 사용하여 각 라디오 버튼의 선택 상태를 표시하고 다른 라디오 버튼이 선택된 경우 뷰 모델의 날짜를 업데이트합니다. 이 구현은 flavor 프래그먼트의 데이터 결합과 비슷합니다.
fragment_pickup.xml
에서:
option0
라디오 버튼은 viewModel
의 dateOptions[0]
(오늘)을 나타냅니다.
option1
라디오 버튼은 viewModel
의 dateOptions[1]
(내일)을 나타냅니다.
option2
라디오 버튼은 viewModel
의 dateOptions[2]
(모레)를 나타냅니다.
option3
라디오 버튼은 viewModel
의 dateOptions[3]
(글피)를 나타냅니다.
fragment_pickup.xml
에서option0
라디오 버튼에 대해 새 레이아웃 변수인viewModel
을 사용하여 뷰 모델의date
값에 따라checked
속성을 설정합니다.viewModel.date
속성을dateOptions
목록의 첫 번째 문자열(즉, 현재 날짜)과 비교합니다. 이때equals
함수를 사용하여 비교합니다. 최종 결합 표현식은 다음과 같습니다.
@{viewModel.date.equals(viewModel.dateOptions[0])}
- 동일한 라디오 버튼에 대해 리스너 결합을 사용하여 이벤트 리스너를
onClick
속성에 추가합니다. 이 라디오 버튼 옵션을 클릭하면viewModel
에서setDate()
를 호출하여dateOptions[0]
을 전달합니다. - 동일한 라디오 버튼에 대해
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]}"
...
/>
- 다른 라디오 버튼에 대해 위 단계를 반복하여
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]}"
... />
- 앱을 실행하면 이용 가능한 수령 옵션으로 '앞으로 며칠'이 표시됩니다. 스크린샷은 당일 날짜에 따라 달라집니다. 기본적으로 선택된 옵션이 없는 것을 확인할 수 있습니다. 다음 단계에서 이를 구현합니다.
OrderViewModel
클래스 내에서resetOrder()
라는 함수를 만들어 뷰 모델의MutableLiveData
속성을 재설정합니다.dateOptions
목록의 현재 날짜 값을_date.
value.
에 할당합니다.
fun resetOrder() {
_quantity.value = 0
_flavor.value = ""
_date.value = dateOptions[0]
_price.value = 0.0
}
- 클래스에
init
블록을 추가하고 여기에서 새로운resetOrder()
메서드를 호출합니다.
init {
resetOrder()
}
- 클래스의 속성 선언에서 초깃값을 삭제합니다. 이제
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
- 앱을 다시 실행합니다. 오늘 날짜가 기본적으로 선택되어 있습니다.
뷰 모델을 사용하도록 Summary 프래그먼트 업데이트
이제 마지막 프래그먼트로 이동하겠습니다. order summary 프래그먼트는 주문 세부정보의 요약을 표시하기 위한 것입니다. 이 작업에서는 공유 뷰 모델의 모든 주문 정보를 활용하고 데이터 결합을 사용하여 화면의 주문 세부정보를 업데이트합니다.
fragment_summary.xml
에서 뷰 모델 데이터 변수인viewModel
이 선언되어 있는지 확인합니다.
<layout ...>
<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>
<ScrollView ...>
...
SummaryFragment
의onViewCreated()
에서binding.viewModel
이 초기화되었는지 확인합니다.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}"
... />
- 앱을 실행하고 테스트하여 선택한 주문 옵션이 주문 요약에 표시되는지 확인합니다.
8. 주문 세부정보에서 가격 계산
이 Codelab의 최종적인 앱 스크린샷을 보면 StartFragment
를 제외한 각 프래그먼트에 가격이 실제로 표시된 것을 확인할 수 있습니다. 그렇게 하면 사용자가 주문 생성 당시의 가격을 알 수 있습니다.
다음은 가격 계산 방법에 관한 컵케이크 매장의 규칙입니다.
- 각 컵케이크의 가격은 $2.00입니다.
- 당일 수령 시 주문에 $3.00의 금액이 추가됩니다.
따라서 6개의 컵케이크 주문 시 가격은 컵케이크 6개 x $2 = $12입니다. 사용자가 당일 수령을 원하는 경우 추가로 $3의 비용이 부가되어 총 주문 가격은 $15가 됩니다.
뷰 모델에서 가격 업데이트
앱에서 이 기능을 지원하도록 하려면 먼저 컵케이크당 가격을 처리하고 지금 당장은 당일 수령 비용을 무시합니다.
OrderViewModel.kt
를 열고 변수에 컵케이크당 가격을 저장합니다. 즉, 파일 맨 위, 클래스 정의 외부에서(하지만 import 문보다 뒤에) 최상위 private 상수로 선언합니다.const
한정자를 사용하고, 읽기 전용으로 만들려면val
을 사용합니다.
package ...
import ...
private const val PRICE_PER_CUPCAKE = 2.00
class OrderViewModel : ViewModel() {
...
상수의 값(Kotlin에서 const
키워드로 표시됨)은 변경되지 않으며 컴파일 시간에 값이 알려진다는 점을 상기하시기 바랍니다. 상수에 관해 자세히 알아보려면 문서를 참고하세요.
- 컵케이크당 가격을 정의했으므로 이제 도우미 메서드를 생성하여 가격을 계산합니다. 이 메서드는 이 클래스 내에서만 사용되므로
private
일 수 있습니다. 다음 작업에서 당일 수령 요금을 포함하도록 가격 로직을 변경합니다.
private fun updatePrice() {
_price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
}
이 코드 줄은 컵케이크당 가격과 컵케이크 주문 수량을 곱합니다. 괄호 안에 있는 코드의 경우 quantity.value
의 값이 null일 수 있으므로 elvis 연산자(?:
)를 사용합니다. elvis 연산자(?:
)는 왼쪽의 표현식이 null이 아니면 이 값을 사용한다는 것을 의미합니다. 이와는 달리 왼쪽의 표현식이 null이면 elvis 연산자의 오른쪽에 있는 표현식(이 경우에는 0
)을 사용합니다.
- 동일한
OrderViewModel
클래스에서 수량이 설정된 경우 가격 변수를 업데이트합니다.setQuantity()
함수에서 새 함수를 호출합니다.
fun setQuantity(numberCupcakes: Int) {
_quantity.value = numberCupcakes
updatePrice()
}
UI에 가격 속성 결합
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 ...>
...
- 각 프래그먼트 클래스의
onViewCreated()
메서드에서 프래그먼트의 뷰 모델 객체 인스턴스를 레이아웃의 뷰 모델 데이터 변수에 결합해야 합니다.
binding?.apply {
viewModel = sharedViewModel
...
}
- 각 프래그먼트 레이아웃 내에서
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>
- 앱을 실행합니다. start 프래그먼트에서 컵케이크 1개를 선택하면 flavor 프래그먼트에 Subtotal 2.0이 표시됩니다. 컵케이크 6개를 선택하면 flavor 프래그먼트에 Subtotal 12.0이 표시됩니다. 나중에 가격을 적절한 통화 형식으로 지정하겠지만, 지금은 이렇게 표시될 것으로 예상됩니다.
- 이제 pickup 및 summary 프래그먼트에서도 이와 비슷하게 변경합니다.
fragment_pickup.xml
및fragment_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개, 6개, 12개의 주문 수량에 맞게 정확하게 계산되었는지 확인합니다. 앞서 언급했듯이 현재 가격 형식이 올바르지 않을 것으로 예상됩니다($2의 경우 2.0으로, $12의 경우 12.0으로 표시됨).
당일 수령 시 추가 요금 청구
이 작업에서는 당일 수령 시 주문에 추가로 $3.00를 부가하는 두 번째 규칙을 구현합니다.
OrderViewModel
클래스에서 당일 수령 비용에 관한 새로운 최상위 private 상수를 정의합니다.
private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00
updatePrice()
에서는 사용자가 당일 수령을 선택했는지 확인합니다. 뷰 모델의 날짜(_date.
value
)가dateOptions
목록의 첫 번째 항목(항상 당일 날짜)과 동일한지 확인합니다.
private fun updatePrice() {
_price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
if (dateOptions[0] == _date.value) {
}
}
- 이러한 계산을 더 간단하게 하려면 임시 변수인
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
}
setDate()
메서드에서updatePrice()
도우미 메서드를 호출하여 당일 수령 요금을 추가합니다.
fun setDate(pickupDate: String) {
_date.value = pickupDate
updatePrice()
}
- 앱을 실행하고 앱을 탐색합니다. 수령 날짜를 변경해도 총가격에서 당일 수령 요금이 삭제되지 않는 것을 확인할 수 있습니다. 이는 뷰 모델에서 가격이 변경되었지만 이 정보가 결합 레이아웃에 알려지지 않았기 때문입니다.
LiveData를 관찰하도록 수명 주기 소유자 설정
LifecycleOwner
는 활동이나 프래그먼트와 같이 Android 수명 주기를 보유한 클래스입니다. LiveData
관찰자는 수명 주기 소유자가 활성 상태(STARTED
또는 RESUMED
)인 경우에만 앱 데이터의 변경사항을 관찰합니다.
이 앱에서 LiveData
객체 또는 관찰 가능한 데이터는 뷰 모델의 price
속성입니다. 수명 주기 소유자는 flavor, pickup, summary 프래그먼트입니다. LiveData
관찰자는 가격과 같은 관찰 가능한 데이터가 있는 레이아웃 파일의 결합 표현식입니다. 데이터 결합을 사용하면 관찰 가능한 값이 변경되는 경우 결합된 UI 요소가 자동으로 업데이트됩니다.
결합 표현식의 예: android:text="@{@string/subtotal_price(viewModel.price)}"
UI 요소가 자동으로 업데이트되도록 하려면 binding.
lifecycleOwner
를
앱의 수명 주기 소유자와 연결해야 합니다. 이는 다음에 구현해 보겠습니다.
FlavorFragment
,PickupFragment
및SummaryFragment
클래스의onViewCreated()
메서드 내에서binding?.apply
블록에 다음을 추가합니다. 이렇게 하면 결합 객체에 수명 주기 소유자가 설정됩니다. 수명 주기 소유자를 설정하면 앱이LiveData
객체를 관찰할 수 있습니다.
binding?.apply {
lifecycleOwner = viewLifecycleOwner
...
}
- 앱을 다시 실행합니다. 수령 화면에서 수령 날짜를 변경하고 가격이 자동으로 변경되는 방식의 차이를 확인합니다. 또한 수령 요금이 요약 화면에 올바르게 반영됩니다.
- 수령일로 오늘 날짜를 선택하면 주문 가격이 $3.00만큼 증가되는 것을 확인할 수 있습니다. 미래의 날짜를 선택하는 경우 가격은 여전히 컵케이크 수량 x $2.00여야 합니다.
- 다양한 컵케이크 수량, 맛, 수령 날짜를 사용하여 다양한 케이스를 테스트합니다. 이제 각 프래그먼트의 뷰 모델에서 가격이 업데이트되는 것을 확인할 수 있습니다. 가장 좋은 점은 매번 바뀐 가격으로 UI를 계속 업데이트하기 위해 추가 Kotlin 코드를 작성할 필요가 없다는 점입니다.
가격 기능 구현을 완료하려면 가격 형식을 현지 통화로 지정해야 합니다.
LiveData 변환을 사용하여 가격 형식 지정
LiveData
변환 메서드는 LiveData
소스에서 데이터 조작을 실행하고 결과 LiveData
객체를 반환하는 방법을 제공합니다. 간단히 말해 LiveData
값을 다른 값으로 변환합니다. 관찰자가 LiveData
객체를 관찰하고 있지 않다면 이러한 변환은 계산되지 않습니다.
Transformations.map()
은 변환 함수 중 하나이며, 이 메서드는 소스 LiveData
및 함수를 매개변수로 사용합니다. 이 함수는 LiveData
소스를 조작하고, 관찰할 수도 있는 업데이트된 값을 반환합니다.
LiveData 변환을 사용할 수 있는 몇 가지 실시간 예는 다음과 같습니다.
- 표시할 날짜 및 시간 문자열 형식 지정
- 항목 목록 정렬
- 항목 필터링 또는 그룹화
- 모든 항목 합계, 항목 수, 마지막 항목 반환 등과 같이 목록의 결과 계산
이 작업에서는 Transformations.map()
메서드를 사용하여 가격에 현지 통화를 사용하도록 가격 형식을 지정합니다. 십진수 값(LiveData<Double>
)의 원래 가격을 문자열 값(LiveData<String>
)으로 변환합니다.
OrderViewModel
클래스에서 지원 속성 유형을LiveData<Double>.
대신LiveData<String>
으로 변경합니다. 형식이 지정된 가격은 '$'와 같은 통화 기호가 있는 문자열입니다. 다음 단계에서 초기화 오류를 수정합니다.
private val _price = MutableLiveData<Double>()
val price: LiveData<String>
Transformations.map()
을 사용하여 새로운 변수를 초기화하고_price
및 람다 함수를 전달합니다.NumberFormat
클래스의getCurrencyInstance()
메서드를 사용하여 가격을 현지 통화 형식으로 변환합니다. 변환 코드는 다음과 같습니다.
private val _price = MutableLiveData<Double>()
val price: LiveData<String> = Transformations.map(_price) {
NumberFormat.getCurrencyInstance().format(it)
}
androidx.lifecycle.Transformations
및 java.text.NumberFormat
을 가져와야 합니다.
- 앱을 실행합니다. 이제 소계 및 합계에 대해 형식이 지정된 가격 문자열이 표시됩니다. 이러한 표시는 훨씬 더 사용자 친화적입니다!
- 앱이 예상대로 작동하는지 테스트합니다. 예를 들어 컵케이크 1개 주문, 컵케이크 6개 주문 또는 컵케이크 12개 주문과 같은 사례를 테스트합니다. 가격이 각 화면에서 올바르게 업데이트되는지 확인합니다. Flavor 및 Pickup 프래그먼트에서는 Subtotal $2.00, Order Summary 프래그먼트에서는 Total $2.00로 표시되어야 합니다. 또한 주문 요약에 정확한 주문 세부정보가 표시되는지 확인합니다.
9. 리스너 결합을 사용하여 클릭 리스너 설정
이 작업에서는 리스너 결합을 사용하여 프래그먼트 클래스의 버튼 클릭 리스너를 레이아웃에 결합합니다.
- 레이아웃 파일
fragment_start.xml
에서com.example.cupcake.StartFragment
유형의startFragment
라는 데이터 변수를 추가합니다. 프래그먼트의 패키지 이름이 앱의 패키지 이름과 일치하는지 확인합니다.
<layout ...>
<data>
<variable
name="startFragment"
type="com.example.cupcake.StartFragment" />
</data>
...
<ScrollView ...>
StartFragment.kt
의onViewCreated()
메서드에서 새 데이터 변수를 프래그먼트 인스턴스에 결합합니다.this
키워드를 사용하여 프래그먼트 내에서 프래그먼트 인스턴스에 액세스할 수 있습니다.binding?.
apply
블록과 그 안에 있는 코드를 함께 삭제합니다. 완성된 메서드는 다음과 같습니다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.startFragment = this
}
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)}"
... />
- 앱을 실행합니다. start 프래그먼트에서 버튼 클릭 핸들러가 예상대로 작동하는지 확인합니다.
- 마찬가지로
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 ...>
- 나머지 프래그먼트 클래스의
onViewCreated()
메서드에서 버튼에 클릭 리스너를 직접 설정하는 코드를 삭제합니다. 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
}
}
- 다른 레이아웃 파일에서도 버튼의
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()}"
...>
- 앱을 실행하여 버튼이 여전히 예상대로 작동하는지 확인합니다. 동작에는 눈에 띄는 변화가 없지만, 이제 리스너 결합을 사용하여 클릭 리스너를 설정했습니다!
이 Codelab을 완료하고 Cupcake 앱을 빌드한 것을 축하합니다! 그러나 앱이 아직 완전히 완성되지는 않았습니다. 다음 Codelab에서는 Cancel 버튼을 추가하고 백 스택을 수정합니다. 또한 백 스택이 무엇인지와 다른 새로운 주제도 학습합니다. 다음 Codelab에서 뵙겠습니다!
10. 솔루션 코드
이 Codelab의 솔루션 코드는 아래 표시된 프로젝트에 있습니다. ViewModel 분기를 사용하여 코드를 가져오거나 다운로드하세요.
이 Codelab의 코드를 가져와서 Android 스튜디오에서 열려면 다음을 실행합니다.
코드 가져오기
- 제공된 URL을 클릭합니다. 브라우저에서 프로젝트의 GitHub 페이지가 열립니다.
- 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 대화상자를 엽니다.
- 대화상자에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
- 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
- ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.
Android 스튜디오에서 프로젝트 열기
- Android 스튜디오를 시작합니다.
- Welcome to Android Studio 창에서 Open an existing Android Studio project를 클릭합니다.
참고: Android 스튜디오가 이미 열려 있는 경우 File > New > Import Project 메뉴 옵션을 대신 선택합니다.
- Import Project 대화상자에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
- 프로젝트 폴더를 더블클릭합니다.
- Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
- Run 버튼 을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.
- Project 도구 창에서 프로젝트 파일을 둘러보고 앱이 설정된 방식을 확인합니다.
11. 요약
ViewModel
은 Android 아키텍처 구성요소의 일부이며,ViewModel
내에 저장된 앱 데이터는 구성 변경 중에도 유지됩니다. 앱에ViewModel
을 추가하려면 새 클래스를 만들어ViewModel
클래스에서 확장합니다.- 공유
ViewModel
은 여러 프래그먼트의 앱 데이터를 단일ViewModel
에 저장하는 데 사용됩니다. 앱의 여러 프래그먼트는 활동 범위를 사용하여 공유ViewModel
에 액세스합니다. LifecycleOwner
는 활동이나 프래그먼트와 같이 Android 수명 주기를 보유한 클래스입니다.LiveData
관찰자는 수명 주기 소유자가 활성 상태(STARTED
또는RESUMED
)인 경우에만 앱 데이터의 변경사항을 관찰합니다.- 리스너 결합은
onClick
이벤트와 같은 이벤트가 발생할 때 실행되는 람다 표현식입니다. 리스너 결합은textview.setOnClickListener(clickListener)
와 같은 메서드 참조와 비슷하지만, 리스너 결합을 사용하면 임의의 데이터 결합 표현식을 실행할 수 있습니다. LiveData
변환 메서드는LiveData
소스에서 데이터 조작을 실행하고 결과LiveData
객체를 반환하는 방법을 제공합니다.- Android 프레임워크는 언어에 민감한 방식으로 날짜 형식을 지정하고 파싱하는 클래스인
SimpleDateFormat
이라는 클래스를 제공합니다. 이 클래스를 통해 날짜의 형식 지정(날짜 → 텍스트) 및 파싱(텍스트 → 날짜)이 가능합니다.