ZoomGestureDetector.kt

/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.viewfinder.core

import android.annotation.SuppressLint
import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
import androidx.viewfinder.core.ZoomGestureDetector.OnZoomGestureListener
import kotlin.math.abs
import kotlin.math.hypot

/**
 * Detects scaling transformation gestures that interprets zooming events using the supplied
 * [MotionEvent]s.
 *
 * The [OnZoomGestureListener] callback will notify users when a particular
 * gesture event has occurred.
 *
 * This class should only be used with [MotionEvent]s reported via touch.
 *
 * To use this class:
 * - Create an instance of the `ZoomGestureDetector` for your [View]
 * - In the [View.onTouchEvent] method ensure you call [onTouchEvent]. The methods defined in your
 * callback will be executed when the events occur.
 */
// TODO(b/314701735): update the documentation with examples using camera classes.
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
class ZoomGestureDetector @SuppressLint("ExecutorRegistration") constructor(
    private val context: Context,
    private val spanSlop: Int = ViewConfiguration.get(context).scaledTouchSlop * 2,
    private val minSpan: Int = DEFAULT_MIN_SPAN,
    private val listener: OnZoomGestureListener
) {
    /**
     * The listener for receiving notifications when gestures occur.
     *
     * An application will receive events in the following order:
     * - One [ZOOM_GESTURE_BEGIN]
     * - Zero or more [ZOOM_GESTURE_MOVE]
     * - One [ZOOM_GESTURE_END]
     */
    fun interface OnZoomGestureListener {
        /**
         * Responds to the events of a zooming gesture.
         *
         * Return `true` to indicate the event is handled by the listener.
         * - For [ZOOM_GESTURE_MOVE] events, the detector will continue to accumulate movement if
         * it's not handled. This can be useful if an application, for example, only wants to update
         * scaling factors if the change is greater than `0.01`.
         * - For [ZOOM_GESTURE_BEGIN] events, the detector will ignore the rest of the gesture if
         * it's not handled. For example, if a gesture is beginning with a focal point outside of a
         * region where it makes sense, [ZOOM_GESTURE_BEGIN] event may return `false` to ignore the
         * rest of the gesture.
         * - For [ZOOM_GESTURE_END] events, the return value is ignored and the zoom gesture will
         * end regardless of what is returned.
         *
         * Once receiving [ZOOM_GESTURE_END] event, [focusX] and [focusY] will return focal point of
         * the pointers remaining on the screen.
         *
         * @type The type of the event. Possible values include [ZOOM_GESTURE_MOVE],
         * [ZOOM_GESTURE_BEGIN] and [ZOOM_GESTURE_END].
         * @param detector The detector reporting the event - use this to retrieve extended info
         * about event state.
         * @return Whether or not the detector should consider this event as handled.
         */
        fun onZoom(type: Int, detector: ZoomGestureDetector): Boolean
    }

    /**
     * The X coordinate of the current gesture's focal point in pixels. If a gesture is in progress,
     * the focal point is between each of the pointers forming the gesture.
     *
     * If [isInProgress] would return `false`, the result of this function is undefined.
     */
    var focusX = 0f
        private set

    /**
     * The Y coordinate of the current gesture's focal point in pixels. If a gesture is in progress,
     * the focal point is between each of the pointers forming the gesture.
     *
     * If [isInProgress] would return `false`, the result of this function is undefined.
     */
    var focusY = 0f
        private set

    /**
     * Whether the quick zoom gesture, in which the user performs a double tap followed by a swipe,
     * should perform zooming.
     *
     * If not set, this is enabled by default.
     */
    var isQuickZoomEnabled: Boolean = true
        set(enabled) {
            field = enabled
            if (field && gestureDetector == null) {
                val gestureListener: GestureDetector.SimpleOnGestureListener =
                    object : GestureDetector.SimpleOnGestureListener() {
                        override fun onDoubleTap(e: MotionEvent): Boolean {
                            // Double tap: start watching for a swipe
                            anchoredZoomStartX = e.x
                            anchoredZoomStartY = e.y
                            anchoredZoomMode = ANCHORED_ZOOM_MODE_DOUBLE_TAP
                            return true
                        }
                    }
                gestureDetector = GestureDetector(context, gestureListener)
            }
        }

    /**
     * Whether the stylus zoom gesture, in which the user uses a stylus and presses the button,
     * should perform zooming.
     *
     * If not set, this is enabled by default.
     */
    var isStylusZoomEnabled = true

    /**
     * The average distance in pixels between each of the pointers forming the gesture in progress
     * through the focal point.
     */
    var currentSpan = 0f
        private set

    /**
     * The previous average distance in pixels between each of the pointers forming the gesture in
     * progress through the focal point.
     */
    var previousSpan = 0f
        private set

    /**
     * The average X distance in pixels between each of the pointers forming the gesture in progress
     * through the focal point.
     */
    var currentSpanX = 0f
        private set

    /**
     * The average Y distance in pixels between each of the pointers forming the gesture in progress
     * through the focal point.
     */
    var currentSpanY = 0f
        private set

    /**
     * The previous average X distance in pixels between each of the pointers forming the gesture in
     * progress through the focal point.
     */
    var previousSpanX = 0f
        private set

    /**
     * The previous average Y distance in pixels between each of the pointers forming the gesture in
     * progress through the focal point.
     */
    var previousSpanY = 0f
        private set

    /**
     * The event time in milliseconds of the current event being processed.
     */
    var eventTime: Long = 0
        private set

    /**
     * Whether a zoom gesture is in progress.
     */
    var isInProgress = false
        private set

    private var initialSpan = 0f
    private var prevTime: Long = 0
    private var anchoredZoomStartX = 0f
    private var anchoredZoomStartY = 0f
    private var anchoredZoomMode = ANCHORED_ZOOM_MODE_NONE
    private var gestureDetector: GestureDetector? = null
    private var eventBeforeOrAboveStartingGestureEvent = false

    /**
     * Accepts [MotionEvent]s and dispatches events to a [OnZoomGestureListener] when appropriate.
     *
     * Applications should pass a complete and consistent event stream to this method.
     *
     * A complete and consistent event stream involves all [MotionEvent]s from the initial
     * [MotionEvent.ACTION_DOWN] to the final [MotionEvent.ACTION_UP] or
     * [MotionEvent.ACTION_CANCEL].
     *
     * @param event The event to process.
     * @return `true` if the event was processed and the detector wants to receive the
     * rest of the MotionEvents in this event stream.
     */
    fun onTouchEvent(event: MotionEvent): Boolean {
        eventTime = event.eventTime

        val action = event.actionMasked

        // Forward the event to check for double tap gesture
        if (isQuickZoomEnabled) {
            gestureDetector!!.onTouchEvent(event)
        }

        val count = event.pointerCount
        val isStylusButtonDown = (event.buttonState and MotionEvent.BUTTON_STYLUS_PRIMARY) != 0

        val anchoredZoomCancelled =
            anchoredZoomMode == ANCHORED_ZOOM_MODE_STYLUS && !isStylusButtonDown
        val streamComplete = action == MotionEvent.ACTION_UP ||
            action == MotionEvent.ACTION_CANCEL ||
            anchoredZoomCancelled

        if (action == MotionEvent.ACTION_DOWN || streamComplete) {
            // Reset any scale in progress with the listener.
            // If it's an ACTION_DOWN we're beginning a new event stream.
            // This means the app probably didn't give us all the events. Shame on it.
            if (isInProgress) {
                listener.onZoom(ZOOM_GESTURE_END, this)
                isInProgress = false
                initialSpan = 0f
                anchoredZoomMode = ANCHORED_ZOOM_MODE_NONE
            } else if (inAnchoredZoomMode() && streamComplete) {
                isInProgress = false
                initialSpan = 0f
                anchoredZoomMode = ANCHORED_ZOOM_MODE_NONE
            }
            if (streamComplete) {
                return true
            }
        }

        if (!isInProgress &&
            isStylusZoomEnabled &&
            !inAnchoredZoomMode() &&
            !streamComplete &&
            isStylusButtonDown) {
            // Start of a button zoom gesture
            anchoredZoomStartX = event.x
            anchoredZoomStartY = event.y
            anchoredZoomMode = ANCHORED_ZOOM_MODE_STYLUS
            initialSpan = 0f
        }

        val configChanged = action == MotionEvent.ACTION_DOWN ||
            action == MotionEvent.ACTION_POINTER_UP ||
            action == MotionEvent.ACTION_POINTER_DOWN ||
            anchoredZoomCancelled

        val pointerUp = action == MotionEvent.ACTION_POINTER_UP
        val skipIndex = if (pointerUp) event.actionIndex else -1

        // Determine focal point
        var sumX = 0f
        var sumY = 0f
        val div = if (pointerUp) count - 1 else count
        val focusX: Float
        val focusY: Float
        if (inAnchoredZoomMode()) {
            // In anchored scale mode, the focal pt is always where the double tap
            // or button down gesture started
            focusX = anchoredZoomStartX
            focusY = anchoredZoomStartY
            eventBeforeOrAboveStartingGestureEvent = if (event.y < focusY) {
                true
            } else {
                false
            }
        } else {
            for (i in 0 until count) {
                if (skipIndex == i) continue
                sumX += event.getX(i)
                sumY += event.getY(i)
            }
            focusX = sumX / div
            focusY = sumY / div
        }

        // Determine average deviation from focal point
        var devSumX = 0f
        var devSumY = 0f
        for (i in 0 until count) {
            if (skipIndex == i) continue

            // Convert the resulting diameter into a radius.
            devSumX += abs((event.getX(i) - focusX))
            devSumY += abs((event.getY(i) - focusY))
        }
        val devX = devSumX / div
        val devY = devSumY / div

        // Span is the average distance between touch points through the focal point;
        // i.e. the diameter of the circle with a radius of the average deviation from
        // the focal point.
        val spanX = devX * 2
        val spanY = devY * 2
        val span: Float = if (inAnchoredZoomMode()) {
            spanY
        } else {
            hypot(spanX, spanY)
        }

        // Dispatch begin/end events as needed.
        // If the configuration changes, notify the app to reset its current state by beginning
        // a fresh zoom event stream.
        val wasInProgress = isInProgress
        this.focusX = focusX
        this.focusY = focusY
        if (!inAnchoredZoomMode() && isInProgress && (span < minSpan || configChanged)) {
            listener.onZoom(ZOOM_GESTURE_END, this)
            isInProgress = false
            initialSpan = span
        }
        if (configChanged) {
            currentSpanX = spanX
            previousSpanX = currentSpanX
            currentSpanY = spanY
            previousSpanY = currentSpanY
            currentSpan = span
            previousSpan = currentSpan
            initialSpan = previousSpan
        }
        val minSpan = if (inAnchoredZoomMode()) spanSlop else minSpan
        if (!isInProgress &&
            span >= minSpan &&
            (wasInProgress || abs((span - initialSpan)) > spanSlop)) {
            currentSpanX = spanX
            previousSpanX = currentSpanX
            currentSpanY = spanY
            previousSpanY = currentSpanY
            currentSpan = span
            previousSpan = currentSpan
            prevTime = eventTime
            isInProgress = listener.onZoom(ZOOM_GESTURE_BEGIN, this)
        }

        // Handle motion; focal point and span/scale factor are changing.
        if (action == MotionEvent.ACTION_MOVE) {
            currentSpanX = spanX
            currentSpanY = spanY
            currentSpan = span

            var updatePrev = true

            if (isInProgress) {
                updatePrev = listener.onZoom(ZOOM_GESTURE_MOVE, this)
            }

            if (updatePrev) {
                previousSpanX = currentSpanX
                previousSpanY = currentSpanY
                previousSpan = currentSpan
                prevTime = eventTime
            }
        }
        return true
    }

    private fun inAnchoredZoomMode(): Boolean {
        return anchoredZoomMode != ANCHORED_ZOOM_MODE_NONE
    }

    val scaleFactor: Float
        /**
         * Returns the scaling factor from the previous zoom event to the current event. This value
         * is defined as ([currentSpan] / [previousSpan]).
         *
         * @return The current scaling factor.
         */
        get() {
            if (inAnchoredZoomMode()) {
                // Drag is moving up; the further away from the gesture start, the smaller the span
                // should be, the closer, the larger the span, and therefore the larger the scale
                val scaleUp = eventBeforeOrAboveStartingGestureEvent &&
                    currentSpan < previousSpan ||
                    !eventBeforeOrAboveStartingGestureEvent &&
                    currentSpan > previousSpan
                val spanDiff = (abs((1 - currentSpan / previousSpan)) * SCALE_FACTOR)
                return if (previousSpan <= spanSlop) 1.0f
                else if (scaleUp) 1.0f + spanDiff
                else 1.0f - spanDiff
            }
            return if (previousSpan > 0) currentSpan / previousSpan else 1.0f
        }

    val timeDelta: Long
        /**
         * Returns the time difference in milliseconds between the previous accepted zooming event
         * and the current zooming event.
         *
         * @return Time difference since the last zooming event in milliseconds.
         */
        get() = eventTime - prevTime

    companion object {
        private const val TAG = "ZoomGestureDetector"

        /** The moving events of a gesture in progress. Reported by pointer motion. */
        const val ZOOM_GESTURE_MOVE = 0
        /** The beginning of a zoom gesture. Reported by new pointers going down. */
        const val ZOOM_GESTURE_BEGIN = 1
        /** The end of a zoom gesture. Reported by existing pointers going up. */
        const val ZOOM_GESTURE_END = 2

        // The default minimum span that the detector interprets a zooming event with. It's set to 0
        // to give the most responsiveness.
        // TODO(b/314702145): define a different span if appropriate.
        private const val DEFAULT_MIN_SPAN = 0
        private const val SCALE_FACTOR = .5f
        private const val ANCHORED_ZOOM_MODE_NONE = 0
        private const val ANCHORED_ZOOM_MODE_DOUBLE_TAP = 1
        private const val ANCHORED_ZOOM_MODE_STYLUS = 2
    }
}