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

ListAdapter를 사용하는 중첩 리사이클러뷰의 서브아이템 업데이트

0 추천

https://imgur.com/qIVcF7N // 결과 이미지

https://imgur.com/mInNyLu   // 구현해야할 이미지

 

 

제목대로 중첩리사이클러뷰를 사용하고 있습니다.

버튼을 누르면 부모아이템을 추가할 수 있습니다.

부모아이템에는 다시 버튼이 달려있어 버튼을 누를때마다 서브아이템의 추가 및 삭제가 가능합니다.

원래는 기본 Adapter를 사용했다가 효율적인 아이템 업데이트를 위해

ListAdapter로 바꿨습니다.

MVVM 패턴을 사용하려고하기에 ViewModel을 사용하고 있고,

ViewModel 클래스 내에서 부모아이템이 추가되는 것을 관찰해서 실시간으로 화면에

업데이트 되도록 하였습니다.

결과는 반은 성공이고 반은 실패입니다. 부모아이템은 추가가 잘되나, 이후에 서브아이템은 

즉각적으로 추가가 되지 않습니다. 다음 부모아이템이 추가되어야 반영되더군요.

어떻게 해야 서브아이템을 추가할때마다 바로바로 업데이트되게 할 수 있을까요?

Fragment

class WriteRoutineFragment : Fragment() {
    private lateinit var adapter : RoutineListAdapter
    private val vm : WriteRoutineViewModel by viewModels { WriteRoutineViewModelFactory() }

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

        adapter = RoutineListAdapter(::addDetail, ::deleteDetail)
        binding.rv.adapter = this.adapter
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // 리사이클러뷰 업데이트
        vm.items.observe(viewLifecycleOwner) { updatedItems ->
            adapter.submitList(updatedItems)
        }
    }

    // 서브아이템 추가 및 삭제
    private fun addDetail(pos: Int) {
        vm.addDetail(pos)
    }

    private fun deleteDetail(pos: Int) {
        vm.deleteDetail(pos)
    }
}

 

ViewModel

class WriteRoutineViewModel : ViewModel() {
    private var _items: MutableLiveData<ArrayList<RoutineModel>> = MutableLiveData(arrayListOf())
    var set: Int = 1

    val items: LiveData<ArrayList<RoutineModel>> = _items

    fun addRoutine(workout: String) { // 부모아이템 추가
        val item = RoutineModel(workout, "TEST")
        _items.value?.add(item)
    }

    fun addDetail(pos: Int) { // 서브아이템 추가
        val detail = RoutineDetailModel(set.toString(), "TEST","33")
        set += 1
        _items.value?.get(pos)?.addSubItem(detail) // 부모아이템의 데이터만 변화시켜서는 관찰하지못함
    }

    fun deleteDetail(pos: Int) { // 서브아이템 삭제
        if(_items.value?.get(pos)?.getSubItemSize()!! > 1) // Detail 아이템이 1개일경우에는 Routine을 삭제
            _items.value?.get(pos)?.deleteSubItem()
        else
            _items.value?.removeAt(pos)
    }
}

 

Listadapter

