perf(live-room): BIG_HEART 메시지 수신 경로를 Path 드로잉으로 전환하여 메모리 절감
This commit is contained in:
@@ -2288,6 +2288,17 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
|||||||
}, animateDuration)
|
}, animateDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ensureBigHeartFloatView(): HeartFloatView {
|
||||||
|
val root = binding.flRoot
|
||||||
|
return HeartFloatView(this).also { view ->
|
||||||
|
val lp = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
root.addView(view, lp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun addBigHeartAnimation(fromMessage: Boolean = false) {
|
private fun addBigHeartAnimation(fromMessage: Boolean = false) {
|
||||||
val heart = binding.heartWave
|
val heart = binding.heartWave
|
||||||
|
|
||||||
@@ -2299,39 +2310,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
|||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메시지로 수신된 경우: WaterWaveView 대신 임시 하트 뷰를 중앙에 표시 후 폭발 실행
|
// 메시지로 수신된 경우: Path 드로잉 기반으로 중앙 하트를 잠깐 표시 후 폭발 실행
|
||||||
if (fromMessage) {
|
if (fromMessage) {
|
||||||
val root = binding.flRoot
|
val floatView = ensureBigHeartFloatView()
|
||||||
// 임시 하트(ImageView) 생성 및 중앙 배치
|
floatView.emitCenterOnce(sizeDp = 133.3f, showDurationMs = 150L)
|
||||||
val tempHeart = ImageView(this).apply {
|
|
||||||
setImageResource(R.drawable.ic_heart_pink)
|
|
||||||
alpha = 0f
|
|
||||||
scaleX = 0.8f
|
|
||||||
scaleY = 0.8f
|
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
|
||||||
120f.dpToPx().toInt(),
|
|
||||||
120f.dpToPx().toInt()
|
|
||||||
).apply {
|
|
||||||
gravity = android.view.Gravity.CENTER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
root.addView(tempHeart)
|
|
||||||
|
|
||||||
// 짧은 페이드/스케일 인 후 폭발 실행 (레이아웃 완료 보장 위해 애니메이션 콜백에서 처리)
|
|
||||||
tempHeart.animate()
|
|
||||||
.alpha(1f)
|
|
||||||
.scaleX(1f)
|
|
||||||
.scaleY(1f)
|
|
||||||
.setDuration(150L)
|
|
||||||
.withEndAction {
|
|
||||||
// 임시 하트를 기준으로 폭발 위치 계산 및 실행
|
|
||||||
explodeAndHideHeart(tempHeart)
|
|
||||||
// 누적 방지: 일정 시간 후 임시 하트 제거
|
|
||||||
handler.postDelayed({
|
|
||||||
(tempHeart.parent as? FrameLayout)?.removeView(tempHeart)
|
|
||||||
}, 1200L)
|
|
||||||
}
|
|
||||||
.start()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2402,6 +2384,203 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
|||||||
}, rainDelay)
|
}, rainDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BIG_HEART 수신 시 중앙에 잠깐 하트를 그렸다가, 같은 뷰에서 바로 폭발까지 표현하고 사라지는 뷰
|
||||||
|
private class HeartFloatView(
|
||||||
|
context: Context
|
||||||
|
) : View(context) {
|
||||||
|
|
||||||
|
private data class Heart(
|
||||||
|
var x: Float,
|
||||||
|
var y: Float,
|
||||||
|
var scale: Float,
|
||||||
|
var alpha: Float,
|
||||||
|
var tMs: Float,
|
||||||
|
val durationInMs: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
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 enum class Phase { SHOW, EXPLODE }
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
color = "#ff959a".toColorInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val baseHeartPath: Path = Path().apply {
|
||||||
|
// 하트 경로(정규화 좌표계 [-1,1])
|
||||||
|
reset()
|
||||||
|
moveTo(0f, -0.2f)
|
||||||
|
cubicTo(
|
||||||
|
0.85f, -1.0f,
|
||||||
|
1.35f, 0.2f,
|
||||||
|
0f, 1f
|
||||||
|
)
|
||||||
|
cubicTo(
|
||||||
|
-1.35f, 0.2f,
|
||||||
|
-0.85f, -1.0f,
|
||||||
|
0f, -0.2f
|
||||||
|
)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val hearts = mutableListOf<Heart>()
|
||||||
|
private var phase: Phase = Phase.SHOW
|
||||||
|
|
||||||
|
// 폭발용 상태
|
||||||
|
private val particles = mutableListOf<Particle>()
|
||||||
|
private var explosionAnimator: ValueAnimator? = null
|
||||||
|
private val explosionDurationMs = 900L
|
||||||
|
private val gravity = 1400f // px/s^2
|
||||||
|
private var explosionCenterX = 0f
|
||||||
|
private var explosionCenterY = 0f
|
||||||
|
|
||||||
|
// 화면 중앙에 하트를 잠깐 표시 후 자동 폭발
|
||||||
|
fun emitCenterOnce(sizeDp: Float = 133.3f, showDurationMs: Long = 150L) {
|
||||||
|
if (width == 0 || height == 0) {
|
||||||
|
post { emitCenterOnce(sizeDp, showDurationMs) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cx = width / 2f
|
||||||
|
val cy = height / 2f
|
||||||
|
val h = Heart(
|
||||||
|
x = cx,
|
||||||
|
y = cy,
|
||||||
|
scale = sizeDp.dpToPx() / 2f,
|
||||||
|
alpha = 0f,
|
||||||
|
tMs = 0f,
|
||||||
|
durationInMs = showDurationMs
|
||||||
|
)
|
||||||
|
hearts.clear()
|
||||||
|
hearts += h
|
||||||
|
phase = Phase.SHOW
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
when (phase) {
|
||||||
|
Phase.SHOW -> drawAndMaybeStartExplosion(canvas)
|
||||||
|
Phase.EXPLODE -> drawExplosion(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawAndMaybeStartExplosion(canvas: Canvas) {
|
||||||
|
val iterator = hearts.iterator()
|
||||||
|
if (!iterator.hasNext()) return
|
||||||
|
val h = iterator.next()
|
||||||
|
|
||||||
|
// 고정 16ms 스텝
|
||||||
|
h.tMs += 16f
|
||||||
|
val f = (h.tMs / h.durationInMs).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val drawAlpha = (f * 255).toInt().coerceIn(0, 255)
|
||||||
|
paint.alpha = drawAlpha
|
||||||
|
|
||||||
|
canvas.withTranslation(h.x, h.y) {
|
||||||
|
val s = h.scale
|
||||||
|
scale(s, s)
|
||||||
|
drawPath(baseHeartPath, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f >= 1f) {
|
||||||
|
iterator.remove()
|
||||||
|
startExplosion(h.x, h.y)
|
||||||
|
} else {
|
||||||
|
postInvalidateOnAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startExplosion(cx: Float, cy: Float) {
|
||||||
|
explosionCenterX = cx
|
||||||
|
explosionCenterY = cy
|
||||||
|
phase = Phase.EXPLODE
|
||||||
|
|
||||||
|
// 파티클 초기화
|
||||||
|
particles.clear()
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
val count = 90
|
||||||
|
val minScalePx = 4f * density
|
||||||
|
val maxScalePx = 10f * density
|
||||||
|
val minSpeed = 350f * density
|
||||||
|
val maxSpeed = 1100f * density
|
||||||
|
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)
|
||||||
|
particles += Particle(cx, cy, angle, speed, scale, rotation, rotationSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폭발과 함께 하트비/우박 애니메이션을 부모에 요청
|
||||||
|
val parentFL = parent as? FrameLayout
|
||||||
|
val lp = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
val rainDelay = (10L..100L).random()
|
||||||
|
postDelayed({
|
||||||
|
parentFL?.let { p ->
|
||||||
|
val rainView = HeartRainView(context)
|
||||||
|
p.addView(rainView, lp)
|
||||||
|
rainView.start()
|
||||||
|
}
|
||||||
|
}, rainDelay)
|
||||||
|
|
||||||
|
// 알파 페이드 아웃은 View 자체 alpha로 처리
|
||||||
|
alpha = 1f
|
||||||
|
explosionAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||||
|
duration = explosionDurationMs
|
||||||
|
interpolator = AccelerateDecelerateInterpolator()
|
||||||
|
addUpdateListener { invalidate() }
|
||||||
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
// 애니메이션 종료 시 자기 자신 제거
|
||||||
|
(parent as? FrameLayout)?.removeView(this@HeartFloatView)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
postInvalidateOnAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawExplosion(canvas: Canvas) {
|
||||||
|
val fraction = (explosionAnimator?.animatedFraction ?: 0f).coerceIn(0f, 1f)
|
||||||
|
val t = fraction * (explosionDurationMs / 1000f) // seconds
|
||||||
|
|
||||||
|
// 전체 뷰 알파를 1→0으로
|
||||||
|
alpha = 1f - fraction
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fraction < 1f) {
|
||||||
|
postInvalidateOnAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class HeartExplosionView(
|
private class HeartExplosionView(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val centerX: Float,
|
private val centerX: Float,
|
||||||
|
|||||||
Reference in New Issue
Block a user