feature(live-room-like-heart): 롱프레스 왕하트 애니메이션 추가

- 물 채우기 애니메이션이 끝난 후 폭발 이펙트 추가
- 왕하트를 받은 크리에이터 및 다른 사람은 1초 동안 하트에 물이 채워지는 애니메이션이 수행된 후 폭발 이펙트가 실행된다.
This commit is contained in:
2025-11-04 21:42:31 +09:00
parent 601405349e
commit a24b1a3b4e

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.live.room package kr.co.vividnext.sodalive.live.room
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.content.ClipData import android.content.ClipData
@@ -8,6 +11,9 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect import android.graphics.Rect
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@@ -38,6 +44,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.core.graphics.withTranslation
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -1139,7 +1146,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
isSpeaker = false isSpeaker = false
isMicrophoneMute = false isMicrophoneMute = false
agora.muteLocalAudioStream(false) agora.muteLocalAudioStream(false)
agora.setClientRole(io.agora.rtc2.Constants.CLIENT_ROLE_AUDIENCE) agora.setClientRole(AgoraConstants.CLIENT_ROLE_AUDIENCE)
handler.postDelayed({ handler.postDelayed({
binding.tvChangeListener.visibility = View.GONE binding.tvChangeListener.visibility = View.GONE
binding.tvChangeListener.setOnClickListener { } binding.tvChangeListener.setOnClickListener { }
@@ -1154,7 +1161,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
isSpeaker = true isSpeaker = true
isMicrophoneMute = false isMicrophoneMute = false
agora.muteLocalAudioStream(false) agora.muteLocalAudioStream(false)
agora.setClientRole(io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER) agora.setClientRole(AgoraConstants.CLIENT_ROLE_BROADCASTER)
handler.postDelayed({ handler.postDelayed({
binding.flMicrophoneMute.visibility = View.VISIBLE binding.flMicrophoneMute.visibility = View.VISIBLE
binding.ivNotiMicrophoneMute.visibility = View.GONE binding.ivNotiMicrophoneMute.visibility = View.GONE
@@ -1693,7 +1700,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
LiveRoomChatRawMessageType.BIG_HEART_DONATION -> { LiveRoomChatRawMessageType.BIG_HEART_DONATION -> {
handler.post { handler.post {
addHeartMessage(nickname) addHeartMessage(nickname)
addHeartAnimation() addBigHeartAnimation()
lifecycleScope.launch { lifecycleScope.launch {
viewModel.addHeartDonation(heartCount = message.can) viewModel.addHeartDonation(heartCount = message.can)
} }
@@ -2120,7 +2127,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
) )
handler.post { handler.post {
addHeartMessage(nickname) addHeartMessage(nickname)
addHeartAnimation() addBigHeartAnimation()
lifecycleScope.launch { viewModel.addHeartDonation(100) } lifecycleScope.launch { viewModel.addHeartDonation(100) }
} }
}, },
@@ -2287,6 +2294,180 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
binding.flRoot.removeView(heart) binding.flRoot.removeView(heart)
}, animateDuration) }, animateDuration)
} }
private fun addBigHeartAnimation() {
val heart = binding.heartWave
// 하트 물결 색상 적용 (#ff959a)
try {
heart.setBorderColor("#ff959a".toColorInt())
heart.setFrontWaveColor("#ff959a".toColorInt())
heart.setBehindWaveColor("#ff959a".toColorInt())
} catch (_: Throwable) {
}
// 수신자 경로: heartWave가 없거나 progress<100이면 1초간 물 채우기 후 폭발
val needFill: Boolean = try {
heart.visibility != View.VISIBLE || heart.progress < 100
} catch (_: Throwable) {
true
}
if (needFill) {
heart.alpha = 1f
heart.visibility = View.VISIBLE
try {
heart.progress = 0
} catch (_: Throwable) {
}
val fillAnim = ObjectAnimator.ofInt(heart, "progress", 0, 100)
fillAnim.duration = 1000L
fillAnim.interpolator = AccelerateDecelerateInterpolator()
fillAnim.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
explodeAndHideHeart(heart)
}
})
fillAnim.start()
} else {
// 발신자 경로: 이미 중앙에 HeartWave가 떠 있고(롱프레스), 100% 상태 → 즉시 폭발
explodeAndHideHeart(heart)
}
}
private fun explodeAndHideHeart(target: View) {
val root = binding.flRoot
// 대상 뷰 중심 좌표(root 기준) 계산
val rootLoc = IntArray(2)
val viewLoc = IntArray(2)
root.getLocationInWindow(rootLoc)
target.getLocationInWindow(viewLoc)
val cx = (viewLoc[0] - rootLoc[0]) + target.width / 2f
val cy = (viewLoc[1] - rootLoc[1]) + target.height / 2f
// 원본 뷰 숨기고 진행도 초기화
try {
if (target is WaterWaveView) target.progress = 0
} catch (_: Throwable) {
}
target.visibility = View.GONE
longPressCenterHeart = null
// 하트 파티클 폭발 오버레이 추가
val overlay = HeartExplosionView(this, cx, cy)
val lp = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
root.addView(overlay, lp)
overlay.start()
}
private class HeartExplosionView(
context: Context,
private val centerX: Float,
private val centerY: Float
) : View(context) {
private data class Particle(
var x0: Float,
var y0: Float,
var angle: Float,
var speed: Float,
var scale: Float,
var rotation: Float,
var rotationSpeed: Float
)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = "#ff959a".toColorInt()
}
private val particles = mutableListOf<Particle>()
private val baseHeartPath: Path = Path().apply {
// 원점 기준 정규화된 하트 경로
moveTo(0f, -1f)
cubicTo(0.5f, -1.5f, 1.3f, -0.1f, 0f, 1f)
cubicTo(-1.3f, -0.1f, -0.5f, -1.5f, 0f, -1f)
close()
}
private val durationMs = 900L
private val gravity = 1400f // px/s^2
private val alphaStart = 1f
private val alphaEnd = 0f
private var animator: ValueAnimator? = null
init {
val count = 90
val density = resources.displayMetrics.density
val minScalePx = 4f * density
val maxScalePx = 10f * density
val minSpeed = 350f * density // px/sec
val maxSpeed = 1100f * density // px/sec
repeat(count) {
val angle = (Math.random() * Math.PI * 2).toFloat()
val speed = minSpeed + (maxSpeed - minSpeed) * Math.random().toFloat()
val scale = minScalePx + (maxScalePx - minScalePx) * Math.random().toFloat()
val rotation = (Math.random().toFloat() * 360f) - 180f
val rotationSpeed = ((Math.random().toFloat() * 360f) - 180f) // deg/sec
particles += Particle(
centerX,
centerY,
angle,
speed,
scale,
rotation,
rotationSpeed
)
}
alpha = alphaStart
}
fun start() {
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = durationMs
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener { va ->
val f = va.animatedValue as Float
// 전체 오버레이 알파 페이드
alpha = alphaStart + (alphaEnd - alphaStart) * f
invalidate()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
(parent as? FrameLayout)?.removeView(this@HeartExplosionView)
}
})
start()
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val fraction = (animator?.animatedFraction ?: 0f).coerceIn(0f, 1f)
val t = fraction * (durationMs / 1000f) // seconds
particles.forEach { p ->
val vx = kotlin.math.cos(p.angle) * p.speed
val vy0 = kotlin.math.sin(p.angle) * p.speed
val x = p.x0 + vx * t
val y = p.y0 + vy0 * t + 0.5f * gravity * t * t
val rot = p.rotation + p.rotationSpeed * t
canvas.withTranslation(x, y) {
rotate(rot)
val s = p.scale
scale(s, s)
drawPath(baseHeartPath, paint)
}
}
}
}
// endregion // endregion
// region signature // region signature