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

Checking if finger is over certain view not working in Android


I am working on a paint app with the following layout:

enter image description here

For the paint app, I detect touch events on the Canvas using onTouchEvent. I have one problem, I want to also detect touch events in which the user begins the swipe on the root and then hovers over the Canvas.

To achieve this, I added the following code:

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

    if (hitRect.contains(motionEvent.rawX.toInt(), motionEvent.rawY.toInt())) {
        binding.activityCanvasPixelGridView.onTouchEvent(motionEvent)
    }
    true
}

It kind of works, but the thing is. It's not detecting the touch events over the canvas (wrapped in a CardView) properly, it's like there's a sort of delay:

enter image description here

XML code:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/fragment_background_color_daynight"
    tools:context=".activities.canvas.CanvasActivity">
    <!-- This view is here to ensure that when the user zooms in, there is no overlap -->
    <View
        android:elevation="20dp"
        android:outlineProvider="none"
        android:id="@+id/activityCanvas_topView"
        android:layout_width="0dp"
        android:layout_height="90dp"
        android:background="@color/fragment_background_color_daynight"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <!-- The ColorSwitcherView is a view I created which helps
         simplify the code for controlling the user's primary/secondary color -->
    <com.therealbluepandabear.pixapencil.customviews.colorswitcherview.ColorSwitcherView
        android:id="@+id/activityCanvas_colorSwitcherView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="16dp"
        android:elevation="20dp"
        android:outlineProvider="none"
        app:isPrimarySelected="true"
        app:layout_constraintEnd_toEndOf="@+id/activityCanvas_topView"
        app:layout_constraintTop_toTopOf="@+id/activityCanvas_colorPickerRecyclerView" />

    <!-- The user's color palette data will be displayed in this RecyclerView -->
    <androidx.recyclerview.widget.RecyclerView
        android:elevation="20dp"
        android:outlineProvider="none"
        android:id="@+id/activityCanvas_colorPickerRecyclerView"
        android:layout_width="0dp"
        android:layout_height="50dp"
        android:layout_marginStart="16dp"
        android:layout_marginEnd="16dp"
        android:orientation="horizontal"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="@+id/activityCanvas_topView"
        app:layout_constraintEnd_toStartOf="@+id/activityCanvas_colorSwitcherView"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="@+id/activityCanvas_primaryFragmentHost"
        tools:listitem="@layout/color_picker_layout" />

    <!-- This FrameLayout is crucial when it comes to the calculation of the TransparentBackgroundView and PixelGridView -->
    <FrameLayout
        android:id="@+id/activityCanvas_distanceContainer"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/activityCanvas_tabLayout"
        app:layout_constraintEnd_toEndOf="@+id/activityCanvas_primaryFragmentHost"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/activityCanvas_topView" />

    <!-- This gives both views (the PixelGridView and TransparentBackgroundView) a nice drop shadow -->
    <com.google.android.material.card.MaterialCardView
        android:id="@+id/activityCanvas_cardView"
        style="@style/activityCanvas_canvasFragmentHostCardViewParent_style"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@+id/activityCanvas_tabLayout"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/activityCanvas_topView">
        <!-- At runtime, the width and height of the TransparentBackgroundView and PixelGridView will be calculated -->
       <com.therealbluepandabear.pixapencil.customviews.transparentbackgroundview.TransparentBackgroundView
            android:id="@+id/activityCanvas_transparentBackgroundView"
            android:layout_width="0dp"
            android:layout_height="0dp" />

        <com.therealbluepandabear.pixapencil.customviews.pixelgridview.PixelGridView
            android:id="@+id/activityCanvas_pixelGridView"
            android:layout_width="0dp"
            android:layout_height="0dp" />
    </com.google.android.material.card.MaterialCardView>

    <!-- The primary tab layout -->
    <com.google.android.material.tabs.TabLayout
        android:elevation="20dp"
        android:outlineProvider="none"
        android:id="@+id/activityCanvas_tabLayout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:tabStripEnabled="false"
        app:layout_constraintBottom_toTopOf="@+id/activityCanvas_viewPager2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent">
        <com.google.android.material.tabs.TabItem
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/activityCanvas_tab_tools_str" />

        <com.google.android.material.tabs.TabItem
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/activityCanvas_tab_filters_str" />

        <com.google.android.material.tabs.TabItem
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/activityCanvas_tab_color_palettes_str" />

        <com.google.android.material.tabs.TabItem
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/activityCanvas_tab_brushes_str" />
    </com.google.android.material.tabs.TabLayout>

    <!-- This view allows move functionality -->
    <View
        android:elevation="20dp"
        android:outlineProvider="none"
        android:id="@+id/activityCanvas_moveView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@android:color/transparent"
        app:layout_constraintBottom_toBottomOf="@+id/activityCanvas_distanceContainer"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/activityCanvas_topView" />

    <!-- The tools, palettes, brushes, and filters fragment will be displayed inside this ViewPager -->
    <androidx.viewpager2.widget.ViewPager2
        android:elevation="20dp"
        android:outlineProvider="none"
        android:id="@+id/activityCanvas_viewPager2"
        android:layout_width="0dp"
        android:layout_height="110dp"
        app:layout_constraintBottom_toBottomOf="@+id/activityCanvas_primaryFragmentHost"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <!-- This CoordinatorLayout is responsible for ensuring that the app's snackbars can be swiped -->
    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:elevation="20dp"
        android:outlineProvider="none"
        android:id="@+id/activityCanvas_coordinatorLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <!-- All of the full page fragments will be displayed in this fragment host -->
    <FrameLayout
        android:elevation="20dp"
        android:outlineProvider="none"
        android:id="@+id/activityCanvas_primaryFragmentHost"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

