/* * Copyright (C) 2011 Cyril Mottier (http://www.cyrilmottier.com) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package greendroid.widget; import greendroid.util.Config; import java.util.LinkedList; import java.util.Queue; import android.content.Context; import android.database.DataSetObserver; import android.os.Handler; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.Log; import android.util.SparseArray; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.widget.Scroller; /** * <p> * A View that shows items in a "paged" manner. Pages can be scrolled * horizontally by swiping the View. The PagedView uses a reuse mechanism * similar to the one used by the ListView widget. Pages come from a * {@link PagedAdapter}. * </p> * <p> * Clients may listen to PagedView changes (scrolling, page change, etc.) using * an {@link OnPagedViewChangeListener} . * </p> * <p> * It is usually a good idea to show the user which page is currently on screen. * This can be easily done with a {@link PageIndicator}. * </p> * * @author Cyril Mottier */ public class PagedView extends ViewGroup { private static final String LOG_TAG = PagedView.class.getSimpleName(); /** * Clients may listen to changes occurring on a PagedView via this * interface. * * @author Cyril Mottier */ public interface OnPagedViewChangeListener { /** * Notify the client the current page has changed. * * @param pagedView The PagedView that changed its current page * @param previousPage The previously selected page * @param newPage The newly selected page */ void onPageChanged(PagedView pagedView, int previousPage, int newPage); /** * Notify the client the user started tracking. * * @param pagedView The PagedView the user started to track. */ void onStartTracking(PagedView pagedView); /** * Notify the client the user ended tracking. * * @param pagedView The PagedView the user ended to track. */ void onStopTracking(PagedView pagedView); } private static final int INVALID_PAGE = -1; private static final int MINIMUM_PAGE_CHANGE_VELOCITY = 500; private static final int VELOCITY_UNITS = 1000; private static final int FRAME_RATE = 1000 / 60; private final Handler mHandler = new Handler(); private int mPageCount; private int mCurrentPage; private int mTargetPage = INVALID_PAGE; private int mPagingTouchSlop; private int mMinimumVelocity; private int mMaximumVelocity; private int mPageSlop; private boolean mIsBeingDragged; private int mOffsetX; private int mStartMotionX; private int mStartOffsetX; private Scroller mScroller; private VelocityTracker mVelocityTracker; private OnPagedViewChangeListener mOnPageChangeListener; private PagedAdapter mAdapter; private SparseArray<View> mActiveViews = new SparseArray<View>(); private Queue<View> mRecycler = new LinkedList<View>(); public PagedView(Context context) { this(context, null); } public PagedView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public PagedView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); initPagedView(); } private void initPagedView() { final Context context = getContext(); mScroller = new Scroller(context, new DecelerateInterpolator()); final ViewConfiguration conf = ViewConfiguration.get(context); // getScaledPagingTouchSlop() only available in API Level 8 mPagingTouchSlop = conf.getScaledTouchSlop() * 2; mMaximumVelocity = conf.getScaledMaximumFlingVelocity(); final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); mMinimumVelocity = (int) (metrics.density * MINIMUM_PAGE_CHANGE_VELOCITY + 0.5f); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int childWidth = 0; int childHeight = 0; int itemCount = mAdapter == null ? 0 : mAdapter.getCount(); if (itemCount > 0) { if (widthMode == MeasureSpec.UNSPECIFIED || heightMode == MeasureSpec.UNSPECIFIED) { final View child = obtainView(mCurrentPage); measureChild(child, widthMeasureSpec, heightMeasureSpec); childWidth = child.getMeasuredWidth(); childHeight = child.getMeasuredHeight(); } if (widthMode == MeasureSpec.UNSPECIFIED) { widthSize = childWidth; } if (heightMode == MeasureSpec.UNSPECIFIED) { heightSize = childHeight; } } setMeasuredDimension(widthSize, heightSize); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mPageSlop = (int) (w * 0.5); // Make sure the offset adapts itself to mCurrentPage mOffsetX = getOffsetForPage(mCurrentPage); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if (mPageCount <= 0) { return; } final int startPage = getPageForOffset(mOffsetX); final int endPage = getPageForOffset(mOffsetX - getWidth() + 1); recycleViews(startPage, endPage); for (int i = startPage; i <= endPage; i++) { View child = mActiveViews.get(i); if (child == null) { child = obtainView(i); } setupView(child, i); } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { /* * Shortcut the most recurring case: the user is in the dragging state * and he is moving his finger. We want to intercept this motion. */ final int action = ev.getAction(); if (action == MotionEvent.ACTION_MOVE && mIsBeingDragged) { return true; } final int x = (int) ev.getX(); switch (action) { case MotionEvent.ACTION_DOWN: mStartMotionX = x; /* * If currently scrolling and user touches the screen, initiate * drag; otherwise don't. mScroller.isFinished should be false * when being flinged. */ mIsBeingDragged = !mScroller.isFinished(); if (mIsBeingDragged) { mScroller.forceFinished(true); mHandler.removeCallbacks(mScrollerRunnable); } break; case MotionEvent.ACTION_MOVE: /* * mIsBeingDragged == false, otherwise the shortcut would have * caught it. Check whether the user has moved far enough from * his original down touch. */ final int xDiff = (int) Math.abs(x - mStartMotionX); if (xDiff > mPagingTouchSlop) { mIsBeingDragged = true; performStartTracking(x); } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: /* * Release the drag */ mIsBeingDragged = false; break; } /* * Motion events are only intercepted during dragging mode. */ return mIsBeingDragged; } @Override public boolean onTouchEvent(MotionEvent ev) { final int action = ev.getAction(); final int x = (int) ev.getX(); if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(ev); switch (action) { case MotionEvent.ACTION_DOWN: if (!mScroller.isFinished()) { mScroller.forceFinished(true); mHandler.removeCallbacks(mScrollerRunnable); } performStartTracking(x); break; case MotionEvent.ACTION_MOVE: // Scroll to follow the motion event final int newOffset = mStartOffsetX - (mStartMotionX - x); if (newOffset > 0 || newOffset < getOffsetForPage(mPageCount - 1)) { mStartOffsetX = mOffsetX; mStartMotionX = x; } else { setOffsetX(newOffset); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: setOffsetX(mStartOffsetX - (mStartMotionX - x)); int direction = 0; final int slop = mStartMotionX - x; if (Math.abs(slop) > mPageSlop) { direction = (slop > 0) ? 1 : -1; } else { mVelocityTracker.computeCurrentVelocity(VELOCITY_UNITS, mMaximumVelocity); final int initialVelocity = (int) mVelocityTracker.getXVelocity(); if (Math.abs(initialVelocity) > mMinimumVelocity) { direction = (initialVelocity > 0) ? -1 : 1; } } if (mOnPageChangeListener != null) { mOnPageChangeListener.onStopTracking(this); } smoothScrollToPage(getActualCurrentPage() + direction); if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } break; } return true; } /** * Set a listener to be notified of changes that may occur in this * {@link PagedView}. * * @param listener The listener to callback. */ public void setOnPageChangeListener(OnPagedViewChangeListener listener) { mOnPageChangeListener = listener; } /** * Sets the {@link PagedAdapter} used to fill this {@link PagedView} with * some basic information : the number of displayed pages, the pages, etc. * * @param adapter The {@link PagedAdapter} to set to this {@link PagedView} */ public void setAdapter(PagedAdapter adapter) { if (null != mAdapter) { mAdapter.unregisterDataSetObserver(mDataSetObserver); } // Reset mRecycler.clear(); mActiveViews.clear(); removeAllViews(); mAdapter = adapter; mTargetPage = INVALID_PAGE; mCurrentPage = 0; mOffsetX = 0; if (null != mAdapter) { mAdapter.registerDataSetObserver(mDataSetObserver); mPageCount = mAdapter.getCount(); } requestLayout(); invalidate(); } /** * Returns the current page. * * @return The current page */ public int getCurrentPage() { return mCurrentPage; } private int getActualCurrentPage() { return mTargetPage != INVALID_PAGE ? mTargetPage : mCurrentPage; } /** * Initiate an animated scrolling from the current position to the given * page * * @param page The page to scroll to. */ public void smoothScrollToPage(int page) { scrollToPage(page, true); } /** * Initiate an animated scrolling to the next page */ public void smoothScrollToNext() { smoothScrollToPage(getActualCurrentPage() + 1); } /** * Initiate an animated scrolling to the previous page */ public void smoothScrollToPrevious() { smoothScrollToPage(getActualCurrentPage() - 1); } /** * Instantly moves the PagedView from the current position to the given * page. * * @param page The page to scroll to. */ public void scrollToPage(int page) { scrollToPage(page, false); } /** * Instantly moves to the next page */ public void scrollToNext() { scrollToPage(getActualCurrentPage() + 1); } /** * Instantly moves to the previous page */ public void scrollToPrevious() { scrollToPage(getActualCurrentPage() - 1); } private void scrollToPage(int page, boolean animated) { // Make sure page is bound to correct values page = Math.max(0, Math.min(page, mPageCount - 1)); final int targetOffset = getOffsetForPage(page); final int dx = targetOffset - mOffsetX; if (dx == 0) { performPageChange(page); return; } if (animated) { mTargetPage = page; mScroller.startScroll(mOffsetX, 0, dx, 0); mHandler.post(mScrollerRunnable); } else { setOffsetX(targetOffset); performPageChange(page); } } private void setOffsetX(int offsetX) { if (offsetX == mOffsetX) { return; } final int startPage = getPageForOffset(offsetX); final int endPage = getPageForOffset(offsetX - getWidth() + 1); recycleViews(startPage, endPage); final int leftAndRightOffset = offsetX - mOffsetX; for (int i = startPage; i <= endPage; i++) { View child = mActiveViews.get(i); if (child == null) { child = obtainView(i); setupView(child, i); } child.offsetLeftAndRight(leftAndRightOffset); } mOffsetX = offsetX; invalidate(); } private int getOffsetForPage(int page) { return -(page * getWidth()); } private int getPageForOffset(int offset) { return -offset / getWidth(); } private void recycleViews(int start, int end) { // [start, end] <=> range of pages that needs to be displayed final SparseArray<View> activeViews = mActiveViews; final int count = activeViews.size(); for (int i = 0; i < count; i++) { final int key = activeViews.keyAt(i); if (key < start || key > end) { final View recycled = activeViews.valueAt(i); removeView(recycled); mRecycler.add(recycled); activeViews.delete(key); } } } private View obtainView(int position) { // Get a view from the recycler final View recycled = mRecycler.poll(); View child = mAdapter.getView(position, recycled, this); if (child == null) { throw new NullPointerException("PagedAdapter.getView must return a non-null View"); } if (recycled != null && child != recycled) { if (Config.GD_WARNING_LOGS_ENABLED) { Log.w(LOG_TAG, "Not reusing the convertView may impact PagedView performance."); } } addView(child); mActiveViews.put(position, child); return child; } private void setupView(View child, int position) { if (child == null) { return; } LayoutParams lp = child.getLayoutParams(); if (lp == null) { lp = new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT); } // Measure the view final int childWidthSpec = getChildMeasureSpec(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY), 0, lp.width); final int childHeightSpec = getChildMeasureSpec(MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY), 0, lp.height); child.measure(childWidthSpec, childHeightSpec); // Layout the view final int childLeft = mOffsetX - getOffsetForPage(position); child.layout(childLeft, 0, childLeft + child.getMeasuredWidth(), child.getMeasuredHeight()); } private void performStartTracking(int startMotionX) { if (mOnPageChangeListener != null) { mOnPageChangeListener.onStartTracking(this); } mStartMotionX = startMotionX; mStartOffsetX = mOffsetX; } private void performPageChange(int newPage) { if (mCurrentPage != newPage) { if (mOnPageChangeListener != null) { mOnPageChangeListener.onPageChanged(this, mCurrentPage, newPage); } mCurrentPage = newPage; } } static class SavedState extends BaseSavedState { int currentPage; SavedState(Parcelable superState) { super(superState); } private SavedState(Parcel in) { super(in); currentPage = in.readInt(); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeInt(currentPage); } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; } @Override public Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState ss = new SavedState(superState); ss.currentPage = mCurrentPage; return ss; } @Override public void onRestoreInstanceState(Parcelable state) { SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); mCurrentPage = ss.currentPage; } private DataSetObserver mDataSetObserver = new DataSetObserver() { public void onInvalidated() { // Not handled }; public void onChanged() { // TODO Cyril : When data has changed we should normally // look for the position that as the same id is case // Adapter.hasStableIds() returns true. final int currentPage = mCurrentPage; setAdapter(mAdapter); mCurrentPage = currentPage; setOffsetX(getOffsetForPage(currentPage)); }; }; private Runnable mScrollerRunnable = new Runnable() { @Override public void run() { final Scroller scroller = mScroller; if (!scroller.isFinished()) { scroller.computeScrollOffset(); setOffsetX(scroller.getCurrX()); mHandler.postDelayed(this, FRAME_RATE); } else { performPageChange(mTargetPage); } } }; }