From a24b1a3b4ec539ae477c0cd969f8922a0beee5d4 Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 4 Nov 2025 21:42:31 +0900 Subject: [PATCH] =?UTF-8?q?feature(live-room-like-heart):=20=EB=A1=B1?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=8A=A4=20=EC=99=95=ED=95=98=ED=8A=B8=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 물 채우기 애니메이션이 끝난 후 폭발 이펙트 추가 - 왕하트를 받은 크리에이터 및 다른 사람은 1초 동안 하트에 물이 채워지는 애니메이션이 수행된 후 폭발 이펙트가 실행된다. --- .../sodalive/live/room/LiveRoomActivity.kt | 189 +++++++++++++++++- 1 file changed, 185 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt index db515355..f61dd625 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt @@ -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(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(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(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(ActivityLiveRoomB ) handler.post { addHeartMessage(nickname) - addHeartAnimation() + addBigHeartAnimation() lifecycleScope.launch { viewModel.addHeartDonation(100) } } }, @@ -2287,6 +2294,180 @@ class LiveRoomActivity : BaseActivity(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() + 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