마스터Q&A 안드로이드는 안드로이드 개발자들의 질문과 답변을 위한 지식 커뮤니티 사이트입니다. 안드로이드펍에서 운영하고 있습니다. [사용법, 운영진]

토글값에따른 텍스트뷰 전환2

0 추천

얼마전에 위 사진에서 토글 버튼에 따른 가운데 EditText의 단위 업데이트 관련해서 질문드렸었는데요

해결은 했는데, 음 좀 석연치않은 부분이 있어서 질문드립니다.

두가지 방법이 있구요. 이 두방법다 장단점(?) 이 있는것같아요..

제가 아직 코딩실력이 부족해서 어떻게 해결하면 더 깔끔한지 궁금합니다.

 

공통코드

class WriteDetailFragment : Fragment() {
    private var _binding : FragmentWriteDetailBinding? = null
    private val binding get() = _binding!!
    val args: WriteDetailFragmentArgs by navArgs()
    lateinit var workout: String
    private lateinit var adapter: DetailAdapter
    private val vm : DetailViewModel by viewModels { DetailViewModelFactory() }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentWriteDetailBinding.inflate(inflater, container, false)

        adapter = DetailAdapter()
        binding.rv.adapter = adapter
        binding.apply {
            rv.adapter = adapter
            rv.itemAnimator = null

            header.workout.text = args.workout

            header.add.setOnClickListener {
                vm.addDetail()
            }
            header.delete.setOnClickListener {
                vm.deleteDetail()
            }
            header.toggleButton.addOnButtonCheckedListener { _, checkedId, isChecked ->
                if(isChecked) {
                    when(checkedId) {
                        R.id.kg -> vm.changeUnit("kg")
                        R.id.lb -> vm.changeUnit("lbs")
                    }
                }
            }
        }
        return binding.root
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        vm.items.observe(viewLifecycleOwner) { newList ->
            adapter.submitList(newList) // 새로운 리스트를 넘김.
        }
    }
}

DiffUtill

class DetailDiffCallback : DiffUtil.ItemCallback<RoutineItem2.Detail>() {
    override fun areItemsTheSame(
        oldItem: RoutineItem2.Detail,
        newItem: RoutineItem2.Detail
    ): Boolean  {
       return (oldItem.id == newItem.id)
    }

    override fun areContentsTheSame(
        oldItem: RoutineItem2.Detail,
        newItem: RoutineItem2.Detail
    ): Boolean {
        return oldItem == newItem
    }
}

 

 

1번방법

RoutineItem

sealed interface RoutineItem {
    val id: String
 class Detail(
        override var id: String = UUID.randomUUID().toString(),
        val unit: String = "kg",
        val set: Int,
        var weight: String = "",
        var reps: String = "",
    ) : RoutineItem
}

ViewModel

class DetailViewModel : ViewModel() {
    private val _items: MutableLiveData<List<RoutineItem.Detail>> = MutableLiveData()
    val items = _items
    private val list: List<RoutineItem.Detail>
        get() = _items.value ?: emptyList()

    fun changeUnit(unit: String) {
        if(list == null) { // 리스트가 없을때 토글 값을 설정하는 부분
            // 어떻게 클래스의 unit 값을 설정해야할까?
            return
        } else {
            val updatedList = list.map {
                it.copy(unit = unit)
            }
            _items.postValue(updatedList)
        }
    }
}


1번 방법은 위 움짤을 보시면 아시겠지만 초기에 아이템이 추가되지 않은상태에서

토글 버튼을 lbs로 설정 해놓고 아이템을 추가했을때 그대로 kg으로 셋팅됩니다.

비정상적이죠.

1번방법에서는 RoutineItem.Detail의 unit이 프로퍼티로 들어가있습니다.

즉 추가 되는 각각의 아이템마다 unit 값을 가진다는 소리겠죠.

이제 ViewModel에서 맨처음 리스트가 존재하지 않을때 토글값을 설정해야하는데..

(if 구문 list == null 에 해당하는 부분)

이 부분을 어떻게 설정할 수 있을까요? 리스트라는 인스턴스가 생성되지도 않았을때 

아이템이 추가되어야 하므로 클래스의 인스턴스가 생성되기전에 unit 값의 변경이 이루어져야하는데

어떻게 클래스의 unit 프로퍼티 값을 변경할 수있을까요?

 

2번방법

 

RoutineItem

sealed interface RoutineItem2 {
    val id: String

    data class Detail(
        override var id: String = UUID.randomUUID().toString(),
        val set: Int,
        var weight: String = "",
        var reps: String = "",
    ) : RoutineItem2 {
        companion object {
            var unit: String = "kg"
        }
    }
}

ViewModel

class DetailViewModel : ViewModel() {
    private val _items: MutableLiveData<List<RoutineItem2.Detail>> = MutableLiveData()
    val items = _items
    private val list: List<RoutineItem2.Detail>
        get() = _items.value ?: emptyList()

    fun changeUnit(unit: String) {
        if(list == null) {
            RoutineItem.Detail.unit = unit
            return
        } else {
            RoutineItem.Detail.unit = unit
            val updatedList = list.map {
                it.copy(id = unit) // id 가 UUID 이어야하는데 말이 안됨.
            }
            _items.postValue(updatedList)
        }
    }
}

두번째 방법입니다.

앱은 정상적으로 동작합니다. 초기 토글버튼에 따라 값이 잘 설정되어 나옵니다.

위 방법은 RoutineItem.Detail의 unit을 companion object로 설정했습니다.

생각해보니 unit 값은 각 아이템의 다른 프로퍼티와 달리 모든 아이템이 unit 값이 똑같이 일정하고 변경되기 때문에