How can I detect touch events properly over a view?


Solution

  • binding.activityCanvasCardView.getHitRect(hitRect) is in the coordinates of the view's parent. See View#getHitRect().

    motionEvent.rawX and (), motionEvent.rawY are in the device display coordinates. See MotionEvent#getRawX().

    The offset is going to be the difference between the two. You will need to transform one set of coordinates to the other to make the comparisons.

    Use MotionEvent#getX() and MotionEvent#getY() for view coordinates.



    The other problem that you may have is that since the touch listener is on the root view, the MotionEvent passed to your custom view, PixelGridView, will be in the coordinates of the root view. The custom view would have to have a way to translate those coordinates to its own coordinates to draw on its canvas properly. Maybe you are accommodating this now, but your code for that custom view is not posted.

    Update: Sample coode

    This is an update to the update with the sample code. Although what I posted before demonstrates the concepts, there were a few things that I thought needed to be corrected for a more complete answer. The following is the updated code.

    Let's consider a simplified layout:

    <layout>
    
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_optimizationLevel="none">
    
            <com.google.android.material.card.MaterialCardView
                android:id="@+id/activityCanvas_cardView"
                android:layout_width="300dp"
                android:layout_height="300dp"
                app:cardBackgroundColor="@android:color/holo_red_light"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent">
    
                <com.example.starterapp.MyView
                    android:id="@+id/activityCanvas_pixelGridView"
                    android:layout_width="200dp"
                    android:layout_height="200dp"
                    android:layout_margin="50dp"
                    android:background="@android:color/holo_blue_light" />
            </com.google.android.material.card.MaterialCardView>
        </androidx.constraintlayout.widget.ConstraintLayout>
    </layout>
    

    And a simple custom view that draws a path:

    class MyView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {
    
        private val mPath = Path()
        private val mPaint = Paint().apply {
            color = context.getColor(android.R.color.black)
            style = Paint.Style.STROKE
            strokeWidth = 5f
        }
        private lateinit var mViewOffset: Point
    
        override fun onDraw(canvas: Canvas) {
            super.onDraw(canvas)
            canvas.drawPath(mPath, mPaint)
        }
    
        fun addMotion(motionEvent: MotionEvent) {
            for (i in 0 until motionEvent.historySize) {
                addPoint(motionEvent.getHistoricalX(i), motionEvent.getHistoricalY(i))
            }
            addPoint(motionEvent.x, motionEvent.y)
            invalidate()
        }
    
        fun startDrawing(motionEvent: MotionEvent) {
            mPath.reset()
            mPath.moveTo(motionEvent.x - mViewOffset.x, motionEvent.y - mViewOffset.y)
            invalidate()
        }
    
        fun setViewOffset(offset: Point) {
            mViewOffset = Point(offset)
        }
    
        private fun addPoint(x: Float, y: Float) {
            mPath.lineTo(x - mViewOffset.x, y - mViewOffset.y)
        }
    }
    

    And, finally a fragment that does the work. Comments are in the code.

    class MainFragment : Fragment() {
        private lateinit var binding: FragmentMainBinding
    
        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View {
            binding = DataBindingUtil.setContentView(requireActivity(), R.layout.fragment_main)
    
            binding.root.setOnTouchListener { _, motionEvent ->
    
                when (motionEvent.action) {
                    MotionEvent.ACTION_DOWN ->
                        binding.activityCanvasPixelGridView.startDrawing(motionEvent)
    
                    MotionEvent.ACTION_MOVE ->
                        binding.activityCanvasPixelGridView.addMotion(motionEvent)
                }
                true
            }
    
            // Wait until everything is laid out so positions and sizes are known.
            binding.root.doOnNextLayout {
                val gridViewOffset = Point()
                var view = binding.activityCanvasPixelGridView as View
    
                while (view != it) {
                    gridViewOffset.x += view.left
                    gridViewOffset.y += view.top
                    view = view.parent as View
                }
    
                binding.activityCanvasPixelGridView.setViewOffset(gridViewOffset)
            }
    
            return binding.root
        }
    
        companion object {
            val TAG = this::class.simpleName
        }
    }
    

    When all this is executed, we see the following:

    enter image description here