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

nav_graph에서 neted_graph로 이동할 수 있는 방법 없을까요

0 추천

 

---

DailyLogFragment( A )

class DailyWorkoutLogFragment : Fragment() {
    private var _binding: FragmentDailyWorkoutLogBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater,
                              container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {

        _binding = FragmentDailyWorkoutLogBinding.inflate(inflater, container, false)
        binding.btn.setOnClickListener {
            findNavController().navigate(R.id.action_dailyWorkoutLogFragment_to_navigation)
        }

        return binding.root
    }
}

 

AddRoutineFragment ( B )

class AddRoutineFragment : Fragment() {
    private var _binding : FragmentAddRoutineBinding? = null
    private val binding get() = _binding!!
    private val vm : DetailViewModel by navGraphViewModels(R.id.navigation) {
        DetailViewModelFactory(requireActivity().application,"")
    }
    private val epoxyController : AddRoutineController by lazy {
        AddRoutineController()
    }

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

        binding.rv.adapter = epoxyController.adapter


        binding.addRoutine.setOnClickListener {
            findNavController().navigate(R.id.action_addRoutine_to_workoutListTabFragment)
        }

        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
//        vm.workoutInfo.observe(viewLifecycleOwner) { routine ->
//            epoxyController.setItem(routine)
//        }
    }
}

 

 

WriteDetail ( D )

class WriteDetailFragment : Fragment() {
    private var _binding : FragmentWriteDetailBinding? = null
    private val binding get() = _binding!!
    private val args: WriteDetailFragmentArgs by navArgs()
    lateinit var workout: String
    private lateinit var adapter: DetailAdapter
    private val vm : DetailViewModel by navGraphViewModels(R.id.navigation) {
        DetailViewModelFactory(requireActivity().application, workout)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        args.let {
            Detail.title = it.workout.toString()
            workout = it.workout.toString()
        }
    }

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

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

            workout.text = args.workout

            // 세트 추가
            add.setOnClickListener {
                vm.addSet()
            }
            // 세트 삭제
            delete.setOnClickListener {
                vm.deleteSet()
            }
            // 단위 변경
            toggleButton.addOnButtonCheckedListener { _, checkedId, isChecked ->
                if(isChecked) {
                    when(checkedId) {
                        R.id.kg -> vm.changeUnit(WorkoutUnit.kg)
                        R.id.lb -> vm.changeUnit(WorkoutUnit.lbs)
                    }
                }
            }
            // 메모
            memo.addTextChangedListener(object : TextWatcher {
                override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
                }

                override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                }

                override fun afterTextChanged(s: Editable?) {
                    // 메모 작성줄 2줄 제한
                    val editTextRowCount = memo.lineCount
                    if (editTextRowCount > 2) {
                        memo.text?.delete(memo.selectionEnd - 1, memo.selectionStart)
                    }
                }
            })
            save.setOnClickListener {
                vm.save()
                findNavController().navigate(R.id.action_writeDetailFragment_to_addRoutine)
                Toast.makeText(context, "눌렀땅", Toast.LENGTH_SHORT).show()
            }
        }
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        vm.items.observe(viewLifecycleOwner) { newList ->
            adapter.submitList(newList) // 새로운 리스트를 넘김.
//            adapter.submitList(vm.getList())
        }
    }
}

 

DetailViewModel

class DetailViewModel(application: Application, title: String) : ViewModel() {
    private val workoutDao = DetailDatabase.getDatabase(application).workoutDao()
    private val repository: WorkoutRepository = WorkoutRepository(workoutDao, title)
    private var _items: MutableLiveData<List<WorkoutSetInfo>> = MutableLiveData()
    var testNum : Int = 0
    val items: LiveData<List<WorkoutSetInfo>>
        get() = _items

    fun changeUnit(unit: WorkoutUnit) {
        repository.changeUnit(unit)
        _items.postValue(repository.getList())
    }

    fun addSet() { 
        viewModelScope.launch(Dispatchers.IO){
            repository.add()
            _items.postValue(repository.getList()) // plus를 사용하면 새로운 List가 반환
        }
        testNum += 1
        Log.i("TestNum", testNum.toString())
    }

    fun deleteSet() {
        repository.delete()
        _items.postValue(repository.getList())
    }

