Android custom view --- gesture recognition, double click, inertial sliding

preface

In view, we can rewrite onTouchEvent to define click events, but MotionEvent gives us too few choices to meet some personalized needs, such as double clicking, inertial sliding, etc., so we introduce GestureDetectorCompat listener to realize some additional functions

1, What is GestureDetectorCompat?

GestureDetectorCompat, translated as gesture detector, is similar to plug-in and hook. It intercepts your touch and click on the screen to replace the default super.onTouchEvent(event), but follow the touch effect customized in the gesture detector

2, Use steps

1. Define an instance of GestureDetectorCompat

The code is as follows:

private val gestureDetectorCompat = GestureDetectorCompat(context, this)

The second parameter is listener. Let the view implement the GestureDetector.OnGestureListener interface. We can directly fill in this, but we need to rewrite the abstract method.

    override fun onDown(e: MotionEvent?): Boolean {
        TODO("Not yet implemented")
    }

    override fun onShowPress(e: MotionEvent?) {
        TODO("Not yet implemented")
    }

    override fun onSingleTapUp(e: MotionEvent?): Boolean {
        TODO("Not yet implemented")
    }

    override fun onScroll(
        e1: MotionEvent?,
        e2: MotionEvent?,
        distanceX: Float,
        distanceY: Float
    ): Boolean {
        TODO("Not yet implemented")
    }

    override fun onLongPress(e: MotionEvent?) {
        TODO("Not yet implemented")
    }

    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent?,
        velocityX: Float,
        velocityY: Float
    ): Boolean {
        TODO("Not yet implemented")
    }

2. Rewrite the method to achieve custom effect

① If you want the detector to consume a series of touch events, you must return true in the rewrite method onDown, and then the subsequent series of touch processes can be obtained by the gesture detector

The code is as follows:

    override fun onDown(e: MotionEvent?): Boolean {
        return true
    }

② Since we want to achieve the double-click effect, we have to set a monitor for the gesture detector

    private val gestureDetectorCompat = GestureDetectorCompat(context, this).apply {
        setOnDoubleTapListener(this@ScalableImageView)
    }

The parameter of setOnDoubleTapListener is to pass in a listener, so let's fill in the view and let the view rewrite the method of the interface GestureDetector.OnDoubleTapListener
Override method:

    override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
        TODO("Not yet implemented")
    }

    override fun onDoubleTap(e: MotionEvent?): Boolean {
        TODO("Not yet implemented")
    }

    override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
        TODO("Not yet implemented")
    }

simplify:

Tracking the source code of the constructor of GestureDetectorCompat, we can see that it helps us determine the type of listener internally. If it is the implementation class of gesturedetector.ondoubletaplistener, it will help us execute the setOnDoubleTapListener method. We don't need to configure it, so the code can be simplified as follows:

    private val gestureDetectorCompat = GestureDetectorCompat(context, this)

③ Double click to make it bigger and smaller

effect:

④ Override onDoubleTap

The purpose is to double-click to enlarge and shrink the picture, so we make an animation to control the size of the picture

    private var scaleFraction = 0f
        set(value) {
            field = value
            invalidate()
        }
    private val animator by lazy { ObjectAnimator.ofFloat(this, "scaleFraction", 0f, 1f) }

In ondraw, get the scale coefficient to be scaled to achieve from small picture to large picture

            val scale = smallScale + (bigScale - smallScale) * scaleFraction
            scale(scale, scale, width / 2f, height / 2f)
            drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)

Therefore, in the method of double clicking, the animation is played normally from small to large, and the animation is played reversely from large to small

    override fun onDoubleTap(e: MotionEvent?): Boolean {
        if (isBig) {
            //It was originally a big picture, so it grew from small to large
            animator.reverse()
        } else {
            animator.start()
        }
        isBig = !isBig
        return true
    }

④ Realize inertial sliding

The core is to rewrite the onFling method
Create an OverScroller object

private val overScroller = OverScroller(context)

Function of OverScroller: control the inertial sliding of a point within a certain range
As shown in the figure

So how to control the inertial movement of a picture?

If the movement of the picture in the large frame is equivalent to the movement of the touch point in the small frame, and the displacement of the touch point on the X and Y axes is synchronized to the offset of the picture on the xy axis, the inertia of the picture can be realized
To put it bluntly, if you control your touch point within a small box, you can control the picture within a large box
Implementation steps:
① : define two variables as the offset of the small circle moving on the x axis and the offset of the small circle moving on the y axis

    private var offsetX = 0f
    private var offsetY = 0f

② : override onFling method

    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent?,
        velocityX: Float,
        velocityY: Float
    ): Boolean {
        overScroller.fling(
            offsetX.toInt(),
            offsetY.toInt(),
            velocityX.toInt(),
            velocityY.toInt(),
            (-(bitmap.width * bigScale - width) / 2f).toInt(),
            ((bitmap.width * bigScale - width) / 2f).toInt(),
            (-(bitmap.height * bigScale - height) / 2f).toInt(),
            ((bitmap.height * bigScale - height) / 2f).toInt(), 40.dp.toInt(), 40.dp.toInt()
        )
        postOnAnimation(this)
        return false
    }