class RoutineListAdapter(val addDetailClicked: (Int) -> Unit,
                         val deleteDetailClicked: (Int) -> Unit) :
    ListAdapter<RoutineModel, RecyclerView.ViewHolder>(RoutineDiffCallback()) {
    private val viewPool = RecyclerView.RecycledViewPool()

    companion object {
        private const val TYPE_ROUTINE = 1
        private const val TYPE_ROUTINE_FOOTER = 2
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        lateinit var itemView: View
        return when (viewType) {
            TYPE_ROUTINE -> {
                val binding = ItemRoutineBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                RoutineViewHolder(binding) // return
            }
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when(getItemViewType(position)) {
            TYPE_ROUTINE -> {
                val item = getItem((position))
                holder as RoutineViewHolder
                holder.bind(item)
                initDetailLayoutManager(holder, item, position)
            }
           
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when(position) {
            currentList.size -> TYPE_ROUTINE_FOOTER
            else -> TYPE_ROUTINE
        }
    }

    override fun getItemCount(): Int = currentList.size + 1 // footer 때문에 +1

    private fun initDetailLayoutManager(holder : RoutineViewHolder, item: RoutineModel, pos: Int) {
        val lm = LinearLayoutManager( // Detail RV를 위한 lm
            holder.subRV.context,
            LinearLayoutManager.VERTICAL,
            false
        )
        lm.initialPrefetchItemCount = item.getSubItemList().size
        val detailAdapter = RoutineDetailListAdapter()
        holder.subRV.apply {
            layoutManager = lm
            adapter = detailAdapter
            detailAdapter.submitList(currentList[pos].getSubItemList()) // 서브아이템 업데이트
            setRecycledViewPool(viewPool)
        }
    }

    inner class RoutineViewHolder(private val binding: ItemRoutineBinding)
        : RecyclerView.ViewHolder(binding.root) {
        val subRV = binding.rv

        init {
            binding.add.setOnClickListener {
                addDetailClicked(adapterPosition)
            }
            binding.delete.setOnClickListener {
                deleteDetailClicked(adapterPosition)
            }
        }
    }
}

 

부모 DIffUtil

class RoutineDiffCallback : DiffUtil.ItemCallback<RoutineModel>() {
    override fun areItemsTheSame(
        oldItem: RoutineModel,
        newItem: RoutineModel
    ): Boolean = (oldItem.workout == newItem.workout)

    override fun areContentsTheSame(
        oldItem: RoutineModel,
        newItem: RoutineModel
    ) : Boolean = oldItem.equals(newItem)
}

 

자식 DIffUtil

class RoutineDetailDiffCallback : DiffUtil.ItemCallback<RoutineDetailModel>() {
    override fun areItemsTheSame(
        oldItem: RoutineDetailModel,
        newItem: RoutineDetailModel
    ): Boolean = (oldItem.set == newItem.set)



    override fun areContentsTheSame(
        oldItem: RoutineDetailModel,
        newItem: RoutineDetailModel
    ): Boolean = oldItem.equals(newItem)
}

 

 

부모 모델

data class RoutineModel(
    val workout: String, // 운동 종목
    val unit: String, // 무게 단위 (kg or lbs)
    var routineDetail: ArrayList<RoutineDetailModel> = arrayListOf()
) {
    init {
        // 루틴 생성시 최소 1개의 상세 아이템을 가지고 있게 하기 위함
        val detail = RoutineDetailModel("1","test","33")
        routineDetail.add(detail)
    }

    fun getSubItemList() : ArrayList<RoutineDetailModel> = routineDetail

    fun getSubItemSize() = routineDetail.size

    fun addSubItem(item: RoutineDetailModel) {
        routineDetail.add(item)
    }

    fun deleteSubItem() {
        routineDetail.removeLast()
    }
}

 

 

codeslave (3,940 포인트) 님이 2021년 9월 7일 질문
codeslave님이 2021년 9월 14일 수정

4개의 답변

0 추천

가장 눈에 띄는 것은 ViewModel 의  addDetail이나 deleteDetail에서 변경된 부분이 liveData를 통해 View쪽으로 전달되지 않는 부분입니다. 올리신 코드는 liveData 안에 있는 데이터를 변경만 했지 liveData에 데이터가 변경되었으니  View에 변경된 내용을 전달하도록 하는 부분이 없어요. 아래와 같은 방식으로 처리해 보세요.

    private val routines: ArrayList<RountineModel> get() = _items.value!!

    fun addDetail(pos: Int) {
        val detail = RoutineDetailModel(set.toString(), "TEST", "33")
        set += 1
        
        routines[pos].addSubItem(detail)

        _items.postValue(routines)
    }
 
    fun deleteDetail(pos: Int) {
        if(routines.[pos].getSubItemSize().isNotEmpyt())
            routines[pos].deleteSubItem()
        else
           routines[pos].removeAt(pos)

        _items.postValue(routines)
    }

 

위처럼 postValue또는  value를 호출해줘야 liveData가 변경사항을 뷰쪽에 전달할 수가 있어요. 그리고 좀 더 안전한 코드를 위해 로직에서 List 의 position 을 사용하고 있기 때문에 IndexOutOfBoundException 또는 로직을 수행하기 전에 파라미터로 넘어온 position 에 List의 범위에 있는지 체크하는 예외처리를 하시기 바랍니다. 이런 예외의 경우는 일어나면 안되지만 혹 일어날 경우 사용자에게 적절한 메세지로 왜 해당 동작이 실패했는지 알려주거나, 좀 더 명료한 방법으로 예외가 발생해서 앱이 크래쉬 나도록 해주셔야 해요. 왜냐하면 버그니깐요.

 

spark (224,800 포인트) 님이 2021년 9월 7일 답변
감사합니다. 그런데 _items.value = _items.value 이던 선생님이 말씀하신 postValue(routine)이던 해봤는데 여전히 결과는 똑같네요.

다만 이 코드를 추가하고 observe내에서 binding.rv.adapter = adapter를 해주니 원하는 결과를 얻을 수 있긴합니다..
아마 submitList한 것을 바로 setAdapter하니 원하는 결과를 얻는걸로 보여지는데요.. 그런데 제가 어디서 듣기로는 setAdapter는 최초 한번만 진행되어야지
이렇게 계속적으로 반복되는건 좋지 않다고 들었거든요..

다른 방법 없을까요?
맞아요. adapter 세팅은 한번만 하면 되구요. 나머지는 아이템을 갱신하는 코드를 사용하는게 좋습니다. postValue나 value를 사용하면 뷰쪽에 변경된 데이터가 전달될거구요 바로 갱신이 안되는거는 뷰쪽의 책임이니 그쪽을 디버깅해 보세요.  ListAdapter를 사용할 때는 DiffUtil의 콜백 부분을 잘 작성하셔야 합니다. RoutineModel의 equals와 hashCode는 님의 요구사항에 맞게 override 해주셔야 하구요. 그리고 경우에 따라서는  같은 리스트를 submit하면 자식 아이템이 바뀌더라도 변경사항이 없을 수도 있어요. 이 부분은 ListAdapter가 변경사항을 알 수 있도록 개발자가 변경해주어야 합니다. 예를 들면 adapter.submitList(giveItems.toMutableList()))같은 형태로 만들어주어야 합니다.
어디가 문제인지 모르는 채로 계속 안되니 좀 답답하네요...

선생님 혹시 Main 아이템의 DiffUtil과 SubItem의 DiffUtil이 각각 존재해야하는 것 아닌가요? 저는 당연하게 둘다 만들었거든요..
선생님 말씀대로 DiffUtil 쪽 문제인가 싶어서 부모 DiffUtil 클래스를 디버깅을 해보는데, 부모아이템을 여러개 추가해도 이 부모 DiffUtil 클래스쪽으로 디버깅이 되지 않았습니다. 오류나거나 그런건 아니고 아예 진입조차 안하는? 그런 것이요.
정상적인 반응인가 싶어서 로그를 찍어보려는데 로그 또한 찍히지 않았습니다.
부모 DiffUtil 클래스에 진입조차 안했다는 뜻이겠지요?
분명 DiffUtil 클래스는 아이템에 변화가 생기면(추가가 되거나 삭제가 되거나 등) 호출되어야할텐데 말이죠..
부모 List의 LiveData를 관찰해서 submitList를 진행하는건 정상적으로 호출됩니다. submitList로 리스트를 전달하면 DiffUtil로 이전 리스트와 새로운 리스트를 비교해서 갱신하는것 아닌가요? 왜 부모아이템을 추가하는데 submitLIst는 호출되고 DiffUtil은 호출안되는지 모르겠어요..

그리고 더 이상한건 부모아이템에서 서브아이템을 추가할때 그제서야
이 부모 DiffUtil이 호출 돼요..저는 당연 서브 DiffUtil이 호출될거라고 생각했는데요..
즉 정리하자면
부모아이템 추가 -> submitList 정상호출, 부모 DiffUitll 호출 X
서브아이템 추가 -> 화면 갱신은 안되지만 부모 DiffUtil 호출


이정도인데..
혹시 이것이 문제일까요?


+++++ 코드추가했습니다
ListAdapter 와 vertical direction을 가진 Nested adapter 를 vertical 로 사용하는 예제는 찾을 수가 없네요. 아마도 이유는 자식아이템의 변화까지 비교해 주어야 하기 때문에 복잡도만 증가할 것 같습니다.  ListAdapter가 제대로 갱신되지 않는 것은 DiffUtil.ItemCallBack입니다. 그 안에 아이템을 비교하는 부분이 DiffUtil로 하여금, 서브아이템의 변경을 알려주지 않기 때문이죠. 이건 님이 말씀하신 부분에서 드러납니다. 아이템이 변경된 것 같은데, DiffUtil.ItemCallback이 호출이 안된다고 하셨으니까요. 이건 DiffUtil.ItemCallback이 제대로 작성되지 않았다는 말입니다. 즉 그안의 areItemsTheSame와 areContentsSame 에 문제가 있을 확률이 매우 큽니다. 님과 같은 경우는 subItem이 존재하기 하기 때문에 이 부분까지 비교가 되어야하지 않을까 생각합니다. 그리고 RoutineModel과 RoutineDetailModel 의 equals와  hashCode를 오버라이드 하지 않은 상태에서 equals 비교를 하시면 제대로 될리가 없죠. 그리고 Kotlin에서 == 는 equals 랑 같습니다.
기본적으로는 님의 경우에 굳이 Nested adapter 를 사용할 이유가 없어요. 그 구조를 사용해서 얻는 이득이 별로 없어요. 복잡도만 증가하구요. Adapter하나에 다른 뷰타입을 사용하던가, 아니면 ConcatAdapter같은 걸 사용해서 처리하면 좀 더 간단해지리라 생각합니다. 일단 데이터구조가 List 안에 List가 들어가 있기 때문에 데이터를 처리하는데 복잡해질 수 밖에 없어요. 저같으면 Flat 한 구조로 만들어 사용할 것 같습니다.
interface class ListItem {
    val viewType: Int
}

data class RoutineModel(...): ListItem
data class RoutineDetailsModel(...)l: ListItem

val adapterItms: List<ListItem>

아니면  ConcatAdapter
class RoutineAdapter: RecyclerView.Adapter<...>
class RoutineDetailsAdapter: RecyclerView.Adapter<...>
val routineAdapter = RoutineAdapter()
val routineDetailsAdapter = RoutineDetailsAdapter()
val adapter = ConcatAdapter(routineAdapter, routineDetailsAdapter)
adapter.add(routineAdapter)
adapter.add(routineDetailsAdapter)

adapter.remove(routineDetailsAdapter)
adapter.remove(routineAdapter)
0 추천

제가 테스트해 본 결과를 알려드릴게요.

먼저 Fragment에서  items 을 observe 할 때 이전에도 말씀드렸다시피 님의 데이터구조는 sub list를 포함하므로, 여기에 대한 변경사항을  같은 List가 들어오더라도 변경되었다는 것을 ListAdapter에게 알려주기 위해, 아래처럼 MutableList 로 변경한 다음 submitList를 해보세요. 아마도 이 다음부터는 DiffUtil.ItemCallback 이 호출되는 걸 볼 수 있을 겁니다.

vm.items.observe(viewLifecycleOwner) { items ->
            adapter.submitList(items.toMutableList())
}

하지만 현재 상태로는 DiffUtil.ItemCallback은 자식 아이템의 변경에도 불구하고 같은 아이템이라고 인식을 할 겁니다. 왜냐하면 RoutineModel클래스의 routineDetail안의 아이템을 일일이 비교하지 않기 때문이죠. 이 부분은 님이 처리를 해주셔야 합니다.

RoutineDiffCallback 의 areContentsSame 을 오버라이드 하셔서 routineDetail 안의 아이템들도 비교해 주세요. 저는 zip + none을 써서 비교했는데, 이렇게 비교하는 것보다 더 좋은 비교 방법은 당장 생각나지 않네요. 

spark (224,800 포인트) 님이 2021년 9월 9일 답변
spark님이 2021년 9월 9일 수정
말씀대로 toMutableList()를 사용하니 DiffUtil에 접근을 하네요왜그런지는 모르겠지만.. 아무튼 아직 시도하다 말았는데 제가 서브아이템을 추가할떄 DiffUtil이 호출되면서 디버깅도 가능하다고 말씀드렸잖아요?
그래서 DiffUtil쪽에 디버깅을 해보니 서브아이템을 추가했을때
oldItem과 newItem의 값이 areItemsTheSame()의 값이 모두 동일하다고 나옵니다.
정상적이라면 서브아이템을 하나 추가했다고 치면, 기본으로 서브아이템은 한개씩 가지고 있도록 설정했으니
oldItem의 서브아이템은 사이즈가 1이어야하고 newItem의 서브아이템은 사이즈가 2이어야 정상일겁니다..
그런데 똑같이 증가가 되어있네요.. 이 두개가 동일하니까 곧바로 화면이 갱신이 안되고 다음 부모아이템이 추가됐을때 갱신이 됐던걸까요?

참고로 말씀하신대로 그 equlas랑 hashCode()는 오버라이딩하지 않고
본문에 잇는 코드 그대로 사용해서 실험한 상태입니다.

또 그리고 이와관련 해서 물어볼게 있는데,
코틀린에서 data class를 사용하면 equlas와 hashcode를 굳이 오버라이딩 할 필요없이 내부적으로 알아서 프로퍼티끼리 비교해준다고 들었는데 아닌가요?
0 추천

먼저 MutableList를 전달할 때 DiffUtil이 동작하는 것은 ListAdapter submitList와 관련이 있습니다.

        if (newList == mList) {
            // nothing to do (Note - still had to inc generation, since may have ongoing work)
            if (commitCallback != null) {
                commitCallback.run();
            }
            return;
        }


        final List<T> previousList = mReadOnlyList;

        // fast simple remove all
        if (newList == null) {
            //noinspection ConstantConditions
            int countRemoved = mList.size();
            mList = null;
            mReadOnlyList = Collections.emptyList();
            // notify last, after list is updated
            mUpdateCallback.onRemoved(0, countRemoved);
            onCurrentListChanged(previousList, commitCallback);
            return;
        }

        // fast simple first insert
        if (mList == null) {
            mList = newList;
            mReadOnlyList = Collections.unmodifiableList(newList);
            // notify last, after list is updated
            mUpdateCallback.onInserted(0, newList.size());
            onCurrentListChanged(previousList, commitCallback);
            return;
        }

        final List<T> oldList = mList;
        mConfig.getBackgroundThreadExecutor().execute(new Runnable() {
            @Override
            public void run() {
...
}

mMainThreadExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        if (mMaxScheduledGeneration == runGeneration) {
                            latchList(newList, result, commitCallback);
                        }
                    }
                });
}

