From 5bcd0f64d2d545af0b9dc23a0d3ae01520fd37eb Mon Sep 17 00:00:00 2001 From: klaus Date: Sun, 12 Jul 2026 01:24:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(content):=20=EC=A0=84=EC=B2=B4=20=ED=83=AD?= =?UTF-8?q?=20pagination=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=9D=84=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/main/content/ContentMainFragment.kt | 10 ++-- .../content/ui/ContentAllAudioCardAdapter.kt | 24 +++++++-- .../content/ui/ContentAllSeriesCardAdapter.kt | 24 +++++++-- .../content/ContentMainFragmentSourceTest.kt | 53 +++++++++++++++++++ 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt index 10b4cf44..877b38f4 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt @@ -411,11 +411,11 @@ class ContentMainFragment : BaseFragment( hideAllTabEmptyError() updateAllTabGridItemWidth() if (state.selectedType.usesSeriesItems()) { - binding.rvContentAllItems.adapter = contentAllSeriesCardAdapter + setAllTabAdapterIfNeeded(contentAllSeriesCardAdapter) contentAllAudioCardAdapter.submitItems(emptyList()) contentAllSeriesCardAdapter.submitItems(state.seriesItems) } else { - binding.rvContentAllItems.adapter = contentAllAudioCardAdapter + setAllTabAdapterIfNeeded(contentAllAudioCardAdapter) contentAllSeriesCardAdapter.submitItems(emptyList()) contentAllAudioCardAdapter.submitItems(state.audioItems) } @@ -439,6 +439,11 @@ class ContentMainFragment : BaseFragment( contentAllSeriesCardAdapter.setGridItemWidthPx(widthPx) } + private fun setAllTabAdapterIfNeeded(adapter: RecyclerView.Adapter<*>) { + if (binding.rvContentAllItems.adapter == adapter) return + binding.rvContentAllItems.adapter = adapter + } + private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) { bindAllTabControls(state) binding.layoutContentAllSurface.visibility = View.VISIBLE @@ -485,7 +490,6 @@ class ContentMainFragment : BaseFragment( } private fun clearAllTabItems() { - binding.rvContentAllItems.adapter = null contentAllAudioCardAdapter.submitItems(emptyList()) contentAllSeriesCardAdapter.submitItems(emptyList()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt index cb08ce5f..7c521049 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.content.ui import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.databinding.ItemContentAudioCardBinding import kr.co.vividnext.sodalive.extensions.loadUrl @@ -17,12 +18,13 @@ class ContentAllAudioCardAdapter( fun setGridItemWidthPx(widthPx: Int) { if (widthPx <= 0 || gridItemWidthPx == widthPx) return gridItemWidthPx = widthPx - notifyDataSetChanged() + if (items.isNotEmpty()) notifyItemRangeChanged(0, items.size) } - fun submitItems(items: List) { - this.items = items - notifyDataSetChanged() + fun submitItems(newItems: List) { + val diffResult = DiffUtil.calculateDiff(ContentAllAudioDiffCallback(items, newItems)) + items = newItems + diffResult.dispatchUpdatesTo(this) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { @@ -41,6 +43,20 @@ class ContentAllAudioCardAdapter( override fun getItemCount(): Int = items.size + private class ContentAllAudioDiffCallback( + private val oldList: List, + private val newList: List + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition].audioContentId == newList[newItemPosition].audioContentId + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition] == newList[newItemPosition] + } + class ViewHolder( private val binding: ItemContentAudioCardBinding, private val onAudioClick: (Long) -> Unit diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt index 8ca3983d..60b6fcaa 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.content.ui import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.databinding.ItemContentAllSeriesCardBinding import kr.co.vividnext.sodalive.extensions.loadUrl @@ -17,12 +18,13 @@ class ContentAllSeriesCardAdapter( fun setGridItemWidthPx(widthPx: Int) { if (widthPx <= 0 || gridItemWidthPx == widthPx) return gridItemWidthPx = widthPx - notifyDataSetChanged() + if (items.isNotEmpty()) notifyItemRangeChanged(0, items.size) } - fun submitItems(items: List) { - this.items = items - notifyDataSetChanged() + fun submitItems(newItems: List) { + val diffResult = DiffUtil.calculateDiff(ContentAllSeriesDiffCallback(items, newItems)) + items = newItems + diffResult.dispatchUpdatesTo(this) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { @@ -41,6 +43,20 @@ class ContentAllSeriesCardAdapter( override fun getItemCount(): Int = items.size + private class ContentAllSeriesDiffCallback( + private val oldList: List, + private val newList: List + ) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition].seriesId == newList[newItemPosition].seriesId + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition] == newList[newItemPosition] + } + class ViewHolder( private val binding: ItemContentAllSeriesCardBinding, private val onSeriesClick: (Long) -> Unit diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt index c0f850f1..631a0d32 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt @@ -572,6 +572,59 @@ class ContentMainFragmentSourceTest { assertSourceContains(seriesCard, "172f / 122f") } + @Test + fun `content 전체 탭 pagination 렌더링은 RecyclerView adapter를 보존한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" + ).readText() + val bindContentSource = source.substringFrom("private fun bindAllTabContent") + val clearItemsSource = source.substringFrom("private fun clearAllTabItems") + val destroyViewSource = source.substringFrom("override fun onDestroyView") + + assertSourceContains(source, "private fun setAllTabAdapterIfNeeded(adapter: RecyclerView.Adapter<*>)") + assertSourceContains(source, "if (binding.rvContentAllItems.adapter == adapter) return") + assertSourceContains(bindContentSource, "setAllTabAdapterIfNeeded(contentAllSeriesCardAdapter)") + assertSourceContains(bindContentSource, "setAllTabAdapterIfNeeded(contentAllAudioCardAdapter)") + assertFalse( + "페이지 추가 상태 재렌더링은 같은 adapter를 직접 재대입하면 스크롤 위치가 흔들릴 수 있다.", + bindContentSource.contains("binding.rvContentAllItems.adapter = contentAll") + ) + assertFalse( + "Loading/Empty/Error 렌더링의 item clear는 adapter를 분리하지 않아야 한다.", + clearItemsSource.contains("binding.rvContentAllItems.adapter = null") + ) + assertSourceContains(destroyViewSource, "binding.rvContentAllItems.adapter = null") + } + + @Test + fun `content 전체 탭 adapter는 DiffUtil로 변경분만 반영한다`() { + val audioAdapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllAudioCardAdapter.kt" + ).readText() + val seriesAdapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentAllSeriesCardAdapter.kt" + ).readText() + val audioSubmitSource = audioAdapter.substringFrom("fun submitItems") + val seriesSubmitSource = seriesAdapter.substringFrom("fun submitItems") + + assertSourceContains(audioAdapter, "import androidx.recyclerview.widget.DiffUtil") + assertSourceContains(seriesAdapter, "import androidx.recyclerview.widget.DiffUtil") + assertSourceContains(audioSubmitSource, "DiffUtil.calculateDiff") + assertSourceContains(seriesSubmitSource, "DiffUtil.calculateDiff") + assertSourceContains(audioSubmitSource, "diffResult.dispatchUpdatesTo(this)") + assertSourceContains(seriesSubmitSource, "diffResult.dispatchUpdatesTo(this)") + assertSourceContains(audioAdapter, "oldList[oldItemPosition].audioContentId ==") + assertSourceContains(seriesAdapter, "oldList[oldItemPosition].seriesId ==") + assertSourceContains(audioAdapter, "oldList[oldItemPosition] == newList[newItemPosition]") + assertSourceContains(seriesAdapter, "oldList[oldItemPosition] == newList[newItemPosition]") + assertFalse(audioSubmitSource.contains("notifyDataSetChanged()")) + assertFalse(seriesSubmitSource.contains("notifyDataSetChanged()")) + assertFalse(audioAdapter.contains("notifyDataSetChanged()")) + assertFalse(seriesAdapter.contains("notifyDataSetChanged()")) + assertSourceContains(audioAdapter, "notifyItemRangeChanged(0, items.size)") + assertSourceContains(seriesAdapter, "notifyItemRangeChanged(0, items.size)") + } + private fun banner( eventItem: EventItem? = null, creatorId: Long? = null,