Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Controlling touch event propagation in case of overlapping views

Tags:

android

sdk

touch

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 Screen

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);
}
like image 505
Chaosit Avatar asked Jun 24 '15 14:06

Chaosit


2 Answers

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.

like image 75
random Avatar answered Sep 27 '22 23:09

random


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.

like image 42
Ciro Rizzo Avatar answered Sep 28 '22 00:09

Ciro Rizzo