클래스당 하나만 존재하고 모든 인스턴스가 공유하는 값이라고 생각했기때문에 companion object로 설정했습니다.

따라서 unit 값이 변경될때는 클래스 이름으로 접근을 했습니다. (-> RoutineItem.Detail.unit)

문제는 ViewModel 클래스에서 list를 copy하는 부분에 있습니다. 핵심이 변경된 값으로 copy 하여

새로운 리스트를 생성하고 postValue하여 observe하게 하는게 핵심인데,

val updatedList = list.map {
    it.copy(id = unit) // id 가 UUID 인데 unit 값 주입은 굉장히 말이 안됨.
}

에서 id = unit 인게 좀 잘못되었죠.

새로운 리스트를 억지로 생성한다고 UUID로 설정되어있는 id 값에 억지로 unit 값을 넣어 새로운 리스트를 

생성하는게 어색한 코드입니다.. 제가 동작의 실행을 위해 이렇게 해놓긴했는데

unit 값을 RoutineItem.Detail.unit으로 변경하는 이 방법에서 어떻게 

새로운 리스트를 copy하여 생성할 수 있을까요?

 꼭 copy가 아니더라도 새로운 리스트를 생성하고 postValue하여 observe에게 알릴수 있을까요?

 

둘중 어떠한 방법을 수정해서 사용하는것이 더 좋을까요?

 

++) 그리고 companinon object도 지금 var로 설정되어있는데, val 로 바꾸면서 값을 변경 할 수 있는 방법 있을까요? get 또는 set 함수를 만들어서 설정해야하 할까요?

codeslave (3,940 포인트) 님이 2022년 3월 16일 질문
codeslave님이 2022년 3월 16일 수정

1개의 답변

0 추천

ViewModel과 같은 패턴을 사용할 때는, 뷰에서 사용하는 뷰의 상태를 가지고 있는 클래스를 보통 사용합니다. 예를 들면, 님의 경우는, 아래와 같은 데이터가 뷰에 필요합니다.

data class DetailsViewState(
    val details: List<RoutineItem2.Detail> = emptyList(),
    val selectedUnit: String = "kg"
)

그리고 ViewModel의 LiveData는 아래처럼 ViewState를 갖도록 만들면, 여러개의 상태값을 표현할 수 있습니다.

private val _viewState: MutableLiveData<DetailsViewState> = MutableLiveData()
val viewState: LiveData<DetailsViewState> = _viewState


fun changeUnit(unit: String) {
    // viewState.value!!.selectedUnit으로 선택된 Unit을 알 수 있기 때문에, 필요에 따라 로직을 수정하면 됩니다. 
      val updatedList = list.map {
                it.copy(unit = unit)
      }
         
     _viewState.postValue(viewState.copy(selectedUnit = unit, details = updatedList))
      
}

 

Fragment에서는 DetailsViewState를 observe하면 됩니다.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        vm.items.observe(viewLifecycleOwner, ::updateUi)
         
}

private fun updateUi(viewState: DetailsViewState) {
   bindUnit(viewState.selectedUnit)
   bindDetailsItems(viewState.details)
}

private fun bindUnit(unit: String) {
     // 선택된 버튼 선택
}

private fun bindDetailsItems(items: List<RoutineItem2.Detail>) {
    adapter.submitList(items)
}

 

LiveData는 observer들이 백그라운드가 되면 자동으로 비활성화 되고, 포그라운드로 돌아오면 자동의 활성화 되기 때문에, 이미 들어 있던 데이터를 자동으로 받게 되므로, 자동으로 뷰상태를 복구할 수 있지만, 이런 특성으로 인해서 ViewState를 잘못 디자인하면 원치않는 side effect가 생길 수 있으니, Device rotation이라던가, 다른 화면으로 이동했다 돌아왔을 때 등을 테스트해보시고 이상이 없는지 확인하는게 안전합니다.

 

참고로 Unit은 String보다는 enum 이나 sealed class를 사용하는 것이 추가 속성이나 함수를 사용할 수 있기 때문에, 장기적으로 코드 유지보수에 좋습니다. 예를 들어서, enum 을 사용할 경우 아래처럼 Resource ID를 가지는 추가 속등을 추가해서 
버튼의 캡션을 하드코딩하지 않고 Resource에 불러오거나, Style id를 추가해서 버튼을 눌렀을 때 style을 설정해 준다던지 할 수 있습니다.

enum class BenchPressUnit(@StringRes val resId: Int) {
     KG(R.string.unit_kg),
     LBS(R.string.unit_lbs)
}

 

spark (227,530 포인트) 님이 2022년 3월 16일 답변
spark님이 2022년 3월 16일 수정
화면 전체가 리사이클러뷰로 보이므로, DetailsViewState의 좀 더 정확한 데이터구조는, 리사이클러뷰에서 사용하는 뷰의 구조에 맞게 설계하는 것같네요.  

대충 아래처럼 될 것 같네요.

sealed class RoutineItem2 {
   data class Header(val selectedUnit: String = "kg"): RoutineItem2() { //<-- 헤더 클래스에 선택된 유닛
속성을 추가
      val isKgSelected: Boolean get = selectedUnit == "kg"
   }
   data class Details(...): RoutineItem2()
}


data class DetailsViewState(
    val header: RoutineItem2.Header = RoutineItem2.Header()
    val details: List<RoutineItem2.Detail> = emptyList()
) {
   val items: List<RoutineItem2>
         get() = listOf(header).plus(details)

   val selectedUnit: String
          get() = header.selectedUnit
}

ViewModel과 Fragment의 관련된 코드도 변경하셔야 겠네요.
...