Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 9 additions & 99 deletions android/src/main/java/com/morphview/MorphViewView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,24 @@ import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.RectF
import android.graphics.RenderEffect
import android.graphics.RenderNode
import android.graphics.RuntimeShader
import android.graphics.Shader
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import android.view.animation.PathInterpolator
import com.morphview.internal.MorphRenderer
import java.net.URL
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt

/**
* Gooey "metaball" morph between two images. Both images are drawn into a single [RenderNode] that
* carries the blur + alpha-threshold [RenderEffect], and only that node is drawn to screen β€” so the
* crossfade and the effect are always committed in the same frame and the raw image never flashes.
* Gooey "metaball" morph between two images. This view owns image loading, the crossfade animation,
* and the optional baked border; the actual effect (crossfade β†’ blur β†’ alpha-threshold) is delegated
* to a [MorphRenderer] whose backend is chosen per API level in [MorphRenderer.create].
*/
class MorphViewView : View {

Expand All @@ -53,19 +45,8 @@ class MorphViewView : View {
private var progress: Float = 0f
private var animator: ValueAnimator? = null

private val shader: RuntimeShader? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(ALPHA_THRESHOLD_SHADER)
} else {
null
}

private val contentNode: RenderNode? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) RenderNode("morph") else null

private val drawPaint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG)
private val dstRect = RectF()
private var tintFilter: PorterDuffColorFilter? = null
// Async backends invalidate the view off-thread when a new frame is ready.
private val renderer = MorphRenderer.create(context) { postInvalidateOnAnimation() }

private val ioExecutor = Executors.newCachedThreadPool()
private val mainHandler = Handler(Looper.getMainLooper())
Expand Down Expand Up @@ -116,7 +97,6 @@ class MorphViewView : View {

fun setTintColorInt(color: Int?) {
tintColor = color
tintFilter = color?.let { PorterDuffColorFilter(it, PorterDuff.Mode.SRC_IN) }
invalidate()
}

Expand Down Expand Up @@ -150,71 +130,15 @@ class MorphViewView : View {
}
}

/** Effect parameters are recomputed from [progress] by the renderer in [onDraw]; just redraw. */
private fun applyProgress() {
// sqrt(sin) has a vertical tangent at the ends, so any motion off rest is already heavily
// blurred β€” a transition interrupted near completion can't flash a crisp, un-fused image.
val blurProgress = sqrt(sin(progress * PI.toFloat()))
val radiusPx = blurProgress * blurRadius * density

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || shader == null) {
invalidate()
return
}

shader.setFloatUniform("threshold", 0.5f)
val thresholdEffect = RenderEffect.createRuntimeShaderEffect(shader, "composable")
// 0.5px floor keeps the same effect chain at rest instead of toggling it on/off at 0 and 1.
val effectiveRadius = radiusPx.coerceAtLeast(0.5f)
val blurEffect = RenderEffect.createBlurEffect(effectiveRadius, effectiveRadius, Shader.TileMode.DECAL)
// createChainEffect(outer, inner): blur runs first, then the threshold.
contentNode?.setRenderEffect(RenderEffect.createChainEffect(thresholdEffect, blurEffect))
invalidate()
}

// MARK: - Drawing

override fun onDraw(canvas: Canvas) {
val node = contentNode
if (node == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || shader == null ||
!canvas.isHardwareAccelerated || width <= 0 || height <= 0
) {
// Fallback (pre-API-33 or software canvas): plain crossfade, no morph effect.
drawImage(canvas, fromBitmap, 1f - progress)
drawImage(canvas, toBitmap, progress)
return
}

node.setPosition(0, 0, width, height)
val recordingCanvas = node.beginRecording()
try {
drawImage(recordingCanvas, fromBitmap, 1f - progress)
drawImage(recordingCanvas, toBitmap, progress)
} finally {
node.endRecording()
}
canvas.drawRenderNode(node)
}

/** Draws [bmp] centered and scaled-to-fit (ImageView FIT_CENTER) at the given [alpha]. */
private fun drawImage(canvas: Canvas, bmp: Bitmap?, alpha: Float) {
if (bmp == null || alpha <= 0.001f) return

val vw = width.toFloat()
val vh = height.toFloat()
val bw = bmp.width.toFloat()
val bh = bmp.height.toFloat()
if (vw <= 0f || vh <= 0f || bw <= 0f || bh <= 0f) return

val scale = min(vw / bw, vh / bh)
val dw = bw * scale
val dh = bh * scale
val left = (vw - dw) / 2f
val top = (vh - dh) / 2f
dstRect.set(left, top, left + dw, top + dh)

drawPaint.alpha = (alpha * 255f).toInt().coerceIn(0, 255)
drawPaint.colorFilter = tintFilter
canvas.drawBitmap(bmp, null, dstRect, drawPaint)
renderer.draw(canvas, width, height, fromBitmap, toBitmap, progress, blurRadius * density, tintColor)
}

// MARK: - Image loading
Expand Down Expand Up @@ -323,24 +247,10 @@ class MorphViewView : View {
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
animator?.cancel()
renderer.release()
}

companion object {
private val MORPH_INTERPOLATOR = PathInterpolator(0.42f, 0f, 0.58f, 1f)

private const val ALPHA_THRESHOLD_SHADER = """
uniform shader composable;
uniform float threshold;

half4 main(float2 coord) {
half4 color = composable.eval(coord);
if (color.a < 0.001) {
return half4(0.0);
}
half alpha = smoothstep(threshold - 0.05, threshold + 0.05, color.a);
half3 straight = color.rgb / color.a;
return half4(straight * alpha, alpha);
}
"""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.morphview.internal

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.RequiresApi
import com.morphview.internal.gl.SwapChain
import java.util.ArrayDeque
import java.util.concurrent.atomic.AtomicBoolean

/**
* API 29–32 backend: renders the morph in OpenGL on a dedicated [HandlerThread] and presents the
* result as a hardware [Bitmap] via [Bitmap.wrapHardwareBuffer] β€” the "present via hardware Bitmap,
* no SurfaceView" path.
*
* Async is hidden behind the synchronous [draw] contract: each draw submits the latest state to the
* GL thread (coalesced to newest-only) and blits the latest ready frame. The result is stale by at
* most one frame β€” invisible mid-morph, where everything is already blurred β€” and the very first
* frames fall back to a plain crossfade until GL catches up.
*/
@RequiresApi(Build.VERSION_CODES.Q)
internal class HardwareBufferMorphRenderer(
private val onFrameReady: () -> Unit,
) : MorphRenderer {

private val fallback = FitCenterPainter()
private val blitPaint = Paint(Paint.FILTER_BITMAP_FLAG)
private val blitDst = RectF()

private var glThread: HandlerThread? = null
private var glHandler: Handler? = null

// GL-thread-confined.
private var pipeline: MorphGlPipeline? = null
private val leases = ArrayDeque<SwapChain.Lease>()

// Latest requested frame: UI thread writes, GL thread reads.
@Volatile
private var pending: RenderRequest? = null
private var lastSubmitted: RenderRequest? = null // UI-thread only
private val renderScheduled = AtomicBoolean(false)

// Newest GL output: GL thread writes, UI thread blits.
@Volatile
private var displayBitmap: Bitmap? = null

override fun draw(
canvas: Canvas,
width: Int,
height: Int,
from: Bitmap?,
to: Bitmap?,
progress: Float,
blurRadiusPx: Float,
tintColor: Int?,
) {
if (width <= 0 || height <= 0) return
ensureThread()

// Submit only when the frame changed β€” otherwise each produced frame's invalidate would
// re-trigger an identical render, spinning the GL thread at rest.
val req = RenderRequest(width, height, from, to, progress, blurRadiusPx, tintColor)
if (req != lastSubmitted) {
lastSubmitted = req
Comment thread
blazejkustra marked this conversation as resolved.
pending = req
scheduleRender()
}

val bmp = displayBitmap
if (bmp != null && !bmp.isRecycled) {
blitDst.set(0f, 0f, width.toFloat(), height.toFloat())
canvas.drawBitmap(bmp, null, blitDst, blitPaint)
} else {
// No GL frame yet β€” crossfade so the view isn't blank on the first frames.
fallback.draw(canvas, from, width, height, 1f - progress, tintColor)
fallback.draw(canvas, to, width, height, progress, tintColor)
}
}

override fun release() {
val thread = glThread ?: return // never started, or already released β€” nothing to reset
displayBitmap = null
Comment thread
blazejkustra marked this conversation as resolved.
glHandler?.post {
pipeline?.release()
pipeline = null
while (leases.isNotEmpty()) leases.removeFirst().close()
}
glThread = null
glHandler = null
// Drain the GL thread before returning so a re-attach can't race this teardown for pipeline/leases.
thread.quitSafely()
thread.join()
// Reset submit state, or a re-attach at the same size/progress never re-renders.
lastSubmitted = null
pending = null
renderScheduled.set(false)
}

private fun ensureThread() {
if (glThread != null) return
val thread = HandlerThread("MorphViewGL").apply { start() }
glThread = thread
glHandler = Handler(thread.looper)
}

// Collapse a burst of draws into a single render of the newest state.
private fun scheduleRender() {
if (renderScheduled.compareAndSet(false, true)) {
glHandler?.post {
renderScheduled.set(false)
pending?.let { renderFrame(it) }
}
}
}

// GL thread.
private fun renderFrame(req: RenderRequest) {
val lease = produceLease(req) ?: return
leases.addLast(lease)
// Keep newest + one previous (HWUI may still be compositing last frame); free older buffers.
while (leases.size > 2) leases.removeFirst().close()
displayBitmap = lease.bitmap
Comment thread
blazejkustra marked this conversation as resolved.
onFrameReady()
}

/**
* GL thread. Lazily creates the pipeline and renders [req]. Returns null if GL initialisation or
* the render fails (e.g. context loss) β€” the caller skips the frame and retries on the next draw.
*/
private fun produceLease(req: RenderRequest): SwapChain.Lease? = try {
val gl = pipeline ?: MorphGlPipeline().also { pipeline = it }
gl.render(req.width, req.height, req.from, req.to, req.progress, req.blurRadiusPx, req.tintColor)
} catch (e: Exception) {
null
}

private data class RenderRequest(
val width: Int,
val height: Int,
val from: Bitmap?,
val to: Bitmap?,
val progress: Float,
val blurRadiusPx: Float,
val tintColor: Int?,
)
}
Loading
Loading