The first parameter and the second parameter: the position when the finger clicks down
The third and fourth parameter: the speed in two directions when the finger slides hard
The fifth and sixth parameter: the range of the small box surrounding the touch point
The last two parameters refer to the range of recovery when sliding out of the small box boundary
The effect shown in the figure below

Summary: synchronize the displacement of the touch point to the picture
③ Smooth inertial sliding
Let the view implement the Runnable interface, rewrite the run method, and update the slider by frame

    override fun run() {
        //As long as the inertia is not over, return to update the picture position
        if (overScroller.computeScrollOffset()) {
            offsetX = overScroller.currX.toFloat()
            offsetY = overScroller.currY.toFloat()
            invalidate()
            postOnAnimation(this)
        }
    }

④ Using the run method in onFling

postOnAnimation(this)

summary

A gesture detector is used to detect some additional gestures, such as double clicking, inertial sliding, etc., and then the abstract method of gesture detector is rewritten to realize the feedback of these gestures

Complete code

package com.lbj23.customview.customview

import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
import com.lbj23.customview.R
import com.lbj23.customview.dp
import com.lbj23.customview.getAvatar
import kotlin.math.max
import kotlin.math.min

private const val EXTRA_SCALE = 1.5f

class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs),
    GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener, Runnable {
    private val gestureDetectorCompat = GestureDetectorCompat(context, this).apply {
        setIsLongpressEnabled(false)
    }
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val imageSize = 120.dp.toInt()
    private val bitmap = getAvatar(resources, R.drawable.test, imageSize)
    private var originalOffsetX = 0f
    private var originalOffsetY = 0f
    private var offsetX = 0f
    private var offsetY = 0f
    private var bigScale = 0f
    private var smallScale = 0f
    private var isBig = false
    private var scaleFraction = 0f
        set(value) {
            field = value
            invalidate()
        }
    private val animator by lazy { ObjectAnimator.ofFloat(this, "scaleFraction", 0f, 1f) }
    private val overScroller = OverScroller(context)
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.apply {
            if (isBig) {
                translate(offsetX, offsetY)
            }
            val scale = smallScale + (bigScale - smallScale) * scaleFraction
            scale(scale, scale, width / 2f, height / 2f)
            drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
        }
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        originalOffsetX = (width - bitmap.width) / 2f
        originalOffsetY = (height - bitmap.height) / 2f
        if (width / height.toFloat() < bitmap.width / bitmap.height.toFloat()) {
            smallScale = width / bitmap.width.toFloat()
            bigScale = (height / bitmap.height.toFloat()) * EXTRA_SCALE
        } else {
            smallScale = height / bitmap.height.toFloat()
            bigScale = (width / bitmap.width.toFloat()) * EXTRA_SCALE
        }
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return gestureDetectorCompat.onTouchEvent(event)
    }

    override fun onDown(e: MotionEvent?): Boolean {
        return true
    }

    override fun onShowPress(e: MotionEvent?) {
    }

    override fun onSingleTapUp(e: MotionEvent?): Boolean {
        return false
    }

    override fun onScroll(
        e1: MotionEvent?,
        e2: MotionEvent?,
        distanceX: Float,
        distanceY: Float
    ): Boolean {
        if (isBig) {
            offsetX -= distanceX
            offsetX = min((bitmap.width * bigScale - width) / 2f, offsetX)
            offsetX = max(-(bitmap.width * bigScale - width) / 2f, offsetX)
            offsetY -= distanceY
            offsetY = min((bitmap.height * bigScale - height) / 2f, offsetY)
            offsetY = max(-(bitmap.height * bigScale - height) / 2f, offsetY)
            invalidate()
        }
        return true
    }

    override fun onLongPress(e: MotionEvent?) {
    }

    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent?,
        velocityX: Float,
        velocityY: Float
    ): Boolean {
        overScroller.fling(
            offsetX.toInt(),
            offsetY.toInt(),
            velocityX.toInt(),
            velocityY.toInt(),
            (-(bitmap.width * bigScale - width) / 2f).toInt(),
            ((bitmap.width * bigScale - width) / 2f).toInt(),
            (-(bitmap.height * bigScale - height) / 2f).toInt(),
            ((bitmap.height * bigScale - height) / 2f).toInt(), 40.dp.toInt(), 40.dp.toInt()
        )
        postOnAnimation(this)
        return false
    }

    override fun run() {
        //As long as the inertia is not over, return to update the picture position
        if (overScroller.computeScrollOffset()) {
            offsetX = overScroller.currX.toFloat()
            offsetY = overScroller.currY.toFloat()
            invalidate()
            postOnAnimation(this)
        }
    }

    override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
        return false
    }

    override fun onDoubleTap(e: MotionEvent?): Boolean {
        if (isBig) {
            //It was originally a big picture, so it grew from small to large
            animator.reverse()
        } else {
            animator.start()
        }
        isBig = !isBig
        return true
    }

    override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
        return false
    }
}

Tags: Android kotlin

Posted by resago on Tue, 05 Oct 2021 06:43:58 +0530