startFragment
에서 one cupcake 버튼을 클릭하면 flavorFragment
로 넘어가고, 컵케이크 한 개에 해당하는 금액이 표시된다. flaverFragment
에서 맛을 선택하고 Next 버튼을 클릭하면 pickupFragment
로 넘어간다.pickupFragment
에서 요일을 선택하고 Next 버튼을 누르면 summaryFragment
로 넘어간다.NavHost
에 표시될 첫 번째 프래그먼트이다.각 화면의 버튼 이동하는 코드 onClickListener
에서 findNavController()
메서드를 사용하여 NavController
를 가져오고 거기에서 navigate()
를 호출하여 작업 ID인 R.id.action_startFragment_to_flavorFragment를 전달한다. 나머지 버튼도 해당하는 action을 연결한다.
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
앱 바에 각 fragment의 라벨을 표시하고 뒤로가기 버튼을 사용하려면 MainActivity에 코드를 추가해야 한다.
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)
}
}
navHostFragment
와 navController
인스턴스를 가져오고, setupActionBarWithNavContoller
를 호출한다.
Label은 Nav Graph에서 설정할 수 있다.
뒤로가기 기능을 사용하려면 아래 메서드를 추가한다.
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp() || super.onSupportNavigateUp()
}
데이터를 하나의 ViewModel
에 저장하고 여러 fragment가 이 ViewModel의 데이터에 접근한다.
Model 패키지를 생성하고, 패키지 안에 OverViewModel.kt
이라는 이름의 클래스를 생성한다.
ViewModel로 사용하기위해 클래스를 ViewModel에서 확장해야한다.
class OrderViewModel : ViewModel() {
}
속성 유형을 LiveData
로하여 데이터가 변경될 때 UI를 업데이트 할 수 있도록한다. 데이터 변수를 private
으로 하고, 외부에서 접근하는 setter
함수는 public
상태인 것을 확인하자.
private val _quantity = MutableLiveData<Int>(0)
val quantity: LiveData<Int> = _quantity
fun setQuantity(numberCupcakes: Int) {
_quantity.value = numberCupcakes
}
viewModels()
는 현재 fragment 범위에서 ViewModel을 사용한다. 즉 fragment 마다 인스턴스가 다르다.
activityViewModels
는 activity 범위에서 ViewModel을 사용한다. 즉 하나의 activiy에 속하는 fragment들에서 동일하게 유지된다.
viewModel을 공유하는 fragment에 아래 코드를 추가해 OrderViewModel
을 sharedViewModel
이라는 이름으로 사용할 수 있도록 한다.
private val sharedViewModel: OrderViewModel by activityViewModels()
StartFragment
에서 누르는 버튼에 따라 quantity
데이터 값을 업데이트해야한다.
navigate로 fragment를 이동하기 전에 OrderViewModel
에 정의해둔 set 메서드를 이용해 데이터를 업데이트한다.
fun orderCupcake(quantity: Int) {
sharedViewModel.setQuantity(quantity)
findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}
flavor 프래그먼트로 이동하기전에 맛이 설정되지 않았다면 기본 맛을 Vanilla로 설정한다.
OrderViewModel
에서 flavor가 설정되어 있는지 확인하는 메서드를 추가한다.
fun hasNoFlavorSet(): Boolean {
return _flavor.value.isNullOrEmpty()
}
StartFragment
의 orderCupcake()
에서 navigate하기 이전에 아래 코드처럼 flavor 설정을 확인한다.
if (sharedViewModel.hasNoFlavorSet()) {
sharedViewModel.setFlavor(getString(R.string.vanilla))
}
지난 시간에 배운 데이터 결합을 사용하면 코드에서 실수로 UI를 업데이트하지 않은 경우 오류가 발생하는 것을 방지할 수 있다.
<data>
<variable
name="viewModel"
type="com.example.cupcake.model.OrderViewModel" />
</data>
각 fragment에서 sharedViewModel
을 레이아웃의 뷰 모델 viewModel
변수에 결합한다.
binding?.apply {
viewModel = sharedViewModel
}
(이 viewModel
이 연결된 xml의 <data><variable name="viewModel" ...
이거!)
apply 범위 함수
emily.apply{
firstName = "Emily"
lastName = "Cho"
}
// 위 아래 같은 코드
emily.firstName = "Emily"
emily.lastName = "Cho"
다음과 같이 결합표현식을 사용하면 viewModel
의 flavor 값이 vanilla일 경우 이 라디오 버튼을 선택된 상태로 설정한다.
android:checked="@{viewModel.flavor.equals(@string/vanilla)}"
결합표현식은 @
로 시작하고 {}
로 묶여있다는 것을 기억하자.
다음과 같이 리스너 결합이라는 람다 표현식을 사용하면 라디오 버튼이 눌렸을 때 viewModel
의 flavor를 설정할 수 있다.
android:onClick="@{() -> viewModel.setFlavor(@string/vanilla)}"
SimpleDateFormat
클래스를 통해 날짜의 형식 지정(날짜 → 텍스트) 및 파싱(텍스트 → 날짜)이 가능하다.
private fun getPickupOptions(): List<String> {
val options = mutableListOf<String>()
val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
val calendar = Calendar.getInstance()
repeat(4) {
options.add(formatter.format(calendar.time))
calendar.add(Calendar.DATE, 1)
}
return options
}
Locale.getDefault()
를 통해 사용자 기기에 설정된 언어 정보를 가져와 SimpleDateFormat
생성자에 전달한다.calendar
변수에는 현재 날짜 및 시간이 포함된다.repeat
문을 돌며 options에 오늘, 1일후, 2일후, 3일후 날짜를 "E MMM d" 포맷으로 담는다.val dateOptions = getPickupOptions()
위에서 vanilla 라디오 버튼에서 설정한 것과 비슷하게 viewModel
의 dateOptions
에서 날짜를 가져와 checked
속성을 정한다. 버튼이 눌렸을 때는 setDate
를 실행해 dateOptions
을 설정한다. text는 오늘 날짜로 설정한다.
android:checked="@{viewModel.date.equals(viewModel.dateOptions[0])}"
android:onClick="@{() -> viewModel.setDate(viewModel.dateOptions[0])}"
android:text="@{viewModel.dateOptions[0]}"
resetOder()
메서드를 정의하고 init에서 실행해 초깃값을 설정한다.
init {
resetOrder()
}
fun resetOrder() {
_quantity.value = 0
_flavor.value = ""
_date.value = dateOptions[0]
_price.value = 0.0
}
viewModel을 사용하도록 수정한다. quantity, flavor, date를 viewModel의 값을 불러와 text에 표시한다.
android:text="@{viewModel.quantity.toString()}"
3개 fragment에서 가격을 화면에 보여준다. 이 부분을 뷰 모델을 사용하도록 수정한다.
OrderViewModel에 가격 상수를 추가한다. (클래스 외부에 private const로)
private const val PRICE_PER_CUPCAKE = 2.00
update할 메서드를 클래스 내부에 추가한다.
private fun updatePrice() {
_price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
}
이 메서드를 setQuantity() 메서드에서 실행해서 수량이 바뀌면 가격도 업데이트한다.
가격을 띄우는 TextView를 다음과 같이 수정한다.
android:text="@{@string/subtotal_price(viewModel.price)}"
당알 수령 시 요금을 상수로 정의하고,
private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00
updatePrice(), setDate()를 수정해서 수령 날짜를 오늘로 선택했을 때 viewModel에 저장된 price 값을 업데이트한다.
viewModel에서 가격이 수정되었지만 화면에 표시되는 가격은 그대로다!
LiveData를 관찰하고 있지 않기 때문!
다음과 같이 price를 띄우는 3개 fragment에서 lifecycleOwner를 설정한다.
binding?.apply {
nextButton.setOnClickListener { goToNextScreen() }
viewModel = sharedViewModel
lifecycleOwner = viewLifecycleOwner
}
Transformation.map()
을 사용해 현지 통화를 사용하도록 가격 형식을 지정할 수 있다.
private val _price = MutableLiveData<Double>()
val price: LiveData<String>
위 코드를 아래 코드로 변경
private val _price = MutableLiveData<Double>()
val price: LiveData<String> = Transformations.map(_price) {
NumberFormat.getCurrencyInstance().format(it)
}
onViewCreated()
에서 setOnClickListener
로 클릭 리스너를 직접 설정하는 코드를 삭제하고 리스너 결합으로 수정한다.
<variable
name="startFragment"
type="com.example.cupcake.StartFragment" />
binding?.apply 블록을 전부 지우고 아래 코드처럼 this
키워드를 사용하도록 수정한다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.startFragment = this
}
xml 영역에서는 각 버튼에 android:onClick="@{() -> startFragment.orderCupcake(1)}"
와 같이 onClick 속성을 추가한다.
명시적으로 onClickListener 생성했던 코드를 지우고 다음과 같이 수정한다.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.apply {
lifecycleOwner = viewLifecycleOwner
viewModel = sharedViewModel
flavorFragment = this@FlavorFragment
}
}
xml 영역의 버튼도 onClick
속성을 추가한다.
android:onClick="@{() -> flavorFragment.goToNextScreen()}"
사용자가 Activity간 이동을 하면 activity는 back stack에 푸시된다. 뒤로가기를 누르면 back stack에 있던 activity가 팝되면서 이전 페이지로 돌아가는 것이다.
Cupcake 앱의 경우 MainActivity안에 fragment가 순서대로 푸시된다. Cancel 버튼을 눌러 StartFragment로 돌아가도록 하려면 한 번에 여러 fragment를 팝해야한다.
flavorFragment
, pickupFragment
, summaryFragment
각각에서 startFragment
로 연결되는 화살표(action)을 만든다.
xml 파일에 cancel 버튼을 추가한다. Material Outlined Button을 사용해 style을 정해줄 수 있다.
<Button
android:id="@+id/cancel_button"
style="?attr/materialButtonOutlinedStyle" />
3개 fragment에 cancelOrder()
메서드를 만든다. Cancel을 진행하면 sharedViewModel
의 값을 리셋하고, 각 fragment에서 startFragment
로 연결되는 action의 이름으로 navigate한다.
fun cancelOrder() {
sharedViewModel.resetOrder()
findNavController().navigate(R.id.action_flavorFragment_to_startFragment)
}
버튼을 클릭하면 cancelOrder()
가 실행되도록 리스너 결합을 사용한다.
<Button
android:onClick="@{() -> flavorFragment.cancelOrder()}" />
app:popUpTo
를 설정하면 summaryFragment
에서 cancel했을 때 flavorFragment
와 pickupFragment
가 한번에 사라진다. 하지만 처음있던 StartFragment
와 cancel 버튼을 눌렀을 때 생성된 StartFragment
두개가 남는다.
app:popUpToInclusive="true"
를 설정해야 StartFragment
가 하나만 남게된다.
설정은 Nav graph에서 action 화살표를 누르면 우측 Pop Behavior에서 설정할 수 있다.
Strings.xml에서 order_details는 아래 코드처럼 작성되어있다.
<string name="order_details">Quantity: %1$s cupcakes \n Flavor: %2$s \nPickup date: %3$s \n Total: %4$s</string>
인수에 1부터 4까지 번호가 매겨져있고 다음과 같이 사용할 수 있다. getString하면 아래 문자열이 생성된다.
getString(R.string.order_details, "12", "Chocolate", "Sat Dec 12", "$24.00")
Quantity: 12 cupcakes
Flavor: Chocolate
Pickup date: Sat Dec 12
Total: $24.00
sendOrder 메서드 내에 암시적 인텐트를 만든다.
val intent = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
.putExtra(Intent.EXTRA_TEXT, orderSummary)
컵케이크가 하나일 때는 cupcakes가 아닌 cupcake가 출력되어야한다.
<plurals name="cupcakes">
<item quantity="one">%d cupcake</item>
<item quantity="other">%d cupcakes</item>
</plurals>
getQuantityString(R.string.cupcakes, 1, 1)
호출 시 문자열 "1 cupcake"를 반환한다.
참고: getQuantityString()을 호출할 때는 올바른 복수형 문자열을 선택하는 데 첫 번째 수량 매개변수가 사용되므로 수량을 두 번 전달해야 합니다. 두 번째 수량 매개변수는 실제 문자열 리소스의 %d 자리표시자에 사용됩니다.
order_details에 작성되어있던 cupcakes를 지우고, 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)
}
}