Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Click through RecyclerView ViewHolder

Tags:

I have a RecyclerView that displays an Button which extends outside its parent ViewHolder. To the button I added a clickListener to display a toast. If you click on the Button and the click is on the area of the Button parent ViewHolder, the toast shows, but if you click on the button but outside its parent ViewHolder the toast doesn't show anymore.

Toast shows if you click here

Toast doesn't show if you click here

Here's what I currently have

RecyclerView:

<androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:clipChildren="false"
        android:clipToPadding="false"
        android:clickable="false"
        android:focusable="false"
        android:focusableInTouchMode="false"
        android:focusedByDefault="false"
        android:scrollbars="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@id/indicator"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:reverseLayout="true"
        app:stackFromEnd="true" />

Item view:

<?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"
    android:id="@+id/layout"
    android:layout_width="15dp"
    android:layout_height="match_parent"
    android:clipChildren="false"
    android:clickable="false"
    android:focusable="false"
    android:focusableInTouchMode="false"
    android:clipToPadding="false"
    android:elevation="0dp"
    android:scaleY="-1.0">

    <View
        android:id="@+id/vertical"
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:alpha="0.25"
        android:background="#ffffff"
        android:visibility="gone"
        android:layout_marginVertical="5dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/annotation"
        android:layout_width="200dp"
        android:layout_height="wrap_content"
        android:visibility="gone"
        android:scaleY="-1.0"
        android:elevation="100dp"
        android:text="TEST ANNOTATION"
        android:clickable="true"
        android:focusable="true"
        android:focusableInTouchMode="true"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Adapter:

class ChartAdapter(
    private val dataSet: MutableList<Float>,
    private val context: Context
) : RecyclerView.Adapter<ChartAdapter.ViewHolder>() {
    private var scaleFactor: Float = 1.0f

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val layout: ConstraintLayout = view.findViewById(R.id.layout)
        val vertical: View = view.findViewById(R.id.vertical)
        val annotation: View = view.findViewById(R.id.annotation)
    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder =
        ViewHolder(
            LayoutInflater.from(viewGroup.context)
                .inflate(R.layout.layout_quadrant, viewGroup, false)
        )

    override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
        viewHolder.layout.layoutParams = ConstraintLayout.LayoutParams(
            (39 * this.scaleFactor).roundToInt(),
            ConstraintLayout.LayoutParams.MATCH_PARENT
        )

        ConstraintSet().apply {
            clone(viewHolder.layout)
            applyTo(viewHolder.layout)
        }

        viewHolder.annotation.scaleX = this.scaleFactor
        viewHolder.annotation.scaleY = this.scaleFactor * -1

        viewHolder.vertical.visibility = when {
            position % 5 == 0 || position == dataSet.size - 1 -> View.VISIBLE
            else -> View.GONE
        }

        viewHolder.annotation.visibility = when (position) {
            15 -> View.VISIBLE
            else -> View.GONE
        }

        viewHolder.annotation.setOnClickListener {
            Toast.makeText(context, "annotation clicked!", Toast.LENGTH_SHORT).show()
        }
    }

    override fun getItemCount() = dataSet.size

    fun setScaleFactor(scaleFactor: Float) {
        this.scaleFactor = scaleFactor
        notifyDataSetChanged()
    }
}

As you can see I've tried to add android:clickable="false", android:focusable="false" and android:focusableInTouchMode="false" to the Item layout and the RecyclerView to prevent it from intercepting the click action but no luck the parent always keep intercepting the click thus preventing the button from being clicked.

Also I've tried this on the ViewHolder but no luck

inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    val layout: ConstraintLayout = view.findViewById(R.id.layout)
    val vertical: View = view.findViewById(R.id.vertical)
    val annotation: View = view.findViewById(R.id.annotation)

    init {
        view.layoutParams = WindowManager.LayoutParams().apply {
            height = WindowManager.LayoutParams.MATCH_PARENT;
            width = WindowManager.LayoutParams.WRAP_CONTENT;
            flags =
                WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
        }
        view.isClickable = false
        view.isFocusable = false
        view.isFocusableInTouchMode = false
        val itemTouchListener = View.OnTouchListener { v, event -> false }
        view.setOnTouchListener(itemTouchListener)
    }
}
like image 707
Joscplan Avatar asked Jun 22 '21 15:06

