/* * Copyright (C) 2012 The Android Open Source Project * * 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 com.embeddedlog.LightUpDroid.widget.sgv; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.PixelFormat; import android.graphics.Point; import android.graphics.Rect; import android.os.Handler; import android.os.Parcel; import android.os.Parcelable; import android.support.v4.util.SparseArrayCompat; import android.support.v4.view.MotionEventCompat; import android.support.v4.view.VelocityTrackerCompat; import android.support.v4.view.ViewCompat; import android.support.v4.widget.EdgeEffectCompat; import android.util.AttributeSet; import android.util.Log; import android.util.SparseArray; import android.view.DragEvent; import android.view.Gravity; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.GridView; import android.widget.ImageView; import android.widget.ScrollView; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Temporarily copied from support v4 library so that StaggeredGridView can access * animation APIs on the current SDK version. */ /** * ListView and GridView just not complex enough? Try StaggeredGridView! * * <p>StaggeredGridView presents a multi-column grid with consistent column sizes * but varying row sizes between the columns. Each successive item from a * {@link android.widget.ListAdapter ListAdapter} will be arranged from top to bottom, * left to right. The largest vertical gap is always filled first.</p> * * <p>Item views may span multiple columns as specified by their {@link LayoutParams}. * The attribute <code>android:layout_span</code> may be used when inflating * item views from xml.</p> */ public class StaggeredGridView extends ViewGroup { private static final String TAG = "Clock-" + StaggeredGridView.class.getSimpleName(); /* * There are a few things you should know if you're going to make modifications * to StaggeredGridView. * * Like ListView, SGV populates from an adapter and recycles views that fall out * of the visible boundaries of the grid. A few invariants always hold: * * - mFirstPosition is the adapter position of the View returned by getChildAt(0). * - Any child index can be translated to an adapter position by adding mFirstPosition. * - Any adapter position can be translated to a child index by subtracting mFirstPosition. * - Views for items in the range [mFirstPosition, mFirstPosition + getChildCount()) are * currently attached to the grid as children. All other adapter positions do not have * active views. * * This means a few things thanks to the staggered grid's nature. Some views may stay attached * long after they have scrolled offscreen if removing and recycling them would result in * breaking one of the invariants above. * * LayoutRecords are used to track data about a particular item's layout after the associated * view has been removed. These let positioning and the choice of column for an item * remain consistent even though the rules for filling content up vs. filling down vary. * * Whenever layout parameters for a known LayoutRecord change, other LayoutRecords before * or after it may need to be invalidated. e.g. if the item's height or the number * of columns it spans changes, all bets for other items in the same direction are off * since the cached information no longer applies. */ private GridAdapter mAdapter; public static final int COLUMN_COUNT_AUTO = -1; /** * The window size to search for a specific item when restoring scroll position. */ private final int SCROLL_RESTORE_WINDOW_SIZE = 10; private static final int CHILD_TO_REORDER_AREA_RATIO = 4; private static final int SINGLE_COL_REORDERING_AREA_SIZE = 30; // Time delay in milliseconds between posting each scroll runnables. private static final int SCROLL_HANDLER_DELAY = 5; // The default rate of pixels to scroll by when a child view is dragged towards the // upper and lower bound of this view. private static final int DRAG_SCROLL_RATE = 10; public static final int ANIMATION_DELAY_IN_MS = 50; private SgvAnimationHelper.AnimationIn mAnimationInMode = SgvAnimationHelper.AnimationIn.NONE; private SgvAnimationHelper.AnimationOut mAnimationOutMode = SgvAnimationHelper.AnimationOut.NONE; private AnimatorSet mCurrentRunningAnimatorSet = null; /** * Flag to indicate whether the current running animator set was canceled before it reaching * the end of the animations. This flag is used to help indicate whether the next set of * animators should resume from where the last animator set left off. */ boolean mIsCurrentAnimationCanceled = false; private int mColCountSetting = 2; private int mColCount = 2; private int mMinColWidth = 0; private int mItemMargin = 0; private int[] mItemTops; private int[] mItemBottoms; private final Rect mTempRect = new Rect(); private boolean mFastChildLayout; private boolean mPopulating; private boolean mInLayout; private boolean mIsRtlLayout; private final RecycleBin mRecycler = new RecycleBin(); private final AdapterDataSetObserver mObserver = new AdapterDataSetObserver(); private boolean mDataChanged; private int mItemCount; /** * After data set change, we ask adapter the first view that changed. * Any view from 0 to mFirstChangedPosition - 1 is not changed. */ private int mFirstChangedPosition; /** * If set to true, then we guard against jagged edges in the grid by doing expensive * computation. Otherwise if this is false, we skip the computation. */ private boolean mGuardAgainstJaggedEdges; private boolean mHasStableIds; /** * List of all views to animate out. This is used when we need to animate out stale views. */ private final List<View> mViewsToAnimateOut = new ArrayList<View>(); private int mFirstPosition; private long mFocusedChildIdToScrollIntoView; private ScrollState mCurrentScrollState; private final int mTouchSlop; private final int mMaximumVelocity; private final int mFlingVelocity; private float mLastTouchY = 0; private float mTouchRemainderY; private int mActivePointerId; private static final int TOUCH_MODE_IDLE = 0; private static final int TOUCH_MODE_DRAGGING = 1; private static final int TOUCH_MODE_FLINGING = 2; private static final int TOUCH_MODE_OVERFLING = 3; // Value used to estimate the range of scroll and scroll position final static int SCROLLING_ESTIMATED_ITEM_HEIGHT = 100; private int mTouchMode; private final VelocityTracker mVelocityTracker = VelocityTracker.obtain(); private final OverScrollerSGV mScroller; private final EdgeEffectCompat mTopEdge; private final EdgeEffectCompat mBottomEdge; private boolean mIsDragReorderingEnabled; private ScrollListener mScrollListener; private OnSizeChangedListener mOnSizeChangedListener; // The view to show when the adapter is empty. private View mEmptyView; // The size of the region at location relative to the child's edges where reordering // can happen if another child view is dragged and dropped over it. private int mHorizontalReorderingAreaSize; // TODO: Put these states into a ReorderingParam object for maintainability. private ImageView mDragView; // X and Y positions of the touch down event that started the drag private int mTouchDownForDragStartX; private int mTouchDownForDragStartY; // X and Y offsets inside the item from where the user grabbed to the // child's left coordinate. // This is used to aid in the drawing of the drag shadow. private int mTouchOffsetToChildLeft; private int mTouchOffsetToChildTop; // Difference between screen coordinates and coordinates in this view. private int mOffsetToAbsoluteX; private int mOffsetToAbsoluteY; // the cached positions of the drag view when released. private Rect mCachedDragViewRect; // the current drag state private int mDragState; // the height of this view private int mHeight; // The bounds of the screen that should initiate scrolling when a view // is dragged past these positions. private int mUpperScrollBound; private int mLowerScrollBound; // The Bitmap that contains the drag shadow. private Bitmap mDragBitmap; private final int mOverscrollDistance; private final WindowManager mWindowManager; private WindowManager.LayoutParams mWindowParams; private static final int mWindowManagerLayoutFlags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; private ReorderHelper mReorderHelper; /** * Indicates whether to use pixels-based or position-based scrollbar * properties. * This property is borrow from AbsListView */ private boolean mSmoothScrollbarEnabled = false; private static final class LayoutRecord { public int column; public long id = -1; public int height; public int span; private int[] mMargins; private final void ensureMargins() { if (mMargins == null) { // Don't need to confirm length; // all layoutrecords are purged when column count changes. mMargins = new int[span * 2]; } } public final int getMarginAbove(int col) { if (mMargins == null) { return 0; } return mMargins[col * 2]; } public final int getMarginBelow(int col) { if (mMargins == null) { return 0; } return mMargins[col * 2 + 1]; } public final void setMarginAbove(int col, int margin) { if (mMargins == null && margin == 0) { return; } ensureMargins(); mMargins[col * 2] = margin; } public final void setMarginBelow(int col, int margin) { if (mMargins == null && margin == 0) { return; } ensureMargins(); mMargins[col * 2 + 1] = margin; } @Override public String toString() { String result = "LayoutRecord{c=" + column + ", id=" + id + " h=" + height + " s=" + span; if (mMargins != null) { result += " margins[above, below]("; for (int i = 0; i < mMargins.length; i += 2) { result += "[" + mMargins[i] + ", " + mMargins[i+1] + "]"; } result += ")"; } return result + "}"; } } private final Map<Long, ViewRectPair> mChildRectsForAnimation = new HashMap<Long, ViewRectPair>(); private final SparseArrayCompat<LayoutRecord> mLayoutRecords = new SparseArrayCompat<LayoutRecord>(); // Handler for executing the scroll runnable private Handler mScrollHandler; // Boolean is true when the {@link #mDragScroller} scroll runanbled has been kicked off. // This is set back to false when it is removed from the handler. private boolean mIsDragScrollerRunning; /** * Scroller runnable to invoke scrolling when user is holding a dragged view over the upper * or lower bounds of the screen. */ private final Runnable mDragScroller = new Runnable() { @Override public void run() { if (mDragState == ReorderUtils.DRAG_STATE_NONE) { return; } boolean enableUpdate = true; if (mLastTouchY >= mLowerScrollBound) { // scroll the list up a bit if we're past the lower bound, and the direction // of the movement is towards the bottom of the view. if (trackMotionScroll(-DRAG_SCROLL_RATE, false)) { // Disable reordering if the view is scrolling enableUpdate = false; } } else if (mLastTouchY <= mUpperScrollBound) { // scroll the list down a bit if we're past the upper bound, and the direction // of the movement is towards the top of the view. if (trackMotionScroll(DRAG_SCROLL_RATE, false)) { // Disable reordering if the view is scrolling enableUpdate = false; } } mReorderHelper.enableUpdatesOnDrag(enableUpdate); mScrollHandler.postDelayed(this, SCROLL_HANDLER_DELAY); } }; public StaggeredGridView(Context context) { this(context, null); } public StaggeredGridView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public StaggeredGridView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); final ViewConfiguration vc = ViewConfiguration.get(context); mTouchSlop = vc.getScaledTouchSlop(); mMaximumVelocity = vc.getScaledMaximumFlingVelocity(); mFlingVelocity = vc.getScaledMinimumFlingVelocity(); mScroller = new OverScrollerSGV(context); mTopEdge = new EdgeEffectCompat(context); mBottomEdge = new EdgeEffectCompat(context); setWillNotDraw(false); setClipToPadding(false); SgvAnimationHelper.initialize(context); mDragState = ReorderUtils.DRAG_STATE_NONE; mIsDragReorderingEnabled = true; mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); final ViewConfiguration configuration = ViewConfiguration.get(context); mOverscrollDistance = configuration.getScaledOverflingDistance(); // Disable splitting event. Only one of the children can handle motion event. setMotionEventSplittingEnabled(false); } /** * Check to see if the current layout is Right-to-Left. This check is only supported for * API 17+. For earlier versions, this method will just return false. * * NOTE: This is based on the private API method in {@link View} class. * * @return boolean Boolean indicating whether the currently locale is RTL. */ @SuppressLint("NewApi") private boolean isLayoutRtl() { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) { return View.LAYOUT_DIRECTION_RTL == getLayoutDirection(); } else { return false; } } /** * Set a fixed number of columns for this grid. Space will be divided evenly * among all columns, respecting the item margin between columns. * The default is 2. (If it were 1, perhaps you should be using a * {@link android.widget.ListView ListView}.) * * @param colCount Number of columns to display. * @see #setMinColumnWidth(int) */ public void setColumnCount(int colCount) { if (colCount < 1 && colCount != COLUMN_COUNT_AUTO) { throw new IllegalArgumentException("Column count must be at least 1 - received " + colCount); } final boolean needsPopulate = colCount != mColCount; mColCount = mColCountSetting = colCount; if (needsPopulate) { // When switching column count, for now, don't restore scroll position, and just // start layout fresh again. clearAllState(); mHorizontalReorderingAreaSize = 0; populate(); } } public int getColumnCount() { return mColCount; } /** * Set whether or not to explicitly guard against "jagged edges" in the grid * (meaning that the top edge of the children views in the first row of the grid can be * horizontally misaligned). * * If guardAgainstJaggedEdges is true, then we prevent jagged edges by computing the heights of * all views starting at the 0th position of the adapter to figure out the proper offset of the * views currently on screen. This is an expensive operation and should be avoided if possible. * * If guardAgainstJaggedEdges is false, then we can skip the expensive computation that * guards against jagged edges and just layout views on the screen starting from mFirstPosition * (ignoring what came before it). */ public void setGuardAgainstJaggedEdges(boolean guardAgainstJaggedEdges) { mGuardAgainstJaggedEdges = guardAgainstJaggedEdges; } /** * Set a minimum column width for * @param minColWidth */ public void setMinColumnWidth(int minColWidth) { mMinColWidth = minColWidth; setColumnCount(COLUMN_COUNT_AUTO); } /** * Set the margin between items in pixels. This margin is applied * both vertically and horizontally. * * @param marginPixels Spacing between items in pixels */ public void setItemMargin(int marginPixels) { // We only need to {@link #populate()} if the margin has been changed. if (marginPixels != mItemMargin) { mItemMargin = marginPixels; populate(); } } public int getItemMargin() { return mItemMargin; } /** * When smooth scrollbar is enabled, the position and size of the scrollbar thumb * is computed based on the number of visible pixels in the visible items. This * however assumes that all list items have the same height. If you use a list in * which items have different heights, the scrollbar will change appearance as the * user scrolls through the list. To avoid this issue, you need to disable this * property. * * When smooth scrollbar is disabled, the position and size of the scrollbar thumb * is based solely on the number of items in the adapter and the position of the * visible items inside the adapter. This provides a stable scrollbar as the user * navigates through a list of items with varying heights. * * @param enabled Whether or not to enable smooth scrollbar. * * @see #setSmoothScrollbarEnabled(boolean) * @attr ref android.R.styleable#AbsListView_smoothScrollbar */ public void setSmoothScrollbarEnabled(boolean enabled) { mSmoothScrollbarEnabled = enabled; } /** * Returns the current state of the fast scroll feature. * * @return True if smooth scrollbar is enabled is enabled, false otherwise. * * @see #setSmoothScrollbarEnabled(boolean) */ public boolean isSmoothScrollbarEnabled() { return mSmoothScrollbarEnabled; } /** * Return the child view specified by the coordinates if * there exists a child there. * * @return the child in this StaggeredGridView at the coordinates, null otherwise. */ private View getChildAtCoordinate(int x, int y) { if (y < 0) { // TODO: If we've dragged off the screen, return null for now until we know what // we'd like the experience to be like. return null; } final Rect frame = new Rect(); final int count = getChildCount(); for (int i = 0; i < count; i++) { final View childView = getChildAt(i); childView.getHitRect(frame); if (frame.contains(x, y)) { return getChildAt(i); } } // No child view at this coordinate. return null; } /** * Get the last Y coordinate on this grid where the last touch was made */ public float getLastTouchY() { return mLastTouchY; } /** * Enable drag reordering of child items. */ public void enableDragReordering() { mIsDragReorderingEnabled = true; } /** * Disable drag reordering of child items. */ public void disableDragReordering() { mIsDragReorderingEnabled = false; } /** * Check to see if drag reordering is supported. The switch must be flipped to true, and there * must be a {@link ReorderListener} registered to listen for reordering events. * * @return boolean indicating whether drag reordering is currently supported. */ private boolean isDragReorderingSupported() { return mIsDragReorderingEnabled && mReorderHelper != null && mReorderHelper.hasReorderListener(); } /** * Calculate bounds to assist in scrolling during a drag * @param y The y coordinate of the current drag. */ private void initializeDragScrollParameters(int y) { // Calculate the upper and lower bound of the screen to support drag scrolling mHeight = getHeight(); mUpperScrollBound = Math.min(y - mTouchSlop, mHeight / 5); mLowerScrollBound = Math.max(y + mTouchSlop, mHeight * 4 / 5); } /** * Initiate the dragging process. Create a bitmap that is displayed as the dragging event * happens and is moved around across the screen. This function is called once for each time * that a dragging event is initiated. * * The logic to this method was borrowed from the TouchInterceptor.java class from the * music app. * * @param draggedChild The child view being dragged * @param x The x coordinate of this view where dragging began * @param y The y coordinate of this view where dragging began */ private void startDragging(final View draggedChild, final int x, final int y) { if (!isDragReorderingSupported()) { return; } mDragBitmap = createDraggedChildBitmap(draggedChild); if (mDragBitmap == null) { // It appears that creating bitmaps for large views fail. For now, don't allow // dragging in this scenario. When using the framework's drag and drop implementation, // drag shadow also fails with a OutofResourceException when trying to draw the drag // shadow onto a Surface. mReorderHelper.handleDragCancelled(draggedChild); return; } mTouchOffsetToChildLeft = x - draggedChild.getLeft(); mTouchOffsetToChildTop = y - draggedChild.getTop(); updateReorderStates(ReorderUtils.DRAG_STATE_DRAGGING); initializeDragScrollParameters(y); final LayoutParams params = (LayoutParams) draggedChild.getLayoutParams(); mReorderHelper.handleDragStart(draggedChild, params.position, params.id, new Point(mTouchDownForDragStartX, mTouchDownForDragStartY)); // TODO: Reconsider using the framework's DragShadow support for dragging, // and only draw the bitmap in onDrop for animation. final Context context = getContext(); mDragView = new ImageView(context); mDragView.setImageBitmap(mDragBitmap); mDragView.setAlpha(160); mWindowParams = new WindowManager.LayoutParams(); mWindowParams.gravity = Gravity.TOP | Gravity.START; mWindowParams.height = WindowManager.LayoutParams.WRAP_CONTENT; mWindowParams.width = WindowManager.LayoutParams.WRAP_CONTENT; mWindowParams.flags = mWindowManagerLayoutFlags; mWindowParams.format = PixelFormat.TRANSLUCENT; // Use WindowManager to overlay a transparent image on drag mWindowManager.addView(mDragView, mWindowParams); updateDraggedBitmapLocation(x, y); } private Bitmap createDraggedChildBitmap(View view) { view.setDrawingCacheEnabled(true); final Bitmap cache = view.getDrawingCache(); Bitmap bitmap = null; if (cache != null) { try { bitmap = cache.copy(Bitmap.Config.ARGB_8888, false); } catch (final OutOfMemoryError e) { Log.w(TAG, "Failed to copy bitmap from Drawing cache", e); bitmap = null; } } view.destroyDrawingCache(); view.setDrawingCacheEnabled(false); return bitmap; } /** * Updates the current drag state and the UI appropriately. * @param state the new drag state to update to. */ private void updateReorderStates(int state) throws IllegalStateException { boolean resetDraggedChildView = false; boolean resetDragProperties = false; mDragState = state; switch (state) { case ReorderUtils.DRAG_STATE_NONE: case ReorderUtils.DRAG_STATE_DRAGGING: // reset all states when a drag is complete or when we're starting a new drag. resetDraggedChildView = true; resetDragProperties = true; break; case ReorderUtils.DRAG_STATE_RELEASED_REORDER: // In a release over a valid reordering zone, don't reset any UI. Let // LayoutChildren() take care of doing the appropriate animation // based on the result break; case ReorderUtils.DRAG_STATE_RELEASED_HOVER: // When a dragged child is released over another child, the dragged child will // remain hidden. It is up to the ReorderListener to refresh the UI state // of the child if it does not handle the drop. resetDragProperties = true; break; default: throw new IllegalStateException("Illegal drag state: " + mDragState); } if (resetDraggedChildView && mReorderHelper.getDraggedChild() != null) { // DraggedChildId and mCachedDragViewRect need to stay around longer than // the other properties because on the next data change, as we lay out, we'll need // mCachedDragViewRect to position the view's animation start position, and // draggedChildId to check if the current was the dragged view. // For the other properties - DraggedOverChildView, DraggedChildView, etc., // as soon as drag is released, we can reset them because they have no impact on the // next layout pass. mReorderHelper.clearDraggedChildId(); mCachedDragViewRect = null; } if (resetDragProperties) { if (mDragView != null) { mDragView.setVisibility(INVISIBLE); mWindowManager.removeView(mDragView); mDragView.setImageDrawable(null); mDragView = null; if (mDragBitmap != null) { mDragBitmap.recycle(); mDragBitmap = null; } } // We don't reset DraggedChildId here because it may still be in used. // Let LayoutChildren reset it when it's done with it. mReorderHelper.clearDraggedChild(); mReorderHelper.clearDraggedOverChild(); } } /** * Redraw the dragged child's bitmap based on the new coordinates. If the reordering direction * is {@link ReorderUtils#REORDER_DIRECTION_VERTICAL}, then ignore the x coordinate, as * only vertical movement is allowed. Similarly, if reordering direction is * {@link ReorderUtils#REORDER_DIRECTION_HORIZONTAL}. Even though this class does not manage * drag shadow directly, we need to make sure we position the dragged bitmap at where the * drag shadow is so that when drag ends, we can swap the shadow and the bitmap to animate * the view into place. * @param x The updated x coordinate of the drag shadow. * @param y THe updated y coordinate of the drag shadow. */ private void updateDraggedBitmapLocation(int x, int y) { final int direction = mAdapter.getReorderingDirection(); if ((direction & ReorderUtils.REORDER_DIRECTION_HORIZONTAL) == ReorderUtils.REORDER_DIRECTION_HORIZONTAL) { if (mDragBitmap != null && mDragBitmap.getWidth() > getWidth()) { // If the bitmap is wider than the width of the screen, then some parts of the view // are off screen. In this case, just set the drag shadow to start at x = 0 // (adjusted to the absolute position on screen) so that at least the beginning of // the drag shadow is guaranteed to be within view. mWindowParams.x = mOffsetToAbsoluteX; } else { // WindowParams is RTL agnostic and operates on raw coordinates. So in an RTL // layout, we would still want to find the view's left coordinate for the // drag shadow, rather than the view's start. mWindowParams.x = x - mTouchOffsetToChildLeft + mOffsetToAbsoluteX; } } else { mWindowParams.x = mOffsetToAbsoluteX; } if ((direction & ReorderUtils.REORDER_DIRECTION_VERTICAL) == ReorderUtils.REORDER_DIRECTION_VERTICAL) { mWindowParams.y = y - mTouchOffsetToChildTop + mOffsetToAbsoluteY; } else { mWindowParams.y = mOffsetToAbsoluteY; } mWindowManager.updateViewLayout(mDragView, mWindowParams); } /** * Update the visual state of the drag event based on the current drag location. If the user * has attempted to re-order by dragging a child over another child's drop zone, call the * appropriate {@link ReorderListener} callback. * * @param x The current x coordinate of the drag event * @param y The current y coordinate of the drag event */ private void handleDrag(int x, int y) { if (mDragState != ReorderUtils.DRAG_STATE_DRAGGING) { return; } // TODO: Consider moving drag shadow management logic into mReorderHelper as well, or // scrap the custom logic and use the framework's drag-and-drop support now that we're not // doing anything special to the drag shadow. updateDraggedBitmapLocation(x, y); if (mCurrentRunningAnimatorSet == null) { // If the current animator set is not null, then animation is running, in which case, // we shouldn't do any reordering processing, as views will be moving around, and // interfering with drag target calculations. mReorderHelper.handleDrag(new Point(x, y)); } } /** * Check if a view is reorderable. * @param i the child index in view group */ public boolean isChildReorderable(int i) { return mAdapter.isDraggable(mFirstPosition + i); } /** * Handle the the release of a dragged view. * @param x The current x coordinate where the drag was released. * @param y The current y coordinate where the drag was released. */ private void handleDrop(int x, int y) { if (!mReorderHelper.hasReorderListener()) { updateReorderStates(ReorderUtils.DRAG_STATE_NONE); return; } if (mReorderHelper.isOverReorderingArea()) { // Store the location of the drag shadow at where dragging stopped // for animation if a reordering has just happened. Since the drag // shadow is drawn as a WindowManager view, its coordinates are // absolute. However, for views inside the grid, we need to operate // with coordinate values that's relative to this grid, so we need // to subtract the offset to absolute screen coordinates that have // been added to mWindowParams. final int left = mWindowParams.x - mOffsetToAbsoluteX; final int top = mWindowParams.y - mOffsetToAbsoluteY; mCachedDragViewRect = new Rect( left, top, left + mDragView.getWidth(), top + mDragView.getHeight()); if (getChildCount() > 0) { final View view = getChildAt(0); final LayoutParams lp = (LayoutParams) view.getLayoutParams(); if (lp.position > mReorderHelper.getDraggedChildPosition()) { // If the adapter position of the first child in view is // greater than the position of the original dragged child, // this means that the user has scrolled the child out of // view. Those off screen views would have been recycled. If // mFirstPosition is currently x, after the reordering // operation, the child[mFirstPosition] will be // at mFirstPosition-1. We want to adjust mFirstPosition so // that we render the view in the correct location after // reordering completes. // // If the user has not scrolled the original dragged child // out of view, then the view has not been recycled and is // still in view. // When onLayout() gets called, we'll automatically fill in // the empty space that the child leaves behind from the // reordering operation. mFirstPosition--; } } // Get the current scroll position so that after reordering // completes, we can restore the scroll position of mFirstPosition. mCurrentScrollState = getScrollState(); } final boolean reordered = mReorderHelper.handleDrop(new Point(x, y)); if (reordered) { updateReorderStates(ReorderUtils.DRAG_STATE_RELEASED_REORDER); } else { updateReorderStates(ReorderUtils.DRAG_STATE_NONE); } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { mVelocityTracker.addMovement(ev); final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: { mOffsetToAbsoluteX = (int)(ev.getRawX() - ev.getX()); mOffsetToAbsoluteY = (int)(ev.getRawY() - ev.getY()); // Per bug 7377413, event.getX() and getY() returns rawX and rawY when accessed in // dispatchDragEvent, so since an action down is required before a drag can be // initiated, initialize mTouchDownForDragStartX/Y here for the most accurate value. mTouchDownForDragStartX = (int) ev.getX(); mTouchDownForDragStartY = (int) ev.getY(); mVelocityTracker.clear(); mScroller.abortAnimation(); mLastTouchY = ev.getY(); mActivePointerId = MotionEventCompat.getPointerId(ev, 0); mTouchRemainderY = 0; if (mTouchMode == TOUCH_MODE_FLINGING) { // Catch! mTouchMode = TOUCH_MODE_DRAGGING; return true; } break; } case MotionEvent.ACTION_MOVE: { final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); if (index < 0) { Log.e(TAG, "onInterceptTouchEvent could not find pointer with id " + mActivePointerId + " - did StaggeredGridView receive an inconsistent " + "event stream?"); return false; } final float y = MotionEventCompat.getY(ev, index); final float dy = y - mLastTouchY + mTouchRemainderY; final int deltaY = (int) dy; mTouchRemainderY = dy - deltaY; if (Math.abs(dy) > mTouchSlop) { mTouchMode = TOUCH_MODE_DRAGGING; return true; } } } return false; } @Override public boolean onTouchEvent(MotionEvent ev) { mVelocityTracker.addMovement(ev); final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: resetScroller(); mVelocityTracker.clear(); mScroller.abortAnimation(); mLastTouchY = ev.getY(); mActivePointerId = MotionEventCompat.getPointerId(ev, 0); mTouchRemainderY = 0; break; case MotionEvent.ACTION_MOVE: { final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); if (index < 0) { Log.e(TAG, "onInterceptTouchEvent could not find pointer with id " + mActivePointerId + " - did StaggeredGridView receive an inconsistent " + "event stream?"); return false; } final float y = MotionEventCompat.getY(ev, index); final float dy = y - mLastTouchY + mTouchRemainderY; final int deltaY = (int) dy; mTouchRemainderY = dy - deltaY; if (Math.abs(dy) > mTouchSlop) { mTouchMode = TOUCH_MODE_DRAGGING; } if (mTouchMode == TOUCH_MODE_DRAGGING) { mLastTouchY = y; if (!trackMotionScroll(deltaY, true)) { // Break fling velocity if we impacted an edge. mVelocityTracker.clear(); } } break; } case MotionEvent.ACTION_CANCEL: { mTouchMode = TOUCH_MODE_IDLE; break; } case MotionEvent.ACTION_UP: { mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); final float velocity = VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId); if (Math.abs(velocity) > mFlingVelocity) { mTouchMode = TOUCH_MODE_FLINGING; resetScroller(); mScroller.fling(0, 0, 0, (int) velocity, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE); mLastTouchY = 0; ViewCompat.postInvalidateOnAnimation(this); } else { mTouchMode = TOUCH_MODE_IDLE; } } break; } return true; } private void resetScroller() { mTouchMode = TOUCH_MODE_IDLE; mTopEdge.finish(); mBottomEdge.finish(); mScroller.abortAnimation(); } @Override public boolean dispatchDragEvent(DragEvent event) { if (!isDragReorderingSupported()) { // If the consumer of this StaggeredGridView has not registered a ReorderListener, // don't bother handling drag events. return super.dispatchDragEvent(event); } switch(event.getAction()) { case DragEvent.ACTION_DRAG_STARTED: // Per bug 7071594, we won't be able to catch this event in onDragEvent, // so we'll handle the event as it is being dispatched on the way down. if (mReorderHelper.hasReorderListener() && mIsDragReorderingEnabled) { final View child = getChildAtCoordinate( mTouchDownForDragStartX, mTouchDownForDragStartY); if (child != null) { // Child can be null if the touch point is not on a child view, but is // still within the bounds of this StaggeredGridView (i.e., margins // between cells). startDragging(child, mTouchDownForDragStartX, mTouchDownForDragStartY); // We must return true in order to continue getting future // {@link DragEvent}s. return true; } } // Be sure to return a value here instead of calling super.dispatchDragEvent() // which will unnecessarily dispatch to all the children (since the // {@link StaggeredGridView} handles all drag events for our purposes) return false; case DragEvent.ACTION_DROP: case DragEvent.ACTION_DRAG_ENDED: if (mDragState == ReorderUtils.DRAG_STATE_DRAGGING) { handleDrop((int)event.getX(), (int)event.getY()); } // Return early here to avoid calling super.dispatchDragEvent() which dispatches to // children (since this view already can handle all drag events). The super call // can also cause a NPE if the view hierarchy changed in the middle of a drag // and the {@link DragEvent} gets nulled out. This is a workaround for // a framework bug: 8298439. // Since the {@link StaggeredGridView} handles all drag events for our purposes, // just manually fire the drag event to ourselves. return onDragEvent(event); } // In all other cases, default to the superclass implementation. We need this so that // the drag/drop framework will fire off {@link #onDragEvent(DragEvent ev)} calls to us. return super.dispatchDragEvent(event); } @Override public boolean onDragEvent(DragEvent ev) { if (!isDragReorderingSupported()) { // If the consumer of this StaggeredGridView has not registered a ReorderListener, // don't bother handling drag events. return false; } final int x = (int)ev.getX(); final int y = (int)ev.getY(); switch(ev.getAction()) { case DragEvent.ACTION_DRAG_LOCATION: if (mDragState == ReorderUtils.DRAG_STATE_DRAGGING) { handleDrag(x, y); mLastTouchY = y; } // Kick off the scroll handler on the first drag location event, // if it's not already running if (!mIsDragScrollerRunning && // And if the distance traveled while dragging exceeds the touch slop ((Math.abs(x - mTouchDownForDragStartX) >= 4 * mTouchSlop) || (Math.abs(y - mTouchDownForDragStartY) >= 4 * mTouchSlop))) { // Set true because that the scroller is running now mIsDragScrollerRunning = true; if (mScrollHandler == null) { mScrollHandler = getHandler(); } mScrollHandler.postDelayed(mDragScroller, SCROLL_HANDLER_DELAY); } return true; case DragEvent.ACTION_DROP: case DragEvent.ACTION_DRAG_ENDED: // We can either expect to receive: // 1. Both {@link DragEvent#ACTION_DROP} and then // {@link DragEvent#ACTION_DRAG_ENDED} if the drop is over this view. // 2. Only {@link DragEvent#ACTION_DRAG_ENDED} if the drop happened over a // different view. // For this reason, we should always handle the drop. In case #1, if this code path // gets executed again then nothing will happen because we will have already // updated {@link #mDragState} to not be {@link ReorderUtils#DRAG_STATE_DRAGGING}. if (mScrollHandler != null) { mScrollHandler.removeCallbacks(mDragScroller); // Scroller is no longer running mIsDragScrollerRunning = false; } return true; } return false; } /** * * @param deltaY Pixels that content should move by * @return true if the movement completed, false if it was stopped prematurely. */ private boolean trackMotionScroll(int deltaY, boolean allowOverScroll) { final boolean contentFits = contentFits(); final int allowOverhang = Math.abs(deltaY); final int overScrolledBy; final int movedBy; if (!contentFits) { int overhang; final boolean up; mPopulating = true; if (deltaY > 0) { overhang = fillUp(mFirstPosition - 1, allowOverhang); up = true; } else { overhang = fillDown(mFirstPosition + getChildCount(), allowOverhang); if (overhang < 0) { // Overhang when filling down indicates how many pixels past the bottom of the // screen has been filled in. If this value is negative, it should be set to // 0 so that we don't allow over scrolling. overhang = 0; } up = false; } movedBy = Math.min(overhang, allowOverhang); offsetChildren(up ? movedBy : -movedBy); recycleOffscreenViews(); mPopulating = false; overScrolledBy = allowOverhang - overhang; } else { overScrolledBy = allowOverhang; movedBy = 0; } if (allowOverScroll) { final int overScrollMode = ViewCompat.getOverScrollMode(this); if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS || (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && !contentFits)) { if (overScrolledBy > 0) { final EdgeEffectCompat edge = deltaY > 0 ? mTopEdge : mBottomEdge; edge.onPull((float) Math.abs(deltaY) / getHeight()); ViewCompat.postInvalidateOnAnimation(this); } } } awakenScrollBars(0 /* show immediately */, true /* invalidate */); return deltaY == 0 || movedBy != 0; } public final boolean contentFits() { if (mFirstPosition != 0 || getChildCount() != mItemCount) { return false; } int topmost = Integer.MAX_VALUE; int bottommost = Integer.MIN_VALUE; for (int i = 0; i < mColCount; i++) { if (mItemTops[i] < topmost) { topmost = mItemTops[i]; } if (mItemBottoms[i] > bottommost) { bottommost = mItemBottoms[i]; } } return topmost >= getPaddingTop() && bottommost <= getHeight() - getPaddingBottom(); } /** * Recycle views within the range starting from startIndex (inclusive) until the last * attached child view. */ private void recycleViewsInRange(int startIndex, int endIndex) { for (int i = endIndex; i >= startIndex; i--) { final View child = getChildAt(i); if (mInLayout) { removeViewsInLayout(i, 1); } else { removeViewAt(i); } mRecycler.addScrap(child); } } // TODO: Have other overloaded recycle methods call into this one so we would just have one // code path. private void recycleView(View view) { if (view == null) { return; } if (mInLayout) { removeViewInLayout(view); invalidate(); } else { removeView(view); } mRecycler.addScrap(view); } /** * Important: this method will leave offscreen views attached if they * are required to maintain the invariant that child view with index i * is always the view corresponding to position mFirstPosition + i. */ private void recycleOffscreenViews() { if (getChildCount() == 0) { return; } final int height = getHeight(); final int clearAbove = -mItemMargin; final int clearBelow = height + mItemMargin; for (int i = getChildCount() - 1; i >= 0; i--) { final View child = getChildAt(i); if (child.getTop() <= clearBelow) { // There may be other offscreen views, but we need to maintain // the invariant documented above. break; } child.clearFocus(); if (mInLayout) { removeViewsInLayout(i, 1); } else { removeViewAt(i); } mRecycler.addScrap(child); } while (getChildCount() > 0) { final View child = getChildAt(0); if (child.getBottom() >= clearAbove) { // There may be other offscreen views, but we need to maintain // the invariant documented above. break; } child.clearFocus(); if (mInLayout) { removeViewsInLayout(0, 1); } else { removeViewAt(0); } mRecycler.addScrap(child); mFirstPosition++; } final int childCount = getChildCount(); if (childCount > 0) { // Repair the top and bottom column boundaries from the views we still have Arrays.fill(mItemTops, Integer.MAX_VALUE); Arrays.fill(mItemBottoms, Integer.MIN_VALUE); for (int i = 0; i < childCount; i++){ final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); final int top = child.getTop() - mItemMargin; final int bottom = child.getBottom(); LayoutRecord rec = mLayoutRecords.get(mFirstPosition + i); // It's possible the layout record could be null for visible views because // they are cleared between adapter data set changes, but the views are left // attached for the purpose of animations. Hence, populate the layout record again. if (rec == null) { rec = recreateLayoutRecord(mFirstPosition + i, child, lp); } // In LTR layout, iterate across each column that this child is laid out in, // starting from the child's first column (lp.column). For each column, update // mItemTops and mItemBottoms appropriately to take into account this child's // dimension. In RTL layout, iterate in reverse, where the child's starting // column would start from the right-most. final int span = Math.min(mColCount, lp.span); for (int spanIndex = 0; spanIndex < span; spanIndex++) { final int col = mIsRtlLayout ? lp.column - spanIndex : lp.column + spanIndex; final int colTop = top - rec.getMarginAbove(spanIndex); final int colBottom = bottom + rec.getMarginBelow(spanIndex); if (colTop < mItemTops[col]) { mItemTops[col] = colTop; } if (colBottom > mItemBottoms[col]) { mItemBottoms[col] = colBottom; } } } for (int col = 0; col < mColCount; col++) { if (mItemTops[col] == Integer.MAX_VALUE) { // If one was untouched, both were. final int top = getPaddingTop(); mItemTops[col] = top; mItemBottoms[col] = top; } } } mCurrentScrollState = getScrollState(); } private LayoutRecord recreateLayoutRecord(int position, View child, LayoutParams lp) { final LayoutRecord rec = new LayoutRecord(); mLayoutRecords.put(position, rec); rec.column = lp.column; rec.height = child.getHeight(); rec.id = lp.id; rec.span = Math.min(mColCount, lp.span); return rec; } @Override public void computeScroll() { if (mTouchMode == TOUCH_MODE_OVERFLING) { handleOverfling(); } else if (mScroller.computeScrollOffset()) { final int overScrollMode = ViewCompat.getOverScrollMode(this); final boolean supportsOverscroll = overScrollMode != ViewCompat.OVER_SCROLL_NEVER; final int y = mScroller.getCurrY(); final int dy = (int) (y - mLastTouchY); // TODO: Figure out why mLastTouchY is being updated here. Consider using a new class // variable since this value does not represent the last place on the screen where a // touch occurred. mLastTouchY = y; // Check if the top of the motion view is where it is // supposed to be final View motionView = supportsOverscroll && getChildCount() > 0 ? getChildAt(0) : null; final int motionViewPrevTop = motionView != null ? motionView.getTop() : 0; final boolean stopped = !trackMotionScroll(dy, false); if (!stopped && !mScroller.isFinished()) { mTouchMode = TOUCH_MODE_IDLE; ViewCompat.postInvalidateOnAnimation(this); } else if (stopped && dy != 0 && supportsOverscroll) { // Check to see if we have bumped into the scroll limit if (motionView != null) { final int motionViewRealTop = motionView.getTop(); // Apply overscroll final int overscroll = -dy - (motionViewRealTop - motionViewPrevTop); overScrollBy(0, overscroll, 0, getScrollY(), 0, 0, 0, mOverscrollDistance, true); } final EdgeEffectCompat edge; if (dy > 0) { edge = mTopEdge; mBottomEdge.finish(); } else { edge = mBottomEdge; mTopEdge.finish(); } edge.onAbsorb(Math.abs((int) mScroller.getCurrVelocity())); if (mScroller.computeScrollOffset()) { mScroller.notifyVerticalEdgeReached(getScrollY(), 0, mOverscrollDistance); } mTouchMode = TOUCH_MODE_OVERFLING; ViewCompat.postInvalidateOnAnimation(this); } else { mTouchMode = TOUCH_MODE_IDLE; } } } private void handleOverfling() { // If the animation is not finished yet, determine next steps. if (mScroller.computeScrollOffset()) { final int scrollY = getScrollY(); final int currY = mScroller.getCurrY(); final int deltaY = currY - scrollY; if (overScrollBy(0, deltaY, 0, scrollY, 0, 0, 0, mOverscrollDistance, false)) { final boolean crossDown = scrollY <= 0 && currY > 0; final boolean crossUp = scrollY >= 0 && currY < 0; if (crossDown || crossUp) { int velocity = (int) mScroller.getCurrVelocity(); if (crossUp) { velocity = -velocity; } // Don't flywheel from this; we're just continuing // things. mTouchMode = TOUCH_MODE_IDLE; mScroller.abortAnimation(); } else { // Spring back! We are done overscrolling. if (mScroller.springBack(0, scrollY, 0, 0, 0, 0)) { mTouchMode = TOUCH_MODE_OVERFLING; ViewCompat.postInvalidateOnAnimation(this); } else { // If already valid, we are done. Exit overfling mode. mTouchMode = TOUCH_MODE_IDLE; } } } else { // Still over-flinging; just post the next frame of the animation. ViewCompat.postInvalidateOnAnimation(this); } } else { // Otherwise, exit overfling mode. mTouchMode = TOUCH_MODE_IDLE; mScroller.abortAnimation(); } } @Override protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { if (getScrollY() != scrollY) { scrollTo(0, scrollY); } } @Override public void draw(Canvas canvas) { super.draw(canvas); if (mTopEdge != null) { boolean needsInvalidate = false; if (!mTopEdge.isFinished()) { final int restoreCount = canvas.save(); canvas.translate(0, 0); mTopEdge.draw(canvas); canvas.restoreToCount(restoreCount); needsInvalidate = true; } if (!mBottomEdge.isFinished()) { final int restoreCount = canvas.save(); final int width = getWidth(); canvas.translate(-width, getHeight()); canvas.rotate(180, width, 0); mBottomEdge.draw(canvas); canvas.restoreToCount(restoreCount); needsInvalidate = true; } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this); } } } public void beginFastChildLayout() { mFastChildLayout = true; } public void endFastChildLayout() { mFastChildLayout = false; populate(); } @Override public void requestLayout() { if (!mPopulating && !mFastChildLayout) { super.requestLayout(); } } /** * Sets the view to show if the adapter is empty */ public void setEmptyView(View emptyView) { mEmptyView = emptyView; updateEmptyStatus(); } public View getEmptyView() { return mEmptyView; } /** * Update the status of the list based on the whether the adapter is empty. If is it empty and * we have an empty view, display it. In all the other cases, make sure that the * StaggeredGridView is VISIBLE and that the empty view is GONE (if it's not null). */ private void updateEmptyStatus() { if (mAdapter == null || mAdapter.isEmpty()) { if (mEmptyView != null) { mEmptyView.setVisibility(View.VISIBLE); setVisibility(View.GONE); } else { setVisibility(View.VISIBLE); } } else { if (mEmptyView != null) { mEmptyView.setVisibility(View.GONE); } setVisibility(View.VISIBLE); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); final int widthSize = MeasureSpec.getSize(widthMeasureSpec); final int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (widthMode != MeasureSpec.EXACTLY) { Log.d(TAG, "onMeasure: must have an exact width or match_parent! " + "Using fallback spec of EXACTLY " + widthSize); widthMode = MeasureSpec.EXACTLY; } if (heightMode != MeasureSpec.EXACTLY) { Log.d(TAG, "onMeasure: must have an exact height or match_parent! " + "Using fallback spec of EXACTLY " + heightSize); heightMode = MeasureSpec.EXACTLY; } setMeasuredDimension(widthSize, heightSize); if (mColCountSetting == COLUMN_COUNT_AUTO) { final int colCount = widthSize / mMinColWidth; if (colCount != mColCount) { mColCount = colCount; } } if (mHorizontalReorderingAreaSize == 0) { if (mColCount > 1) { final int totalMarginWidth = mItemMargin * (mColCount + 1); final int singleViewWidth = (widthSize - totalMarginWidth) / mColCount; mHorizontalReorderingAreaSize = singleViewWidth / CHILD_TO_REORDER_AREA_RATIO; } else { mHorizontalReorderingAreaSize = SINGLE_COL_REORDERING_AREA_SIZE; } } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { mIsRtlLayout = isLayoutRtl(); mInLayout = true; populate(); mInLayout = false; final int width = r - l; final int height = b - t; mTopEdge.setSize(width, height); mBottomEdge.setSize(width, height); } private void populate() { if (getWidth() == 0 || getHeight() == 0 || mAdapter == null) { return; } if (mColCount == COLUMN_COUNT_AUTO) { final int colCount = getWidth() / mMinColWidth; if (colCount != mColCount) { mColCount = colCount; } } final int colCount = mColCount; if (mItemTops == null || mItemBottoms == null || mItemTops.length != colCount || mItemBottoms.length != colCount) { mItemTops = new int[colCount]; mItemBottoms = new int[colCount]; mLayoutRecords.clear(); if (mInLayout) { removeAllViewsInLayout(); } else { removeAllViews(); } } // Before we do layout, if there are any pending animations and data has changed, // cancel the animation, as layout on new data will likely trigger another animation // set to be run. if (mDataChanged && mCurrentRunningAnimatorSet != null) { mCurrentRunningAnimatorSet.cancel(); mCurrentRunningAnimatorSet = null; } if (isSelectionAtTop()) { mCurrentScrollState = null; } if (mCurrentScrollState != null) { restoreScrollPosition(mCurrentScrollState); } else { calculateLayoutStartOffsets(getPaddingTop() /* layout start offset */); } mPopulating = true; mFocusedChildIdToScrollIntoView = -1; final View focusedChild = getFocusedChild(); if (focusedChild != null) { final LayoutParams lp = (LayoutParams) focusedChild.getLayoutParams(); mFocusedChildIdToScrollIntoView = lp.id; } layoutChildren(mDataChanged); fillDown(mFirstPosition + getChildCount(), 0); fillUp(mFirstPosition - 1, 0); if (isDragReorderingSupported() && mDragState == ReorderUtils.DRAG_STATE_RELEASED_REORDER || mDragState == ReorderUtils.DRAG_STATE_RELEASED_HOVER) { // This child was dragged and dropped with the UI likely // still showing. Call updateReorderStates, to update // all UI appropriately. mReorderHelper.clearDraggedChildId(); updateReorderStates(ReorderUtils.DRAG_STATE_NONE); } if (mDataChanged) { // Animation should only play if data has changed since populate() can be called // multiple times with the same data set (e.g., screen size changed). handleLayoutAnimation(); } recycleOffscreenViews(); mPopulating = false; mDataChanged = false; } @Override public void scrollBy(int x, int y) { if (y != 0) { // TODO: Implement smooth scrolling for this so that scrolling does more than just // jumping by y pixels. trackMotionScroll(y, false /* over scroll */); } } private void offsetChildren(int offset) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); child.offsetTopAndBottom(offset); // As we're scrolling, we need to make sure the children that are coming into view // have their reordering area set. final LayoutParams lp = (LayoutParams) child.getLayoutParams(); setReorderingArea(lp); } final int colCount = mColCount; for (int i = 0; i < colCount; i++) { mItemTops[i] += offset; mItemBottoms[i] += offset; } if (mScrollListener != null) { mScrollListener.onScrollChanged(offset, computeVerticalScrollOffset(), computeVerticalScrollRange()); } } /** * Performs layout animation of child views. * @throws IllegalStateException Exception is thrown of currently set animation mode is * not recognized. */ private void handleLayoutAnimation() throws IllegalStateException { final List<Animator> animators = new ArrayList<Animator>(); // b/8422632 - Without this dummy first animator, startDelays of subsequent animators won't // be honored correctly; all animators will block regardless of startDelay until the first // animator in the AnimatorSet truly starts playing. final ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f); anim.setDuration(0); animators.add(anim); addOutAnimatorsForStaleViews(animators, mAnimationOutMode); // Play the In animators at a slight delay after all Out animators have started. final int animationInStartDelay = animators.size() > 0 ? (SgvAnimationHelper.getDefaultAnimationDuration() / 2) : 0; addInAnimators(animators, mAnimationInMode, animationInStartDelay); if (animators != null && animators.size() > 0) { final AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playTogether(animators); animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { mIsCurrentAnimationCanceled = false; mCurrentRunningAnimatorSet = animatorSet; } @Override public void onAnimationCancel(Animator animation) { mIsCurrentAnimationCanceled = true; } @Override public void onAnimationEnd(Animator animation) { if (!mIsCurrentAnimationCanceled) { // If this animation ended naturally, not because it was canceled, then // reset the animation mode back to ANIMATION_MODE_NONE. However, if // the animation was canceled by a data change, then keep the mode as is, // so that on a re-layout, we can resume animation from the views' current // positions. resetAnimationMode(); } mCurrentRunningAnimatorSet = null; } }); Log.v(TAG, "starting"); animatorSet.start(); } else { resetAnimationMode(); } mViewsToAnimateOut.clear(); mChildRectsForAnimation.clear(); } /** * Reset the current animation mode. */ private void resetAnimationMode() { mAnimationInMode = SgvAnimationHelper.AnimationIn.NONE; mAnimationOutMode = SgvAnimationHelper.AnimationOut.NONE; } /** * Add animators for animating in new views as well as updating positions of views that * should remain on screen. */ private void addInAnimators(List<Animator> animators, SgvAnimationHelper.AnimationIn animationInMode, int startDelay) { if (animationInMode == SgvAnimationHelper.AnimationIn.NONE) { return; } switch (animationInMode) { case FLY_UP_ALL_VIEWS: addFlyInAllViewsAnimators(animators); break; case EXPAND_NEW_VIEWS: addUpdateViewPositionsAnimators(animators, true /* cascade animation */, SgvAnimationHelper.AnimationIn.EXPAND_NEW_VIEWS, startDelay); break; case EXPAND_NEW_VIEWS_NO_CASCADE: addUpdateViewPositionsAnimators(animators, false /* cascade animation */, SgvAnimationHelper.AnimationIn.EXPAND_NEW_VIEWS_NO_CASCADE, startDelay); break; case SLIDE_IN_NEW_VIEWS: addUpdateViewPositionsAnimators(animators, true /* cascade animation */, SgvAnimationHelper.AnimationIn.SLIDE_IN_NEW_VIEWS, startDelay); break; case FLY_IN_NEW_VIEWS: addUpdateViewPositionsAnimators(animators, true /* cascade animation */, SgvAnimationHelper.AnimationIn.FLY_IN_NEW_VIEWS, startDelay); break; case FADE: addUpdateViewPositionsAnimators(animators, true /* cascade animation */, SgvAnimationHelper.AnimationIn.FADE, startDelay); break; default: throw new IllegalStateException("Unknown animationInMode: " + mAnimationInMode); } } /** * Add animators for animating out stale views * @param animationOutMode The animation mode to play for stale views */ private void addOutAnimatorsForStaleViews(List<Animator> animators, SgvAnimationHelper.AnimationOut animationOutMode) { if (animationOutMode == SgvAnimationHelper.AnimationOut.NONE) { return; } for (final View v : mViewsToAnimateOut) { // For each stale view to animate out, retrieve the animators for the view, then attach // the StaleViewAnimationEndListener which checks to see if the view should be recycled // at the end of the animation. final List<Animator> viewAnimators = new ArrayList<Animator>(); switch (animationOutMode) { case SLIDE: final LayoutParams lp = (LayoutParams) v.getLayoutParams(); // Bias towards sliding right, but depending on the column that this view // is laid out in, slide towards the nearest side edge. int endTranslation = (int)(v.getWidth() * 1.5); if (lp.column < (mColCount / 2)) { endTranslation = -endTranslation; } SgvAnimationHelper.addSlideOutAnimators(viewAnimators, v, (int) v.getTranslationX(), endTranslation); break; case COLLAPSE: SgvAnimationHelper.addCollapseOutAnimators(viewAnimators, v); break; case FLY_DOWN: SgvAnimationHelper.addFlyOutAnimators(viewAnimators, v, (int) v.getTranslationY(), getHeight()); break; case FADE: SgvAnimationHelper.addFadeAnimators(viewAnimators, v, v.getAlpha(), 0 /* end alpha */); break; default: throw new IllegalStateException("Unknown animationOutMode: " + animationOutMode); } if (viewAnimators.size() > 0) { addStaleViewAnimationEndListener(v, viewAnimators); animators.addAll(viewAnimators); } } } /** * Handle setting up the animators of child views when the animation is invoked by a change * in the adapter. This method has a side effect of translating view positions in preparation * for the animations. */ private List<Animator> addFlyInAllViewsAnimators(List<Animator> animators) { final int childCount = getChildCount(); if (childCount == 0) { return null; } if (animators == null) { animators = new ArrayList<Animator>(); } for (int i = 0; i < childCount; i++) { final int animationDelay = i * ANIMATION_DELAY_IN_MS; final View childToAnimate = getChildAt(i); // Start all views from below the bottom of this grid and animate them upwards. This // is done simply by translating the current view's vertical position by the height // of the entire grid. float yTranslation = getHeight(); float rotation = SgvAnimationHelper.ANIMATION_ROTATION_DEGREES; if (mIsCurrentAnimationCanceled) { // If mIsAnimationCanceled is true, then this is not the first time that this // animation is running. For this particular case, we should resume from where // the previous animation left off, rather than resetting translation and rotation. yTranslation = childToAnimate.getTranslationY(); rotation = childToAnimate.getRotation(); } SgvAnimationHelper.addTranslationRotationAnimators(animators, childToAnimate, 0 /* xTranslation */, (int) yTranslation, rotation, animationDelay); } return animators; } /** * Animations to update the views on screen to their new positions. For new views that aren't * currently on screen, animate them in using the specified animationInMode. */ private List<Animator> addUpdateViewPositionsAnimators(List<Animator> animators, boolean cascadeAnimation, SgvAnimationHelper.AnimationIn animationInMode, int startDelay) { final int childCount = getChildCount(); if (childCount == 0) { return null; } if (animators == null) { animators = new ArrayList<Animator>(); } int viewsAnimated = 0; for (int i = 0; i < childCount; i++) { final View childToAnimate = getChildAt(i); if (mViewsToAnimateOut.contains(childToAnimate)) { // If the stale views are still animating, then they are still laid out, so // getChildCount() would've accounted for them. Since they have their own set // of animations to play, we'll skip over them in this loop. continue; } // Use progressive animation delay to create the staggered effect of animating // views. This is done by having each view delay their animation by // ANIMATION_DELAY_IN_MS after the animation of the previous view. int animationDelay = startDelay + (cascadeAnimation ? viewsAnimated * ANIMATION_DELAY_IN_MS : 0); // Figure out whether a view with this item ID existed before final LayoutParams lp = (LayoutParams) childToAnimate.getLayoutParams(); final ViewRectPair viewRectPair = mChildRectsForAnimation.get(lp.id); final int xTranslation; final int yTranslation; // If there is a valid {@link Rect} for the view with this newId, then // setup an animation. if (viewRectPair != null && viewRectPair.rect != null) { // In the special case where the items are explicitly fading, we don't want to do // any of the translations. if (animationInMode == SgvAnimationHelper.AnimationIn.FADE) { SgvAnimationHelper.addFadeAnimators(animators, childToAnimate, 0 /* start alpha */, 1.0f /* end alpha */, animationDelay); continue; } final Rect oldRect = viewRectPair.rect; // Since the view already exists, translate it to its new position. // Reset the child back to its previous position given by oldRect if the child // has not already been translated. If the child has been translated, use the // current translated values, as this child may be in the middle of a previous // animation, so we don't want to simply force it to new location. xTranslation = oldRect.left - childToAnimate.getLeft(); yTranslation = oldRect.top - childToAnimate.getTop(); final float rotation = childToAnimate.getRotation(); // First set the translation X and Y. The current translation might be out of date. childToAnimate.setTranslationX(xTranslation); childToAnimate.setTranslationY(yTranslation); if (xTranslation == 0 && yTranslation == 0 && rotation == 0) { // Bail early if this view doesn't need to be translated. continue; } SgvAnimationHelper.addTranslationRotationAnimators(animators, childToAnimate, xTranslation, yTranslation, rotation, animationDelay); } else { // If this view was not present before the data updated, rather than just flashing // the view into its designated position, fly it up from the bottom. xTranslation = 0; yTranslation = (animationInMode == SgvAnimationHelper.AnimationIn.FLY_IN_NEW_VIEWS) ? getHeight() : 0; // Since this is a new view coming in, add additional delays so that these IN // animations start after all the OUT animations have been played. animationDelay += SgvAnimationHelper.getDefaultAnimationDuration(); childToAnimate.setTranslationX(xTranslation); childToAnimate.setTranslationY(yTranslation); switch (animationInMode) { case FLY_IN_NEW_VIEWS: SgvAnimationHelper.addTranslationRotationAnimators(animators, childToAnimate, xTranslation, yTranslation, SgvAnimationHelper.ANIMATION_ROTATION_DEGREES, animationDelay); break; case SLIDE_IN_NEW_VIEWS: // Bias towards sliding right, but depending on the column that this view // is laid out in, slide towards the nearest side edge. int startTranslation = (int)(childToAnimate.getWidth() * 1.5); if (lp.column < (mColCount / 2)) { startTranslation = -startTranslation; } SgvAnimationHelper.addSlideInFromRightAnimators(animators, childToAnimate, startTranslation, animationDelay); break; case EXPAND_NEW_VIEWS: case EXPAND_NEW_VIEWS_NO_CASCADE: if (i == 0) { // Initially set the alpha of this view to be invisible, then fade in. childToAnimate.setAlpha(0); // Create animators that translate the view back to translation = 0 // which would be its new layout position final int offset = -1 * childToAnimate.getHeight(); SgvAnimationHelper.addXYTranslationAnimators(animators, childToAnimate, 0 /* xTranslation */, offset, animationDelay); SgvAnimationHelper.addFadeAnimators(animators, childToAnimate, 0 /* start alpha */, 1.0f /* end alpha */, animationDelay); } else { SgvAnimationHelper.addExpandInAnimators(animators, childToAnimate, animationDelay); } break; case FADE: SgvAnimationHelper.addFadeAnimators(animators, childToAnimate, 0 /* start alpha */, 1.0f /* end alpha */, animationDelay); break; default: continue; } } viewsAnimated++; } return animators; } private void addStaleViewAnimationEndListener(final View view, List<Animator> viewAnimators) { if (viewAnimators == null) { return; } for (final Animator animator : viewAnimators) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { // In the event that onChanged is called before this animation finishes, // we would have mistakenly cached a view that would be recycled. So // check if it's there, and remove it so that obtainView() doesn't // accidentally use the cached view later when it's already been // moved to the recycler. final LayoutParams lp = (LayoutParams) view.getLayoutParams(); if (mChildRectsForAnimation.containsKey(lp.id)) { mChildRectsForAnimation.remove(lp.id); } recycleView(view); } }); } } /** * Calculate and cache the {@link LayoutRecord}s for all positions up to mFirstPosition. * mFirstPosition is the position that layout will start from, but we need to know where all * views preceding it will be laid out so that mFirstPosition will be laid out at the correct * position. If this is not done, mFirstPosition will be laid out at the first empty space * possible (i.e., top left), and this may not be the correct position in the overall layout. * * This can be optimized if we don't need to guard against jagged edges in the grid or if * mFirstChangedPosition is set to a non-zero value (so we can skip calculating some views). */ private void calculateLayoutStartOffsets(int offset) { // Bail early if we don't guard against jagged edges or if nothing has changed before // mFirstPosition. // Also check that we're not at the top of the list because sometimes grid padding isn't set // until after mItemTops and mItemBottoms arrays have been initialized, so we should // go through and compute the right layout start offset for mFirstPosition = 0. if (mFirstPosition != 0 && (!mGuardAgainstJaggedEdges || mFirstPosition < mFirstChangedPosition)) { // At this time, we know that mItemTops should be the same, because // nothing has changed before view at mFirstPosition. The only thing // we need to do is to reset mItemBottoms. The result should be the // same, if we don't bail early and execute the following code // again. Notice that mItemBottoms always equal to mItemTops after // this method. System.arraycopy(mItemTops, 0, mItemBottoms, 0, mColCount); return; } final int colWidth = (getWidth() - getPaddingLeft() - getPaddingRight() - mItemMargin * (mColCount - 1)) / mColCount; Arrays.fill(mItemTops, getPaddingTop()); Arrays.fill(mItemBottoms, getPaddingTop()); // Since we will be doing a pass to calculate all views up to mFirstPosition, it is likely // that all existing {@link LayoutRecord}s will be stale, so clear it out to avoid // accidentally the re-use of stale values. // // Note: We cannot just invalidate all layout records after mFirstPosition because it is // possible that this layout pass is caused by a down sync from the server that may affect // the layout of views from position 0 to mFirstPosition - 1. if (mDataChanged) { mLayoutRecords.clear(); } for (int i = 0; i < mFirstPosition; i++) { LayoutRecord rec = mLayoutRecords.get(i); if (mDataChanged || rec == null) { final View view = obtainView(i, null); final LayoutParams lp = (LayoutParams) view.getLayoutParams(); final int heightSpec; if (lp.height == LayoutParams.WRAP_CONTENT) { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } else { heightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); } final int span = Math.min(mColCount, lp.span); final int widthSize = colWidth * span + mItemMargin * (span - 1); final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); view.measure(widthSpec, heightSpec); final int height = view.getMeasuredHeight(); if (rec == null) { rec = new LayoutRecord(); mLayoutRecords.put(i, rec); } rec.height = height; rec.id = lp.id; rec.span = span; // We're not actually using this view, so add this back to the recycler. mRecycler.addScrap(view); } int nextColumn = getNextColumnDown(); // Given the span, check if there's enough space to put this view at this column. // IMPORTANT Use the same logic in {@link #layoutChildren}. if (rec.span > 1) { if (mIsRtlLayout) { if (nextColumn + 1 < rec.span) { nextColumn = mColCount - 1; } } else { if (mColCount - nextColumn < rec.span) { nextColumn = 0; } } } rec.column = nextColumn; // Place the top of this child beneath the last by finding the lowest coordinate across // the columns that this child will span. For LTR layout, we scan across from left to // right, and for RTL layout, we scan from right to left. // TODO: Consolidate this logic with getNextRecordDown() in the future, as that method // already calculates the margins for us. This will keep the implementation consistent // with layoutChildren(), fillUp() and fillDown(). int lowest = mItemBottoms[nextColumn] + mItemMargin; if (rec.span > 1) { for (int spanIndex = 0; spanIndex < rec.span; spanIndex++) { final int index = mIsRtlLayout ? nextColumn - spanIndex : nextColumn + spanIndex; final int bottom = mItemBottoms[index] + mItemMargin; if (bottom > lowest) { lowest = bottom; } } } for (int spanIndex = 0; spanIndex < rec.span; spanIndex++) { final int col = mIsRtlLayout ? nextColumn - spanIndex : nextColumn + spanIndex; mItemBottoms[col] = lowest + rec.height; if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, " position: " + i + " bottoms: "); for (int j = 0; j < mColCount; j++) { Log.v(TAG, " mItemBottoms["+j+"]: " + mItemBottoms[j]); } } } } // mItemBottoms[] at this point contains the values of all views up to mFirstPosition. To // figure out where view at mFirstPosition will be laid out, we'll need to find the column // that is the highest (i.e., i where mItemBottoms[i] <= mItemBottoms[j] for all j // from 0 to mColCount.) int highestValue = Integer.MAX_VALUE; for (int k = 0; k < mColCount; k++) { if (mItemBottoms[k] < highestValue) { highestValue = mItemBottoms[k]; } } // Adjust the offsets in each column so that values in mItemTops[] and mItemBottoms[] // reflect coordinates on screen. These offsets will be the actual values where layout // will start from, otherwise, we'd naively start at (leftPadding, topPadding) for // mFirstPosition. for (int k = 0; k < mColCount; k++) { mItemBottoms[k] = mItemBottoms[k] - highestValue + offset; mItemTops[k] = mItemBottoms[k]; // Log.v(TAG, "Adjusting to offset = mItemBottoms[" + k + "]: " + mItemBottoms[k]); } } /** * Measure and layout all currently visible children. * * @param queryAdapter true to requery the adapter for view data */ final void layoutChildren(boolean queryAdapter) { final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int itemMargin = mItemMargin; final int availableWidth = (getWidth() - paddingLeft - paddingRight - itemMargin * (mColCount - 1)); final int colWidth = availableWidth / mColCount; // The availableWidth may not be divisible by mColCount. Keep the // remainder. It will be added to the width of the last view in the row. final int remainder = availableWidth % mColCount; boolean viewsRemovedInLayout = false; // If we're animating out stale views, then we want to defer recycling of views. final boolean deferRecyclingForAnimation = mAnimationOutMode != SgvAnimationHelper.AnimationOut.NONE; if (!deferRecyclingForAnimation) { final int childCount = getChildCount(); // If the latest data set has fewer data items than mFirstPosition, don't keep any // views on screen, and just let the layout logic below retrieve appropriate views // from the recycler. final int viewsToKeepOnScreen = (mItemCount <= mFirstPosition) ? 0 : mItemCount - mFirstPosition; if (childCount > viewsToKeepOnScreen) { // If there are more views laid out than the number of data items remaining to be // laid out, recycle the extraneous views. recycleViewsInRange(viewsToKeepOnScreen, childCount - 1); viewsRemovedInLayout = true; } } else { mViewsToAnimateOut.clear(); } for (int i = 0; i < getChildCount(); i++) { final int position = mFirstPosition + i; View child = getChildAt(i); final int highestAvailableLayoutPosition = mItemBottoms[getNextColumnDown()]; if (deferRecyclingForAnimation && (position >= mItemCount || highestAvailableLayoutPosition >= getHeight())) { // For the remainder of views on screen, they should not be on screen, so we can // skip layout. Add them to the list of views to animate out. // We should only get in this position if deferRecyclingForAnimation = true, // otherwise, we should've recycled all views before getting into this layout loop. mViewsToAnimateOut.add(child); continue; } LayoutParams lp = null; int col = -1; if (child != null) { lp = (LayoutParams) child.getLayoutParams(); col = lp.column; } final boolean needsLayout = queryAdapter || child == null || child.isLayoutRequested(); if (queryAdapter) { View newView = null; if (deferRecyclingForAnimation) { // If we are deferring recycling for animation, then we don't want to pass the // current child in to obtainView for re-use. obtainView() in this case should // try to find the view belonging to this item on screen, or populate a fresh // one from the recycler. newView = obtainView(position); } else { newView = obtainView(position, child); } // Update layout params since they may have changed lp = (LayoutParams) newView.getLayoutParams(); if (newView != child) { if (child != null && !deferRecyclingForAnimation) { mRecycler.addScrap(child); removeViewInLayout(child); viewsRemovedInLayout = true; } // If this view is already in the layout hierarchy, we can just detach it // from the parent and re-attach it at the correct index. If the view has // already been removed from the layout hierarchy, getParent() == null. if (newView.getParent() == this) { detachViewFromParent(newView); attachViewToParent(newView, i, lp); } else { addViewInLayout(newView, i, lp); } } child = newView; // Since the data has changed, we need to make sure the next child is in the // right column. We choose the next column down (vs. next column up) because we // are filling from the top of the screen downwards as we iterate through // visible children. (We take span into account below.) lp.column = getNextColumnDown(); col = lp.column; } setReorderingArea(lp); final int span = Math.min(mColCount, lp.span); // Given the span, check if there's enough space to put this view at this column. // IMPORTANT Propagate the same logic to {@link #calculateLayoutStartOffsets}. if (span > 1) { if (mIsRtlLayout) { // For RTL layout, if the current column index is less than the span of the // child, then we know that there is not enough room remaining to lay this // child out (e.g., if col == 0, but span == 2, then laying this child down // at column = col would put us out of bound into a negative column index.). // For this scenario, reset the index back to the right-most column, and lay // out the child at this position where we can ensure that we can display as // much of the child as possible. if (col + 1 < span) { col = mColCount - 1; } } else { if (mColCount - col < span) { // If not, reset the col to 0. col = 0; } } lp.column = col; } int widthSize = (colWidth * span + itemMargin * (span - 1)); // If it is rtl, we layout the view from col to col - span + // 1. If it reaches the most left column, i.e. we added the // additional width. So the check it span == col +1 if ((mIsRtlLayout && span == col + 1) || (!mIsRtlLayout && span + col == mColCount)) { widthSize += remainder; } if (needsLayout) { final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); final int heightSpec; if (lp.height == LayoutParams.WRAP_CONTENT) { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } else { heightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); } child.measure(widthSpec, heightSpec); } // Place the top of this child beneath the last by finding the lowest coordinate across // the columns that this child will span. For LTR layout, we scan across from left to // right, and for RTL layout, we scan from right to left. // TODO: Consolidate this logic with getNextRecordDown() in the future, as that method // already calculates the margins for us. This will keep the implementation consistent // with fillUp() and fillDown(). int childTop = mItemBottoms[col] + mItemMargin; if (span > 1) { int lowest = childTop; for (int spanIndex = 0; spanIndex < span; spanIndex++) { final int index = mIsRtlLayout ? col - spanIndex : col + spanIndex; final int bottom = mItemBottoms[index] + mItemMargin; if (bottom > lowest) { lowest = bottom; } } childTop = lowest; } final int childHeight = child.getMeasuredHeight(); final int childBottom = childTop + childHeight; int childLeft = 0; int childRight = 0; if (mIsRtlLayout) { childRight = (getWidth() - paddingRight) - (mColCount - col - 1) * (colWidth + itemMargin); childLeft = childRight - child.getMeasuredWidth(); } else { childLeft = paddingLeft + col * (colWidth + itemMargin); childRight = childLeft + child.getMeasuredWidth(); } /* Log.v(TAG, "[layoutChildren] height: " + childHeight + " top: " + childTop + " bottom: " + childBottom + " left: " + childLeft + " column: " + col + " position: " + position + " id: " + lp.id); */ child.layout(childLeft, childTop, childRight, childBottom); if (lp.id == mFocusedChildIdToScrollIntoView) { child.requestFocus(); } for (int spanIndex = 0; spanIndex < span; spanIndex++) { final int index = mIsRtlLayout ? col - spanIndex : col + spanIndex; mItemBottoms[index] = childBottom; } // Whether or not LayoutRecords may have already existed for the view at this position // on screen, we'll update it after we lay out to ensure that the LayoutRecord // has the most updated information about the view at this position. We can be assured // that all views before those on screen (views with adapter position < mFirstPosition) // have the correct LayoutRecords because calculateLayoutStartOffsets() would have // set them appropriately. LayoutRecord rec = mLayoutRecords.get(position); if (rec == null) { rec = new LayoutRecord(); mLayoutRecords.put(position, rec); } rec.column = lp.column; rec.height = childHeight; rec.id = lp.id; rec.span = span; } // It appears that removeViewInLayout() does not invalidate. So if we make use of this // method during layout, we should invalidate explicitly. if (viewsRemovedInLayout || deferRecyclingForAnimation) { invalidate(); } } /** * Set the reordering area for the child layout specified */ private void setReorderingArea(LayoutParams childLayoutParams) { final boolean isLastColumn = childLayoutParams.column == (mColCount - 1); childLayoutParams.reorderingArea = mAdapter.getReorderingArea(childLayoutParams.position, isLastColumn); } final void invalidateLayoutRecordsBeforePosition(int position) { int endAt = 0; while (endAt < mLayoutRecords.size() && mLayoutRecords.keyAt(endAt) < position) { endAt++; } mLayoutRecords.removeAtRange(0, endAt); } final void invalidateLayoutRecordsAfterPosition(int position) { int beginAt = mLayoutRecords.size() - 1; while (beginAt >= 0 && mLayoutRecords.keyAt(beginAt) > position) { beginAt--; } beginAt++; mLayoutRecords.removeAtRange(beginAt + 1, mLayoutRecords.size() - beginAt); } /** * Before doing an animation, map the item IDs for the currently visible children to the * {@link Rect} that defines their position on the screen so a translation animation * can be applied to their new layout positions. */ private void cacheChildRects() { final int childCount = getChildCount(); mChildRectsForAnimation.clear(); long originalDraggedChildId = -1; if (isDragReorderingSupported()) { originalDraggedChildId = mReorderHelper.getDraggedChildId(); if (mCachedDragViewRect != null && originalDraggedChildId != -1) { // This child was dragged in a reordering operation. Use the cached position // of where the drag event was released as the cached location. mChildRectsForAnimation.put(originalDraggedChildId, new ViewRectPair(mDragView, mCachedDragViewRect)); mCachedDragViewRect = null; } } for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); Rect rect; if (lp.id != originalDraggedChildId) { final int childTop = (int) child.getY(); final int childBottom = childTop + child.getHeight(); final int childLeft = (int) child.getX(); final int childRight = childLeft + child.getWidth(); rect = new Rect(childLeft, childTop, childRight, childBottom); mChildRectsForAnimation.put(lp.id /* item id */, new ViewRectPair(child, rect)); } } } /** * Should be called with mPopulating set to true * * @param fromPosition Position to start filling from * @param overhang the number of extra pixels to fill beyond the current top edge * @return the max overhang beyond the beginning of the view of any added items at the top */ final int fillUp(int fromPosition, int overhang) { final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int itemMargin = mItemMargin; final int availableWidth = (getWidth() - paddingLeft - paddingRight - itemMargin * (mColCount - 1)); final int colWidth = availableWidth / mColCount; // The availableWidth may not be divisible by mColCount. Keep the // remainder. It will be added to the width of the last view in the row. final int remainder = availableWidth % mColCount; final int gridTop = getPaddingTop(); final int fillTo = -overhang; int nextCol = getNextColumnUp(); int position = fromPosition; while (nextCol >= 0 && mItemTops[nextCol] > fillTo && position >= 0) { final View child = obtainView(position, null); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (child.getParent() != this) { if (mInLayout) { addViewInLayout(child, 0, lp); } else { addView(child, 0); } } final int span = Math.min(mColCount, lp.span); LayoutRecord rec; if (span > 1) { rec = getNextRecordUp(position, span); nextCol = rec.column; } else { rec = mLayoutRecords.get(position); } boolean invalidateBefore = false; if (rec == null) { rec = new LayoutRecord(); mLayoutRecords.put(position, rec); rec.column = nextCol; rec.span = span; } else if (span != rec.span) { rec.span = span; rec.column = nextCol; invalidateBefore = true; } else { nextCol = rec.column; } if (mHasStableIds) { rec.id = lp.id; } lp.column = nextCol; setReorderingArea(lp); int widthSize = colWidth * span + itemMargin * (span - 1); // If it is rtl, we layout the view from nextCol to nextCol - span + // 1. If it reaches the most left column, i.e. we added the // additional width. So the check it span == nextCol + 1 if ((mIsRtlLayout && span == nextCol + 1) || (!mIsRtlLayout && span + nextCol == mColCount)) { widthSize += remainder; } final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); final int heightSpec; if (lp.height == LayoutParams.WRAP_CONTENT) { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } else { heightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); } child.measure(widthSpec, heightSpec); final int childHeight = child.getMeasuredHeight(); if (invalidateBefore || (childHeight != rec.height && rec.height > 0)) { invalidateLayoutRecordsBeforePosition(position); } rec.height = childHeight; // Iterate across each column that this child spans and add the margin calculated // for that column to mItemTops. getMarginBelow() is expected to give us the correct // margin values at each column such that mItemTops ends up with a smooth edge across // the column spans. We need to do this before actually laying down the child, // otherwise we risk overlapping one child over another. mItemTops stores the top // index for where the next child should be laid out. For RTL, we do the update // in reverse order. for (int i = 0; i < span; i++) { final int index = mIsRtlLayout ? nextCol - i : nextCol + i; mItemTops[index] += rec.getMarginBelow(i); } final int startFrom = mItemTops[nextCol]; final int childBottom = startFrom; final int childTop = childBottom - childHeight; int childLeft = 0; int childRight = 0; // For LTR layout, the child's left is calculated as the // (column index from left) * (columnWidth plus item margins). // For RTL layout, the child's left is relative to its right, and its right coordinate // is calculated as the difference between the width of this grid and // (column index from right) * (columnWidth plus item margins). if (mIsRtlLayout) { childRight = (getWidth() - paddingRight) - (mColCount - nextCol - 1) * (colWidth + itemMargin); childLeft = childRight - child.getMeasuredWidth(); } else { childLeft = paddingLeft + nextCol * (colWidth + itemMargin); childRight = childLeft + child.getMeasuredWidth(); } child.layout(childLeft, childTop, childRight, childBottom); Log.v(TAG, "[fillUp] position: " + position + " id: " + lp.id + " childLeft: " + childLeft + " childTop: " + childTop + " column: " + rec.column + " childHeight:" + childHeight); // Since we're filling up, once the child is laid out, update mItemTops again // to reflect the next available top value at this column. This is simply the child's // top coordinates, minus any available margins set. For LTR, we start at the column // that this child is laid out from (nextCol) and move right for span amount. For RTL // layout, we start at the column that this child is laid out from and move left. for (int i = 0; i < span; i++) { final int index = mIsRtlLayout ? nextCol - i : nextCol + i; mItemTops[index] = childTop - rec.getMarginAbove(i) - itemMargin; } if (lp.id == mFocusedChildIdToScrollIntoView) { child.requestFocus(); } nextCol = getNextColumnUp(); mFirstPosition = position--; } int highestView = getHeight(); for (int i = 0; i < mColCount; i++) { if (mItemTops[i] < highestView) { highestView = mItemTops[i]; } } return gridTop - highestView; } /** * Should be called with mPopulating set to true * * @param fromPosition Position to start filling from * @param overhang the number of extra pixels to fill beyond the current bottom edge * @return the max overhang beyond the end of the view of any added items at the bottom */ final int fillDown(int fromPosition, int overhang) { final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int itemMargin = mItemMargin; final int availableWidth = (getWidth() - paddingLeft - paddingRight - itemMargin * (mColCount - 1)); final int colWidth = availableWidth / mColCount; // The availableWidth may not be divisible by mColCount. Keep the // remainder. It will be added to the width of the last view in the row. final int remainder = availableWidth % mColCount; final int gridBottom = getHeight() - getPaddingBottom(); final int fillTo = gridBottom + overhang; int nextCol = getNextColumnDown(); int position = fromPosition; while (nextCol >= 0 && mItemBottoms[nextCol] < fillTo && position < mItemCount) { final View child = obtainView(position, null); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (child.getParent() != this) { if (mInLayout) { addViewInLayout(child, -1, lp); } else { addView(child); } } final int span = Math.min(mColCount, lp.span); LayoutRecord rec; if (span > 1) { rec = getNextRecordDown(position, span); nextCol = rec.column; } else { rec = mLayoutRecords.get(position); } boolean invalidateAfter = false; if (rec == null) { rec = new LayoutRecord(); mLayoutRecords.put(position, rec); rec.column = nextCol; rec.span = span; } else if (span != rec.span) { rec.span = span; rec.column = nextCol; invalidateAfter = true; } else { nextCol = rec.column; } if (mHasStableIds) { rec.id = lp.id; } lp.column = nextCol; setReorderingArea(lp); int widthSize = colWidth * span + itemMargin * (span - 1); // If it is rtl, we layout the view from nextCol to nextCol - span + // 1. If it reaches the most left column, i.e. we added the // additional width. So the check it span == nextCol +1 if ((mIsRtlLayout && span == nextCol + 1) || (!mIsRtlLayout && span + nextCol == mColCount)) { widthSize += remainder; } final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); final int heightSpec; if (lp.height == LayoutParams.WRAP_CONTENT) { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } else { heightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); } child.measure(widthSpec, heightSpec); final int childHeight = child.getMeasuredHeight(); if (invalidateAfter || (childHeight != rec.height && rec.height > 0)) { invalidateLayoutRecordsAfterPosition(position); } rec.height = childHeight; // Before laying out the child, we need to make sure mItemBottoms is updated with the // correct values such that there is a smooth edge across the child's span. // getMarginAbove() is expected to give us these values. For LTR layout, we start at // nextCol, and update forward for the number of columns this child spans. For RTL // layout, we start at nextCol and update backwards for the same number of columns. for (int i = 0; i < span; i++) { final int index = mIsRtlLayout ? nextCol - i : nextCol + i; mItemBottoms[index] += rec.getMarginAbove(i); } final int startFrom = mItemBottoms[nextCol]; final int childTop = startFrom + itemMargin; final int childBottom = childTop + childHeight; int childLeft = 0; int childRight = 0; if (mIsRtlLayout) { childRight = (getWidth() - paddingRight) - (mColCount - nextCol - 1) * (colWidth + itemMargin); childLeft = childRight - child.getMeasuredWidth(); } else { childLeft = paddingLeft + nextCol * (colWidth + itemMargin); childRight = childLeft + child.getMeasuredWidth(); } Log.v(TAG, "[fillDown] position: " + position + " id: " + lp.id + " childLeft: " + childLeft + " childTop: " + childTop + " column: " + rec.column + " childHeight:" + childHeight); child.layout(childLeft, childTop, childRight, childBottom); // Once we've laid down the child, update mItemBottoms again to reflect the next // available set of bottom values for the next child. for (int i = 0; i < span; i++) { final int index = mIsRtlLayout ? nextCol - i : nextCol + i; mItemBottoms[index] = childBottom + rec.getMarginBelow(i); } if (lp.id == mFocusedChildIdToScrollIntoView) { child.requestFocus(); } nextCol = getNextColumnDown(); position++; } int lowestView = 0; for (int i = 0; i < mColCount; i++) { final int index = mIsRtlLayout ? mColCount - (i + 1) : i; if (mItemBottoms[index] > lowestView) { lowestView = mItemBottoms[index]; } } return lowestView - gridBottom; } /** * @return column that the next view filling upwards should occupy. This is the bottom-most * position available for a single-column item. */ final int getNextColumnUp() { int result = -1; int bottomMost = Integer.MIN_VALUE; final int colCount = mColCount; for (int i = colCount - 1; i >= 0; i--) { final int index = mIsRtlLayout ? colCount - (i + 1) : i; final int top = mItemTops[index]; if (top > bottomMost) { bottomMost = top; result = index; } } return result; } /** * Return a LayoutRecord for the given position * @param position * @param span * @return */ final LayoutRecord getNextRecordUp(int position, int span) { LayoutRecord rec = mLayoutRecords.get(position); if (rec == null || rec.span != span) { if (span > mColCount) { throw new IllegalStateException("Span larger than column count! Span:" + span + " ColumnCount:" + mColCount); } rec = new LayoutRecord(); rec.span = span; mLayoutRecords.put(position, rec); } int targetCol = -1; int bottomMost = Integer.MIN_VALUE; // For LTR layout, we start from the bottom-right corner upwards when we need to find the // NextRecordUp. For RTL, we will start from bottom-left. final int colCount = mColCount; if (mIsRtlLayout) { for (int i = span - 1; i < colCount; i++) { int top = Integer.MAX_VALUE; for (int j = i; j > i - span; j--) { final int singleTop = mItemTops[j]; if (singleTop < top) { top = singleTop; } } if (top > bottomMost) { bottomMost = top; targetCol = i; } } } else { for (int i = colCount - span; i >= 0; i--) { int top = Integer.MAX_VALUE; for (int j = i; j < i + span; j++) { final int singleTop = mItemTops[j]; if (singleTop < top) { top = singleTop; } } if (top > bottomMost) { bottomMost = top; targetCol = i; } } } rec.column = targetCol; // Once we've found the target column for the view at this position, we update mItemTops // for all columns that this view will occupy. We set the margin such that mItemTops is // equal for all columns in the view's span. For LTR layout, we start at targetCol and // move right, and for RTL, we start at targetCol and move left. for (int i = 0; i < span; i++) { final int nextCol = mIsRtlLayout ? targetCol - i : targetCol + i; rec.setMarginBelow(i, mItemTops[nextCol] - bottomMost); } return rec; } /** * @return column that the next view filling downwards should occupy. This is the top-most * position available. */ final int getNextColumnDown() { int topMost = Integer.MAX_VALUE; int result = 0; final int colCount = mColCount; for (int i = 0; i < colCount; i++) { final int index = mIsRtlLayout ? colCount - (i + 1) : i; final int bottom = mItemBottoms[index]; if (bottom < topMost) { topMost = bottom; result = index; } } return result; } final LayoutRecord getNextRecordDown(int position, int span) { LayoutRecord rec = mLayoutRecords.get(position); if (rec == null || rec.span != span) { if (span > mColCount) { throw new IllegalStateException("Span larger than column count! Span:" + span + " ColumnCount:" + mColCount); } rec = new LayoutRecord(); rec.span = span; mLayoutRecords.put(position, rec); } int targetCol = -1; int topMost = Integer.MAX_VALUE; final int colCount = mColCount; // For LTR layout, we start from the top-left corner and move right-downwards, when we // need to find the NextRecordDown. For RTL we will start from Top-Right corner, and move // left-downwards. if (mIsRtlLayout) { for (int i = colCount - 1; i >= span - 1; i--) { int bottom = Integer.MIN_VALUE; for (int j = i; j > i - span; j--) { final int singleBottom = mItemBottoms[j]; if (singleBottom > bottom) { bottom = singleBottom; } } if (bottom < topMost) { topMost = bottom; targetCol = i; } } } else { for (int i = 0; i <= colCount - span; i++) { int bottom = Integer.MIN_VALUE; for (int j = i; j < i + span; j++) { final int singleBottom = mItemBottoms[j]; if (singleBottom > bottom) { bottom = singleBottom; } } if (bottom < topMost) { topMost = bottom; targetCol = i; } } } rec.column = targetCol; // Once we've found the target column for the view at this position, we update mItemBottoms // for all columns that this view will occupy. We set the margins such that mItemBottoms // is equal for all columns in the view's span. For LTR layout, we start at targetCol and // move right, and for RTL, we start at targetCol and move left. for (int i = 0; i < span; i++) { final int nextCol = mIsRtlLayout ? targetCol - i : targetCol + i; rec.setMarginAbove(i, topMost - mItemBottoms[nextCol]); } return rec; } private int getItemWidth(int itemColumnSpan) { final int colWidth = (getWidth() - getPaddingLeft() - getPaddingRight() - mItemMargin * (mColCount - 1)) / mColCount; return colWidth * itemColumnSpan + mItemMargin * (itemColumnSpan - 1); } /** * Obtain a populated view from the adapter. This method checks to see if the view to populate * is already laid out on screen somewhere by comparing the item ids. * * If the view is already laid out, and the view type has not changed, populate the contents * and return. * * If the view is not laid out on screen somewhere, grab a view from the recycler and populate. * * NOTE: This method should be called during layout. * * TODO: This can probably be consolidated with the overloaded {@link #obtainView(int, View)}. * * @param position Position to get the view for. */ final View obtainView(int position) { // TODO: This method currently does not support transient state views. final Object item = mAdapter.getItem(position); View scrap = null; final int positionViewType = mAdapter.getItemViewType(item, position); final long id = mAdapter.getItemId(item, position); final ViewRectPair viewRectPair = mChildRectsForAnimation.get(id); if (viewRectPair != null) { scrap = viewRectPair.view; // TODO: Make use of stable ids by retrieving the cached views using stable ids. In // theory, we should maintain a list of active views, and then fetch the views // from that list. If that fails, then we should go to the recycler. // For the collection holding stable ids, we must ensure that those views don't get // repurposed for other items at different positions. } final int scrapViewType = scrap != null && (scrap.getLayoutParams() instanceof LayoutParams) ? ((LayoutParams) scrap.getLayoutParams()).viewType : -1; if (scrap == null || scrapViewType != positionViewType) { // If there is no cached view or the cached view's type no longer match the type // of the item at the specified position, retrieve a new view from the recycler and // recycle the cached view. if (scrap != null) { // The cached view we had is not valid, so add it to the recycler and // remove it from the current layout. recycleView(scrap); } scrap = mRecycler.getScrapView(positionViewType); } final int itemColumnSpan = mAdapter.getItemColumnSpan(item, position); final int itemWidth = getItemWidth(itemColumnSpan); final View view = mAdapter.getView(item, position, scrap, this, itemWidth); ViewGroup.LayoutParams lp = view.getLayoutParams(); if (view.getParent() != this) { if (lp == null) { lp = generateDefaultLayoutParams(); } else if (!checkLayoutParams(lp)) { lp = generateLayoutParams(lp); } view.setLayoutParams(lp); } final LayoutParams sglp = (LayoutParams) view.getLayoutParams(); sglp.position = position; sglp.viewType = positionViewType; sglp.id = id; sglp.span = itemColumnSpan; // When the view at the positions we are tracking update, make sure to // update our views as well. That way, we have the correct // rectangle for comparing when the drag target enters/ leaves the // placeholder view. if (isDragReorderingSupported() && mReorderHelper.getDraggedChildId() == id) { mReorderHelper.updateDraggedChildView(view); mReorderHelper.updateDraggedOverChildView(view); } return view; } /** * Obtain a populated view from the adapter. If optScrap is non-null and is not * reused it will be placed in the recycle bin. * * @param position position to get view for * @param optScrap Optional scrap view; will be reused if possible * @return A new view, a recycled view from mRecycler, or optScrap */ final View obtainView(int position, View optScrap) { View view = mRecycler.getTransientStateView(position); final Object item = mAdapter.getItem(position); final int positionViewType = mAdapter.getItemViewType(item, position); if (view == null) { // Reuse optScrap if it's of the right type (and not null) final int optType = optScrap != null ? ((LayoutParams) optScrap.getLayoutParams()).viewType : -1; final View scrap = optType == positionViewType ? optScrap : mRecycler.getScrapView(positionViewType); final int itemColumnSpan = mAdapter.getItemColumnSpan(item, position); final int itemWidth = getItemWidth(itemColumnSpan); view = mAdapter.getView(item, position, scrap, this, itemWidth); if (view != scrap && scrap != null) { // The adapter didn't use it; put it back. mRecycler.addScrap(scrap); } ViewGroup.LayoutParams lp = view.getLayoutParams(); if (view.getParent() != this) { if (lp == null) { lp = generateDefaultLayoutParams(); } else if (!checkLayoutParams(lp)) { lp = generateLayoutParams(lp); } view.setLayoutParams(lp); } } final LayoutParams sglp = (LayoutParams) view.getLayoutParams(); sglp.position = position; sglp.viewType = positionViewType; final long id = mAdapter.getItemIdFromView(view, position); sglp.id = id; sglp.span = mAdapter.getItemColumnSpan(item, position); // When the view at the positions we are tracking update, make sure to // update our views as well. That way, we have the correct // rectangle for comparing when the drag target enters/ leaves the // placeholder view. if (isDragReorderingSupported() && mReorderHelper.getDraggedChildId() == id) { mReorderHelper.updateDraggedChildView(view); mReorderHelper.updateDraggedOverChildView(view); } return view; } /** * Animation mode to play for new data coming in as well as the stale data that should be * animated out. * @param animationIn The animation to play to introduce new or updated data into view * @param animationOut The animation to play to transition stale data out of view. */ public void setAnimationMode(SgvAnimationHelper.AnimationIn animationIn, SgvAnimationHelper.AnimationOut animationOut) { mAnimationInMode = animationIn; mAnimationOutMode = animationOut; } public SgvAnimationHelper.AnimationIn getAnimationInMode() { return mAnimationInMode; } public SgvAnimationHelper.AnimationOut getAnimationOutMode() { return mAnimationOutMode; } public GridAdapter getAdapter() { return mAdapter; } public void setAdapter(GridAdapter adapter) { if (mAdapter != null) { mAdapter.unregisterDataSetObserver(mObserver); } clearAllState(); mAdapter = adapter; mDataChanged = true; mItemCount = adapter != null ? adapter.getCount() : 0; if (adapter != null) { adapter.registerDataSetObserver(mObserver); mRecycler.setViewTypeCount(adapter.getViewTypeCount()); mHasStableIds = adapter.hasStableIds(); } else { mHasStableIds = false; } if (isDragReorderingSupported()) { updateReorderStates(ReorderUtils.DRAG_STATE_NONE); } updateEmptyStatus(); } public void setAdapter(GridAdapter adapter, ScrollState scrollState) { setAdapter(adapter); mCurrentScrollState = scrollState; } /** * Clear all state because the grid will be used for a completely different set of data. */ private void clearAllState() { // Clear all layout records and views mLayoutRecords.clear(); removeAllViews(); mItemTops = null; mItemBottoms = null; setSelectionToTop(); // Clear recycler because there could be different view types now mRecycler.clear(); // Reset the last touch y coordinate so that any animation/events won't use stale values. mLastTouchY = 0; // Reset the first changed position to 0. At least we will update all views. mFirstChangedPosition = 0; } /** * Scroll the list so the first visible position in the grid is the first item in the adapter. */ public void setSelectionToTop() { mCurrentScrollState = null; setFirstPositionAndOffsets(0 /* position */, getPaddingTop() /* offset */); } /** * Get {@link #mFirstPosition}, which is the adapter position of the View * returned by getChildAt(0). */ public int getCurrentFirstPosition() { return mFirstPosition; } /** * Indicate whether the scrolling state is currently at the topmost of this grid * @return boolean Indicates whether the current view is the top most of this grid. */ private boolean isSelectionAtTop() { if (mCurrentScrollState != null && mCurrentScrollState.getAdapterPosition() == 0) { // ScrollState is how far the top of the first child is from the top of the screen, and // does not include top padding when the adapter position is the first child. If the // vertical offset of the scroll state is exactly equal to {@link #mItemMargin}, then // the first item, and therefore the view of the grid, is at the top. return mCurrentScrollState.getVerticalOffset() == mItemMargin; } return false; } /** * Set the first position and offset so that on layout, we would start laying out starting * with the specified position at the top of the view. * @param position The child position to place at the top of this view. * @param offset The vertical layout offset of the view at the specified position. */ public void setFirstPositionAndOffsets(int position, int offset) { // Reset the first visible position in the grid to be item 0 mFirstPosition = position; if (mItemTops == null || mItemBottoms == null) { mItemTops = new int[mColCount]; mItemBottoms = new int[mColCount]; } calculateLayoutStartOffsets(offset); } /** * Restore the view to the states specified by the {@link ScrollState}. * @param scrollState {@link ScrollState} containing the scroll states to restore to. */ private void restoreScrollPosition(ScrollState scrollState) { if (mAdapter == null || scrollState == null || mAdapter.getCount() == 0) { return; } Log.v(TAG, "[restoreScrollPosition] " + scrollState); int targetPosition = 0; long itemId = -1; final int originalPosition = scrollState.getAdapterPosition(); final int adapterCount = mAdapter.getCount(); // ScrollState is defined as the vertical offset of the first item that is laid out // on screen. To restore scroll state, we check within a window to see if we can // find that original first item in this new data set. If we can, restore that item // to the first position on screen, offset by its previous vertical offset. If we // cannot find that item, then we'll simply layout out everything from the beginning // again. // TODO: Perhaps it is more efficient if we check the cursor in one direction first // before going backwards, rather than jumping back and forth as we are doing now. for (int i = 0; i < SCROLL_RESTORE_WINDOW_SIZE; i++) { if (originalPosition + i < adapterCount) { itemId = mAdapter.getItemId(originalPosition + i); if (itemId != -1 && itemId == scrollState.getItemId()) { targetPosition = originalPosition + i; break; } } if (originalPosition - i >= 0 && originalPosition - i < adapterCount) { itemId = mAdapter.getItemId(originalPosition - i); if (itemId != -1 && itemId == scrollState.getItemId()) { targetPosition = originalPosition - i; break; } } } // layoutChildren(), fillDown() and fillUp() always apply mItemMargin when laying out // views. Since restoring scroll position is effectively laying out a particular child // as the first child, we need to ensure we strip mItemMargin from the offset, as it // will be re-applied when the view is laid out. // // Since top padding varies with screen orientation and is not stored in the scroll // state when the scroll adapter position is the first child, we add it here. int offset = scrollState.getVerticalOffset() - mItemMargin; if (targetPosition == 0) { offset += getPaddingTop(); } setFirstPositionAndOffsets(targetPosition, offset); mCurrentScrollState = null; } /** * Return the current scroll state of this view. * @return {@link ScrollState} The current scroll state */ public ScrollState getScrollState() { final View v = getChildAt(0); if (v == null) { return null; } final LayoutParams lp = (LayoutParams) v.getLayoutParams(); // Since top padding varies with screen orientation, it is not stored in the scroll state // when the scroll adapter position is the first child. final int offset = (lp.position == 0 ? v.getTop() - getPaddingTop() : v.getTop()); return new ScrollState(lp.id, lp.position, offset); } /** * NOTE This method is borrowed from {@link ScrollView}. */ @Override public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) { // offset into coordinate space of this scroll view rectangle.offset(child.getLeft() - child.getScrollX(), child.getTop() - child.getScrollY()); return scrollToChildRect(rectangle, immediate); } /** * If rect is off screen, scroll just enough to get it (or at least the * first screen size chunk of it) on screen. * NOTE This method is borrowed from {@link ScrollView}. * * @param rect The rectangle. * @param immediate True to scroll immediately without animation. Not used here. * @return true if scrolling was performed */ private boolean scrollToChildRect(Rect rect, boolean immediate) { final int delta = computeScrollDeltaToGetChildRectOnScreen(rect); final boolean scroll = delta != 0; if (scroll) { // TODO smoothScrollBy if immediate is false. scrollBy(0, delta); } return scroll; } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (mOnSizeChangedListener != null) { mOnSizeChangedListener.onSizeChanged(w, h, oldw, oldh); } // NOTE Below is borrowed from {@link ScrollView}. final View currentFocused = findFocus(); if (null == currentFocused || this == currentFocused) { return; } // If the currently-focused view was visible on the screen when the // screen was at the old height, then scroll the screen to make that // view visible with the new screen height. if (isWithinDeltaOfScreen(currentFocused, 0, oldh)) { currentFocused.getDrawingRect(mTempRect); offsetDescendantRectToMyCoords(currentFocused, mTempRect); scrollBy(0, computeScrollDeltaToGetChildRectOnScreen(mTempRect)); } } /** * * NOTE This method is borrowed from {@link ScrollView}. * * @return whether the descendant of this scroll view is within delta * pixels of being on the screen. */ private boolean isWithinDeltaOfScreen(View descendant, int delta, int height) { descendant.getDrawingRect(mTempRect); offsetDescendantRectToMyCoords(descendant, mTempRect); return (mTempRect.bottom + delta) >= getScrollY() && (mTempRect.top - delta) <= (getScrollY() + height); } /** * NOTE: borrowed from {@link GridView} * Comments from {@link View} * * Compute the vertical extent of the vertical scrollbar's thumb within the vertical range. * This value is used to compute the length of the thumb within the scrollbar's track. * The range is expressed in arbitrary units that must be the same as the units used by * {@link #computeVerticalScrollRange} and {@link #computeVerticalScrollOffset}. * * The default extent is the drawing height of this view. * * @return the vertical extent of the scrollbar's thumb */ @Override protected int computeVerticalScrollExtent() { final int count = getChildCount(); if (count > 0) { if (mSmoothScrollbarEnabled) { final int rowCount = (count + mColCount - 1) / mColCount; int extent = rowCount * SCROLLING_ESTIMATED_ITEM_HEIGHT; View view = getChildAt(0); final int top = view.getTop(); int height = view.getHeight(); if (height > 0) { extent += (top * SCROLLING_ESTIMATED_ITEM_HEIGHT) / height; } view = getChildAt(count - 1); final int bottom = view.getBottom(); height = view.getHeight(); if (height > 0) { extent -= ((bottom - getHeight()) * SCROLLING_ESTIMATED_ITEM_HEIGHT) / height; } return extent; } else { return 1; } } return 0; } /** * NOTE: borrowed from {@link GridView} and altered as appropriate to accommodate for * {@link StaggeredGridView} * * Comments from {@link View} * * Compute the vertical offset of the vertical scrollbar's thumb within the horizontal range. * This value is used to compute the position of the thumb within the scrollbar's track. * The range is expressed in arbitrary units that must be the same as the units used by * {@link #computeVerticalScrollRange()} and {@link #computeVerticalScrollExtent()}. * * The default offset is the scroll offset of this view. * * @return the vertical offset of the scrollbar's thumb */ @Override protected int computeVerticalScrollOffset() { final int firstPosition = mFirstPosition; final int childCount = getChildCount(); final int paddingTop = getPaddingTop(); if (firstPosition >= 0 && childCount > 0) { if (mSmoothScrollbarEnabled) { final View view = getChildAt(0); final int top = view.getTop(); final int currentTopViewHeight = view.getHeight(); if (currentTopViewHeight > 0) { // In an ideal world, all items would have a fixed height that we would know // a priori, calculating the scroll offset would simply be: // [A] (mFirstPosition * fixedHeight) - childView[0].top // where childView[0] is the first view on screen. // // However, given that we do not know the height ahead of time, and that each // item in this grid can have varying heights, we'd need to assign an arbitrary // item height (SCROLLING_ESTIMATED_ITEM_HEIGHT) in order to estimate the scroll // offset. The previous equation thus transforms to: // [B] (mFirstPosition * SCROLLING_ESTIMATED_ITEM_HEIGHT) - // ((childView[0].top * SCROLLING_ESTIMATED_ITEM_HEIGHT) / // childView[0].height) // // Equation [B] gives a pretty good calculation of the offset if this were a // single column grid view, for a multi-column grid, one slight modification is // needed: // [C] ((mFirstPosition * SCROLLING_ESTIMATED_ITEM_HEIGHT) / mColCount) - // ((childView[0].top * SCROLLING_ESTIMATED_ITEM_HEIGHT) / // childView[0].height) final int estimatedScrollOffset = ((firstPosition * SCROLLING_ESTIMATED_ITEM_HEIGHT) / mColCount) - ((top * SCROLLING_ESTIMATED_ITEM_HEIGHT) / currentTopViewHeight); final int rowCount = (mItemCount + mColCount - 1) / mColCount; final int overScrollCompensation = (int) ((float) getScrollY() / getHeight() * rowCount * SCROLLING_ESTIMATED_ITEM_HEIGHT); int val = Math.max(estimatedScrollOffset + overScrollCompensation, 0); // If mFirstPosition is currently the very first item in the adapter, check to // see if we need to take into account any top padding. This is so that we // don't return 0 when in fact the user may still be scrolling through some // top padding. if (firstPosition == 0 && paddingTop > 0) { val += paddingTop - top + mItemMargin; } return val; } } else { int index; final int count = mItemCount; if (firstPosition == 0) { index = 0; } else if (firstPosition + childCount == count) { index = count; } else { index = firstPosition + childCount / 2; } return (int) (firstPosition + childCount * (index / (float) count)); } } return paddingTop; } /** * NOTE: borrowed from {@link GridView} and altered as appropriate to accommodate for * {@link StaggeredGridView} * * Comments from {@link View} * * Compute the vertical range that the vertical scrollbar represents. * The range is expressed in arbitrary units that must be the same as the units used by * {@link #computeVerticalScrollExtent} and {@link #computeVerticalScrollOffset}. * * The default range is the drawing height of this view. * * @return the total vertical range represented by the vertical scrollbar */ @Override protected int computeVerticalScrollRange() { final int rowCount = (mItemCount + mColCount - 1) / mColCount; int result = Math.max(rowCount * SCROLLING_ESTIMATED_ITEM_HEIGHT, 0); if (mSmoothScrollbarEnabled) { if (getScrollY() != 0) { // Compensate for overscroll result += Math.abs((int) ((float) getScrollY() / getHeight() * rowCount * SCROLLING_ESTIMATED_ITEM_HEIGHT)); } } else { result = mItemCount; } return result; } /** * Compute the amount to scroll in the Y direction in order to get * a rectangle completely on the screen (or, if taller than the screen, * at least the first screen size chunk of it). * * NOTE This method is borrowed from {@link ScrollView}. * * @param rect The rect. * @return The scroll delta. */ protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) { if (getChildCount() == 0) { return 0; } final int height = getHeight(); final int fadingEdge = getVerticalFadingEdgeLength(); int screenTop = getScrollY(); int screenBottom = screenTop + height; // leave room for top fading edge as long as rect isn't at very top if (rect.top > 0) { screenTop += fadingEdge; } // leave room for bottom fading edge as long as rect isn't at very bottom if (rect.bottom < getHeight()) { screenBottom -= fadingEdge; } int scrollYDelta = 0; if (rect.bottom > screenBottom && rect.top > screenTop) { // need to move down to get it in view: move down just enough so // that the entire rectangle is in view (or at least the first // screen size chunk). if (rect.height() > height) { // just enough to get screen size chunk on scrollYDelta = screenTop - rect.top; } else { // get entire rect at bottom of screen scrollYDelta = screenBottom - rect.bottom; } } else if (rect.top < screenTop && rect.bottom < screenBottom) { // need to move up to get it in view: move up just enough so that // entire rectangle is in view (or at least the first screen // size chunk of it). if (rect.height() > height) { // screen size chunk scrollYDelta = screenBottom - rect.bottom; } else { // entire rect at top scrollYDelta = screenTop - rect.top; } } return scrollYDelta; } @Override protected LayoutParams generateDefaultLayoutParams() { return new LayoutParams(LayoutParams.WRAP_CONTENT); } @Override protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { return new LayoutParams(lp); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams lp) { return lp instanceof LayoutParams; } @Override public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { return new LayoutParams(getContext(), attrs); } @Override public Parcelable onSaveInstanceState() { final Parcelable superState = super.onSaveInstanceState(); final SavedState ss = new SavedState(superState); final int position = mFirstPosition; ss.position = position; if (position >= 0 && mAdapter != null && position < mAdapter.getCount()) { ss.firstId = mAdapter.getItemId(position); } if (getChildCount() > 0) { // Since top padding varies with screen orientation, it is not stored in the scroll // state when the scroll adapter position is the first child. ss.topOffset = position == 0 ? getChildAt(0).getTop() - getPaddingTop() : getChildAt(0).getTop(); } return ss; } @Override public void onRestoreInstanceState(Parcelable state) { final SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); mDataChanged = true; mFirstPosition = ss.position; mCurrentScrollState = new ScrollState(ss.firstId, ss.position, ss.topOffset); requestLayout(); } public static class LayoutParams extends ViewGroup.LayoutParams { private static final int[] LAYOUT_ATTRS = new int[] { android.R.attr.layout_span }; private static final int SPAN_INDEX = 0; /** * The number of columns this item should span */ public int span = 1; /** * Item position this view represents */ public int position = -1; /** * Type of this view as reported by the adapter */ int viewType; /** * The column this view is occupying */ int column; /** * The stable ID of the item this view displays */ long id = -1; /** * The position where reordering can happen for this view */ public int reorderingArea = ReorderUtils.REORDER_AREA_NONE; public LayoutParams(int height) { super(MATCH_PARENT, height); if (this.height == MATCH_PARENT) { Log.w(TAG, "Constructing LayoutParams with height FILL_PARENT - " + "impossible! Falling back to WRAP_CONTENT"); this.height = WRAP_CONTENT; } } public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); if (this.width != MATCH_PARENT) { Log.w(TAG, "Inflation setting LayoutParams width to " + this.width + " - must be MATCH_PARENT"); this.width = MATCH_PARENT; } if (this.height == MATCH_PARENT) { Log.w(TAG, "Inflation setting LayoutParams height to MATCH_PARENT - " + "impossible! Falling back to WRAP_CONTENT"); this.height = WRAP_CONTENT; } final TypedArray a = c.obtainStyledAttributes(attrs, LAYOUT_ATTRS); span = a.getInteger(SPAN_INDEX, 1); a.recycle(); } public LayoutParams(ViewGroup.LayoutParams other) { super(other); if (this.width != MATCH_PARENT) { Log.w(TAG, "Constructing LayoutParams with width " + this.width + " - must be MATCH_PARENT"); this.width = MATCH_PARENT; } if (this.height == MATCH_PARENT) { Log.w(TAG, "Constructing LayoutParams with height MATCH_PARENT - " + "impossible! Falling back to WRAP_CONTENT"); this.height = WRAP_CONTENT; } } } private class RecycleBin { private ArrayList<View>[] mScrapViews; private int mViewTypeCount; private int mMaxScrap; private SparseArray<View> mTransientStateViews; public void setViewTypeCount(int viewTypeCount) { if (viewTypeCount < 1) { throw new IllegalArgumentException("Must have at least one view type (" + viewTypeCount + " types reported)"); } if (viewTypeCount == mViewTypeCount) { return; } final ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount]; for (int i = 0; i < viewTypeCount; i++) { scrapViews[i] = new ArrayList<View>(); } mViewTypeCount = viewTypeCount; mScrapViews = scrapViews; } public void clear() { final int typeCount = mViewTypeCount; for (int i = 0; i < typeCount; i++) { mScrapViews[i].clear(); } if (mTransientStateViews != null) { mTransientStateViews.clear(); } } public void clearTransientViews() { if (mTransientStateViews != null) { mTransientStateViews.clear(); } } public void addScrap(View v) { if (!(v.getLayoutParams() instanceof LayoutParams)) { return; } final LayoutParams lp = (LayoutParams) v.getLayoutParams(); if (ViewCompat.hasTransientState(v)) { if (mTransientStateViews == null) { mTransientStateViews = new SparseArray<View>(); } mTransientStateViews.put(lp.position, v); return; } final int childCount = getChildCount(); if (childCount > mMaxScrap) { mMaxScrap = childCount; } // Clear possible modified states applied to the view when adding to the recycler. // This view may have been part of a cancelled animation, so clear that state so that // future consumer of this view won't have to deal with states from its past life. v.setTranslationX(0); v.setTranslationY(0); v.setRotation(0); v.setAlpha(1.0f); v.setScaleY(1.0f); final ArrayList<View> scrap = mScrapViews[lp.viewType]; if (scrap.size() < mMaxScrap) { // The number of scraps have not yet exceeded our limit, check to see that this // view does not already exist in the recycler. This can happen if a caller // mistakenly calls addScrap(view) multiple times for the same view. if (!scrap.contains(v)) { scrap.add(v); } } } public View getTransientStateView(int position) { if (mTransientStateViews == null) { return null; } final View result = mTransientStateViews.get(position); if (result != null) { mTransientStateViews.remove(position); } return result; } public View getScrapView(int type) { final ArrayList<View> scrap = mScrapViews[type]; if (scrap.isEmpty()) { return null; } final int index = scrap.size() - 1; final View result = scrap.remove(index); return result; } // TODO: Implement support to maintain a list of active views so that we can make use of // stable ids to retrieve the same view that is currently laid out for a particular item. // Currently, all views "recycled" are shoved into the same collection, this may not be // the most effective way. Refer to the RecycleBin as implemented for AbsListView. public View getView(int type, long stableId) { final ArrayList<View> scrap = mScrapViews[type]; if (scrap.isEmpty()) { return null; } for (int i = 0; i < scrap.size(); i++) { final View v = scrap.get(i); final LayoutParams lp = (LayoutParams) v.getLayoutParams(); if (lp.id == stableId) { scrap.remove(i); return v; } } return null; } } private class AdapterDataSetObserver extends DataSetObserver { @Override public void onChanged() { mDataChanged = true; mItemCount = mAdapter.getCount(); mFirstChangedPosition = mAdapter.getFirstChangedPosition(); if (mFirstPosition >= mItemCount) { // If the latest data set has fewer data items than mFirstPosition, we will not be // able to accurately restore scroll state, so just reset to the top. mFirstPosition = 0; mCurrentScrollState = null; } // TODO: Consider matching these back up if we have stable IDs. mRecycler.clearTransientViews(); if (mHasStableIds) { // If we will animate the transition to the new layout, cache the current positions // of the visible children. This is before any views get removed below. cacheChildRects(); } else { // Clear all layout records mLayoutRecords.clear(); // Reset item bottoms to be equal to item tops final int colCount = mColCount; for (int i = 0; i < colCount; i++) { mItemBottoms[i] = mItemTops[i]; } } updateEmptyStatus(); // TODO: consider repopulating in a deferred runnable instead // (so that successive changes may still be batched) requestLayout(); } @Override public void onInvalidated() { } } static class SavedState extends BaseSavedState { long firstId = -1; int position; // topOffset is the vertical value that the view specified by position should // start rendering from. If it is 0, the view would be at the top of the grid. int topOffset; SavedState(Parcelable superState) { super(superState); } private SavedState(Parcel in) { super(in); firstId = in.readLong(); position = in.readInt(); topOffset = in.readInt(); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeLong(firstId); out.writeInt(position); out.writeInt(topOffset); } @Override public String toString() { return "StaggereGridView.SavedState{" + Integer.toHexString(System.identityHashCode(this)) + " firstId=" + firstId + " position=" + position + "}"; } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; } public void setDropListener(ReorderListener listener) { mReorderHelper = new ReorderHelper(listener, this); } public void setScrollListener(ScrollListener listener) { mScrollListener = listener; } public void setOnSizeChangedListener(OnSizeChangedListener listener) { mOnSizeChangedListener = listener; } /** * Helper class to store a {@link View} with its corresponding layout positions * as a {@link Rect}. */ private static class ViewRectPair { public final View view; public final Rect rect; public ViewRectPair(View v, Rect r) { view = v; rect = r; } } public static class ScrollState implements Parcelable { private final long mItemId; private final int mAdapterPosition; // The offset that the view specified by mAdapterPosition should start rendering from. If // this value is 0, then the view would be rendered from the very top of this grid. private int mVerticalOffset; public ScrollState(long itemId, int adapterPosition, int offset) { mItemId = itemId; mAdapterPosition = adapterPosition; mVerticalOffset = offset; } private ScrollState(Parcel in) { mItemId = in.readLong(); mAdapterPosition = in.readInt(); mVerticalOffset = in.readInt(); } public long getItemId() { return mItemId; } public int getAdapterPosition() { return mAdapterPosition; } public void setVerticalOffset(int offset) { mVerticalOffset = offset; } public int getVerticalOffset() { return mVerticalOffset; } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeLong(mItemId); dest.writeInt(mAdapterPosition); dest.writeInt(mVerticalOffset); } public static final Parcelable.Creator<ScrollState> CREATOR = new Parcelable.Creator<ScrollState>() { @Override public ScrollState createFromParcel(Parcel source) { return new ScrollState(source); } @Override public ScrollState[] newArray(int size) { return new ScrollState[size]; } }; @Override public String toString() { return "ScrollState {mItemId=" + mItemId + " mAdapterPosition=" + mAdapterPosition + " mVerticalOffset=" + mVerticalOffset + "}"; } } /** * Listener of {@Link StaggeredGridView} for grid size change. */ public interface OnSizeChangedListener { void onSizeChanged(int width, int height, int oldWidth, int oldHeight); } /** * Listener of {@Link StaggeredGridView} for scroll change. */ public interface ScrollListener { /** * Called when scroll happens on this view. * * @param offset The scroll offset amount. * @param currentScrollY The current y position of this view. * @param maxScrollY The maximum amount of scroll possible in this view. */ void onScrollChanged(int offset, int currentScrollY, int maxScrollY); } /** * Listener of {@link StaggeredGridView} for animations. This listener is responsible * for playing all animations created by this {@link StaggeredGridView} */ public interface AnimationListener { /** * Called when animations are ready to be played * @param animationMode The current animation mode based on the state of the data. Valid * animation modes are {@link ANIMATION_MODE_NONE}, {@link ANIMATION_MODE_NEW_DATA}, and * {@link ANIMATION_MODE_UPDATE_DATA}. * @param animators The list of animators to be played */ void onAnimationReady(int animationMode, List<Animator> animators); } /** * Listener of {@link StaggeredGridView} for drag and drop reordering of child views. */ public interface ReorderListener { /** * onPickedUp is called to notify listeners that an item has been picked up for reordering. * @param draggedChild the original child view that picked up. */ void onPickedUp(View draggedChild); /** * onDrop is called to notify listeners that an intent to drop the * item at position "from" over the position "target" * @param draggedView the original child view that was dropped * @param sourcePosition the original position where the item was dragged from * @param targetPosition the target position where the item is dropped at */ void onDrop(View draggedView, int sourcePosition, int targetPosition); /** * onCancelDrag is called to notify listeners that the drag event has been cancelled. * @param draggediew the original child view that was dragged. */ void onCancelDrag(View draggediew); /** * onReorder is called to notify listeners that an intent to move the * item at position "from" to position "to" * @param draggedView the original child view that was dragged * @param id id of the original item that was picked up * @param from * @param to the target position where the item is dropped at */ boolean onReorder(View draggedView, long id, int from, int to); /** * Event handler for a drag entering the {@link StaggeredGridView} element's * reordering area. * @param view The child view that just received an enter event on the reordering area. * @param position The adapter position of the view that just received an enter event. */ void onEnterReorderArea(View view, int position); } }