/* * Copyright (C) 2014 Lucas Rocha * * 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.marshalchen.common.uimodule.twowayview; import android.content.Context; import android.content.res.TypedArray; import android.graphics.PointF; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.support.v7.widget.LinearSmoothScroller; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.Adapter; import android.support.v7.widget.RecyclerView.LayoutManager; import android.support.v7.widget.RecyclerView.LayoutParams; import android.support.v7.widget.RecyclerView.Recycler; import android.support.v7.widget.RecyclerView.State; import android.support.v7.widget.RecyclerView.ViewHolder; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup.MarginLayoutParams; import com.marshalchen.common.uimodule.R; import java.util.List; public abstract class TwoWayLayoutManager extends LayoutManager { private static final String LOGTAG = "TwoWayLayoutManager"; public static enum Orientation { HORIZONTAL, VERTICAL } public static enum Direction { START, END } private RecyclerView mRecyclerView; private boolean mIsVertical = true; private SavedState mPendingSavedState = null; private int mPendingScrollPosition = RecyclerView.NO_POSITION; private int mPendingScrollOffset = 0; private int mLayoutStart; private int mLayoutEnd; public TwoWayLayoutManager(Context context, AttributeSet attrs) { this(context, attrs, 0); } public TwoWayLayoutManager(Context context, AttributeSet attrs, int defStyle) { final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.twowayview_TwoWayLayoutManager, defStyle, 0); final int indexCount = a.getIndexCount(); for (int i = 0; i < indexCount; i++) { final int attr = a.getIndex(i); if (attr == R.styleable.twowayview_TwoWayLayoutManager_android_orientation) { final int orientation = a.getInt(attr, -1); if (orientation >= 0) { setOrientation(Orientation.values()[orientation]); } } } a.recycle(); } public TwoWayLayoutManager(Orientation orientation) { mIsVertical = (orientation == Orientation.VERTICAL); } private int getTotalSpace() { if (mIsVertical) { return getHeight() - getPaddingBottom() - getPaddingTop(); } else { return getWidth() - getPaddingRight() - getPaddingLeft(); } } protected int getStartWithPadding() { return (mIsVertical ? getPaddingTop() : getPaddingLeft()); } protected int getEndWithPadding() { if (mIsVertical) { return (getHeight() - getPaddingBottom()); } else { return (getWidth() - getPaddingRight()); } } protected int getChildStart(View child) { return (mIsVertical ? getDecoratedTop(child) : getDecoratedLeft(child)); } protected int getChildEnd(View child) { return (mIsVertical ? getDecoratedBottom(child) : getDecoratedRight(child)); } protected Adapter getAdapter() { return (mRecyclerView != null ? mRecyclerView.getAdapter() : null); } private void offsetChildren(int offset) { if (mIsVertical) { offsetChildrenVertical(offset); } else { offsetChildrenHorizontal(offset); } mLayoutStart += offset; mLayoutEnd += offset; } private void recycleChildrenOutOfBounds(Direction direction, Recycler recycler) { if (direction == Direction.END) { recycleChildrenFromStart(direction, recycler); } else { recycleChildrenFromEnd(direction, recycler); } } private void recycleChildrenFromStart(Direction direction, Recycler recycler) { final int childCount = getChildCount(); final int childrenStart = getStartWithPadding(); int detachedCount = 0; for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); final int childEnd = getChildEnd(child); if (childEnd >= childrenStart) { break; } detachedCount++; detachChild(child, direction); } while (--detachedCount >= 0) { final View child = getChildAt(0); removeAndRecycleView(child, recycler); updateLayoutEdgesFromRemovedChild(child, direction); } } private void recycleChildrenFromEnd(Direction direction, Recycler recycler) { final int childrenEnd = getEndWithPadding(); final int childCount = getChildCount(); int firstDetachedPos = 0; int detachedCount = 0; for (int i = childCount - 1; i >= 0; i--) { final View child = getChildAt(i); final int childStart = getChildStart(child); if (childStart <= childrenEnd) { break; } firstDetachedPos = i; detachedCount++; detachChild(child, direction); } while (--detachedCount >= 0) { final View child = getChildAt(firstDetachedPos); removeAndRecycleViewAt(firstDetachedPos, recycler); updateLayoutEdgesFromRemovedChild(child, direction); } } private int scrollBy(int delta, Recycler recycler, State state) { final int childCount = getChildCount(); if (childCount == 0 || delta == 0) { return 0; } final int start = getStartWithPadding(); final int end = getEndWithPadding(); final int firstPosition = getFirstVisiblePosition(); final int totalSpace = getTotalSpace(); if (delta < 0) { delta = Math.max(-(totalSpace - 1), delta); } else { delta = Math.min(totalSpace - 1, delta); } final boolean cannotScrollBackward = (firstPosition == 0 && mLayoutStart >= start && delta <= 0); final boolean cannotScrollForward = (firstPosition + childCount == state.getItemCount() && mLayoutEnd <= end && delta >= 0); if (cannotScrollForward || cannotScrollBackward) { return 0; } offsetChildren(-delta); final Direction direction = (delta > 0 ? Direction.END : Direction.START); recycleChildrenOutOfBounds(direction, recycler); final int absDelta = Math.abs(delta); if (canAddMoreViews(Direction.START, start - absDelta) || canAddMoreViews(Direction.END, end + absDelta)) { fillGap(direction, recycler, state); } return delta; } private void fillGap(Direction direction, Recycler recycler, State state) { final int childCount = getChildCount(); final int extraSpace = getExtraLayoutSpace(state); final int firstPosition = getFirstVisiblePosition(); if (direction == Direction.END) { fillAfter(firstPosition + childCount, recycler, state, extraSpace); correctTooHigh(childCount, recycler, state); } else { fillBefore(firstPosition - 1, recycler, extraSpace); correctTooLow(childCount, recycler, state); } } private void fillBefore(int pos, Recycler recycler) { fillBefore(pos, recycler, 0); } private void fillBefore(int position, Recycler recycler, int extraSpace) { final int limit = getStartWithPadding() - extraSpace; while (canAddMoreViews(Direction.START, limit) && position >= 0) { makeAndAddView(position, Direction.START, recycler); position--; } } private void fillAfter(int pos, Recycler recycler, State state) { fillAfter(pos, recycler, state, 0); } private void fillAfter(int position, Recycler recycler, State state, int extraSpace) { final int limit = getEndWithPadding() + extraSpace; final int itemCount = state.getItemCount(); while (canAddMoreViews(Direction.END, limit) && position < itemCount) { makeAndAddView(position, Direction.END, recycler); position++; } } private void fillSpecific(int position, Recycler recycler, State state) { if (state.getItemCount() == 0) { return; } makeAndAddView(position, Direction.END, recycler); final int extraSpaceBefore; final int extraSpaceAfter; final int extraSpace = getExtraLayoutSpace(state); if (state.getTargetScrollPosition() < position) { extraSpaceAfter = 0; extraSpaceBefore = extraSpace; } else { extraSpaceAfter = extraSpace; extraSpaceBefore = 0; } fillBefore(position - 1, recycler, extraSpaceBefore); // This will correct for the top of the first view not // touching the top of the parent. adjustViewsStartOrEnd(); fillAfter(position + 1, recycler, state, extraSpaceAfter); correctTooHigh(getChildCount(), recycler, state); } private void correctTooHigh(int childCount, Recycler recycler, State state) { // First see if the last item is visible. If it is not, it is OK for the // top of the list to be pushed up. final int lastPosition = getLastVisiblePosition(); if (lastPosition != state.getItemCount() - 1 || childCount == 0) { return; } // This is bottom of our drawable area. final int start = getStartWithPadding(); final int end = getEndWithPadding(); final int firstPosition = getFirstVisiblePosition(); // This is how far the end edge of the last view is from the end of the // drawable area. int endOffset = end - mLayoutEnd; // Make sure we are 1) Too high, and 2) Either there are more rows above the // first row or the first row is scrolled off the top of the drawable area if (endOffset > 0 && (firstPosition > 0 || mLayoutStart < start)) { if (firstPosition == 0) { // Don't pull the top too far down. endOffset = Math.min(endOffset, start - mLayoutStart); } // Move everything down offsetChildren(endOffset); if (firstPosition > 0) { // Fill the gap that was opened above first position with more // children, if possible. fillBefore(firstPosition - 1, recycler); // Close up the remaining gap. adjustViewsStartOrEnd(); } } } private void correctTooLow(int childCount, Recycler recycler, State state) { // First see if the first item is visible. If it is not, it is OK for the // end of the list to be pushed forward. final int firstPosition = getFirstVisiblePosition(); if (firstPosition != 0 || childCount == 0) { return; } final int start = getStartWithPadding(); final int end = getEndWithPadding(); final int itemCount = state.getItemCount(); final int lastPosition = getLastVisiblePosition(); // This is how far the start edge of the first view is from the start of the // drawable area. int startOffset = mLayoutStart - start; // Make sure we are 1) Too low, and 2) Either there are more columns/rows below the // last column/row or the last column/row is scrolled off the end of the // drawable area. if (startOffset > 0) { if (lastPosition < itemCount - 1 || mLayoutEnd > end) { if (lastPosition == itemCount - 1) { // Don't pull the bottom too far up. startOffset = Math.min(startOffset, mLayoutEnd - end); } // Move everything up. offsetChildren(-startOffset); if (lastPosition < itemCount - 1) { // Fill the gap that was opened below the last position with more // children, if possible. fillAfter(lastPosition + 1, recycler, state); // Close up the remaining gap. adjustViewsStartOrEnd(); } } else if (lastPosition == itemCount - 1) { adjustViewsStartOrEnd(); } } } private void adjustViewsStartOrEnd() { if (getChildCount() == 0) { return; } int delta = mLayoutStart - getStartWithPadding(); if (delta < 0) { // We only are looking to see if we are too low, not too high delta = 0; } if (delta != 0) { offsetChildren(-delta); } } private static View findNextScrapView(List<ViewHolder> scrapList, Direction direction, int position) { final int scrapCount = scrapList.size(); ViewHolder closest = null; int closestDistance = Integer.MAX_VALUE; for (int i = 0; i < scrapCount; i++) { final ViewHolder holder = scrapList.get(i); final int distance = holder.getPosition() - position; if ((distance < 0 && direction == Direction.END) || (distance > 0 && direction == Direction.START)) { continue; } final int absDistance = Math.abs(distance); if (absDistance < closestDistance) { closest = holder; closestDistance = absDistance; if (distance == 0) { break; } } } if (closest != null) { return closest.itemView; } return null; } private void fillFromScrapList(List<ViewHolder> scrapList, Direction direction) { final int firstPosition = getFirstVisiblePosition(); int position; if (direction == Direction.END) { position = firstPosition + getChildCount(); } else { position = firstPosition - 1; } View scrapChild; while ((scrapChild = findNextScrapView(scrapList, direction, position)) != null) { setupChild(scrapChild, direction); position += (direction == Direction.END ? 1 : -1); } } private void setupChild(View child, Direction direction) { final ItemSelectionSupport itemSelection = ItemSelectionSupport.from(mRecyclerView); if (itemSelection != null) { final int position = getPosition(child); itemSelection.setViewChecked(child, itemSelection.isItemChecked(position)); } measureChild(child, direction); layoutChild(child, direction); } private View makeAndAddView(int position, Direction direction, Recycler recycler) { final View child = recycler.getViewForPosition(position); final boolean isItemRemoved = ((LayoutParams) child.getLayoutParams()).isItemRemoved(); if (!isItemRemoved) { addView(child, (direction == Direction.END ? -1 : 0)); } setupChild(child, direction); if (!isItemRemoved) { updateLayoutEdgesFromNewChild(child); } return child; } private void handleUpdate() { // Refresh state by requesting layout without changing the // first visible position. This will ensure the layout will // sync with the adapter changes. final int firstPosition = getFirstVisiblePosition(); final View firstChild = findViewByPosition(firstPosition); if (firstChild != null) { setPendingScrollPositionWithOffset(firstPosition, getChildStart(firstChild)); } else { setPendingScrollPositionWithOffset(RecyclerView.NO_POSITION, 0); } } private void updateLayoutEdgesFromNewChild(View newChild) { final int childStart = getChildStart(newChild); if (childStart < mLayoutStart) { mLayoutStart = childStart; } final int childEnd = getChildEnd(newChild); if (childEnd > mLayoutEnd) { mLayoutEnd = childEnd; } } private void updateLayoutEdgesFromRemovedChild(View removedChild, Direction direction) { final int childCount = getChildCount(); if (childCount == 0) { resetLayoutEdges(); return; } final int removedChildStart = getChildStart(removedChild); final int removedChildEnd = getChildEnd(removedChild); if (removedChildStart > mLayoutStart && removedChildEnd < mLayoutEnd) { return; } int index; final int limit; if (direction == Direction.END) { // Scrolling towards the end of the layout, child view being // removed from the start. mLayoutStart = Integer.MAX_VALUE; index = 0; limit = removedChildEnd; } else { // Scrolling towards the start of the layout, child view being // removed from the end. mLayoutEnd = Integer.MIN_VALUE; index = childCount - 1; limit = removedChildStart; } while (index >= 0 && index <= childCount - 1) { final View child = getChildAt(index); if (direction == Direction.END) { final int childStart = getChildStart(child); if (childStart < mLayoutStart) { mLayoutStart = childStart; } // Checked enough child views to update the minimum // layout start edge, stop. if (childStart >= limit) { break; } index++; } else { final int childEnd = getChildEnd(child); if (childEnd > mLayoutEnd) { mLayoutEnd = childEnd; } // Checked enough child views to update the minimum // layout end edge, stop. if (childEnd <= limit) { break; } index--; } } } private void resetLayoutEdges() { mLayoutStart = getStartWithPadding(); mLayoutEnd = mLayoutStart; } protected int getExtraLayoutSpace(State state) { if (state.hasTargetScrollPosition()) { return getTotalSpace(); } else { return 0; } } private Bundle getPendingItemSelectionState() { if (mPendingSavedState != null) { return mPendingSavedState.itemSelectionState; } return null; } protected void setPendingScrollPositionWithOffset(int position, int offset) { mPendingScrollPosition = position; mPendingScrollOffset = offset; } protected int getPendingScrollPosition() { if (mPendingSavedState != null) { return mPendingSavedState.anchorItemPosition; } return mPendingScrollPosition; } protected int getPendingScrollOffset() { if (mPendingSavedState != null) { return 0; } return mPendingScrollOffset; } protected int getAnchorItemPosition(State state) { final int itemCount = state.getItemCount(); int pendingPosition = getPendingScrollPosition(); if (pendingPosition != RecyclerView.NO_POSITION) { if (pendingPosition < 0 || pendingPosition >= itemCount) { pendingPosition = RecyclerView.NO_POSITION; } } if (pendingPosition != RecyclerView.NO_POSITION) { return pendingPosition; } else if (getChildCount() > 0) { return findFirstValidChildPosition(itemCount); } else { return 0; } } private int findFirstValidChildPosition(int itemCount) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View view = getChildAt(i); final int position = getPosition(view); if (position >= 0 && position < itemCount) { return position; } } return 0; } @Override public int getDecoratedMeasuredWidth(View child) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); return super.getDecoratedMeasuredWidth(child) + lp.leftMargin + lp.rightMargin; } @Override public int getDecoratedMeasuredHeight(View child) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); return super.getDecoratedMeasuredHeight(child) + lp.topMargin + lp.bottomMargin; } @Override public int getDecoratedLeft(View child) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); return super.getDecoratedLeft(child) - lp.leftMargin; } @Override public int getDecoratedTop(View child) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); return super.getDecoratedTop(child) - lp.topMargin; } @Override public int getDecoratedRight(View child) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); return super.getDecoratedRight(child) + lp.rightMargin; } @Override public int getDecoratedBottom(View child) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); return super.getDecoratedBottom(child) + lp.bottomMargin; } @Override public void layoutDecorated(View child, int left, int top, int right, int bottom) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); super.layoutDecorated(child, left + lp.leftMargin, top + lp.topMargin, right - lp.rightMargin, bottom - lp.bottomMargin); } @Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); mRecyclerView = view; } @Override public void onDetachedFromWindow(RecyclerView view, Recycler recycler) { super.onDetachedFromWindow(view, recycler); mRecyclerView = null; } @Override public void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter) { super.onAdapterChanged(oldAdapter, newAdapter); final ItemSelectionSupport itemSelectionSupport = ItemSelectionSupport.from(mRecyclerView); if (oldAdapter != null && itemSelectionSupport != null) { itemSelectionSupport.clearChoices(); } } @Override public void onLayoutChildren(Recycler recycler, State state) { final ItemSelectionSupport itemSelection = ItemSelectionSupport.from(mRecyclerView); if (itemSelection != null) { final Bundle itemSelectionState = getPendingItemSelectionState(); if (itemSelectionState != null) { itemSelection.onRestoreInstanceState(itemSelectionState); } if (state.didStructureChange()) { itemSelection.onAdapterDataChanged(); } } final int anchorItemPosition = getAnchorItemPosition(state); detachAndScrapAttachedViews(recycler); fillSpecific(anchorItemPosition, recycler, state); onLayoutScrapList(recycler, state); setPendingScrollPositionWithOffset(RecyclerView.NO_POSITION, 0); mPendingSavedState = null; } protected void onLayoutScrapList(Recycler recycler, State state) { final int childCount = getChildCount(); if (childCount == 0 || state.isPreLayout() || !supportsPredictiveItemAnimations()) { return; } final List<ViewHolder> scrapList = recycler.getScrapList(); fillFromScrapList(scrapList, Direction.START); fillFromScrapList(scrapList, Direction.END); } protected void detachChild(View child, Direction direction) { // Do nothing by default. } @Override public void onItemsAdded(RecyclerView recyclerView, int positionStart, int itemCount) { handleUpdate(); } @Override public void onItemsRemoved(RecyclerView recyclerView, int positionStart, int itemCount) { handleUpdate(); } @Override public void onItemsUpdated(RecyclerView recyclerView, int positionStart, int itemCount) { handleUpdate(); } @Override public void onItemsMoved(RecyclerView recyclerView, int from, int to, int itemCount) { handleUpdate(); } @Override public void onItemsChanged(RecyclerView recyclerView) { handleUpdate(); } @Override public LayoutParams generateDefaultLayoutParams() { if (mIsVertical) { return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } else { return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); } } @Override public boolean supportsPredictiveItemAnimations() { return true; } @Override public int scrollHorizontallyBy(int dx, Recycler recycler, State state) { if (mIsVertical) { return 0; } return scrollBy(dx, recycler, state); } @Override public int scrollVerticallyBy(int dy, Recycler recycler, State state) { if (!mIsVertical) { return 0; } return scrollBy(dy, recycler, state); } @Override public boolean canScrollHorizontally() { return !mIsVertical; } @Override public boolean canScrollVertically() { return mIsVertical; } @Override public void scrollToPosition(int position) { scrollToPositionWithOffset(position, 0); } public void scrollToPositionWithOffset(int position, int offset) { setPendingScrollPositionWithOffset(position, offset); requestLayout(); } @Override public void smoothScrollToPosition(RecyclerView recyclerView, State state, int position) { final LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) { @Override public PointF computeScrollVectorForPosition(int targetPosition) { if (getChildCount() == 0) { return null; } final int direction = targetPosition < getFirstVisiblePosition() ? -1 : 1; if (mIsVertical) { return new PointF(0, direction); } else { return new PointF(direction, 0); } } @Override protected int getVerticalSnapPreference() { return LinearSmoothScroller.SNAP_TO_START; } @Override protected int getHorizontalSnapPreference() { return LinearSmoothScroller.SNAP_TO_START; } }; scroller.setTargetPosition(position); startSmoothScroll(scroller); } @Override public int computeHorizontalScrollOffset(State state) { if (getChildCount() == 0) { return 0; } return getFirstVisiblePosition(); } @Override public int computeVerticalScrollOffset(State state) { if (getChildCount() == 0) { return 0; } return getFirstVisiblePosition(); } @Override public int computeHorizontalScrollExtent(State state) { return getChildCount(); } @Override public int computeVerticalScrollExtent(State state) { return getChildCount(); } @Override public int computeHorizontalScrollRange(State state) { return state.getItemCount(); } @Override public int computeVerticalScrollRange(State state) { return state.getItemCount(); } @Override public void onMeasure(Recycler recycler, State state, int widthSpec, int heightSpec) { super.onMeasure(recycler, state, widthSpec, heightSpec); } @Override public Parcelable onSaveInstanceState() { final SavedState state = new SavedState(SavedState.EMPTY_STATE); int anchorItemPosition = getPendingScrollPosition(); if (anchorItemPosition == RecyclerView.NO_POSITION) { anchorItemPosition = getFirstVisiblePosition(); } state.anchorItemPosition = anchorItemPosition; final ItemSelectionSupport itemSelection = ItemSelectionSupport.from(mRecyclerView); if (itemSelection != null) { state.itemSelectionState = itemSelection.onSaveInstanceState(); } else { state.itemSelectionState = Bundle.EMPTY; } return state; } @Override public void onRestoreInstanceState(Parcelable state) { mPendingSavedState = (SavedState) state; requestLayout(); } public Orientation getOrientation() { return (mIsVertical ? Orientation.VERTICAL : Orientation.HORIZONTAL); } public void setOrientation(Orientation orientation) { final boolean isVertical = (orientation == Orientation.VERTICAL); if (this.mIsVertical == isVertical) { return; } this.mIsVertical = isVertical; requestLayout(); } public int getFirstVisiblePosition() { if (getChildCount() == 0) { return 0; } return getPosition(getChildAt(0)); } public int getLastVisiblePosition() { final int childCount = getChildCount(); if (childCount == 0) { return 0; } return getPosition(getChildAt(childCount - 1)); } protected abstract void measureChild(View child, Direction direction); protected abstract void layoutChild(View child, Direction direction); protected abstract boolean canAddMoreViews(Direction direction, int limit); protected static class SavedState implements Parcelable { protected static final SavedState EMPTY_STATE = new SavedState(); private final Parcelable superState; private int anchorItemPosition; private Bundle itemSelectionState; private SavedState() { superState = null; } protected SavedState(Parcelable superState) { if (superState == null) { throw new IllegalArgumentException("superState must not be null"); } this.superState = (superState != EMPTY_STATE ? superState : null); } protected SavedState(Parcel in) { this.superState = EMPTY_STATE; anchorItemPosition = in.readInt(); itemSelectionState = in.readParcelable(((Object) this).getClass().getClassLoader()); } public Parcelable getSuperState() { return superState; } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel out, int flags) { out.writeInt(anchorItemPosition); out.writeParcelable(itemSelectionState, flags); } public static final Creator<SavedState> CREATOR = new Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; } }