    fun save() {
        viewModelScope.launch(Dispatchers.IO) {
            repository.save()
        }
    }
}

 

WorkoutRepository

class WorkoutRepository(private val workoutDao : WorkoutDao, title: String) {
    private val workout = Workout(title = title)
    private var setInfoList = ArrayList<WorkoutSetInfo>()
    lateinit var updatedList : List<WorkoutSetInfo>

    fun changeUnit(unit: WorkoutUnit) {
       updatedList = setInfoList.map { setInfo ->
            setInfo.copy(unit = unit)
        }
    }

    fun add() {
        setInfoList.let { list ->
            val item = WorkoutSetInfo(set = setInfoList.size + 1)
            list.add(item)
            updatedList = setInfoList.toList()
        }
    }

    fun delete() {
        if(setInfoList.size != 0) {
            setInfoList.let { list ->
                list.removeLast()
                updatedList = list.toList()
            }
        }
        return
    }

    fun save() {
        val workoutId = workoutDao.insertWorkout(workout) // Workout 삽입 및 삽입된 Workout의 ID 반환
        val newWorkoutSetInfoList = setInfoList.map { setInfo -> // workoutId를 기반으로 새 리스트 리턴
            setInfo.copy(parentWorkoutId = workoutId)
        }
        workoutDao.insertSetInfoList(newWorkoutSetInfoList)
    }

    // toList를 하는 이유는 새로운 리스트를 반환하기때문에 postValue 가능하게끔 하기 위함
    fun getList() : List<WorkoutSetInfo> = updatedList
}

 

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

3개의 답변

0 추천
Nested graph를 사용하는 것보다는 그냥 뷰모델을 두개 사용하면 어떨까요? 공유할 데이터는  navGraphViewModels를 이용해서 처리하고 그렇지 않는 데이터는 viewModels를 사용하는 거죠.

A: 운동기록: observe sharedViewModel.workoutLog
B: 운동루틴추가: viewModelB.workoutRoutine -> sharedViewModel.workoutRoutine
C: 운동종목 선택: viewModelC.workout -> shareViewModel.workout
D: 운동내용 입력: viewModelD.workoutDetails + sharedViewModel.workoutRoutine + shareViewModel.workout -> shareViewModel.saveWorkoutLog

이렇게 하면 B, D로 이동하게 되면 각자 ViewModel을 데이터 입력하는 소스로 사용하므로 입력 흐름 반복시 당연히 초기화가 되겠죠. D에서 저장을 하게되면  자동으로 공유된 ViewModel을 통해 변경사항을 가져올 수 있을 것 같네요.
spark (226,720 포인트) 님이 2022년 9월 30일 답변
뷰모델을 하나만 가지고도 멤버 속성을 어떻게 가져가느냐에 따라 (예를 들면 State machine) 구현이 가능할 것 같아 보이네요. 구현하신 현재 소스를 올리시면 확인해 보겠습니다.
코드 올렸습니다. 내용이 길어서 코드까지 안올려져서 질문부분은 삭제했습니다.
좀 난잡할겁니다..

참고로 A에는 지금은 빈 Fragment지만 B에서 최종적으로 작성을 완료하면 날짜형, 운동부위 형태로 데이터를 올릴예정입니다.
0 추천

사용자 입력용 데이터 저장 용도로 DB를 사용하셔도 무방할 것 같구요, 사실 이게 SavedStateHandle과 더불어 process death나 configuration changes 등까지 모두 커버할 수 있는 방법 중의 하나입니다.

DB를 이용하는 방법 외에에 말씀드린 ViewModel을 두개 사용하는 방법도 테스트해 보면 무리없이 동작을 합니다.

WriteDetailsFragment에 WriteDetailsViewModel 을 두고 여기에 사용자가 입력하는 세트데이터를 저장하고 "추가"버튼을 누를 때 WriteDetailsFragment에 이벤트를 주어서 DetailViewModel에 입력받은 데이터를 합치도록 하면 됩니다. 코드로 보면 아래와 같은 흐름이 됩니다.

아래처럼 클래스명을 고쳤습니다.
WriteDetailFragment => WorkoutSetFragment
DertailViewMoel => WorkoutViewModel

