feat(creator): 본인 FAB 액션을 연결한다

This commit is contained in:
2026-06-16 22:24:30 +09:00
parent 6a6b1138a8
commit 5d52787ea9
2 changed files with 184 additions and 7 deletions

View File

@@ -1,13 +1,18 @@
package kr.co.vividnext.sodalive.v2.creator.channel package kr.co.vividnext.sodalive.v2.creator.channel
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.view.animation.Interpolator
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.MeasureSpec import android.view.View.MeasureSpec
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -20,6 +25,9 @@ import com.google.android.material.tabs.TabLayoutMediator
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.CreatorCommunityWriteActivity
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
@@ -54,6 +62,7 @@ class CreatorChannelActivity :
private var tabLayoutMediator: TabLayoutMediator? = null private var tabLayoutMediator: TabLayoutMediator? = null
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
private var isOwnerFabExpanded: Boolean = false private var isOwnerFabExpanded: Boolean = false
private var isOwnerFabAnimating: Boolean = false
private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() } private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() }
override val shouldApplySystemBarTopInset: Boolean = false override val shouldApplySystemBarTopInset: Boolean = false
@@ -68,6 +77,7 @@ class CreatorChannelActivity :
setupTabsAndPager() setupTabsAndPager()
setStatusBarIconAppearance() setStatusBarIconAppearance()
setTitleBarTopInset() setTitleBarTopInset()
setupOwnerFabInsets()
setupScrollListener() setupScrollListener()
setupClickListeners() setupClickListeners()
} }
@@ -80,6 +90,9 @@ class CreatorChannelActivity :
binding.ownerFabButton.setOnClickListener { expandOwnerFab() } binding.ownerFabButton.setOnClickListener { expandOwnerFab() }
binding.ownerFabDim.setOnClickListener { collapseOwnerFab() } binding.ownerFabDim.setOnClickListener { collapseOwnerFab() }
binding.ownerFabCloseButton.setOnClickListener { collapseOwnerFab() } binding.ownerFabCloseButton.setOnClickListener { collapseOwnerFab() }
binding.ownerFabCommunityButton.setOnClickListener { onOwnerFabCommunityClicked() }
binding.ownerFabAudioButton.setOnClickListener { onOwnerFabAudioClicked() }
binding.ownerFabLiveButton.setOnClickListener { onOwnerFabLiveClicked() }
binding.tvChatButton.setOnClickListener { binding.tvChatButton.setOnClickListener {
currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) } currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) }
} }
@@ -271,7 +284,7 @@ class CreatorChannelActivity :
val callback = object : ViewPager2.OnPageChangeCallback() { val callback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
if (position != CreatorChannelTab.Home.ordinal) { if (position != CreatorChannelTab.Home.ordinal) {
isOwnerFabExpanded = false collapseOwnerFab(animate = false)
} }
updateOwnerFabVisibility() updateOwnerFabVisibility()
updateViewPagerHeight() updateViewPagerHeight()
@@ -319,14 +332,75 @@ class CreatorChannelActivity :
updateViewPagerHeight() updateViewPagerHeight()
} }
private fun expandOwnerFab() { private fun setupOwnerFabInsets() {
isOwnerFabExpanded = true ViewCompat.setOnApplyWindowInsetsListener(binding.ownerFabButton) { _, insets ->
updateOwnerFabVisibility() 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 collapseOwnerFab() { private fun expandOwnerFab() {
if (isOwnerFabAnimating) return
if (isOwnerFabExpanded) return
isOwnerFabExpanded = true
animateOwnerFab(expand = true)
}
private fun collapseOwnerFab(animate: Boolean = true) {
if (isOwnerFabAnimating) return
if (!isOwnerFabExpanded) {
updateOwnerFabVisibility()
return
}
isOwnerFabExpanded = false isOwnerFabExpanded = false
updateOwnerFabVisibility() if (animate) {
animateOwnerFab(expand = false)
} else {
updateOwnerFabVisibility()
}
}
private fun animateOwnerFab(expand: Boolean) {
isOwnerFabAnimating = true
binding.ownerFabDim.isVisible = true
binding.ownerFabExpandedContainer.isVisible = true
binding.ownerFabButton.isVisible = true
val start = if (expand) 0f else 1f
val end = if (expand) 1f else 0f
ValueAnimator.ofFloat(start, end).apply {
duration = OWNER_FAB_ANIMATION_DURATION_MS
interpolator = SpringInterpolator(
mass = OWNER_FAB_SPRING_MASS,
stiffness = OWNER_FAB_SPRING_STIFFNESS,
damping = OWNER_FAB_SPRING_DAMPING
)
addUpdateListener { animator ->
val value = animator.animatedValue as Float
binding.ownerFabDim.alpha = value
binding.ownerFabExpandedContainer.alpha = value
binding.ownerFabExpandedContainer.scaleX = value
binding.ownerFabExpandedContainer.scaleY = value
binding.ownerFabButton.alpha = 1f - value
}
addListener(
onEnd = {
isOwnerFabAnimating = false
updateOwnerFabVisibility()
}
)
start()
}
} }
private fun updateOwnerFabVisibility() { private fun updateOwnerFabVisibility() {
@@ -335,6 +409,31 @@ class CreatorChannelActivity :
binding.ownerFabDim.isVisible = shouldShowOwnerFab && isOwnerFabExpanded binding.ownerFabDim.isVisible = shouldShowOwnerFab && isOwnerFabExpanded
binding.ownerFabExpandedContainer.isVisible = shouldShowOwnerFab && isOwnerFabExpanded binding.ownerFabExpandedContainer.isVisible = shouldShowOwnerFab && isOwnerFabExpanded
binding.ownerFabButton.isVisible = shouldShowOwnerFab && !isOwnerFabExpanded binding.ownerFabButton.isVisible = shouldShowOwnerFab && !isOwnerFabExpanded
if (!shouldShowOwnerFab) {
isOwnerFabExpanded = false
}
if (!binding.ownerFabExpandedContainer.isVisible) {
binding.ownerFabDim.alpha = 1f
binding.ownerFabExpandedContainer.alpha = 1f
binding.ownerFabExpandedContainer.scaleX = 1f
binding.ownerFabExpandedContainer.scaleY = 1f
binding.ownerFabButton.alpha = 1f
}
}
private fun onOwnerFabCommunityClicked() {
collapseOwnerFab(animate = false)
startActivity(Intent(this, CreatorCommunityWriteActivity::class.java))
}
private fun onOwnerFabAudioClicked() {
collapseOwnerFab(animate = false)
startActivity(Intent(this, AudioContentUploadActivity::class.java))
}
private fun onOwnerFabLiveClicked() {
collapseOwnerFab(animate = false)
startActivity(Intent(this, LiveRoomCreateActivity::class.java))
} }
override fun onCreatorChannelDonationClicked() { override fun onCreatorChannelDonationClicked() {
@@ -424,8 +523,33 @@ class CreatorChannelActivity :
super.onDestroy() super.onDestroy()
} }
private fun ValueAnimator.addListener(onEnd: () -> Unit) {
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) = onEnd()
})
}
private class SpringInterpolator(
private val mass: Float,
private val stiffness: Float,
private val damping: Float
) : Interpolator {
override fun getInterpolation(input: Float): Float {
val angularFrequency = kotlin.math.sqrt(stiffness / mass)
val decay = kotlin.math.exp(-damping / (2f * mass) * input)
val oscillation = kotlin.math.cos(angularFrequency * input)
return (1f - decay * oscillation).coerceIn(0f, 1f)
}
}
companion object { companion object {
const val EXTRA_CREATOR_ID: String = "extra_creator_id" 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
private const val OWNER_FAB_SPRING_STIFFNESS = 256f
private const val OWNER_FAB_SPRING_DAMPING = 24f
fun newIntent(context: Context, creatorId: Long): Intent { fun newIntent(context: Context, creatorId: Long): Intent {
return Intent(context, CreatorChannelActivity::class.java).apply { return Intent(context, CreatorChannelActivity::class.java).apply {

View File

@@ -1415,7 +1415,11 @@ class CreatorChannelActivitySourceTest {
assertTrue(strings.contains("name=\"creator_channel_owner_fab_close\">닫기")) assertTrue(strings.contains("name=\"creator_channel_owner_fab_close\">닫기"))
assertTrue(source.contains("private var isOwnerFabExpanded: Boolean = false")) assertTrue(source.contains("private var isOwnerFabExpanded: Boolean = false"))
assertTrue(source.contains("updateOwnerFabVisibility()")) assertTrue(source.contains("updateOwnerFabVisibility()"))
assertTrue(source.contains("currentHeader?.isOwner == true && binding.viewPager.currentItem == CreatorChannelTab.Home.ordinal")) assertTrue(
source.contains(
"currentHeader?.isOwner == true && binding.viewPager.currentItem == CreatorChannelTab.Home.ordinal"
)
)
assertTrue(source.contains("binding.ownerFabDim.setOnClickListener { collapseOwnerFab() }")) assertTrue(source.contains("binding.ownerFabDim.setOnClickListener { collapseOwnerFab() }"))
assertTrue(source.contains("binding.ownerFabCloseButton.setOnClickListener { collapseOwnerFab() }")) assertTrue(source.contains("binding.ownerFabCloseButton.setOnClickListener { collapseOwnerFab() }"))
assertTrue(source.contains("binding.ownerFabButton.setOnClickListener { expandOwnerFab() }")) assertTrue(source.contains("binding.ownerFabButton.setOnClickListener { expandOwnerFab() }"))
@@ -1424,6 +1428,55 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("binding.ownerFabButton.isVisible = shouldShowOwnerFab && !isOwnerFabExpanded")) assertTrue(source.contains("binding.ownerFabButton.isVisible = shouldShowOwnerFab && !isOwnerFabExpanded"))
} }
@Test
fun `Phase 13 owner FAB source는 spring animation과 navigation inset을 적용한다`() {
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>"))
assertTrue(
source.contains(
"binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt())"
)
)
assertTrue(source.contains("ValueAnimator.ofFloat"))
assertTrue(source.contains("SpringInterpolator("))
assertTrue(source.contains("mass = OWNER_FAB_SPRING_MASS"))
assertTrue(source.contains("stiffness = OWNER_FAB_SPRING_STIFFNESS"))
assertTrue(source.contains("damping = OWNER_FAB_SPRING_DAMPING"))
assertTrue(source.contains("if (isOwnerFabAnimating) return"))
assertTrue(source.contains("duration = OWNER_FAB_ANIMATION_DURATION_MS"))
}
@Test
fun `Phase 13 owner FAB source는 3개 액션 진입점을 연결하고 클릭 후 닫는다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertTrue(source.contains("CreatorCommunityWriteActivity"))
assertTrue(source.contains("AudioContentUploadActivity"))
assertTrue(source.contains("LiveRoomCreateActivity"))
assertTrue(
source.contains("binding.ownerFabCommunityButton.setOnClickListener { onOwnerFabCommunityClicked() }")
)
assertTrue(source.contains("binding.ownerFabAudioButton.setOnClickListener { onOwnerFabAudioClicked() }"))
assertTrue(source.contains("binding.ownerFabLiveButton.setOnClickListener { onOwnerFabLiveClicked() }"))
assertTrue(source.contains("private fun onOwnerFabCommunityClicked()"))
assertTrue(source.contains("private fun onOwnerFabAudioClicked()"))
assertTrue(source.contains("private fun onOwnerFabLiveClicked()"))
assertTrue(source.contains("startActivity(Intent(this, CreatorCommunityWriteActivity::class.java))"))
assertTrue(source.contains("startActivity(Intent(this, AudioContentUploadActivity::class.java))"))
assertTrue(source.contains("startActivity(Intent(this, LiveRoomCreateActivity::class.java))"))
assertTrue(source.contains("collapseOwnerFab(animate = false)"))
}
@Test @Test
fun `남은 section item layouts는 legacy generic card id를 제거한다`() { fun `남은 section item layouts는 legacy generic card id를 제거한다`() {
val layoutNames = listOf( val layoutNames = listOf(