I have some problem trying to control touch event propagation within my RecycleView. So I have a RecyclerView populating a set of CardViews imitating a card stack (so they overlap each other with a certain displacement, though they have different elevation value). My current problem is that each card has a button and since relative card displacement is smaller than height of the button it results in the situation that buttons are overlapping and whenever a touch event is dispatched it starts propagating from the bottom of view hierarchy (from children with highest child number).
According to articles I read (this, this and also this video) touch propagation is dependent on the order of views in parent view, so touch will first be delivered to the child with highest index, while I want the touch event to be processed only by touchables of the topmost view and RecyclerView (it also has to process drag and fling gestures). Currently I am using a fallback with cards that are aware of their position within parent's view hierarchy to prevent wrong children from processing touches but this is really ugly way to do that. My assumption is that I have to override dispatchTouchEvent method of the RecyclerView and properly dispatch a touch event only to topmost child. However, when I tried this way of doing that (which is also kind of clumsy):
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
View topChild = getChildAt(0);
for (View touchable : topChild.getTouchables()) {
int[] location = new int[2];
touchable.getLocationOnScreen(location);
RectF touchableRect = new RectF(location[0],
location[1],
location[0] + touchable.getWidth(),
location[1] + touchable.getHeight());
if (touchableRect.contains(ev.getRawX(), ev.getRawY())) {
return touchable.dispatchTouchEvent(ev);
}
}
return onTouchEvent(ev);
}
Only DOWN event was delivered to the button within a card (no click event triggered). I will appreciate any advice on the way of reversing touch event propagation order or on delegating of touch event to a specific View. Thank you very much in advance.
EDIT: This is the screenshot of how the example card stack is looking like
Example adapter code:
public class TestAdapter extends RecyclerView.Adapter {
List<Integer> items;
public TestAdapter(List<Integer> items) {
this.items = items;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.test_layout, parent, false);
return new TestHolder(v);
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
TestHolder currentHolder = (TestHolder) holder;
currentHolder.number.setText(Integer.toString(position));
currentHolder.tv.setTag(position);
currentHolder.tv.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
int pos = (int) v.getTag();
Toast.makeText(v.getContext(), Integer.toString(pos) + "clicked", Toast.LENGTH_SHORT).show();
}
});
}
@Override
public int getItemCount() {
return items.size();
}
private class TestHolder extends RecyclerView.ViewHolder {
private TextView tv;
private TextView number;
public TestHolder(View itemView) {
super(itemView);
tv = (TextView) itemView.findViewById(R.id.click);
number = (TextView) itemView.findViewById(R.id.number);
}
}
}
and an example card layout:
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_margin="16dp"
android:textSize="24sp"
android:id="@+id/number"/>
<TextView android:layout_width="wrap_content"
android:layout_height="64dp"
android:gravity="center"
android:layout_alignParentBottom="true"
android:layout_alignParentLeft="true"
android:layout_margin="16dp"
android:textSize="24sp"
android:text="CLICK ME"
android:id="@+id/click"/>
</RelativeLayout>
</android.support.v7.widget.CardView>
And here is the code that I am using now to solve the problem (this approach I do not like, I want to find better way)
public class PositionAwareCardView extends CardView {
private int mChildPosition;
public PositionAwareCardView(Context context) {
super(context);
}
public PositionAwareCardView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public PositionAwareCardView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setChildPosition(int pos) {
mChildPosition = pos;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// if it's not the topmost view in view hierarchy then block touch events
return mChildPosition != 0 || super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return false;
}
}
EDIT 2: I've forgotten to mention, this problem is present only on pre-Lolipop devices, it seems that starting from Lolipop, ViewGroups also take elevation into consideration while dispatching touch events
EDIT 3: Here is my current child drawing order:
@Override
protected int getChildDrawingOrder(int childCount, int i) {
return childCount - i - 1;
}
EDIT 4: Finally I was able to fix the problem thanks to user random, and this answer, the solution was extremely simple:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!onInterceptTouchEvent(ev)) {
if (getChildCount() > 0) {
final float offsetX = -getChildAt(0).getLeft();
final float offsetY = -getChildAt(0).getTop();
ev.offsetLocation(offsetX, offsetY);
if (getChildAt(0).dispatchTouchEvent(ev)) {
// if touch event is consumed by the child - then just record it's coordinates
x = ev.getRawX();
y = ev.getRawY();
return true;
}
ev.offsetLocation(-offsetX, -offsetY);
}
}
return super.dispatchTouchEvent(ev);
}
CardView uses elevation API on L and before L, it changes the shadow size. dispatchTouchEvent
in L respects Z ordering when iterating over children which doesn't happen in pre L.
Looking at the source code:
Pre Lollipop
ViewGroup#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = customOrder ?
getChildDrawingOrder(childrenCount, i) : i;
final View child = children[childIndex];
...
Lollipop
ViewGroup#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
// Check whether any clickable siblings cover the child
// view and if so keep track of the intersections. Also
// respect Z ordering when iterating over children.
ArrayList<View> orderedList = buildOrderedChildList();
final boolean useCustomOrder = orderedList == null
&& isChildrenDrawingOrderEnabled();
final int childCount = mChildrenCount;
for (int i = childCount - 1; i >= 0; i--) {
final int childIndex = useCustomOrder
? getChildDrawingOrder(childCount, i) : i;
final View sibling = (orderedList == null)
? mChildren[childIndex] : orderedList.get(childIndex);
// We care only about siblings over the child
if (sibling == child) {
break;
}
...
The child drawing order can be overridden with custom child drawing order in a ViewGroup, and with setZ(float) custom Z values set on Views.
You might want to check custom child drawing order in a ViewGroup but I think your current fix for the problem is good enough.
Did you try to set your topmost view as clickable ?
button.setClickable(true);
If this attribute is not set (as default) in the View XML it propagate the click event upwards.
But if you set it on the topmost view as true, it shouldn't propagate any event on any other view.
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