1

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?

halfer
  • 19,824
  • 17
  • 99
  • 186
thebluepandabear
  • 263
  • 2
  • 7
  • 29

1 Answers1

1

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

Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • Android has no in built way of doing it? – thebluepandabear Jul 31 '22 at 03:29
  • Yes, see updated answer. – Cheticamp Jul 31 '22 at 13:53
  • Unfortunately `getX()` and `getY()` is not working, so I have unmarked this as the correct answer until I find a solution, I think you've given me a hint with the translation of coordinates. – thebluepandabear Aug 06 '22 at 00:49
  • I have already figured out how to convert between coordinates, but again, there is still an offset. – thebluepandabear Aug 06 '22 at 00:54
  • With all due respect, I'm feeling regret for not checking your solution first, would you be able to try to help me with this issue? I can't add another bounty which sucks :/ – thebluepandabear Aug 06 '22 at 03:35
  • If you look at the distance from the bottom of the canvas to where the lines start, I think that you will find that it equals the height of your status bar. (I am looking at the code you posted. I don't know what changes you have made.) So the root is the _ConstraintLayout_ and you want to draw on the children of the _CardView_ when the touch event occurs anywhere outside the _CardView_ children. Is that a simplified view of what you are trying to do? – Cheticamp Aug 06 '22 at 09:10
  • It's not the height of the status bar as I did measure it. In regards to your view, yes, it's exactly what I'm trying to do -- I was shocked at how *complex* it is to do these types of things and how the SDK doesn't have anything predefined to use . I think in WPF or any other SDK these things are very easy to do. – thebluepandabear Aug 06 '22 at 10:32
  • See the updated answer. – Cheticamp Aug 06 '22 at 14:36
  • Looks promising, thanks! I'll take a look tomorrow. – thebluepandabear Aug 08 '22 at 08:05
  • I tried your demo code (haven't implemented yet into my code) and it works so I'm marking this as the correct solution. I appreciate the high quality and effort you've put into this answer. Amazing. – thebluepandabear Aug 11 '22 at 06:12
  • Your code has two faults: when I set a custom width/height for the customview in the code it doesn't work, and when I zoom in it doesn't work :/ – thebluepandabear Aug 17 '22 at 04:41