package com.marshalchen.common.uimodule.foldablelayout; import android.content.Context; import android.database.DataSetObserver; import android.graphics.Canvas; import android.util.AttributeSet; import android.util.SparseArray; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.BaseAdapter; import android.widget.FrameLayout; import com.marshalchen.common.uimodule.foldablelayout.shading.FoldShading; import com.marshalchen.common.uimodule.foldablelayout.shading.SimpleFoldShading; import com.marshalchen.common.uimodule.nineoldandroids.animation.ObjectAnimator; import java.util.LinkedList; import java.util.Queue; /** * Foldable items list layout. * <p/> * It wraps views created by given BaseAdapter into FoldableItemLayouts and provides functionality to scroll * among them. */ public class FoldableListLayout extends FrameLayout implements GestureDetector.OnGestureListener { private static final long ANIMATION_DURATION_PER_ITEM = 600; private static final LayoutParams PARAMS = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); private static final int CACHED_LAYOUTS_OFFSET = 2; private OnFoldRotationListener mFoldRotationListener; private BaseAdapter mAdapter; private float mFoldRotation; private float mMinRotation, mMaxRotation; private FoldableItemLayout mFirstLayout, mSecondLayout; private FoldShading mFoldShading; private SparseArray<FoldableItemLayout> mFoldableLayoutsMap = new SparseArray<FoldableItemLayout>(); private Queue<FoldableItemLayout> mFoldableLayoutsCache = new LinkedList<FoldableItemLayout>(); private ObjectAnimator mAnimator; private long mLastEventTime; private boolean mLastEventResult; private GestureDetector mGestureDetector; private float mMinDistanceBeforeScroll; private boolean mIsScrollDetected; private float mScrollStartRotation; private float mScrollStartDistance; public FoldableListLayout(Context context) { super(context); init(context); } public FoldableListLayout(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public FoldableListLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } private void init(Context context) { mGestureDetector = new GestureDetector(context, this); mAnimator = ObjectAnimator.ofFloat(this, "foldRotation", 0); mMinDistanceBeforeScroll = ViewConfiguration.get(context).getScaledPagingTouchSlop(); mFoldShading = new SimpleFoldShading(); } @Override protected void dispatchDraw(Canvas canvas) { // We want manually draw only selected children if (mFirstLayout != null) mFirstLayout.draw(canvas); if (mSecondLayout != null) mSecondLayout.draw(canvas); } @Override public boolean dispatchTouchEvent(MotionEvent ev) { super.dispatchTouchEvent(ev); return getCount() > 0; // No touches for underlying views if we have items } @Override public boolean onInterceptTouchEvent(MotionEvent event) { // Listening for events but propogates them to children if no own gestures are detected return processTouch(event); } @Override public boolean onTouchEvent(MotionEvent event) { // We will be here if no children wants to handle current touches or if own gesture is detected return processTouch(event); } public void setOnFoldRotationListener(OnFoldRotationListener listener) { mFoldRotationListener = listener; } /** * Setting shading to use during fold rotation. Should be called before {@link #setAdapter(android.widget.BaseAdapter)} */ public void setFoldShading(FoldShading shading) { mFoldShading = shading; } public void setAdapter(BaseAdapter adapter) { if (mAdapter != null) mAdapter.unregisterDataSetObserver(mDataObserver); mAdapter = adapter; if (mAdapter != null) mAdapter.registerDataSetObserver(mDataObserver); updateAdapterData(); } public BaseAdapter getAdapter() { return mAdapter; } public int getCount() { return mAdapter == null ? 0 : mAdapter.getCount(); } private void updateAdapterData() { int size = getCount(); mMinRotation = 0; mMaxRotation = size == 0 ? 0 : 180 * (size - 1); freeAllLayouts(); // clearing old bindings // recalculating items setFoldRotation(mFoldRotation); } public final void setFoldRotation(float rotation) { setFoldRotation(rotation, false); } protected void setFoldRotation(float rotation, boolean isFromUser) { if (isFromUser) mAnimator.cancel(); rotation = Math.min(Math.max(mMinRotation, rotation), mMaxRotation); mFoldRotation = rotation; int firstVisiblePosition = (int) (rotation / 180); float localRotation = rotation % 180; int size = getCount(); boolean isHasFirst = firstVisiblePosition < size; boolean isHasSecond = firstVisiblePosition + 1 < size; FoldableItemLayout firstLayout = isHasFirst ? getLayoutForItem(firstVisiblePosition) : null; FoldableItemLayout secondLayout = isHasSecond ? getLayoutForItem(firstVisiblePosition + 1) : null; if (isHasFirst) { firstLayout.setFoldRotation(localRotation); onFoldRotationChanged(firstLayout, firstVisiblePosition); } if (isHasSecond) { secondLayout.setFoldRotation(localRotation - 180); onFoldRotationChanged(secondLayout, firstVisiblePosition + 1); } boolean isReversedOrder = localRotation <= 90; if (isReversedOrder) { mFirstLayout = secondLayout; mSecondLayout = firstLayout; } else { mFirstLayout = firstLayout; mSecondLayout = secondLayout; } if (mFoldRotationListener != null) mFoldRotationListener.onFoldRotation(rotation, isFromUser); invalidate(); // when hardware acceleration is enabled view may not be invalidated and redrawn, but we need it } protected void onFoldRotationChanged(FoldableItemLayout layout, int position) { // Subclasses can apply their transformations here } public float getFoldRotation() { return mFoldRotation; } private FoldableItemLayout getLayoutForItem(int position) { FoldableItemLayout layout = mFoldableLayoutsMap.get(position); if (layout != null) return layout; // we already have bound layout // trying to find cached layout layout = mFoldableLayoutsCache.poll(); if (layout == null) { // trying to free used layout (far enough from currently requested) int farthestItem = position; int size = mFoldableLayoutsMap.size(); for (int i = 0; i < size; i++) { int pos = mFoldableLayoutsMap.keyAt(i); if (Math.abs(position - pos) > Math.abs(position - farthestItem)) { farthestItem = pos; } } if (Math.abs(farthestItem - position) > CACHED_LAYOUTS_OFFSET) { layout = mFoldableLayoutsMap.get(farthestItem); mFoldableLayoutsMap.remove(farthestItem); layout.getBaseLayout().removeAllViews(); // clearing old data } } if (layout == null) { // if still no suited layout - create it layout = new FoldableItemLayout(getContext()); layout.setFoldShading(mFoldShading); addView(layout, PARAMS); } // binding layout to new data View view = mAdapter.getView(position, null, layout.getBaseLayout()); // TODO: use recycler layout.getBaseLayout().addView(view, PARAMS); mFoldableLayoutsMap.put(position, layout); return layout; } private void freeAllLayouts() { int size = mFoldableLayoutsMap.size(); for (int i = 0; i < size; i++) { FoldableItemLayout layout = mFoldableLayoutsMap.valueAt(i); layout.getBaseLayout().removeAllViews(); mFoldableLayoutsCache.offer(layout); } mFoldableLayoutsMap.clear(); } public void scrollToPosition(int index) { index = Math.max(0, Math.min(index, getCount() - 1)); float rotation = index * 180f; float current = getFoldRotation(); long duration = (long) Math.abs(ANIMATION_DURATION_PER_ITEM * (rotation - current) / 180f); mAnimator.cancel(); mAnimator.setFloatValues(current, rotation); mAnimator.setDuration(duration).start(); } protected void scrollToNearestPosition() { float current = getFoldRotation(); scrollToPosition((int) ((current + 90f) / 180f)); } private boolean processTouch(MotionEvent event) { // Checking if that event was already processed (by onInterceptTouchEvent prior to onTouchEvent) long eventTime = event.getEventTime(); if (mLastEventTime == eventTime) return mLastEventResult; mLastEventTime = eventTime; if (event.getActionMasked() == MotionEvent.ACTION_UP && mIsScrollDetected) { mIsScrollDetected = false; scrollToNearestPosition(); } if (getCount() > 0) { // Fixing event's Y position due to performed translation MotionEvent eventCopy = MotionEvent.obtain(event); eventCopy.offsetLocation(0, getTranslationY()); mLastEventResult = mGestureDetector.onTouchEvent(eventCopy); eventCopy.recycle(); } else { mLastEventResult = false; } return mLastEventResult; } @Override public boolean onDown(MotionEvent event) { return false; } @Override public void onShowPress(MotionEvent event) { // NO-OP } @Override public boolean onSingleTapUp(MotionEvent event) { return false; } @Override public void onLongPress(MotionEvent event) { // NO-OP } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { float distance = e1.getY() - e2.getY(); if (!mIsScrollDetected && Math.abs(distance) > mMinDistanceBeforeScroll) { mIsScrollDetected = true; mScrollStartRotation = getFoldRotation(); mScrollStartDistance = distance; } if (mIsScrollDetected) { float rotation = (2 * (distance - mScrollStartDistance) / getHeight()) * 180f; setFoldRotation(mScrollStartRotation + rotation, true); } return mIsScrollDetected; } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { float rotation = getFoldRotation(); if (rotation % 180 == 0) return false; int position = (int) (rotation / 180f); scrollToPosition(velocityY > 0 ? position : position + 1); return true; } private DataSetObserver mDataObserver = new DataSetObserver() { @Override public void onChanged() { super.onChanged(); updateAdapterData(); } @Override public void onInvalidated() { super.onInvalidated(); updateAdapterData(); } }; public interface OnFoldRotationListener { void onFoldRotation(float rotation, boolean isFromUser); } }