data class Workout(
    val id: Int,
    val name: String,
    val bodyPart: BodyPart
) : Serializable 

data class WorkoutSet(
    val id: Int = -1,
    val workout: Workout = Workout.NullWorkout,
    val memo: String = "",
    val weightUnit: WeightUnit = WeightUnit.KG,
    val items: List<WorkoutSetInfo> = emptyList()
) {
    val nextWorkoutSetId: Int
        get() = items.size.inc()
}

val NullWorkoutSet = WorkoutSet()
data class WorkoutSetInfo(
    val workoutSetId: Int = 0,
    val sets: Int = 0,
    val weight: Int = 0,
    val reps: Int = 0,
    val weightUnit: WeightUnit = WeightUnit.KG
) {
    val displayUnit: String
        get() = weightUnit.name.lowercase()
}


val NullWorkoutSet = WorkoutSet()
val NullWorkout = Workout(0, "", BodyPart.CHEST)

sealed class WorkoutSetEvent {
    object WorkoutMissing : WorkoutSetEvent()
    data class SubmitWorkoutSet(val workoutSet: WorkoutSet) : WorkoutSetEvent()
}

class WorkoutSetViewModel : ViewModel() {

    private var workoutSet = NullWorkoutSet

    private val mWorkoutSetState = MutableLiveData(workoutSet)
    val workoutSetState: LiveData<WorkoutSet> = mWorkoutSetState

    private val eventChannel = Channel<WorkoutSetEvent>()
    val event = eventChannel.receiveAsFlow()

    fun onStop() {
        mWorkoutSetState.postValue(workoutSet)
    }

    fun onWorkoutSelected(workout: Workout) {
        if (workoutSet.workout.id == workout.id) return

        workoutSet = workoutSet.copy(workout = workout, items = listOf(newWorkoutSetINfo()))
        mWorkoutSetState.postValue(workoutSet)
    }

    fun onAddWorkoutSetInfo() {
        viewModelScope.launch {
            if (workoutSet.workout == NullWorkout) {
                eventChannel.send(WorkoutSetEvent.WorkoutMissing)
                return@launch
            }

            workoutSet = workoutSet.copy(items = workoutSetItemAdded())
            mWorkoutSetState.postValue(workoutSet)
        }
    }

    private fun workoutSetItemAdded(): List<WorkoutSetInfo> {
        return workoutSet.items.plus(newWorkoutSetINfo())
    }

    private fun newWorkoutSetINfo(): WorkoutSetInfo {
        return WorkoutSetInfo(
            workoutSetId = workoutSet.id,
            sets = workoutSet.nextWorkoutSetId,
            weightUnit = workoutSet.weightUnit
        )
    }

    fun onDeleteWorkoutSetInfo() {
        if (workoutSet.items.size <= 1) return

        workoutSet = workoutSet.copy(items = workoutSet.items.dropLast(1))
        mWorkoutSetState.postValue(workoutSet)
    }

    fun onUnitSelected(unit: WeightUnit) {
        workoutSet = workoutSet.copy(weightUnit = unit)
    }


    fun onMemoChanged(memo: String) {
        workoutSet = workoutSet.copy(memo = memo)
    }

    fun onWeightChanged(item: WorkoutSetInfo) {
        viewModelScope.launch {
            val items = workoutSet.items.map { workoutSetInfo ->
                if (workoutSetInfo.sets == item.sets)
                    workoutSetInfo.copy(weight = item.weight)
                else
                    workoutSetInfo
            }

            workoutSet = workoutSet.copy(items = items)
        }
    }

    fun onRepsChanged(item: WorkoutSetInfo) {
        viewModelScope.launch {
            val items = workoutSet.items.map { workoutSetInfo ->
                if (workoutSetInfo.sets == item.sets)
                    workoutSetInfo.copy(reps = item.reps)
                else
                    workoutSetInfo
            }

            workoutSet = workoutSet.copy(items = items)
        }
    }

    fun onSubmitWorkoutSet() {
        viewModelScope.launch {
            eventChannel.send(WorkoutSetEvent.SubmitWorkoutSet(workoutSet))
        }
    }
}

 

 

spark (226,720 포인트) 님이 2022년 10월 1일 답변
spark님이 2022년 10월 1일 수정
0 추천

