I am working on a paint app with the following layout:
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:
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?
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: