Add full-screen notification image viewer

This commit is contained in:
ajp_anton
2026-07-27 17:43:42 +00:00
parent d09fb4fa09
commit 83a71378fb
3 changed files with 259 additions and 0 deletions
@@ -0,0 +1,132 @@
package se.ajpanton.notificationlog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import androidx.appcompat.widget.AppCompatImageView
/** A fit-to-screen image which supports pinch zoom and one-finger panning. */
class ZoomImageView @JvmOverloads constructor(
context: Context,
attributes: AttributeSet? = null,
) : AppCompatImageView(context, attributes) {
var onSingleTap: (() -> Unit)? = null
private val transform = Matrix()
private val bounds = RectF()
private var minimumScale = 1f
private var currentScale = 1f
private var lastX = 0f
private var lastY = 0f
private var dragging = false
private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val scale = (currentScale * detector.scaleFactor).coerceIn(minimumScale, minimumScale * MAX_ZOOM)
transform.postScale(scale / currentScale, scale / currentScale, detector.focusX, detector.focusY)
currentScale = scale
constrain()
return true
}
})
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
onSingleTap?.invoke()
return true
}
override fun onDoubleTap(event: MotionEvent): Boolean {
val target = if (currentScale > minimumScale * 1.1f) minimumScale else minimumScale * DOUBLE_TAP_ZOOM
transform.postScale(target / currentScale, target / currentScale, event.x, event.y)
currentScale = target
constrain()
return true
}
})
init {
scaleType = ScaleType.MATRIX
}
override fun setImageBitmap(bitmap: Bitmap?) {
super.setImageBitmap(bitmap)
post(::resetToFit)
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
resetToFit()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
dragging = false
}
MotionEvent.ACTION_MOVE -> if (!scaleDetector.isInProgress) {
val dx = event.x - lastX
val dy = event.y - lastY
if (dx != 0f || dy != 0f) {
dragging = true
transform.postTranslate(dx, dy)
constrain()
}
lastX = event.x
lastY = event.y
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (dragging) performClick()
}
}
return true
}
override fun performClick(): Boolean = super.performClick()
private fun resetToFit() {
val drawable = drawable ?: return
if (width == 0 || height == 0 || drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) return
minimumScale = minOf(width.toFloat() / drawable.intrinsicWidth, height.toFloat() / drawable.intrinsicHeight)
currentScale = minimumScale
transform.reset()
transform.postScale(minimumScale, minimumScale)
transform.postTranslate(
(width - drawable.intrinsicWidth * minimumScale) / 2f,
(height - drawable.intrinsicHeight * minimumScale) / 2f,
)
imageMatrix = transform
}
private fun constrain() {
val drawable = drawable ?: return
bounds.set(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
transform.mapRect(bounds)
val dx = when {
bounds.width() <= width -> width / 2f - bounds.centerX()
bounds.left > 0f -> -bounds.left
bounds.right < width -> width - bounds.right
else -> 0f
}
val dy = when {
bounds.height() <= height -> height / 2f - bounds.centerY()
bounds.top > 0f -> -bounds.top
bounds.bottom < height -> height - bounds.bottom
else -> 0f
}
transform.postTranslate(dx, dy)
imageMatrix = transform
}
private companion object {
const val MAX_ZOOM = 6f
const val DOUBLE_TAP_ZOOM = 2.5f
}
}