다음은 Fragment 코드입니다.

class WorkoutSetFragment : Fragment(), WorkoutSetInfoListener,
    MaterialButtonToggleGroup.OnButtonCheckedListener {

    private var _binding: FragmentWorkoutSetBinding? = null
    private val binding get() = _binding!!

    private val workoutViewModel: WorkoutViewModel by navGraphViewModels(R.id.mobile_navigation)
    private val workoutSetViewModel: WorkoutSetViewModel by viewModels()

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    override fun onStop() {
        super.onStop()
        workoutSetViewModel.onStop()
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentWorkoutSetBinding.inflate(inflater, container, false)
        return binding.root
    }

    private val workoutSetInfoAdapter by lazy { WorkoutSetInfoAdapter(this) }

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

        setupViews()
        setupViewModel()
        setupNavResult()
    }

    private fun setupViews() {
        binding.apply {
            workoutBtn.setOnClickListener {
                showWorkoutSelection()
            }

            kgBtn.isChecked = true
            unitGroup.addOnButtonCheckedListener(this@WorkoutSetFragment)

            addSetBtn.setOnClickListener {
                workoutSetViewModel.onAddWorkoutSetInfo()
            }

            deleteSetBtn.setOnClickListener {
                workoutSetViewModel.onDeleteWorkoutSetInfo()
            }

            addToLogBtn.setOnClickListener {
                workoutSetViewModel.onSubmitWorkoutSet()
            }

            memoEdt.doAfterTextChanged {
                if (memoEdt.hasFocus()) {
                    workoutSetViewModel.onMemoChanged(it.toString())
                }
            }

            detailRcv.adapter = workoutSetInfoAdapter
        }
    }

    private fun setupViewModel() {
        workoutSetViewModel.workoutSetState.observe(viewLifecycleOwner, ::handleWorkoutSet)

        lifecycleScope.launchWhenStarted {
            workoutSetViewModel.event.collect(::handleWorkoutSetEvent)
        }
    }

    private fun setupNavResult() {
        getNavResult<Workout> { workout ->
            workoutSetViewModel.onWorkoutSelected(workout)
        }
    }

    private fun showWorkoutSelector() {
        findNavController().navigate(R.id.action_writeDetailFragment_to_workoutSelectionFragment)
    }

    private fun getWeightUnitByViewId(checkedId: Int): WeightUnit =
        if (checkedId == R.id.kgBtn) WeightUnit.KG else WeightUnit.LBS

    private fun handleWorkoutSet(workoutSet: WorkoutSet) {
        if (workoutSet == NullWorkoutSet) return

        bindAdapter(workoutSet.items)
        bindWeightUnits(workoutSet.weightUnit)
        bindWorkout(workoutSet.workout)
        bindMemo(workoutSet.memo)
    }

    private fun bindAdapter(items: List<WorkoutSetInfo>) {
        workoutSetInfoAdapter.submitList(items)
    }

    private fun bindWeightUnits(weightUnit: WeightUnit) {
        binding.apply {
            unitGroup.removeOnButtonCheckedListener(this@WorkoutSetFragment)
            kgBtn.isChecked = weightUnit == WeightUnit.KG
            lbsBtn.isChecked = weightUnit == WeightUnit.LBS
            unitGroup.addOnButtonCheckedListener(this@WorkoutSetFragment)
        }
    }

    private fun bindWorkout(workout: Workout) {
        binding.workoutBtn.text = workout.name
    }

    private fun bindMemo(memo: String) {
        binding.apply {
            if (memoEdt.text.toString() != memo) {
                memoEdt.setText(memo)
            }
        }
    }

    private fun handleWorkoutSetEvent(event: WorkoutSetEvent) {
        when (event) {
            is WorkoutSetEvent.WorkoutMissing -> showWorkoutSelector()
            is WorkoutSetEvent.SubmitWorkoutSet -> submitWorkoutSet(event.workoutSet)
        }
    }

    private fun submitWorkoutSet(workoutSet: WorkoutSet) {
        workoutViewModel.onAddWorkoutSet(workoutSet)
        findNavController().popBackStack()
    }

    override fun onWeightChanged(item: WorkoutSetInfo, value: String) {
        val weight = value.toIntOrNull() ?: return
        workoutSetViewModel.onWeightChanged(item.copy(weight = weight))
    }

    override fun onRepsChanged(item: WorkoutSetInfo, value: String) {
        val reps = value.toIntOrNull() ?: return
        workoutSetViewModel.onRepsChanged(item.copy(reps = reps))
    }

    override fun onButtonChecked(
        group: MaterialButtonToggleGroup?,
        checkedId: Int,
        isChecked: Boolean
    ) {
        workoutSetViewModel.onUnitSelected(getWeightUnitByViewId(checkedId))
    }
}

