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.
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)
}
}
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With