Improve full-screen image viewer gestures

This commit is contained in:
ajp_anton
2026-07-27 21:05:20 +00:00
parent 9f880c5f1c
commit 2efd923f7f
2 changed files with 76 additions and 10 deletions
@@ -10,6 +10,7 @@ import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
@@ -48,7 +49,15 @@ class ImageViewerDialogFragment : DialogFragment() {
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
setBackgroundDrawable(ColorDrawable(Color.BLACK)) setBackgroundDrawable(ColorDrawable(Color.BLACK))
WindowCompat.setDecorFitsSystemWindows(this, false) WindowCompat.setDecorFitsSystemWindows(this, false)
addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
attributes = attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} }
hideSystemControls() hideSystemControls()
loadImage(requireArguments().getString(ARGUMENT_IMAGE_ID)!!) loadImage(requireArguments().getString(ARGUMENT_IMAGE_ID)!!)
@@ -8,7 +8,10 @@ import android.util.AttributeSet
import android.view.GestureDetector import android.view.GestureDetector
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector
import android.view.ViewConfiguration
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import kotlin.math.abs
import kotlin.math.exp
/** A fit-to-screen image which supports pinch zoom and one-finger panning. */ /** A fit-to-screen image which supports pinch zoom and one-finger panning. */
class ZoomImageView @JvmOverloads constructor( class ZoomImageView @JvmOverloads constructor(
@@ -24,11 +27,29 @@ class ZoomImageView @JvmOverloads constructor(
private var lastX = 0f private var lastX = 0f
private var lastY = 0f private var lastY = 0f
private var dragging = false private var dragging = false
private var lastFocusX = 0f
private var lastFocusY = 0f
private var doubleTapZooming = false
private var doubleTapMoved = false
private var doubleTapX = 0f
private var doubleTapY = 0f
private var doubleTapStartY = 0f
private var doubleTapBaseScale = 1f
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
lastFocusX = detector.focusX
lastFocusY = detector.focusY
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean { override fun onScale(detector: ScaleGestureDetector): Boolean {
val scale = (currentScale * detector.scaleFactor).coerceIn(minimumScale, minimumScale * MAX_ZOOM) // A ScaleGestureDetector reports focus movement even when the two
transform.postScale(scale / currentScale, scale / currentScale, detector.focusX, detector.focusY) // fingers stay the same distance apart, which is two-finger panning.
currentScale = scale setScaleAround((currentScale * detector.scaleFactor).coerceIn(minimumScale, minimumScale * MAX_ZOOM), detector.focusX, detector.focusY)
transform.postTranslate(detector.focusX - lastFocusX, detector.focusY - lastFocusY)
lastFocusX = detector.focusX
lastFocusY = detector.focusY
constrain() constrain()
return true return true
} }
@@ -39,11 +60,36 @@ class ZoomImageView @JvmOverloads constructor(
return true return true
} }
override fun onDoubleTap(event: MotionEvent): Boolean { override fun onDoubleTap(event: MotionEvent): Boolean = true
val target = if (currentScale > minimumScale * 1.1f) minimumScale else minimumScale * DOUBLE_TAP_ZOOM
transform.postScale(target / currentScale, target / currentScale, event.x, event.y) override fun onDoubleTapEvent(event: MotionEvent): Boolean {
currentScale = target when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
doubleTapZooming = true
doubleTapMoved = false
doubleTapX = event.x
doubleTapY = event.y
doubleTapStartY = event.y
doubleTapBaseScale = currentScale
}
MotionEvent.ACTION_MOVE -> if (doubleTapZooming) {
val distance = doubleTapStartY - event.y
if (abs(distance) > touchSlop) doubleTapMoved = true
val target = (doubleTapBaseScale * exp((distance / DOUBLE_TAP_DRAG_DISTANCE).toDouble()).toFloat())
.coerceIn(minimumScale, minimumScale * MAX_ZOOM)
setScaleAround(target, doubleTapX, doubleTapY)
constrain() constrain()
}
MotionEvent.ACTION_UP -> if (doubleTapZooming) {
if (!doubleTapMoved) {
val target = if (currentScale > minimumScale * 1.1f) minimumScale else minimumScale * DOUBLE_TAP_ZOOM
setScaleAround(target, doubleTapX, doubleTapY)
constrain()
}
doubleTapZooming = false
}
MotionEvent.ACTION_CANCEL -> doubleTapZooming = false
}
return true return true
} }
}) })
@@ -71,7 +117,7 @@ class ZoomImageView @JvmOverloads constructor(
lastY = event.y lastY = event.y
dragging = false dragging = false
} }
MotionEvent.ACTION_MOVE -> if (!scaleDetector.isInProgress) { MotionEvent.ACTION_MOVE -> if (event.pointerCount == 1 && !scaleDetector.isInProgress && !doubleTapZooming) {
val dx = event.x - lastX val dx = event.x - lastX
val dy = event.y - lastY val dy = event.y - lastY
if (dx != 0f || dy != 0f) { if (dx != 0f || dy != 0f) {
@@ -82,6 +128,11 @@ class ZoomImageView @JvmOverloads constructor(
lastX = event.x lastX = event.x
lastY = event.y lastY = event.y
} }
MotionEvent.ACTION_POINTER_UP -> {
val remainingPointer = if (event.actionIndex == 0) 1 else 0
lastX = event.getX(remainingPointer)
lastY = event.getY(remainingPointer)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (dragging) performClick() if (dragging) performClick()
} }
@@ -91,6 +142,11 @@ class ZoomImageView @JvmOverloads constructor(
override fun performClick(): Boolean = super.performClick() override fun performClick(): Boolean = super.performClick()
private fun setScaleAround(scale: Float, pivotX: Float, pivotY: Float) {
transform.postScale(scale / currentScale, scale / currentScale, pivotX, pivotY)
currentScale = scale
}
private fun resetToFit() { private fun resetToFit() {
val drawable = drawable ?: return val drawable = drawable ?: return
if (width == 0 || height == 0 || drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) return if (width == 0 || height == 0 || drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) return
@@ -128,5 +184,6 @@ class ZoomImageView @JvmOverloads constructor(
private companion object { private companion object {
const val MAX_ZOOM = 6f const val MAX_ZOOM = 6f
const val DOUBLE_TAP_ZOOM = 2.5f const val DOUBLE_TAP_ZOOM = 2.5f
const val DOUBLE_TAP_DRAG_DISTANCE = 280f
} }
} }