void latchList(
            @NonNull List<T> newList,
            @NonNull DiffUtil.DiffResult diffResult,
            @Nullable Runnable commitCallback) {
        final List<T> previousList = mReadOnlyList;
        mList = newList;
        // notify last, after list is updated
        mReadOnlyList = Collections.unmodifiableList(newList);
        diffResult.dispatchUpdatesTo(mUpdateCallback);
        onCurrentListChanged(previousList, commitCallback);
    }

ListAdapter 의 소스코드를 보면 위처럼 같은 List 로 간주되면 Adapter를 갱신하지 않습니다. RoutineDetailModel를 추가한 후에 변경 전후를 equals비교해 시면 true가 나옵니다. 따라서 toMutableList를 호출하는 약간의 트릭을 사용한 거구요.

디버깅을 해보면 ListAdapter 내부에서 oldList와 newList가 같은 인스턴스를 가리키고 있기 때문에 갱신이 되지않고 있습니다.
서브아이템이 없을 때는 상관이 없지만 서브 아이템이 존재할 경우는 latchList()가 호출이 되면서 항상 같은 인스턴스를 가리키고 있네요. 이 부분은 흥미로운 사실이네요. 디버깅이 좀 더 필요해 보입니다. (결론적으로는 님의 경우는 이렇게 복잡도를 증가시킨다면 ListAdapter 가 그렇게 유용해 보이지는 않네요.)

