feature(live-room-like-heart): 롱프레스 왕하트 애니메이션 추가
- 물 채우기 애니메이션이 끝난 후 폭발 이펙트 추가 - 왕하트를 받은 크리에이터 및 다른 사람은 1초 동안 하트에 물이 채워지는 애니메이션이 수행된 후 폭발 이펙트가 실행된다.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user