Question

I have a use case where there are two views on screen one of which is partially covering another. The one that is above needs to handle scroll events and ignore touch up. The partially obscured view should handle touch up events, including those that happen in the area of overlap that are ignored by the obscuring view.

a simplified example layout is below.

simplified example layout

the closest i've come uses GestureDetectorCompat on the top view returning true in onDown (otherwise i don't get any further events,) true in onScroll, and false in onSingleTapUp. i have tried several things in the view behind all with the same results: i get taps on the un-obscured section, but the top view eats all of the motion events for the obscured portion.

Was it helpful?

Solution

What you want to do is not as straightforward as you would probably like because of how Android handles touch event flow. So let me set the stage with a little context first:

The reason this is a tricky proposition is because Android defines a gesture as all the events between an ACTION_DOWN and the corresponding ACTION_UP. ACTION_DOWN is the only point at which the framework is searching for a touch target (which is why you have to return true for that event to see any others). Once a suitable target has been found, ALL the remaining events in that gesture will be delivered directly to that view and nobody else.

This means that if you want a single event to go to a different destination, you will have to capture and redirect it yourself. All touch events flow from parent views to child views in one long chain. Parent views control when and how touch events move from one child to the next, including modifying the coordinates of the MotionEvent so the match the local bounds of each child view. Because of this, the most effective place to manipulate touch events is in a custom ViewGroup parent implementation.

The following example comes with a big bag of assumptions. Basically, I'm assuming that both views are nothing more than a dumb View with no internal wishes to handle touch (which is probably wrong). Applying this code to other, more complex, child views may requires some rework...but this should get you started.

The best place to force touch redirection is in a common parent of the two views, since it is the origin of the touch for both (as described above).

public class TouchUpRedirectLayout extends FrameLayout implements View.OnTouchListener {

    private int mTargetViewId;
    private View mTargetView;
    private boolean mTargetTouchActive;

    private GestureDetector mGestureDetector;

    public TouchUpRedirectLayout(Context context) {
        super(context);
        init(context);
    }

    public TouchUpRedirectLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public TouchUpRedirectLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init(context);
    }

    private void init(Context context) {
        mGestureDetector = new GestureDetector(context, mGestureListener);
    }

    public void setTargetViewId(int resId) {
        mTargetViewId = resId;
        updateTargetView();
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        //Find the target view, if set, once inflated
        updateTargetView();
    }

    //Set the target view to handle gestures
    private void updateTargetView() {
        if (mTargetViewId > 0) {
            mTargetView = findViewById(mTargetViewId);
            if (mTargetView != null) {
                mTargetView.setOnTouchListener(this);
            }
        }
    }

    private Rect mHitRect = new Rect();
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_UP:
                if (mTargetTouchActive) {
                    mTargetTouchActive = false;

                    //Validate the up
                    int index = indexOfChild(mTargetView) - 1;
                    if (index < 0) {
                        return false;
                    }

                    for (int i=index; i >= 0; i--) {
                        final View child = getChildAt(i);
                        child.getHitRect(mHitRect);
                        if (mHitRect.contains((int) event.getX(), (int) event.getY())) {
                            //Dispatch and mark handled
                            return child.dispatchTouchEvent(event);
                        }
                    }

                    //Steal this event
                    return true;
                }
                //Allow default processing
                return false;
            default:
                //Allow default processing
                return false;
        }
    }

    //Receive touch events from the target (scroll handling) view
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        mTargetTouchActive = true;
        return mGestureDetector.onTouchEvent(event);
    }

    //Handle gesture events in target view
    private GestureDetector.SimpleOnGestureListener mGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onDown(MotionEvent e) {
            Log.d("TAG", "onDown");
            return true;
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            Log.d("TAG", "Scrolling...");
            return true;
        }
    };
}

This example layout (I subclassed FrameLayout, but you could choose whichever layout you are using currently as the parent of the two views) tracks a single "target" view for the purposes of notifying the "down" and "scroll" gestures. It also notifies us when a gesture is in play that will include an ACTION_UP event that we need to capture and forward to another obscured view.

When an up event occurs, we use the intercept functionality of ViewGroup to direct that event away from the original "target" view, and dispatch it to the next available child view whose bounds fit the event. You could just as easily hard-code the second "obscured" view here as well, but I've written it to dispatch to any and all possible children underneath...similar to the way ViewGroup handles touch delegation to children in the first place.

Here is an example layout:

<com.example.touchoverlaptest.app.TouchUpRedirectLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/view_root"
    android:layout_width="match_parent"
    android:layout_height="400dp"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:paddingBottom="@dimen/activity_vertical_margin"
    tools:context="com.example.touchoverlaptest.app.MainActivity">

    <View
        android:id="@+id/view_obscured"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:background="#7A00" />
    <View
        android:id="@+id/view_overlap"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:layout_gravity="bottom"
        android:background="#70A0" />

</com.example.touchoverlaptest.app.TouchUpRedirectLayout>

...and Activity with the view in action:

public class MainActivity extends Activity implements View.OnTouchListener {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        TouchUpRedirectLayout layout = (TouchUpRedirectLayout) findViewById(R.id.view_root);
        layout.setTargetViewId(R.id.view_overlap);

        layout.findViewById(R.id.view_obscured).setOnTouchListener(this);
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        Log.i("TAG", "Obscured touch "+event.getActionMasked());
        return true;
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {

        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.main, menu);
        return true;
    }
}

The target view will fire all the gesture callbacks, and the obscured view will receive the up events. The OnTouchListener in the activity is simply to validate that the events are delivered.

If you would like more detail about custom touch handling in Android, here is a video link to a presentation I did recently on the topic.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top