말씀하신 대로 data class를 사용하면 haschode 자동으로 만들어 줍니다. RoutineModel를 디컴파일해서 equals의 구현을 살펴보면 아랫처럼 되어 이씁니다.

따라서 data class의 equals 와 hashCode는 님의 상황에 맞춰서 override하셔야 합니다. 예를 들면 User class가 있다고 치죠.

data class(val id: String, val name: String)

Kotlin에서 제공하는 equals는 id, name을 모두 비교하도록 될 겁니다. 그런데 실제 비지니스의 경우를 생각해 보면,

Member(id = "1", name = "홍길만")
Member(id = "2", name = "홍길동")

위의 두사람이 다른 Member일까요. Member의 경우는 id가 같다면 보통은 같은 member로 간주를 할 겁니다. 그렇다면 equals 에서도 null이 아니고 id가 같다면 true를 리턴해야 겠죠. equals가 바뀌면 hashCode도 맞춰서 변경해주는 것이 일반적입니다.

 

spark (224,800 포인트) 님이 2021년 9월 10일 답변
spark님이 2021년 9월 10일 수정
대충 이해가 갈 것 같네요. AsyncListDiffer는 submitList를 호출하게 되면 넘겨받은 아이템리스트를 보관합니다. 따라서 내부적으로는 ViewModel에서 넘겨준 리스트를 가리키고 있습니다. 여기에 서브 아이템을 추가할 경우 이미 AsyncListDiffer로 서브아이템이 추가된 상황입니다. 따라서 아무리 비교를 해도 기본 동작으로는 다르다는 걸 알 수 없습니다. 이건 제가 보기에는 안드로이드 버그에 가깝다고 보여지네요, 아니면 RecyclerView가 Nested 의 경우를 염두에 두고 디자인되지 않은 것으로 보입니다.. 현재 AsyncListDiffer는 이 부분을 지원하지 않고 있네요. 따라서 서브아이템을 포함한 처리를 할 때는 그냥 Adapter를 사용하거나 추가로 DiffUtil.ItemCallback을 구현해 주셔야할 것 같네요. 이게 바로 제가 아무리 찾아봐도 Nested RecyclerView의 예제로 ListAdapter를 사용한 예를 찾기가 힘들었던 이유인 것 같습니다.
ListAdapter를 사용할 때는 readonly 타입을 가지고 하는 것이 좀 더 안전해 보입니다.
아래처럼 아이템을 추가하면, 서브리스트가 갱신이 되네요.

    fun addDetail(pos: Int) { // 서브아이템 추가
        val detail = RoutineDetailModel(set.toString(), "TEST", "33")
        set += 1
        val listItems = _items.value!!.mapIndexed { index, routineModel ->
            if (index == pos) routineModel.copy(
                routineDetail = routineModel.routineDetail.plus(
                    detail
                )
            ) else routineModel
        }

        _items.value = listItems
    }

