perf(live-room): BIG_HEART 메시지 수신 경로를 Path 드로잉으로 전환하여 메모리 절감
This commit is contained in:
@@ -2288,6 +2288,17 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
||||
}, 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) {
|
||||
val heart = binding.heartWave
|
||||
|
||||
@@ -2299,39 +2310,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
|
||||
// 메시지로 수신된 경우: WaterWaveView 대신 임시 하트 뷰를 중앙에 표시 후 폭발 실행
|
||||
// 메시지로 수신된 경우: Path 드로잉 기반으로 중앙 하트를 잠깐 표시 후 폭발 실행
|
||||
if (fromMessage) {
|
||||
val root = binding.flRoot
|
||||
// 임시 하트(ImageView) 생성 및 중앙 배치
|
||||
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()
|
||||
val floatView = ensureBigHeartFloatView()
|
||||
floatView.emitCenterOnce(sizeDp = 133.3f, showDurationMs = 150L)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2402,6 +2384,203 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
||||
}, 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(
|
||||
context: Context,
|
||||
private val centerX: Float,
|
||||
|
||||
Reference in New Issue
Block a user