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