참고로, 저 같은 경우는 가능한 ArrayList는 사용하지 않습니다. 그래서 님의 코드를 보고 디버깅하는데 좀 시간이 걸렸어요.
ArrayList를 사용하실 경우 adapter 에 들어있는 RountineModel의 workout 필드를 업데이트하려고 해도 그냥은 변경이 안될 겁니다. 위에서 설명드린 마찬가지 이유입니다. 따라서 ListAdapter를 사용할 때는 위처럼 List같은 readonly를 사용하시는게 좋을 것 같습니다.
그리고 좀 생각을 해보니 이건 의도된 디자인일 것 같다는 생각이 드네요.
감사합니다. 갈수록 머리가 아프네요 선생님이 실험해보신건 ListAdapter로 진행해보신거죠?
그런데  읽기 전용인 List(listof())의 사용을 권장 하셨는데, 그럼 저처럼 데이터의 추가삭제가 많은 유동적인 상황이 자주 발생하는 경우에는
toMutable로 매번 변경가능한 list로 매번 새로 형변환? 아니면 새로운 list를 반환받아서 추가하는 방식을 사용해야하나요?

방금
private var _items: MutableLiveData<List<RoutineModel>> = MutableLiveData(listOf())
fun addRoutine(workout: String) {
        val item = RoutineModel(workout, "TEST")
        _items.value?.toMutableList()?.add(item)
    }
