
com.alexvasilkov.foldablelayout.FoldableListLayout Maven / Gradle / Ivy
package com.alexvasilkov.foldablelayout;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.database.DataSetObserver;
import android.graphics.Canvas;
import android.os.Handler;
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.Adapter;
import android.widget.BaseAdapter;
import android.widget.FrameLayout;
import com.alexvasilkov.foldablelayout.shading.FoldShading;
import com.alexvasilkov.foldablelayout.shading.SimpleFoldShading;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;
/**
* Foldable items list layout.
*
* 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 float MIN_FLING_VELOCITY = 600;
private static final float DEFAULT_SCROLL_FACTOR = 1.33f;
private static final LayoutParams PARAMS = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
private static final int MAX_CHILDREN_COUNT = 3;
private OnFoldRotationListener mFoldRotationListener;
private BaseAdapter mAdapter;
private float mFoldRotation;
private float mMinRotation, mMaxRotation;
private FoldableItemLayout mBackLayout, mFrontLayout;
private FoldShading mFoldShading;
private final SparseArray mFoldableItemsMap = new SparseArray<>();
private final Queue mFoldableItemsCache = new LinkedList<>();
private final SparseArray> mRecycledViews = new SparseArray<>();
private final Map mViewsTypesMap = new HashMap<>();
private boolean mIsGesturesEnabled = true;
private ObjectAnimator mAnimator;
private long mLastTouchEventTime;
private int mLastTouchEventAction;
private boolean mLastTouchEventResult;
private GestureDetector mGestureDetector;
private FlingAnimation mFlingAnimation;
private float mMinDistanceBeforeScroll;
private boolean mIsScrollDetected;
private float mScrollFactor = DEFAULT_SCROLL_FACTOR;
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);
mGestureDetector.setIsLongpressEnabled(false);
mAnimator = ObjectAnimator.ofFloat(this, "foldRotation", 0);
mMinDistanceBeforeScroll = ViewConfiguration.get(context).getScaledPagingTouchSlop();
mFlingAnimation = new FlingAnimation();
mFoldShading = new SimpleFoldShading();
setChildrenDrawingOrderEnabled(true);
}
@Override
protected void dispatchDraw(Canvas canvas) {
// We want manually draw only selected children
if (mBackLayout != null) mBackLayout.draw(canvas);
if (mFrontLayout != null) mFrontLayout.draw(canvas);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
super.dispatchTouchEvent(ev);
return getCount() > 0; // No touches for underlying views if we have items
}
@Override
protected int getChildDrawingOrder(int childCount, int i) {
if (mFrontLayout == null) return i; // Default order
// We need to return front view as last item for correct touches handling
int front = indexOfChild(mFrontLayout);
return i == childCount - 1 ? front : (i >= front ? i + 1 : i);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
// Listening for events but propagates them to children if no own gestures are detected
return mIsGesturesEnabled && 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 mIsGesturesEnabled && 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;
}
/**
* Set whether gestures are enabled or not. Useful when layout content is scrollable.
*/
public void setGesturesEnabled(boolean isGesturesEnabled) {
mIsGesturesEnabled = isGesturesEnabled;
}
/**
* Internal parameter. Defines scroll velocity when user scrolls list.
*/
protected void setScrollFactor(float scrollFactor) {
mScrollFactor = scrollFactor;
}
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
mRecycledViews.clear();
mViewsTypesMap.clear();
// recalculating items
setFoldRotation(mFoldRotation);
}
public final void setFoldRotation(float rotation) {
setFoldRotation(rotation, false);
}
protected void setFoldRotation(float rotation, boolean isFromUser) {
if (isFromUser) {
mAnimator.cancel();
mFlingAnimation.stop();
}
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) {
mBackLayout = secondLayout;
mFrontLayout = firstLayout;
} else {
mBackLayout = firstLayout;
mFrontLayout = secondLayout;
}
if (mFoldRotationListener != null)
mFoldRotationListener.onFoldRotation(rotation, isFromUser);
// When hardware acceleration is enabled view may not be invalidated and redrawn,
// but we need it to properly draw animation
invalidate();
}
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 = mFoldableItemsMap.get(position);
if (layout != null) return layout; // we already have bound layout
// trying to free used layout (far enough from currently requested)
int farthestItem = position;
int size = mFoldableItemsMap.size();
for (int i = 0; i < size; i++) {
int pos = mFoldableItemsMap.keyAt(i);
if (Math.abs(position - pos) > Math.abs(position - farthestItem)) {
farthestItem = pos;
}
}
if (Math.abs(farthestItem - position) >= MAX_CHILDREN_COUNT) {
layout = mFoldableItemsMap.get(farthestItem);
mFoldableItemsMap.remove(farthestItem);
recycleAdapterView(layout);
}
if (layout == null) {
// trying to find cached layout
layout = mFoldableItemsCache.poll();
}
if (layout == null) {
// if still no suited layout - create it
layout = new FoldableItemLayout(getContext());
layout.setFoldShading(mFoldShading);
addView(layout, PARAMS);
}
setupAdapterView(layout, position);
mFoldableItemsMap.put(position, layout);
return layout;
}
private View setupAdapterView(FoldableItemLayout layout, int position) {
// binding layout to new data
int type = mAdapter.getItemViewType(position);
View recycledView = null;
if (type != Adapter.IGNORE_ITEM_VIEW_TYPE) {
Queue cache = mRecycledViews.get(type);
recycledView = cache == null ? null : cache.poll();
}
View view = mAdapter.getView(position, recycledView, layout.getBaseLayout());
if (type != Adapter.IGNORE_ITEM_VIEW_TYPE) {
mViewsTypesMap.put(view, type);
}
layout.getBaseLayout().addView(view, PARAMS);
return view;
}
private void recycleAdapterView(FoldableItemLayout layout) {
if (layout.getBaseLayout().getChildCount() == 0) return; // Nothing to recycle
View view = layout.getBaseLayout().getChildAt(0);
layout.getBaseLayout().removeAllViews();
Integer type = mViewsTypesMap.remove(view);
if (type != null) {
Queue cache = mRecycledViews.get(type);
if (cache == null) mRecycledViews.put(type, cache = new LinkedList<>());
cache.offer(view);
}
}
private void freeAllLayouts() {
int size = mFoldableItemsMap.size();
for (int i = 0; i < size; i++) {
FoldableItemLayout layout = mFoldableItemsMap.valueAt(i);
layout.getBaseLayout().removeAllViews(); // clearing old data
mFoldableItemsCache.offer(layout);
}
mFoldableItemsMap.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);
mFlingAnimation.stop();
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();
int action = event.getActionMasked();
if (mLastTouchEventTime == eventTime && mLastTouchEventAction == action)
return mLastTouchEventResult;
mLastTouchEventTime = eventTime;
mLastTouchEventAction = action;
if (getCount() > 0) {
// Fixing event's Y position due to performed translation
MotionEvent eventCopy = MotionEvent.obtain(event);
eventCopy.offsetLocation(0, getTranslationY());
mLastTouchEventResult = mGestureDetector.onTouchEvent(eventCopy);
eventCopy.recycle();
} else {
mLastTouchEventResult = false;
}
if (action == MotionEvent.ACTION_UP) {
if (!mFlingAnimation.isAnimating()) scrollToNearestPosition();
}
return mLastTouchEventResult;
}
@Override
public boolean onDown(MotionEvent event) {
mIsScrollDetected = false;
mAnimator.cancel();
mFlingAnimation.stop();
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();
int h = getHeight();
if (!mIsScrollDetected && Math.abs(distance) > mMinDistanceBeforeScroll && h != 0) {
mIsScrollDetected = true;
mScrollStartRotation = getFoldRotation();
mScrollStartDistance = distance;
}
if (mIsScrollDetected) {
float rotation = 180f * mScrollFactor * (distance - mScrollStartDistance) / h;
setFoldRotation(mScrollStartRotation + rotation, true);
}
return mIsScrollDetected;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
int h = getHeight();
if (h == 0) return false;
float velocity = -velocityY / h * 180f;
velocity = Math.max(MIN_FLING_VELOCITY, Math.abs(velocity)) * Math.signum(velocity);
return mFlingAnimation.fling(velocity);
}
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);
}
private class FlingAnimation implements Runnable {
private final Handler mHandler = new Handler();
private boolean mIsAnimating;
private long mLastTime;
private float mVelocity;
private float mMin, mMax;
@Override
public void run() {
long now = System.currentTimeMillis();
float delta = mVelocity / 1000f * (now - mLastTime);
mLastTime = now;
float rotation = getFoldRotation();
rotation = Math.max(mMin, Math.min(rotation + delta, mMax));
setFoldRotation(rotation);
if (rotation != mMin && rotation != mMax) {
startInternal();
} else {
stop();
}
}
private void startInternal() {
mHandler.removeCallbacks(this);
mHandler.postDelayed(this, 10); // small delay is required (sometimes runnable can be called immediately)
mIsAnimating = true;
}
void stop() {
mHandler.removeCallbacks(this);
mIsAnimating = false;
}
boolean isAnimating() {
return mIsAnimating;
}
boolean fling(float velocity) {
float rotation = getFoldRotation();
if (rotation % 180 == 0) return false;
int position = (int) (rotation / 180f);
mLastTime = System.currentTimeMillis();
mVelocity = velocity;
mMin = position * 180f;
mMax = mMin + 180f;
startInternal();
return true;
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy