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
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.ClipData
@@ -8,6 +11,9 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.Typeface
import android.graphics.drawable.Drawable
@@ -38,6 +44,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import androidx.core.graphics.withTranslation
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -1139,7 +1146,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
isSpeaker = false
isMicrophoneMute = false
agora.muteLocalAudioStream(false)
agora.setClientRole(io.agora.rtc2.Constants.CLIENT_ROLE_AUDIENCE)
agora.setClientRole(AgoraConstants.CLIENT_ROLE_AUDIENCE)
handler.postDelayed({
binding.tvChangeListener.visibility = View.GONE
binding.tvChangeListener.setOnClickListener { }
@@ -1154,7 +1161,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
isSpeaker = true
isMicrophoneMute = false
agora.muteLocalAudioStream(false)
agora.setClientRole(io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER)
agora.setClientRole(AgoraConstants.CLIENT_ROLE_BROADCASTER)
handler.postDelayed({
binding.flMicrophoneMute.visibility = View.VISIBLE
binding.ivNotiMicrophoneMute.visibility = View.GONE
@@ -1693,7 +1700,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
LiveRoomChatRawMessageType.BIG_HEART_DONATION -> {
handler.post {
addHeartMessage(nickname)
addHeartAnimation()
addBigHeartAnimation()
lifecycleScope.launch {
viewModel.addHeartDonation(heartCount = message.can)
}
@@ -2120,7 +2127,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
)
handler.post {
addHeartMessage(nickname)
addHeartAnimation()
addBigHeartAnimation()
lifecycleScope.launch { viewModel.addHeartDonation(100) }
}
},
@@ -2287,6 +2294,180 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
binding.flRoot.removeView(heart)
}, 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
// region signature