Android: Creating a “Snapping” Horizontal Scroll View

  • Joel Douglass
  • November 17, 2010

Update: It appears as though the android.widget. Gallery layout can provide this same functionality. I'm not sure how it was missed when building this out, but if you're interested in a custom solution, read on!

This type of view is fairly common in the mobile world, but doesn't come out of the box with the Android SDK.  Think of the home screen(s) on an Android device.  You can slide between each home screen and when you lift your finger, it "snaps" to the most appropriate screen.  This is fairly simple to build yourself using the existing HorizontalScrollView (or regular ScrollView for vertical scrolling) and adding some code on top of it to handle snapping to each screen.  In this post, I'll show you how I implemented this view for a rotating article feature.

image rotator example for creating a snapping horizontal view in android

Requirements

There are a few simple requirements that we had for creating this rotating feature view:

  • If the user slowly scrolls from one screen to another, the view should snap to the screen that is showing more than 50% when the user lifts their finger.
  • If the user quickly swipes in one direction, the view should scroll to the next (or previous) screen even if less than 50% of the next/previous screen is showing.  This allows users to quickly scroll through the features without requiring a long swipe each time.

How it's Done

First, create your own view and extend HorizontalScrollView.  My sample below has a few constants for determining a "swipe", as well as an array of Article items (one for each screen), a GestureDetector for detecting the swipe, and an integer value to keep track of the active feature.

public class HomeFeatureLayout extends HorizontalScrollView {
	private static final int SWIPE_MIN_DISTANCE = 5;
	private static final int SWIPE_THRESHOLD_VELOCITY = 300;

	private ArrayList<ArticleListItem> mItems = null;
	private GestureDetector mGestureDetector;
	private int mActiveFeature = 0;

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

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

	public HomeFeatureLayout(Context context) {
		super(context);
	}

...

}

The next step is to create a method that adds each screen to your horizontal scroll view.  In my example, I first create a LinearLayout to hold my screens (1 for each article).  Then I iterate through each article, creating a view for each and adding it to my LinearLayout.

 public void setFeatureItems(ArrayList<ArticleListItem> items){
		//Create a linear layout to hold each screen in the scroll view
		LinearLayout internalWrapper = new LinearLayout(getContext());
		internalWrapper.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT));
		internalWrapper.setOrientation(LinearLayout.HORIZONTAL);
		addView(internalWrapper);
		this.mItems = items;
		for(int i = 0; i < items.size(); i++){
			//...
			//Create the view for each screen in the scroll view
			//...
			internalWrapper.addView(myView);
		}

                ...
	}

Next, we'll handle the "slow drag and release" requirement by setting the "OnTouchListener" for the view.  This listener will be triggered every time there is a touch event on our Scroll View, so we can easily trap out the relevant actions and handle them appropriately. We want to trigger our custom logic on the "ACTION_UP" and "ACTION_CANCEL" touch actions, so the snap happens when the user either lifts their finger, or the touch action is cancelled for any reason.  In the sample below I'm first checking for the fast swipe case (which will be described next) and then checking for the ACTION_UP or ACTION_CANCEL actions.  If we have one of these 2 actions, we know it's time to snap. We use some math to figure out which view is showing more than 50% and smooth scroll to that view's X position.  Finally, we return true to indicate that the touch event was handled by us.

//Set the touch listener for handling a slow drag and release
		setOnTouchListener(new View.OnTouchListener() {
			@Override
			public boolean onTouch(View v, MotionEvent event) {
				//If the user swipes
				if (mGestureDetector.onTouchEvent(event)) {
					return true;
				}
				else if(event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL ){
					int scrollX = getScrollX();
					int featureWidth = v.getMeasuredWidth();
					mActiveFeature = ((scrollX + (featureWidth/2))/featureWidth);
					int scrollTo = mActiveFeature*featureWidth;
					smoothScrollTo(scrollTo, 0);
					return true;
				}
				else{
					return false;
				}
			}
		});