inline fun <T> Fragment.getNavResult(
    key: String = NAV_RESULT_KEY,
    crossinline onResult: (T) -> Unit,
) {
    findNavController().currentBackStackEntry?.also { stack ->
        stack.savedStateHandle.getLiveData<T>(key)
            .observe(
                viewLifecycleOwner,
                Observer { result ->
                    onResult(result)
                    stack.savedStateHandle.remove<T>(key)
                }
            )
    }
}

fun <T> Fragment.setNavResult(data: T, key: String = NAV_RESULT_KEY) {
    findNavController().previousBackStackEntry?.also { stack ->
        stack.savedStateHandle[key] = data
    }
}

 

제 코드는 화면 흐름이 님의 것과 살짝 다른데, 님의 경우는 먼저 운동종목을 선택한 후 운동세트 화면으로 이동했지만, 저의 경우는운동세트 화면이 먼저 나오고 여기에서 화면에 운동종목 선택버튼을 눌러서 운동종목 화면을 불러 운동종목을 선택하도록 했습니다.

addToBtn이 3번 화면의 추가버튼에 해당합니다. 여기의 이벤트가 중요한데, 버튼 클릭시 WorkoutSetViewModel로 액션을 보내서 현재까지 입력된 데이터를 Fragment쪽에 보내도록 요청합니다. addToBtn 클릭이벤트는 결과적으로 handleWorkoutSetEvent -> submitWorkoutSet 함수를 호출하게 됩니다. submitWorkoutSet은 navigation graph scope의 WorkoutViewModel에 입력받은 운동세트를 추가하도록 하고 화면을 빠져나갑니다.

이렇게 되면 WorkoutViewModel에 데이터를 observe하고 있던 운동기록 화면은 자동으로 데이터를 업데이트 할 수 있게 됩니다.제가 테스트해 본 바로는 별 문제없이 처리가 되는 것으로 보입니다.

spark (226,720 포인트) 님이 2022년 10월 1일 답변
spark님이 2022년 10월 1일 수정
어제부터 시간날때마다 조금씩 봤는데 이해가 조금가면서도 어렵고 그러네요 ㅋㅋ솔직히 이해가 덜가는 쪽에 가깝긴한거같습니다. 아직 flow나 channel같은걸 잘 몰라서 그런지 이부분은 잘모르겠구요. 앱실행해서 직접해보지않아서 어떤식으로 동작하는지는 아직 이해가 잘안가는데 천천히 봐야겠습니다 감사합니다
여기서 channel은 네비게이션같이 메세지가 한번만 나가야 하는 경우에 사용하기 위해 SingleLiveEvent 대신 사용했습니다. 즉, 공용뷰모델과 내뷰모델을 두개 사용하고 나만 필요한 데이터는 내뷰모델에 보관한 다음, 공용뷰모델로 이동할 때 이벤트를 보내서 공용뷰모델에 추가하라고 알려주는 흐름입니다. WorkoutSetViewModel에서는 다른 부분은 기존의 DetailViewModel과 같은 기능이이므로 onSubmitWorkoutSet함수만 눈여겨 보시면 됩니다. flow, channel, getNavResult, setNavResult 등은 부가적인 것이므로 신경쓰지 마세요. 데이터의 흐름에만 집중해서 보시면 됩니다.
그리고 (공용)뷰모델을 하나만 사용해서도 구현이 가능할 것 같은데, 다만 여러개의 프레그먼트가 같이 사용하는 거라 코드가 좀 헷갈릴 수도 있을 것 같고, 내 프레그먼트에서만 호출해야하는 함수를 다른 프레그먼트가 호출할 수 있는 실수를 할 수 있기 때문에 잘 검토를 해보야 할 것 같네요.
...