루틴을 추가하기위해서 이러한 코드를 사용하는데 ..
이상하게 적용이 안되어서요....


그리고 위 얘기와는 번외의 이야기입니다만, 제가 만드려는 기능이 현재 다양한 운동일지 앱에서 흔히 사용되고 있는데.. 이 기능을 그 앱들에서는 그럼 중첩리사이클러뷰를 사용안하고 말씀하신 ConcatAdapter인가? 그것이나..
아니면 Adapter를 하나만 사용해서 다루는 방식을 사용하는걸까요?


저도 계속하다보니 너무 복잡해지는것같아 그냥 다시 말씀하신 ConcatAdapter나 Adapter를 하나만 사용해서 Any타입에 부모타입과 서브타입아이템을 모조리 다넣고 이것을 Adapter내의  onCreateViewHolder에서 타입을 검사해서 나열하는 그런 방식을 써볼까하는데..어떨까요?
fun addRoutine(workout: String) {
        val itemList = _items.value?.toMutableList()!!
        val item = RoutineModel(workout, "TEST")
        itemList.add(item)
        _items.value = itemList //이 부분이 빠졌어요. 이래야 뷰쪽으로 이벤트가 전달이 됩니다.
    }

ConcatAdapter를 사용하셔도 됩니다.
val concatAdapter = ConcatAdapter(RoutineAdapter(), RoutineDetailAdapter())

Routine을 추가할 때는 아래처럼 routineAdapter, routineDetailAdapter두개를 add주고
concatAdapter.add(RoutineAdapter(), RoutineDetailAdapter())

routineDetails이 변경되면 연결된 RoutineDetailAdapter.submitList()를 호출하면 될 것 같구요.
다만 자료구조를 어떻게 가져가느냐가 핵심이 될 것 같네요.

ConcatAdapter 대신에 Adapter를 하나만 써는 것도 방법입니다. 이 방법은 현재 사용 중인 데이터구조 변경없이 적용할 수 있을 것 같아 보여요.
Routine과 RoutineDetail를 동일한 interface나 abstract class 또는 sealed class 로 만들고
아랫처럼 하면, Flat 한 구조의 리스트가 만들어질겁니다. 이걸 그냥 submitList하면 될 것 같긴하네요.

abstract class RoutineItem
data class RoutineModel(...) : RoutineItem()
data class RoutineDetailModel(...): RoutineItem()

val itemList: List<RoutineItem> = routines.flatMap { it.routineDetail }

이렇게 하면 RoutineAdapter 쪽에 뷰타입만 하나더 처리해 주면 될 것 으로 보입니다.
개인적으로는 sealed class를 만드는 것이 좋은 옵션 같아 보입니다.
sealed class RoutineItem(val viewType: Int) {
    data class Routine: ( ....) : RoutineItem(viewType = ROUTINE_ITEM_LAYOUOT_ID)
    data class Detail(...): RoutineItem(viewType = ROUTINE_DETAIL_ITEM_LAYOUOT_ID)
}

그리고 Adapter는

class RoutineListAdapter(
     val addDetailClicked: (Int) -> Unit,
    val deleteDetailClicked: (Int) -> Unit) :
    ListAdapter<RoutineItem, RecyclerView.ViewHolder>(RoutineItemDiffCallback()) {

    override fun getItemViewType(position: Int): Int  = getItem(position).viewType

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
        when (viewType) {
            ROUTINE_ITEM_LAYOUOT_ID -> RoutineViewHolder(itemView)
            ROUTINE_DETAIL_ITEM_LAYOUOT_ID -> RoutineDetailViewHolder(itemView)
        }
    }
 
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when(getItem(position)) {
            is RoutineModel-> {
                
            }
            is RoutineDetailModel -> [

            }
        }
    }
}