Joscplan


1 Answers

The problem is that the Android touch system initiates the touch on the ViewGroup (here a ConstraintLayout) and then propagates it to the children of the ViewGroup but the touch must be on the portion of the child that overlaps the ViewGroup. This is what you see.

Here is a good explanation of what happens.

I think that the best approach, if you need to stick to the current design, will be to capture the touch of the first ancestor of your view item that encapsulates the entirety of the button. You could then test touch events on that ancestor to see if they are also within the bounds of the button. If they are, you would then dispatch the touch event to dispatchTouchEvent() of the button.

Here is a simple demo of what is happening. I don't use a RecyclerView but, instead, use a simpler layout that show a button that straddles the right edge of a ConstraintLayout that is contained within a LinearLayout. The goal is to get the button 1/2 in its parent ViewGroup and 1/2 out to show how clicks happen.

In the demo, a switch determines whether we want to detect clicks on the part of the button that resides outside its parent. When the switch is "off", clicks on the outside are not detected and when the switch is on, clicks on the outside are detected.

enter image description here

Here is the code for the demo. The code establishing an on-screen hit rectangle for the button and checks within the dispatchTouchEvent() for the main activity if the touch is inside the hit rectangle or not.

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var mBaseLayout: LinearLayoutCompat
    private lateinit var mButton: Button
    private val mButtonOnScreenBounds = Rect()
    private var mIsOutsideClickEnabled = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mBaseLayout = findViewById(R.id.baseLayout)
        findViewById<SwitchCompat>(R.id.enableClicks).setOnCheckedChangeListener { _, isChecked ->
            mIsOutsideClickEnabled = isChecked
        }
        mButton = findViewById(R.id.button)
    }

    override fun dispatchTouchEvent(ev: MotionEvent) =
        super.dispatchTouchEvent(ev) ||
            (mIsOutsideClickEnabled && isClickWithinView(mButton, ev)
                && mButton.dispatchTouchEvent(ev))

    fun isClickWithinView(view: View, ev: MotionEvent) =
        Rect().let { rect ->
            view.getGlobalVisibleRect(rect)
            rect.contains(ev.x.toInt(), ev.y.toInt())
        }

    fun onClick(view: View) {
        when (view.id) {
            R.id.baseLayout -> Toast.makeText(this, "baseLayout clicked!", Toast.LENGTH_SHORT)
            R.id.innerLayout -> Toast.makeText(this, "innerLayout clicked!", Toast.LENGTH_SHORT)
            R.id.button -> Toast.makeText(this, "button clicked!", Toast.LENGTH_SHORT)
            else -> Toast.makeText(this, "Something else clicked!", Toast.LENGTH_SHORT)
        }.show()
    }
}

Here is the layout that is used:

activity_main.xml

<androidx.appcompat.widget.LinearLayoutCompat 
    android:id="@+id/baseLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/holo_red_light"
    android:clipChildren="false"
    android:orientation="horizontal"
    tools:context=".MainActivity">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/innerLayout"
        android:layout_width="300dp"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:layout_marginBottom="96dp"
        android:background="@android:color/holo_blue_light"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <androidx.appcompat.widget.SwitchCompat
            android:id="@+id/enableClicks"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:text="Enable clicks outside "
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/button"
            android:layout_width="100dp"
            android:layout_height="wrap_content"
            android:onClick="onClick"
            android:text="Button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />


    </androidx.constraintlayout.widget.ConstraintLayout>

</androidx.appcompat.widget.LinearLayoutCompat>

This processing, or similar processing, could also be done in other functions that are called during touch processing such as a touch listener.



Although the preceding appears to work, if you take a look at the ripple effect when the button is touched inside the LinearLayout and outside it, it is different. When the touch is on the button but inside the LinearLayout, the ripple extends from the touch point as expected. When the touch is outside the LinearLayout, the ripple is not from the touch point but from the bottom center. This causes me concern since it indicates that, somehow, processing is different. There may be other problems with things like accessibility, etc. So, caveat emptor.

The better approach would be to somehow extend the view holder to encompass the whole extent of the button.

like image 50
Cheticamp Avatar answered Sep 30 '22 15:09

Cheticamp