Search code examples
androidkotlinandroid-layoutandroid-xmlandroid-touch-event

HitRect returning wrong Y value for unknown reason


I am creating a pixel art app, which has the following layout:

enter image description here

Input events are detected inside the pixel art board, meaning that if the user swipes from the root layout and travels their finger inside the pixel art board, it doesn't detect it. This is obviously a minor issue.

To fix this, I looked online and I found the following code which kind of fixed the problem:

binding.root.setOnTouchListener { _, motionEvent ->
    val hitRect = Rect()
    binding.activityCanvasCardView.getHitRect(hitRect)

    if (hitRect.contains(motionEvent.x.toInt(), motionEvent.y.toInt())) {
        Log.d("LOG123", "Hi ${motionEvent.xPrecision} ${motionEvent.yPrecision}")

        binding.activityCanvasPixelGridView.onTouchEvent(motionEvent)
    }
    true
}

Note that the view coordinates are converted into pixel coordinates in the onTouchEvent method.

Simple enough, right? In a perfect world, that code should fix the issue.

There's only one problem, for some reason, there is an offset with the y value:

enter image description here

I am unsure why I am having this strange delay with the Y coordinates.

I've tried to fix this issue, some of the things I tried were:

  • manually applying offset values
  • using different rect functions of the View class
  • look online to see if anyone has a similar issue

I'm following things by the book.


I tried the code that Sergei Kozelko gave me, I don't know if it's because I'm scaling/sizing the view in onCreate, but the code isn't working:

enter image description here

Code I tried:

binding.root.setOnTouchListener { _, motionEvent ->
val hitRect = Rect()
binding.activityCanvasCardView.getHitRect(hitRect)

if (hitRect.contains(motionEvent.x.toInt(), motionEvent.y.toInt())) {
    val offsetX = motionEvent.x - binding.activityCanvasPixelGridView.left
    val offsetY = motionEvent.y - binding.activityCanvasPixelGridView.top

    motionEvent.offsetLocation(offsetX, offsetY)
    val inverseCopy = Matrix()

    if (!binding.activityCanvasPixelGridView.matrix.isIdentity) {
        binding.activityCanvasPixelGridView.matrix.invert(inverseCopy)
        motionEvent.transform(inverseCopy)
    }

    binding.activityCanvasPixelGridView.dispatchTouchEvent(motionEvent)
}
true
}

Solution

  • The problem is likely that you are calling activityCanvasPixelGridView.onTouchEvent with unmodified parent event. The coordinates are in parent's coordinate space while onTouchEvent expects them to be in this view's coordinate space.

    Android does this automatically in ViewGroup#dispatchTransformedTouchEvent:

    final float offsetX = mScrollX - child.mLeft;
    final float offsetY = mScrollY - child.mTop;
    transformedEvent.offsetLocation(offsetX, offsetY);
    if (! child.hasIdentityMatrix()) {
        transformedEvent.transform(child.getInverseMatrix());
    }
    
    handled = child.dispatchTouchEvent(transformedEvent);
    

    There is also getTransformedMotionEvent which just transforms the event but it is marked private. So you'll have to reimplement it yourself. The only non-straightforward moment is dealing with transformation, as both hasIdentityMatrix and getInverseMatrix are marked with @UnsupportedAppUsage and should not be used. Instead you'll need to use Matrix#isIdentity and Matrix#invert which do the same calculations, just with more overhead.

    Combined together it looks like this:

    binding.root.setOnTouchListener { _, motionEvent ->
    val hitRect = Rect()
    binding.activityCanvasCardView.getHitRect(hitRect)
    
    if (hitRect.contains(motionEvent.x.toInt(), motionEvent.y.toInt())) {
        val parent = binding.root
        val child = binding.activityCanvasCardView
        val offsetX = parent.scrollX - child.left
        val offsetY = parent.scrollY - child.top
    
        motionEvent.offsetLocation(offsetX, offsetY)
        val inverseCopy = Matrix()
    
        if (!child.matrix.isIdentity) {
            child.matrix.invert(inverseCopy)
            motionEvent.transform(inverseCopy)
        }
    
        child.dispatchTouchEvent(motionEvent)
    }
    true
    }
    

    Now, all this works only if child (activityCanvasCardView) is a direct child of parent (root). If it is not you'll need to apply the above transformation for every view between them. Again, there is nothing in Android that does exactly what you need, the closest thing is ViewGroup#offsetRectIntoDescendantCoords. But it operates on Rects and doesn't account for transformations. And again you can implement it yourself like this:

    private fun transformToChild(parent: View, child: View, event: MotionEvent) {
        val offsetX = parent.scrollX - child.left;
        val offsetY = parent.scrollY - child.top;
        event.offsetLocation(offsetX.toFloat(), offsetY.toFloat());
        if (!child.matrix.isIdentity) {
            val inverse = Matrix()
            child.matrix.invert(inverse)
            event.transform(inverse);
        }
    }
    
    private fun transformToDescendant(parent: View, descendant: View, event: MotionEvent) {
        if (parent == descendant) {
            return
        }
    
        var currentDescendant = descendant
        val stack = mutableListOf<View>()
        while (currentDescendant != parent) {
            stack.add(currentDescendant)
            // safe to cast to View as parent function argument is View
            currentDescendant = currentDescendant.parent as View
        }
    
        for (view in stack.asReversed()) {
            transformToChild(view.parent as View, view, event)
        }
    }
    

    and call like transformToDescendant(binding.root, binding.activityCanvasPixelGridView, event) inside setOnTouchListener.

    Please note that this code was not properly tested, throws ClassCastException if descendant is not actually a descendant of parent and maybe could be optimized better to remove list. However it should work, or at least give you general idea how to implement it yourself.

    Finally, this doesn't happen on X axis because the canvas fills full width, so the X coordinates in parent and child views are the same.