Finally, we'll handle the quick swipe case.  For this case we'll use a custom gesture listener.  I won't get into the specifics of how these work, but here is a good tutorial on implementing the Swipe action in Android that I referenced when building this.  The idea is to determine when there is a left or a right swipe using the distance and velocity constants defined by us.  If a swipe has been captured, we activate the snapping logic to smooth scroll to the next screen (for right swipe) or previous screen (for left swipe). If there is no next or previous screen, we remain on the current screen.

//Set the gesture detector for detecting a swipe
mGestureDetector = new GestureDetector(new MyGestureDetector());

Create the gesture detector

class MyGestureDetector extends SimpleOnGestureListener {
		@Override
		public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
			try {
				//right to left
				if(e1.getX() - e2.getX() > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > SWIPE_THRESHOLD_VELOCITY) {
					int featureWidth = getMeasuredWidth();
					mActiveFeature = (mActiveFeature < (mItems.size() - 1))? mActiveFeature + 1:mItems.size() -1;
					smoothScrollTo(mActiveFeature*featureWidth, 0);
					return true;
				}
				//left to right
				else if (e2.getX() - e1.getX() > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > SWIPE_THRESHOLD_VELOCITY) {
					int featureWidth = getMeasuredWidth();
					mActiveFeature = (mActiveFeature > 0)? mActiveFeature - 1:0;
					smoothScrollTo(mActiveFeature*featureWidth, 0);
					return true;
				}
			} catch (Exception e) {
				Log.e("Fling", "There was an error processing the Fling event:" + e.getMessage());
			}
			return false;
		}
	}

That's all there is to it! Here is the code for the full custom scroll class:

public class HomeFeatureLayout extends HorizontalScrollView {
	private static final int SWIPE_MIN_DISTANCE = 5;
	private static final int SWIPE_THRESHOLD_VELOCITY = 300;

	private ArrayList mItems = null;
	private GestureDetector mGestureDetector;
	private int mActiveFeature = 0;

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

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

	public HomeFeatureLayout(Context context) {
		super(context);
	}

	public void setFeatureItems(ArrayList items){
		LinearLayout internalWrapper = new LinearLayout(getContext());
		internalWrapper.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT));
		internalWrapper.setOrientation(LinearLayout.HORIZONTAL);
		addView(internalWrapper);
		this.mItems = items;
		for(int i = 0; i< items.size();i++){
 			LinearLayout featureLayout = (LinearLayout) View.inflate(this.getContext(),R.layout.homefeature,null);
 			//...
 		  //Create the view for each screen in the scroll view
 			//...
 			internalWrapper.addView(featureLayout);
 		}
 		setOnTouchListener(new View.OnTouchListener() {
 			@Override
 			public boolean onTouch(View v, MotionEvent event) {
 				//If the user swipes
 				if (mGestureDetector.onTouchEvent(event)) {
 					return true;
 				}
 				else if(event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL ){
 					int scrollX = getScrollX();
 					int featureWidth = v.getMeasuredWidth();
 					mActiveFeature = ((scrollX + (featureWidth/2))/featureWidth);
 					int scrollTo = mActiveFeature*featureWidth;
 					smoothScrollTo(scrollTo, 0);
 					return true;
 				}
 				else{
 					return false;
 				}
 			}
 		});
 		mGestureDetector = new GestureDetector(new MyGestureDetector());
 	}
 	 	class MyGestureDetector extends SimpleOnGestureListener {
 		@Override
 		public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
 			try {
 				//right to left
  				if(e1.getX() - e2.getX() > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > SWIPE_THRESHOLD_VELOCITY) {
					int featureWidth = getMeasuredWidth();
					mActiveFeature = (mActiveFeature < (mItems.size() - 1))? mActiveFeature + 1:mItems.size() -1;
 					smoothScrollTo(mActiveFeature*featureWidth, 0);
 					return true;
 				}
   				//left to right
 				else if (e2.getX() - e1.getX() > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > SWIPE_THRESHOLD_VELOCITY) {
					int featureWidth = getMeasuredWidth();
					mActiveFeature = (mActiveFeature > 0)? mActiveFeature - 1:0;
					smoothScrollTo(mActiveFeature*featureWidth, 0);
					return true;
				}
			} catch (Exception e) {
			        Log.e("Fling", "There was an error processing the Fling event:" + e.getMessage());
			}
			return false;
		}
	}
}