TaskRabbit is Hiring!

We’re a tight-knit team that’s passionate about building a solution that helps people by maximizing their time, talent and skills. We are actively hiring for our Engineering and Design teams. Click To Learn more

Colin Madere

Android Cooperative Scrolling

@ 23 Jan 2015

android UI


Android Hierarchical Cooperative Scrolling

Sometimes you need to break the rules. A scrollable view inside another scrollable view is frowned upon by the Google representatives. It has some tricky interaction implications and can easily be done in a way that works poorly, so it’s understandable they take that position. As you might expect, Google isn’t always right and there are exceptions, and there should be a way to do it cleanly. Done well, you can make a scrollable view work for you when it is inside another scrollable view.

We needed to support this a screen designed for the TaskRabbit Android and iOS app. After finding some related posts on StackOverflow and implementing the solution, I realized I could generalize the solution to be used elsewhere, if needed, and also for another common case that doesn’t immediately reveal itself as related.

And the obligatory:

Primary Case - Scrolling view within scrolling view

Notice I do not say ScrollView or ListView. Many different views have the ability to scroll, but they don’t use the same method to determine if they are at the top or bottom of their scrollable area, so I generalized that for the solution as well as how I’m talking about it.

Let’s say you have a layout similar to this:

<ScrollView
            android:id="@+id/outer_scroll"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">

        <TextView
                android:id="@+id/title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:padding="4dp"
                android:layout_gravity="center"
                android:text="Check this out:"/>

	    <ListView android:id="@+id/rabbit_reviews_list"
	              android:layout_below="@id/rabbit_reviews_title"
	              android:layout_width="match_parent"
	              android:layout_height="match_parent"
	              android:dividerHeight="1dp"
	              android:divider="@color/tr_border_line_color"
	              android:visibility="visible"
	            />
    </LinearLayout>
</ScrollView>

and you want the ListView, when at the top or the bottom of it’s list to then, within the same gesture, to start scrolling the outer ScrollView. You don’t want to make custom view classes for such a simple ask, you want to just add a flag to one or both and be done with it! No luck. Basically you have to capture the gesture and then “pass it to the other view” when you hit the edges of the inner view. Unfortunately, there’s so single way to accomplish this.

Below is the code and the setup for how I solved the problem, in a generic way, that makes it easy to put any kind of scrollable view (that you can detect being at it’s top or bottom scroll area) inside any other scrollable view and have them cooperate.

CooperativeScrollGestureListener

(Maintained @ GitHub Gist)

import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ViewGroup;
 
/**
 * Detects if scroll gesture happens, and if the secondary (often, the "inner" scroller is
 * at the edge in the gesture's direction, have the primary scrollable process the gesture
 */
public class CooperativeScrollGestureListener implements GestureDetector.OnGestureListener {
 
    private boolean isInGesture = false;
    private boolean isScrollingVertical = false;
 
    private ViewGroup mPrimaryView;
    private ICoopInnerScrollableView mSecondaryView;
 
    private GestureDetector mGestureDetector;
 
