Unit 3: Navigation (7)

quokka·2021년 11월 18일
0

Android Basics in Kotlin

목록 보기
16/25
post-thumbnail

Cupcake App

  1. startFragment에서 one cupcake 버튼을 클릭하면 flavorFragment로 넘어가고, 컵케이크 한 개에 해당하는 금액이 표시된다.
  2. flaverFragment에서 맛을 선택하고 Next 버튼을 클릭하면 pickupFragment로 넘어간다.
  3. pickupFragment에서 요일을 선택하고 Next 버튼을 누르면 summaryFragment로 넘어간다.

1. Navigator Editor에서 Action 연결하기

  • Fragment를 동작 순서에 맞게 탐색 그래프를 연결한다.
  • startFragment가 NavHost에 표시될 첫 번째 프래그먼트이다.

2. 버튼을 눌러 fragment 이동

각 화면의 버튼 이동하는 코드 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)
    }
}

navHostFragmentnavController 인스턴스를 가져오고, setupActionBarWithNavContoller를 호출한다.

label

Label은 Nav Graph에서 설정할 수 있다.

뒤로가기 Up button

뒤로가기 기능을 사용하려면 아래 메서드를 추가한다.

override fun onSupportNavigateUp(): Boolean {
   return navController.navigateUp() || super.onSupportNavigateUp()
}

Shared ViewModel

데이터를 하나의 ViewModel에 저장하고 여러 fragment가 이 ViewModel의 데이터에 접근한다.

1. OrderViewModel 생성

Model 패키지를 생성하고, 패키지 안에 OverViewModel.kt이라는 이름의 클래스를 생성한다.
ViewModel로 사용하기위해 클래스를 ViewModel에서 확장해야한다.

class OrderViewModel : ViewModel() {

}

2. LiveData

속성 유형을 LiveData로하여 데이터가 변경될 때 UI를 업데이트 할 수 있도록한다. 데이터 변수를 private으로 하고, 외부에서 접근하는 setter 함수는 public 상태인 것을 확인하자.

private val _quantity = MutableLiveData<Int>(0)
val quantity: LiveData<Int> = _quantity

fun setQuantity(numberCupcakes: Int) {
    _quantity.value = numberCupcakes
}

activityViewModels()

1. viewModels vs activityViewModels

viewModels()는 현재 fragment 범위에서 ViewModel을 사용한다. 즉 fragment 마다 인스턴스가 다르다.
activityViewModels는 activity 범위에서 ViewModel을 사용한다. 즉 하나의 activiy에 속하는 fragment들에서 동일하게 유지된다.

2. 각 fragment에 OderViewModel 정의

viewModel을 공유하는 fragment에 아래 코드를 추가해 OrderViewModelsharedViewModel이라는 이름으로 사용할 수 있도록 한다.

private val sharedViewModel: OrderViewModel by activityViewModels()

3. OrderViewModel의 데이터 업데이트

StartFragment에서 누르는 버튼에 따라 quantity 데이터 값을 업데이트해야한다.
navigate로 fragment를 이동하기 전에 OrderViewModel에 정의해둔 set 메서드를 이용해 데이터를 업데이트한다.

fun orderCupcake(quantity: Int) {
    sharedViewModel.setQuantity(quantity)
    findNavController().navigate(R.id.action_startFragment_to_flavorFragment)
}

4. isNullOrEmpty()

flavor 프래그먼트로 이동하기전에 맛이 설정되지 않았다면 기본 맛을 Vanilla로 설정한다.
OrderViewModel에서 flavor가 설정되어 있는지 확인하는 메서드를 추가한다.

fun hasNoFlavorSet(): Boolean {
    return _flavor.value.isNullOrEmpty()
}

StartFragmentorderCupcake()에서 navigate하기 이전에 아래 코드처럼 flavor 설정을 확인한다.

    if (sharedViewModel.hasNoFlavorSet()) {
        sharedViewModel.setFlavor(getString(R.string.vanilla))
    }

데이터 결합 & ViewModel

지난 시간에 배운 데이터 결합을 사용하면 코드에서 실수로 UI를 업데이트하지 않은 경우 오류가 발생하는 것을 방지할 수 있다.

1. xml에 data variable 추가

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

2. fragment의 binding에 코드 추가

각 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"

3. 라디오 버튼의 checked 속성

다음과 같이 결합표현식을 사용하면 viewModel의 flavor 값이 vanilla일 경우 이 라디오 버튼을 선택된 상태로 설정한다.

android:checked="@{viewModel.flavor.equals(@string/vanilla)}"

