diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerDrawable.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerDrawable.kt index 3f8412a84f..5b1ce7f6b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerDrawable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerDrawable.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerPaint.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerPaint.kt new file mode 100644 index 0000000000..5c151c07f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerPaint.kt @@ -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() + ) + } +}