    /**
     * You may pass in 'null' for the 'secondaryView' parameter if you do NOT want the outer
     * view to scroll when you hit the top/bottom of the inner scroll view.  This will still 
     * make the inner scroll work when inside an outer scrollable view.
     * @param ctx
     * @param primaryView Usually the "outer" scrollable view you want cooperating
     * @param secondaryView Usually the "inner" scrollable (null = ignore overscroll)
     */
    public CooperativeScrollGestureListener(Context ctx, 
											ViewGroup primaryView, 
											ICoopInnerScrollableView secondaryView) {
        mPrimaryView = primaryView;
        mSecondaryView = secondaryView;
        mGestureDetector = new GestureDetector(ctx, this);
    }
 
    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distX, float distY) {
        if (!isInGesture) {
            isInGesture = true;
            isScrollingVertical = (Math.abs(distY) > Math.abs(distX));
        }
        if (!isScrollingVertical) return false;
 
        if (distanceY < 0 && null != mSecondaryView && mSecondaryView.isScrollableAtTop()) {
            // VIEW GOING UP
            mPrimaryView.requestDisallowInterceptTouchEvent(false);
 
        } else if (distanceY > 0 && null != mSecondaryView 
					&& mSecondaryView.isScrollableAtBottom()) {
            // VIEW GOING DOWN
            mPrimaryView.requestDisallowInterceptTouchEvent(false);
 
        } else {
            // Pulling up but at bottom of secondary view OR pulling down and at top
            mPrimaryView.requestDisallowInterceptTouchEvent(true);
        }
        return false;
    }
 
    // This MUST return true for DOWN event to have this see subsequent events
    @Override
    public boolean onDown(MotionEvent e) {
        isInGesture = false;
        isScrollingVertical = true;
        return true;
    }
 
    @Override
    public void onShowPress(MotionEvent e) {
        // nothing
    }
 
    @Override
    public boolean onSingleTapUp(MotionEvent e) {
        isInGesture = false;
        isScrollingVertical = false;
        return false;
    }
 
    @Override
    public void onLongPress(MotionEvent e) {
        // nothing
    }
 
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float vX, float vY) {
        return false;
    }
 
    // ---------------------- methods -----------------------
 
    /**
     * Your Activity must call this in it's dispatchTouchEvent() override.
     * @param ev
     */
    public void dispatchTouchEvent(MotionEvent ev) {
        mGestureDetector.onTouchEvent(ev);
    }
 
    /**
     * The scrollable views (ScrollView, ListView, etc) should be wrapped in this interface
     * to be generically handled by this listener.  The operations done in these method
     * calls should not be computationally intensive since it would otherwise affect
     * scrolling performance.
     */
    public interface ICoopInnerScrollableView {
        public boolean isScrollableAtTop();
        public boolean isScrollableAtBottom();
    }
}

Activity implementation (ListView as the inner scroller)

public class TemplateDetailActivity extends TRBaseActivity 
								    implements ITemplateDetailView, 
								    ViewPager.OnPageChangeListener {

...

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

        mSomeScroller = (ScrollView) findViewById(R.id.outer_scroll);

        mCoopScrollHandler = new CooperativeScrollGestureListener(this, 
            mSomeScroller, new CooperativeScrollGestureListener.ICoopInnerScrollableView() {
            @Override
            public boolean isScrollableAtTop() {
                return isInnerScrollerAtTop();
            }
            @Override
            public boolean isScrollableAtBottom() {
                return isInnerScrollerAtBottom();
            }
        });
    }

...
	
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        // MUST send event to coopscrollhandler, but note we DO NOT depend on the response
        mCoopScrollHandler.dispatchTouchEvent(ev);
        return super.dispatchTouchEvent(ev);
    }

...

	private boolean isInnerScrollerAtTop() {
		if (null != mListView) {
		    if (Build.VERSION.SDK_INT >= 14) {
		        return !mListView.canScrollVertically(-1);
		    } else {
		        return !ViewCompat.canScrollVertically(mListView, -1);
		    }
		}
		return true;
	}

	private boolean isInnerScrollerAtBottom() {
		if (null != mListView) {
		    if (Build.VERSION.SDK_INT >= 14) {
		        return !mListView.canScrollVertically(1);
		    } else {
		        return !ViewCompat.canScrollVertically(mListView, 1);
		    }
		}
		return true;
	}
}

Implementation with WebView as inner scroller

private boolean isInnerScrollerAtTop() {
    return mWebView.getScrollY() <= 0;
}

private boolean isInnerScrollerAtBottom() {
	 // getScale() can be wrong in certain cases, see docs is your case is complex
    int height = (int) Math.floor(mWebView.getContentHeight() * mWebView.getScale());
    int webViewHeight = mWebView.getMeasuredHeight();
    return (mWebView.getScrollY() + webViewHeight >= height);
}

Alternate Use

If you just want an inner scrollable to simply work within an outer scroller, meaning, the inner scroller will work when you touch it, the outer will work when you’re not on the inner scroller, then simply provide a null inner scroller to CooperativeScrollGestureListener.

	mCoopScrollHandler = new CooperativeScrollGestureListener(getActivity(), 
															  outerScrollView, 
															  null);

	@Override
	public void dispatchTouchEvent(MotionEvent ev) {
	    mCoopScrollHandler.dispatchTouchEvent(ev);
	}

Conclusion

While it does require you to remember to send along the dispatch event from your Activity, hopefully the name containing GestureListener helps you remember you have to pass along the events from your Activity. The action is very smooth and can make interaction for the user far more usable than trying to find the outer area on the display to get the outer area to move. Much easier to just mash in the center and scroll!

Comments

Coments Loading...