결합표현식은 @로 시작하고 {}로 묶여있다는 것을 기억하자.

4. 리스너 결합

다음과 같이 리스너 결합이라는 람다 표현식을 사용하면 라디오 버튼이 눌렸을 때 viewModel의 flavor를 설정할 수 있다.

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

SimpleDateFormat

1. options 채우기

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" 포맷으로 담는다.

2. OrderViewModel에 dateOptions 데이터 추가

val dateOptions = getPickupOptions()

3. dateOptions로 레이아웃 업데이트

위에서 vanilla 라디오 버튼에서 설정한 것과 비슷하게 viewModeldateOptions에서 날짜를 가져와 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
    }

fragment_summary.xml 수정

viewModel을 사용하도록 수정한다. quantity, flavor, date를 viewModel의 값을 불러와 text에 표시한다.

android:text="@{viewModel.quantity.toString()}"

뷰 모델에서 가격 업데이트

3개 fragment에서 가격을 화면에 보여준다. 이 부분을 뷰 모델을 사용하도록 수정한다.

1. 가격 상수와 업데이트 메서드

OrderViewModel에 가격 상수를 추가한다. (클래스 외부에 private const로)

private const val PRICE_PER_CUPCAKE = 2.00

update할 메서드를 클래스 내부에 추가한다.

private fun updatePrice() {
    _price.value = (quantity.value ?: 0) * PRICE_PER_CUPCAKE
}

이 메서드를 setQuantity() 메서드에서 실행해서 수량이 바뀌면 가격도 업데이트한다.

2. 레이아웃 text 수정

가격을 띄우는 TextView를 다음과 같이 수정한다.

android:text="@{@string/subtotal_price(viewModel.price)}"

당일 수령 추가 요금

당알 수령 시 요금을 상수로 정의하고,

private const val PRICE_FOR_SAME_DAY_PICKUP = 3.00

updatePrice(), setDate()를 수정해서 수령 날짜를 오늘로 선택했을 때 viewModel에 저장된 price 값을 업데이트한다.

viewModel에서 가격이 수정되었지만 화면에 표시되는 가격은 그대로다!
LiveData를 관찰하고 있지 않기 때문!

LiveData를 관찰하도록 viewLifecycleOwner 설정

다음과 같이 price를 띄우는 3개 fragment에서 lifecycleOwner를 설정한다.

        binding?.apply {
            nextButton.setOnClickListener { goToNextScreen() }
            viewModel = sharedViewModel
            lifecycleOwner = viewLifecycleOwner
        }

LiveData에서 가격을 현지 통화 형식으로

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

onClickListner 대신 리스너 결합으로

onViewCreated()에서 setOnClickListener로 클릭 리스너를 직접 설정하는 코드를 삭제하고 리스너 결합으로 수정한다.

1. 각 xml에 각 fragment 이름의 variable을 추가한다.

<variable
    name="startFragment"
    type="com.example.cupcake.StartFragment" />

2. 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 속성을 추가한다.

3. 나머지 fragment

명시적으로 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()}"

Back Stack

사용자가 Activity간 이동을 하면 activity는 back stack에 푸시된다. 뒤로가기를 누르면 back stack에 있던 activity가 팝되면서 이전 페이지로 돌아가는 것이다.
Cupcake 앱의 경우 MainActivity안에 fragment가 순서대로 푸시된다. Cancel 버튼을 눌러 StartFragment로 돌아가도록 하려면 한 번에 여러 fragment를 팝해야한다.

1. Nav Graph 수정

flavorFragment, pickupFragment, summaryFragment 각각에서 startFragment로 연결되는 화살표(action)을 만든다.

2. Cancel 버튼 생성

xml 파일에 cancel 버튼을 추가한다. Material Outlined Button을 사용해 style을 정해줄 수 있다.

<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle" />

3. Cancel 버튼 onClick

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()}" />

4. Back Stack에서 여러 개 제거 popUpTo, popUpToInclusive

app:popUpTo를 설정하면 summaryFragment에서 cancel했을 때 flavorFragmentpickupFragment가 한번에 사라진다. 하지만 처음있던 StartFragment와 cancel 버튼을 눌렀을 때 생성된 StartFragment 두개가 남는다.

app:popUpToInclusive="true"를 설정해야 StartFragment가 하나만 남게된다.

설정은 Nav graph에서 action 화살표를 누르면 우측 Pop Behavior에서 설정할 수 있다.

주문 전송 - email Intent

1. R.string.order_details

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

2. 암시적 인텐트 - email

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)

3. strings.xml 복수형 리소스

컵케이크가 하나일 때는 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)
    }
}

0개의 댓글