Improve spoiler drawing performance.

This commit is contained in:
Greyson Parrelli
2023-04-10 10:18:03 -04:00
parent a56e9e502e
commit d88534e71f
2 changed files with 178 additions and 117 deletions

View File

@@ -2,147 +2,37 @@ package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.Util
import kotlin.random.Random
/**
* Drawable that animates a sparkle effect for spoilers.
*/
class SpoilerDrawable(@ColorInt color: Int) : Drawable() {
private val alphaStrength = arrayOf(0.9f, 0.7f, 0.5f)
private val paints = listOf(Paint(), Paint(), Paint())
private var lastDrawTime: Long = 0
private var particleCount = 60
private var allParticles = Array(3) { Array(particleCount) { Particle(random) } }
private var allPoints = Array(3) { FloatArray(particleCount * 2) { 0f } }
init {
for (paint in paints) {
paint.strokeCap = Paint.Cap.ROUND
paint.strokeWidth = DimensionUnit.DP.toPixels(1.5f)
}
alpha = 255
colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
override fun onBoundsChange(bounds: Rect) {
val pixelArea = (bounds.right - bounds.left) * (bounds.bottom - bounds.top)
val newParticleCount = (pixelArea.toFloat() * PARTICLES_PER_PIXEL).toInt()
if (newParticleCount != particleCount) {
if (newParticleCount > allParticles[0].size) {
allParticles = Array(3) { i ->
Array(newParticleCount) { particleIndex ->
allParticles[i].getOrNull(particleIndex) ?: Particle(random)
}
}
allPoints = Array(3) { i ->
FloatArray(newParticleCount * 2) { pointIndex ->
allPoints[i].getOrNull(pointIndex) ?: 0f
}
}
}
particleCount = newParticleCount
}
}
override fun draw(canvas: Canvas) {
val left = bounds.left
val top = bounds.top
val right = bounds.right
val bottom = bounds.bottom
val now = System.currentTimeMillis()
val dt = now - lastDrawTime
lastDrawTime = now
for (allIndex in allParticles.indices) {
val particles = allParticles[allIndex]
for (index in 0 until particleCount) {
val particle = particles[index]
particle.timeRemaining = particle.timeRemaining - dt
if (particle.timeRemaining < 0 || !bounds.contains(particle.x.toInt(), particle.y.toInt())) {
particle.x = (random.nextFloat() * (right - left)) + left
particle.y = (random.nextFloat() * (bottom - top)) + top
particle.xVel = nextDirection()
particle.yVel = nextDirection()
particle.timeRemaining = 350 + 750 * random.nextFloat()
} else {
val change = dt * velocity
particle.x += particle.xVel * change
particle.y += particle.yVel * change
}
allPoints[allIndex][index * 2] = particle.x
allPoints[allIndex][index * 2 + 1] = particle.y
}
}
canvas.drawPoints(allPoints[0], 0, particleCount * 2, paints[0])
canvas.drawPoints(allPoints[1], 0, particleCount * 2, paints[1])
canvas.drawPoints(allPoints[2], 0, particleCount * 2, paints[2])
canvas.drawRect(bounds, SpoilerPaint.paint)
SpoilerPaint.update()
invalidateSelf()
}
override fun setAlpha(alpha: Int) {
paints.forEachIndexed { index, paint ->
paint.alpha = (alpha * alphaStrength[index]).toInt()
}
}
override fun setColorFilter(colorFilter: ColorFilter?) {
for (paint in paints) {
paint.colorFilter = colorFilter
}
SpoilerPaint.applyAlpha(alpha)
}
@Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.TRANSPARENT", "android.graphics.PixelFormat"))
override fun getOpacity(): Int {
return PixelFormat.TRANSPARENT
return SpoilerPaint.paint.alpha
}
data class Particle(
var x: Float,
var y: Float,
var xVel: Float,
var yVel: Float,
var timeRemaining: Float
) {
constructor(random: Random) : this(
-1f,
-1f,
if (random.nextFloat() < 0.5f) 1f else -1f,
if (random.nextFloat() < 0.5f) 1f else -1f,
500 + 1000 * random.nextFloat()
)
}
companion object {
private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 0.002f else 0.005f
private val velocity: Float = DimensionUnit.DP.toPixels(16f) / 1000f
private val random = Random(System.currentTimeMillis())
fun nextDirection(): Float {
val rand = random.nextFloat()
return if (rand < 0.5f) {
0.1f + 0.9f * rand
} else {
-0.1f - 0.9f * (rand - 0.5f)
}
}
override fun setColorFilter(colorFilter: ColorFilter?) {
SpoilerPaint.applyColorFilter(colorFilter)
}
}

View File

@@ -0,0 +1,171 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Shader
import androidx.annotation.MainThread
import org.signal.core.util.DimensionUnit
import org.signal.core.util.dp
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.Util
import kotlin.random.Random
/**
* A wrapper around a paint object that can be used to apply the spoiler effect.
*
* The intended usage pattern is to call [update] before your item needs to be drawn.
* Then, draw with the [paint].
*/
object SpoilerPaint {
/**
* A paint that can be used to apply the spoiler effect.
*/
val paint: Paint = Paint()
private val SIZE = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 100.dp else 200.dp
private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 0.002f else 0.005f
private var shaderBitmap: Bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888)
private var bufferBitmap: Bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888)
private val bounds: Rect = Rect(0, 0, SIZE, SIZE)
private val paddedBounds: Rect
private val alphaStrength = arrayOf(0.9f, 0.7f, 0.5f)
private val particlePaints = listOf(Paint(), Paint(), Paint())
private var lastDrawTime: Long = 0
private val random = Random(System.currentTimeMillis())
private var particleCount = ((bounds.width() * bounds.height()) * PARTICLES_PER_PIXEL).toInt()
private val allParticles = Array(3) { Array(particleCount) { Particle(random) } }
private val allPoints = Array(3) { FloatArray(particleCount * 2) { 0f } }
private val velocity: Float = DimensionUnit.DP.toPixels(16f) / 1000f
init {
val strokeWidth = DimensionUnit.DP.toPixels(1.5f)
particlePaints.forEach { paint ->
paint.strokeCap = Paint.Cap.ROUND
paint.strokeWidth = strokeWidth
}
paddedBounds = Rect(
bounds.left - strokeWidth.toInt(),
bounds.top - strokeWidth.toInt(),
bounds.right + strokeWidth.toInt(),
bounds.bottom + strokeWidth.toInt()
)
}
/**
* Invoke every time before you need to use the [paint].
*/
@MainThread
fun update() {
val now = System.currentTimeMillis()
val dt = now - lastDrawTime
if (dt < 16) {
return
}
lastDrawTime = now
// The shader draws the live contents of the bitmap at potentially any point.
// That means that if we draw directly to the in-use bitmap, it could potentially flicker.
// To avoid that, we draw into a buffer, then swap the buffer into the shader when it's fully drawn.
val canvas = Canvas(bufferBitmap)
bufferBitmap.eraseColor(Color.TRANSPARENT)
draw(canvas, dt)
val swap = shaderBitmap
shaderBitmap = bufferBitmap
bufferBitmap = swap
paint.shader = BitmapShader(shaderBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
}
@MainThread
fun applyColorFilter(colorFilter: ColorFilter?) {
for (paint in particlePaints) {
paint.colorFilter = colorFilter
}
lastDrawTime = 0
update()
}
@MainThread
fun applyAlpha(alpha: Int) {
particlePaints.forEachIndexed { index, paint ->
paint.alpha = (alpha * alphaStrength[index]).toInt()
}
}
/**
* Draws the newest particle state into the canvas based on [dt].
*
* Note that we use [paddedBounds] here so that the draw space starts just outside the visible area.
*
* This is because we're going to end up tiling this, and being fuzzy around the edges helps reduce
* the visual "gaps" between the tiles.
*/
private fun draw(canvas: Canvas, dt: Long) {
for (allIndex in allParticles.indices) {
val particles = allParticles[allIndex]
for (index in 0 until particleCount) {
val particle = particles[index]
particle.timeRemaining = particle.timeRemaining - dt
if (particle.timeRemaining < 0 || !paddedBounds.contains(particle.x.toInt(), particle.y.toInt())) {
particle.x = (random.nextFloat() * paddedBounds.width()) + paddedBounds.left
particle.y = (random.nextFloat() * paddedBounds.height()) + paddedBounds.top
particle.xVel = nextDirection()
particle.yVel = nextDirection()
particle.timeRemaining = 350 + 750 * random.nextFloat()
} else {
val change = dt * velocity
particle.x += particle.xVel * change
particle.y += particle.yVel * change
}
allPoints[allIndex][index * 2] = particle.x
allPoints[allIndex][index * 2 + 1] = particle.y
}
}
canvas.drawPoints(allPoints[0], 0, particleCount * 2, particlePaints[0])
canvas.drawPoints(allPoints[1], 0, particleCount * 2, particlePaints[1])
canvas.drawPoints(allPoints[2], 0, particleCount * 2, particlePaints[2])
}
private fun nextDirection(): Float {
val rand = random.nextFloat()
return if (rand < 0.5f) {
0.1f + 0.9f * rand
} else {
-0.1f - 0.9f * (rand - 0.5f)
}
}
private data class Particle(
var x: Float,
var y: Float,
var xVel: Float,
var yVel: Float,
var timeRemaining: Float
) {
constructor(random: Random) : this(
x = -1f,
y = -1f,
xVel = if (random.nextFloat() < 0.5f) 1f else -1f,
yVel = if (random.nextFloat() < 0.5f) 1f else -1f,
timeRemaining = 500 + 1000 * random.nextFloat()
)
}
}