fix(creator): 라이브 탭 하단 CTA와 sticky 전환을 보정한다

This commit is contained in:
2026-06-18 15:21:18 +09:00
parent a1e8f8edb3
commit f4af9868e6
3 changed files with 183 additions and 43 deletions

View File

@@ -14,7 +14,6 @@ import android.view.View.MeasureSpec
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -80,6 +79,7 @@ class CreatorChannelActivity :
private var statusBarHeight: Int = 0
private var tabLayoutMediator: TabLayoutMediator? = null
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
private var lastSelectedCreatorChannelTabPosition: Int? = null
private var isOwnerFabExpanded: Boolean = false
private var isOwnerFabAnimating: Boolean = false
private lateinit var loadingDialog: LoadingDialog
@@ -129,6 +129,7 @@ class CreatorChannelActivity :
setStatusBarIconAppearance()
setTitleBarTopInset()
setupOwnerFabInsets()
setupLiveOwnerCtaInsets()
setupScrollListener()
setupClickListeners()
setupLiveEntryObservers()
@@ -159,6 +160,7 @@ class CreatorChannelActivity :
binding.ownerFabCommunityButton.setOnClickListener { onOwnerFabCommunityClicked() }
binding.ownerFabAudioButton.setOnClickListener { onOwnerFabAudioClicked() }
binding.ownerFabLiveButton.setOnClickListener { onOwnerFabLiveClicked() }
binding.btnCreatorChannelLiveOwnerCta.setOnClickListener { onLiveOwnerCtaClicked() }
binding.tvChatButton.setOnClickListener {
currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) }
}
@@ -308,6 +310,22 @@ class CreatorChannelActivity :
binding.tvTitleNickname.isVisible = shouldUseBlackTitleBar
}
private fun adjustCreatorChannelStickyAnchorOnTabSelected(position: Int) {
val previousPosition = lastSelectedCreatorChannelTabPosition
lastSelectedCreatorChannelTabPosition = position
if (previousPosition == null || previousPosition == position) return
val stickyScrollY = calculateCreatorChannelStickyScrollY()
if (binding.nestedScrollView.scrollY < stickyScrollY) {
binding.nestedScrollView.scrollTo(0, stickyScrollY)
}
}
private fun calculateCreatorChannelStickyScrollY(): Int {
val stickyTop = CreatorChannelScrollState.calculateStickyTop(statusBarHeight, baseTitleBarHeight)
return (binding.headerContainer.height - stickyTop).coerceAtLeast(0)
}
private fun setStatusBarIconAppearance() {
WindowCompat.getInsetsController(window, binding.root).isAppearanceLightStatusBars = false
}
@@ -364,12 +382,16 @@ class CreatorChannelActivity :
}.also {
it.attach()
}
lastSelectedCreatorChannelTabPosition = binding.viewPager.currentItem
val callback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
adjustCreatorChannelStickyAnchorOnTabSelected(position)
if (position != CreatorChannelTab.Home.ordinal) {
collapseOwnerFab(animate = false)
}
updateOwnerFabVisibility()
updateLiveOwnerCtaVisibility()
updateCreatorChannelLiveViewportHeight()
updateViewPagerHeight()
if (position == CreatorChannelTab.Live.ordinal) {
binding.viewPager.post {
@@ -387,7 +409,7 @@ class CreatorChannelActivity :
bindHeader(header)
bindTitleBar(header)
updateOwnerFabVisibility()
findLiveFragment()?.onCreatorChannelOwnerChanged(header.isOwner)
updateLiveOwnerCtaVisibility()
}
override fun onCreatorChannelFollowProgressChanged(inProgress: Boolean) {
@@ -422,28 +444,28 @@ class CreatorChannelActivity :
}
override fun onCreatorChannelLiveContentChanged() {
updateCreatorChannelLiveViewportHeight()
updateViewPagerHeight()
postCheckCreatorChannelLiveNeedsMore()
}
override fun isCreatorChannelOwner(): Boolean {
return currentHeader?.isOwner == true
private fun setupOwnerFabInsets() {
binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt())
}
private fun setupOwnerFabInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.ownerFabButton) { _, insets ->
val navigationBottomInset = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
val bottomMargin = OWNER_FAB_BASE_MARGIN_DP.dpToPx().toInt() + navigationBottomInset
binding.ownerFabButton.updateLayoutParams<ConstraintLayout.LayoutParams> {
this.bottomMargin = bottomMargin
}
binding.ownerFabExpandedContainer.updateLayoutParams<ConstraintLayout.LayoutParams> {
this.bottomMargin = bottomMargin
}
binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt())
insets
}
ViewCompat.requestApplyInsets(binding.ownerFabButton)
private fun setupLiveOwnerCtaInsets() {
updateLiveOwnerCtaVisibility()
}
private fun updateLiveOwnerCtaVisibility() {
val shouldShowLiveOwnerCta = shouldShowLiveOwnerCta()
binding.layoutCreatorChannelLiveOwnerCta.isVisible = shouldShowLiveOwnerCta
binding.btnCreatorChannelLiveOwnerCta.isEnabled = true
findLiveFragment()?.onCreatorChannelLiveOwnerCtaVisibilityChanged(shouldShowLiveOwnerCta)
}
private fun shouldShowLiveOwnerCta(): Boolean {
return currentHeader?.isOwner == true && binding.viewPager.currentItem == CreatorChannelTab.Live.ordinal
}
private fun expandOwnerFab() {
@@ -534,6 +556,11 @@ class CreatorChannelActivity :
liveRoomCreateLauncher.launch(Intent(this, LiveRoomCreateActivity::class.java))
}
private fun onLiveOwnerCtaClicked() {
binding.btnCreatorChannelLiveOwnerCta.isEnabled = false
onOwnerFabLiveClicked()
}
override fun onCreatorChannelDonationClicked() {
val header = currentHeader ?: return
if (header.isOwner) return
@@ -561,10 +588,6 @@ class CreatorChannelActivity :
startAudioContentDetail(audioContentId)
}
override fun onCreatorChannelLiveStartClicked() {
onOwnerFabLiveClicked()
}
private fun findLiveFragment(): CreatorChannelLiveFragment? {
val fragmentTag = "f${CreatorChannelTab.Live.ordinal}"
return supportFragmentManager.findFragmentByTag(fragmentTag) as? CreatorChannelLiveFragment
@@ -636,10 +659,10 @@ class CreatorChannelActivity :
}
private fun updateViewPagerHeight() {
updateCreatorChannelLiveViewportHeight()
binding.viewPager.post {
val recyclerView = binding.viewPager.getChildAt(0) as? RecyclerView ?: return@post
val currentPage = recyclerView.layoutManager?.findViewByPosition(binding.viewPager.currentItem) ?: return@post
currentPage.minimumHeight = calculateCreatorChannelTabViewportHeight()
val widthSpec = MeasureSpec.makeMeasureSpec(binding.viewPager.width, MeasureSpec.EXACTLY)
val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
currentPage.measure(widthSpec, heightSpec)
@@ -652,6 +675,23 @@ class CreatorChannelActivity :
}
}
private fun updateCreatorChannelLiveViewportHeight() {
if (binding.viewPager.currentItem != CreatorChannelTab.Live.ordinal) return
findLiveFragment()?.onCreatorChannelLiveViewportHeightChanged(
calculateCreatorChannelLiveEmptyMinHeight()
)
}
private fun calculateCreatorChannelLiveEmptyMinHeight(): Int {
val stickyScrollY = calculateCreatorChannelStickyScrollY()
val visibleTabViewportHeight = binding.nestedScrollView.height - binding.tabLayout.height
val scrollRangeRequiredHeight = binding.nestedScrollView.height +
stickyScrollY -
binding.headerContainer.height
return maxOf(visibleTabViewportHeight, scrollRangeRequiredHeight, 0)
}
private fun postCheckCreatorChannelLiveNeedsMore() {
binding.nestedScrollView.post {
checkCreatorChannelLiveNeedsMore()
@@ -672,10 +712,6 @@ class CreatorChannelActivity :
}
}
private fun calculateCreatorChannelTabViewportHeight(): Int {
return (binding.nestedScrollView.height - binding.tabLayout.height).coerceAtLeast(0)
}
private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse) {
when (schedule.type) {
CreatorActivityType.Audio,
@@ -711,6 +747,11 @@ class CreatorChannelActivity :
)
}
override fun onResume() {
super.onResume()
binding.btnCreatorChannelLiveOwnerCta.isEnabled = true
}
override fun onDestroy() {
tabLayoutMediator?.detach()
pageChangeCallback?.let { callback ->
@@ -741,7 +782,6 @@ class CreatorChannelActivity :
companion object {
const val EXTRA_CREATOR_ID: String = "extra_creator_id"
private const val OWNER_FAB_BASE_MARGIN_DP = 14
private const val OWNER_FAB_CONTENT_BOTTOM_PADDING_DP = 96
private const val OWNER_FAB_ANIMATION_DURATION_MS = 260L
private const val OWNER_FAB_SPRING_MASS = 1f

View File

@@ -238,6 +238,45 @@
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<FrameLayout
android:id="@+id/layout_creator_channel_live_owner_cta"
android:layout_width="0dp"
android:layout_height="100dp"
android:background="@color/black"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:id="@+id/btn_creator_channel_live_owner_cta"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginHorizontal="@dimen/spacing_14"
android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="horizontal">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_create_live" />
<TextView
style="@style/Typography.Heading3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:includeFontPadding="false"
android:text="@string/creator_channel_live_start_button"
android:textColor="@color/white" />
</LinearLayout>
</FrameLayout>
<View
android:id="@+id/owner_fab_dim"
android:layout_width="0dp"

View File

@@ -932,16 +932,57 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("override fun onCreatorChannelLiveContentChanged()"))
assertTrue(source.contains("postCheckCreatorChannelLiveNeedsMore()"))
assertTrue(source.contains("updateViewPagerHeight()"))
assertTrue(source.contains("updateCreatorChannelLiveViewportHeight()"))
assertTrue(source.contains("if (position == CreatorChannelTab.Live.ordinal)"))
assertTrue(source.contains("findLiveFragment()?.onCreatorChannelLiveTabSelected()"))
assertTrue(source.contains("binding.viewPager.offscreenPageLimit = CreatorChannelTab.entries.size - 1"))
assertTrue(source.contains("currentPage.minimumHeight = calculateCreatorChannelTabViewportHeight()"))
assertTrue(source.contains("private fun calculateCreatorChannelTabViewportHeight(): Int"))
assertFalse(source.contains("currentPage.minimumHeight = calculateCreatorChannelTabViewportHeight()"))
assertFalse(source.contains("private fun calculateCreatorChannelTabViewportHeight(): Int"))
assertTrue(fragment.contains("fun onCreatorChannelLiveScrolledToBottom()"))
assertTrue(fragment.contains("fun onCreatorChannelLiveTabSelected()"))
assertTrue(fragment.contains("fun onCreatorChannelLiveViewportHeightChanged(minHeight: Int)"))
assertTrue(fragment.contains("fun onCreatorChannelLiveContentChanged()"))
}
@Test
fun `라이브 empty 최소 높이는 sticky anchor 이후 탭 viewport 기준으로 전달한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertTrue(source.contains("private fun updateCreatorChannelLiveViewportHeight()"))
assertTrue(source.contains("findLiveFragment()?.onCreatorChannelLiveViewportHeightChanged"))
assertTrue(source.contains("calculateCreatorChannelLiveEmptyMinHeight()"))
assertTrue(source.contains("private fun calculateCreatorChannelLiveEmptyMinHeight(): Int"))
assertTrue(source.contains("val stickyScrollY = calculateCreatorChannelStickyScrollY()"))
assertTrue(source.contains("binding.nestedScrollView.height - binding.tabLayout.height"))
assertTrue(source.contains("val scrollRangeRequiredHeight = binding.nestedScrollView.height +"))
assertTrue(source.contains("stickyScrollY -"))
assertTrue(source.contains("binding.headerContainer.height"))
assertTrue(source.contains("return maxOf(visibleTabViewportHeight, scrollRangeRequiredHeight, 0)"))
}
@Test
fun `탭 전환은 sticky tabbar anchor 아래로 내려간 scroll 위치를 되돌리지 않고 부족할 때만 보정한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertTrue(source.contains("private var lastSelectedCreatorChannelTabPosition: Int? = null"))
assertTrue(source.contains("lastSelectedCreatorChannelTabPosition = binding.viewPager.currentItem"))
assertTrue(source.contains("lastSelectedCreatorChannelTabPosition = position"))
assertTrue(source.contains("adjustCreatorChannelStickyAnchorOnTabSelected(position)"))
assertTrue(source.contains("private fun adjustCreatorChannelStickyAnchorOnTabSelected(position: Int)"))
assertTrue(source.contains("val previousPosition = lastSelectedCreatorChannelTabPosition"))
assertTrue(source.contains("if (previousPosition == null || previousPosition == position)"))
assertTrue(source.contains("val stickyScrollY = calculateCreatorChannelStickyScrollY()"))
assertTrue(source.contains("if (binding.nestedScrollView.scrollY < stickyScrollY)"))
assertTrue(source.contains("binding.nestedScrollView.scrollTo(0, stickyScrollY)"))
assertTrue(source.contains("private fun calculateCreatorChannelStickyScrollY(): Int"))
assertTrue(source.contains("CreatorChannelScrollState.calculateStickyTop(statusBarHeight, baseTitleBarHeight)"))
assertTrue(source.contains("return (binding.headerContainer.height - stickyTop).coerceAtLeast(0)"))
}
@Test
fun `라이브 content 변경은 현재 scroll bottom 조건을 재평가한다`() {
val source = projectFile(
@@ -999,18 +1040,39 @@ class CreatorChannelActivitySourceTest {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt"
).readText()
val activityLayout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
assertTrue(source.contains("findLiveFragment()?.onCreatorChannelOwnerChanged(header.isOwner)"))
assertTrue(source.contains("override fun isCreatorChannelOwner(): Boolean"))
assertTrue(activityLayout.contains("android:id=\"@+id/layout_creator_channel_live_owner_cta\""))
assertTrue(source.contains("updateLiveOwnerCtaVisibility()"))
assertTrue(
source.contains(
"currentHeader?.isOwner == true && binding.viewPager.currentItem == CreatorChannelTab.Live.ordinal"
)
)
assertTrue(source.contains("binding.layoutCreatorChannelLiveOwnerCta.isVisible = shouldShowLiveOwnerCta"))
assertTrue(source.contains("findLiveFragment()?.onCreatorChannelLiveOwnerCtaVisibilityChanged(shouldShowLiveOwnerCta)"))
assertTrue(source.contains("return currentHeader?.isOwner == true"))
assertTrue(source.contains("override fun onCreatorChannelLiveStartClicked()"))
assertTrue(source.contains("binding.btnCreatorChannelLiveOwnerCta.setOnClickListener { onLiveOwnerCtaClicked() }"))
assertTrue(source.contains("onOwnerFabLiveClicked()"))
assertTrue(source.contains("liveRoomCreateLauncher.launch(Intent(this, LiveRoomCreateActivity::class.java))"))
assertTrue(fragment.contains("fun onCreatorChannelOwnerChanged(isOwner: Boolean)"))
assertTrue(fragment.contains("host.onCreatorChannelLiveStartClicked()"))
}
@Test
fun `크리에이터 채널 하단 고정 UI는 BaseActivity root bottom padding과 navigation inset을 중복 적용하지 않는다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertFalse(source.contains("binding.ownerFabButton.updateLayoutParams<ConstraintLayout.LayoutParams>"))
assertFalse(source.contains("binding.ownerFabExpandedContainer.updateLayoutParams<ConstraintLayout.LayoutParams>"))
assertFalse(source.contains("binding.layoutCreatorChannelLiveOwnerCta.updateLayoutParams<ConstraintLayout.LayoutParams>"))
assertFalse(source.contains("WindowInsetsCompat.Type.navigationBars()).bottom"))
assertTrue(
source.contains(
"binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt())"
)
)
assertFalse(source.contains("binding.nestedScrollView.updatePadding(bottom = liveOwnerCtaBottomPadding)"))
}
@Test
@@ -1644,17 +1706,16 @@ class CreatorChannelActivitySourceTest {
}
@Test
fun `Phase 13 owner FAB source는 spring animation과 navigation inset을 적용한다`() {
fun `Phase 13 owner FAB source는 spring animation과 content padding을 적용한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertTrue(source.contains("private var isOwnerFabAnimating: Boolean = false"))
assertTrue(source.contains("setupOwnerFabInsets()"))
assertTrue(source.contains("WindowInsetsCompat.Type.navigationBars()"))
assertTrue(source.contains("OWNER_FAB_BASE_MARGIN_DP.dpToPx().toInt() + navigationBottomInset"))
assertTrue(source.contains("binding.ownerFabButton.updateLayoutParams<ConstraintLayout.LayoutParams>"))
assertTrue(source.contains("binding.ownerFabExpandedContainer.updateLayoutParams<ConstraintLayout.LayoutParams>"))
assertFalse(source.contains("OWNER_FAB_BASE_MARGIN_DP.dpToPx().toInt() + navigationBottomInset"))
assertFalse(source.contains("binding.ownerFabButton.updateLayoutParams<ConstraintLayout.LayoutParams>"))
assertFalse(source.contains("binding.ownerFabExpandedContainer.updateLayoutParams<ConstraintLayout.LayoutParams>"))
assertTrue(
source.contains(
"binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt())"