selead class 의 좋은 점은 onBindViewHolder 같은 데서 타입을 체크할 때 RoutineItem에 딱 두개의 클래스만 있기 대무네 enum 처럼 모든 클래스가 처리되고 있는지 체크해 줍니다. 그리고 좀 더 코드를 모듈화 하시고 싶다면 onCreateViewHolder, onBindViewHolder도 abstract 타입의 ViewHolder를 사용하고 코드를 좀 더 추가하면 저렇게 when으로 클래스를 스를 비교하는 부분도 제거할 수 있습니다.
감사합니다 읽어보니 대강 3가지 방법으로 나눌수 있겠네요..
Concatadapter 사용, abstract or interface 사용, sealed 클래스 사용.
셋다 장단점이 있는것같은데 정확하게는 모르겠네요..특히 두번째와 세번째는 어떠한 이점이 있는지 궁금해요. 첫번째 ConcatAdapter는 그냥 라이브러리 쓰는것이라 특별한것이 있어보이지는 않구요..
interface와 sealed 사용하는법 두개다 구현을 해보기는 할건데,
저도 모듈화라던지 캡슐화 라던지 좀 알아두고 싶어서 sealed 클래스로 구현하는 방법을 아마도 하지않을까 싶은데..잘될지는 모르겠네요 ㅎㅎ아직 sealed 클래스 개념도 잘모르는지라서요..
sealed 클래스 안에 여러개의 클래스를 두고 selaed 클래스에 전달받은 타입 같은것으로 사용할 클래스를 구분하는 것같은데.. 일단 블로그나 좀 뒤져가면서 공부해봐야겠네요.

제가 간혹 git 코드를 보다보면 사람들이 뭐.. Base액티비티.. 뭐 Base 어쩌고하면서 상위 파일을 만들고, 여기서 레이아웃의 아이디만 받아서 바로바로 생성?해내던데 그것과 비슷한 개념일까요?
상속을 사용하는 건 같지만, usecase는 다릅니다. seleaed class는 enum클래스를 확장한 것과 비슷합니다. enum은 추가 속성을 갖는 integer처럼 생각할 수 있지만, selead class는 enum과 같은 역할을 할 수 있으면서, class이기 때문에 확장성이 enum보다 더 좋습니다.
대표적으로 많이 사용하는 경우가 API response의 리턴타입으로 사용하는 경우입니다.

sead class Resource<out T> {
    data class Error(val e: Exception): Resource<Nothing>()
    data class Success(val value: T): Resource<T>()
}

class UserRpoisotory constructor(val api: NetworkApi) {
    fun getUsers(): Resource<List<User>> {
       return try {
           val response = api.getUsers()
           Resource.Success(response)
        } catch (e: IoException) {
           Resource.Error(e);
       }
   }
}

위의 예처럼, getUsers가 성공하면 Success를 실패하면 Error를 리턴합니다. 둘은 모두 Resource타입이므로, 위의 함수를 호출하여 처리할 때 코틀린 컴파일러가 Resource에는 Error와 Success만 있다는 것을 알기 때문에, 둘 중의 하나라도 처리를 하지 않을 경우는 경고를 해줍니다. 안전하게 코딩하기에 좋죠.

// Resource.Error나 Resource.Success 처리를 생략한다면, 컴파일어가 의심스러운 코드라고 경고를 날려 줌.
when (val result = userRepository.getUsers()) {
    is Resource.Error -> handleError(reuslt.e)
    is Resource.Success -> handleSuccess(result.value)
}

그리고 Resource클래스의 경우는 확장함수를 추가하여 좀 더 concise(정확한)하게 사용할 수가 있습니다.

inline fun <reified T> Resource<T>.onError(action: (Exception) -> Unit) {
     if (this is Resource.Error) {
        action(this.e)
     }
}


inline fun <reified T> Resource<T>.onSuccess(action: (T) -> Unit) {
     if (this is Resource.Success) {
        action(this.value)
     }
}


inline fun <reified T, R> Resource<T>.map(action: (T) -> R): Resource<R> =
     when (this) {
        is Resource.Error -> this as Resource<R>
        is Resource.Success -> Resouce.Success(action(value))
      }

val result = userRepository.getUsers())
result.onError { e -> handleError(e) }
result.onSuccess { users -> handleSuccess(users)  }

결론적으로, selead class는 클래스를 사용하면서도 제한적인 하위타입을 강제할 때 아주 유용합니다.
감사합니다. 조금 덜하긴해도 아직 어렵긴 어렵네요 sealed 클래스의 사용이..게다가 inline과 익숙치 않은 reified까지 사용하니 더욱더요..
아무튼 sealed 클래스를 이용한 방법으로는 아직 구현을 못했고,
아래 interface를 이용한 방법만 적용시켜서 해보았습니다.
interface를 이용한 방법은 코드를 따라적으면서 해보니 이해가 어느정도 됐습니다. viewmodel에서 repository를 이용한 방법도 지금 당장에서는 이해가 갔습니다.
본문에서 언급은 안했지만 Routine을 추가하기위한 Footer 뷰홀더도 있고,
데이터바인딩을 사용하기때문에 코드 수정사항이 군데군데 있었습니다.
결과는 반은 성공이고 반은 실패였습니다. 첫 루틴 추가하고 서브아이템까지 추가까지는 되는데, 두번째 루틴을 추가하고 서브아이템을 추가하려고하면 에러가나더라구요. 아직 디버깅까지는 안해봐서 원인이 뭔지는 모르겠지만 일단 결과는 이렇게 나왔습니다.

그런데 실행해보고 깨달은것이..제 Routine의 UI가 CardView로 이루어져있어고 거기에 서브 RecyclerView가 있는 형태라 지금의 코드로는 어댑터를 하나만 사용하기에 서브아이템이 Routine의 CardView 내에 추가되는것이아니라 바깥에 생성되더라구요.. 결과 이미지는 본문에 링크로 업로드하였습니다.

선생님이 보시기에 결국에는 ConcatAdapter를 사용할 수밖에 없는 구조로 보이시나요?
0 추천

Adapter 를 한개만 사용할 때, 기존 코드의 변경을 적게 가하면서, 아래처럼 할 수 있을 것 같습니다.

interface RoutineItem {
    val viewType: Int
}

data class RoutineModel(
    val workout: String,
    val unit: String,
    val details: ArrayList<RoutineDetailModel> = arrayListOf(),
    override val viewType: Int = VIEW_TYPE
) : RoutineItem {
    companion object {
        const val VIEW_TYPE = R.layout.item_routine
    }
}

data class RoutineDetailModel(
    val id: String,
    val set: String,
    val rep: String,
    override val viewType: Int = VIEW_TYPE
) : RoutineItem {
    companion object {
        const val VIEW_TYPE = R.layout.item_routine_detail
    }
}

class RoutineListAdapter(
    val addDetailClicked: (Int) -> Unit,
    val deleteDetailClicked: (Int) -> Unit
) : ListAdapter<RoutineItem, RecyclerView.ViewHolder>(DIFF_CALLBACK) {

    override fun getItemViewType(position: Int): Int = getItem(position).viewType

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
        return when (viewType) {
            RoutineModel.VIEW_TYPE -> RoutineViewHolder(
                itemView,
                addDetailClicked,
                deleteDetailClicked
            )
            RoutineDetailModel.VIEW_TYPE -> RoutineDetailViewHolder(itemView)
            else -> throw IllegalArgumentException("Invalid view type: $viewType")
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val item = getItem(position)
        when (holder) {
            is RoutineViewHolder -> holder.bind(item as RoutineModel)
            is RoutineDetailViewHolder -> holder.bind(item as RoutineDetailModel)
        }
    }

    companion object {
        private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<RoutineItem>() {
            override fun areItemsTheSame(
                oldItem: RoutineItem,
                newItem: RoutineItem
            ): Boolean {
                return oldItem.javaClass == newItem.javaClass && oldItem == newItem
            }

            override fun areContentsTheSame(
                oldItem: RoutineItem,
                newItem: RoutineItem
            ): Boolean {
                return oldItem == newItem
            }
        }
    }
}

class WriteRoutineViewModel(
    private val repository: RoutineRepository = RoutineRepository()
) : ViewModel() {

    private val _items: MutableLiveData<List<RoutineItem>> = MutableLiveData(listOf())
    val items: LiveData<List<RoutineItem>> = _items

    fun addRoutine(workout: String) { // 부모아이템 추가
        repository.addRoutine(RoutineModel(workout, "TEST"))
        emitListItems()
    }

    fun addDetail(pos: Int) { // 서브아이템 추가
        val detail = RoutineDetailModel(routineUseCase.setId.toString(), "TEST", "33")
        repository.addDetail(pos, detail)
        emitListItems()
    }

    fun deleteDetail(pos: Int) { // 서브아이템 삭제
        repository.deleteDetail(pos)
        emitListItems()
    }

    private fun emitListItems() {
        _items.postValue(repository.getListItems())
    }
}

// 리스트 관리하는 클래스는 아무래도 분리하는게 깔끔할 것 같아서 별도의 클래스로 만듦.
class RoutineRepository() {
    private val routines = arrayListOf<RoutineModel>()
    var setId = 1
        private set

    fun addRoutine(routine: RoutineModel) {
        routines.add(routine)
    }

    fun addDetail(pos: Int, detail: RoutineDetailModel) {
        routines[pos].details.add(detail)
        setId++
    }

    fun deleteDetail(pos: Int) {
        if (routines[pos].details.isEmpty()) {
            routines.removeAt(pos)
            return
        }

        routines[pos].details.removeAt(routines[pos].details.lastIndex)
    }

    fun getListItems(): List<RoutineItem> {
        val result = arrayListOf<RoutineItem>()
        for (routine in routines) {
            result.add(routine)
            result.addAll(routine.details)
        }
        return result
    }
}

 

spark (224,800 포인트) 님이 2021년 9월 11일 답변
...