Carousel.java

/*
 * Copyright (C) 2020 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 androidx.constraintlayout.helper.widget;

import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;

import androidx.annotation.RequiresApi;
import androidx.constraintlayout.motion.widget.MotionHelper;
import androidx.constraintlayout.motion.widget.MotionLayout;
import androidx.constraintlayout.motion.widget.MotionScene;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.R;

import java.util.ArrayList;

/**
 * Carousel works within a MotionLayout to provide a simple recycler like pattern.
 * Based on a series of Transitions and callback to give you the ability to swap views.
 */
public class Carousel extends MotionHelper {
    private static final boolean DEBUG = false;
    private static final String TAG = "Carousel";
    private Adapter mAdapter = null;
    private final ArrayList<View> mList = new ArrayList<>();
    private int mPreviousIndex = 0;
    private int mIndex = 0;
    private MotionLayout mMotionLayout;
    private int firstViewReference = -1;
    private boolean infiniteCarousel = false;
    private int backwardTransition = -1;
    private int forwardTransition = -1;
    private int previousState = -1;
    private int nextState = -1;
    private float dampening = 0.9f;
    private int startIndex = 0;
    private int emptyViewBehavior = INVISIBLE;

    public static final int TOUCH_UP_IMMEDIATE_STOP = 1;
    public static final int TOUCH_UP_CARRY_ON = 2;

    private int touchUpMode = TOUCH_UP_IMMEDIATE_STOP;
    private float velocityThreshold = 2f;
    private int mTargetIndex = -1;
    private int mAnimateTargetDelay = 200;

    /**
     * Adapter for a Carousel
     */
    public interface Adapter {
        /**
         * Number of items you want to display in the Carousel
         * @return number of items
         */
        int count();

        /**
         * Callback to populate the view for the given index
         *
         * @param view
         * @param index
         */
        void populate(View view, int index);

        /**
         * Callback when we reach a new index
         * @param index
         */
        void onNewItem(int index);
    }

    public Carousel(Context context) {
        super(context);
    }

    public Carousel(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs);
    }

    public Carousel(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, AttributeSet attrs) {
        if (attrs != null) {
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Carousel);
            final int N = a.getIndexCount();
            for (int i = 0; i < N; i++) {
                int attr = a.getIndex(i);
                if (attr == R.styleable.Carousel_carousel_firstView) {
                    firstViewReference = a.getResourceId(attr, firstViewReference);
                } else if (attr == R.styleable.Carousel_carousel_backwardTransition) {
                    backwardTransition = a.getResourceId(attr, backwardTransition);
                } else if (attr == R.styleable.Carousel_carousel_forwardTransition) {
                    forwardTransition = a.getResourceId(attr, forwardTransition);
                } else if (attr == R.styleable.Carousel_carousel_emptyViewsBehavior) {
                    emptyViewBehavior = a.getInt(attr, emptyViewBehavior);
                } else if (attr == R.styleable.Carousel_carousel_previousState) {
                    previousState = a.getResourceId(attr, previousState);
                } else if (attr == R.styleable.Carousel_carousel_nextState) {
                    nextState = a.getResourceId(attr, nextState);
                } else if (attr == R.styleable.Carousel_carousel_touchUp_dampeningFactor) {
                    dampening = a.getFloat(attr, dampening);
                } else if (attr == R.styleable.Carousel_carousel_touchUpMode) {
                    touchUpMode = a.getInt(attr, touchUpMode);
                } else if (attr == R.styleable.Carousel_carousel_touchUp_velocityThreshold) {
                    velocityThreshold = a.getFloat(attr, velocityThreshold);
                } else if (attr == R.styleable.Carousel_carousel_infinite) {
                    infiniteCarousel = a.getBoolean(attr, infiniteCarousel);
                }
            }
            a.recycle();
        }
    }

    public void setAdapter(Adapter adapter) {
        mAdapter = adapter;
    }

    /**
     * Returns the number of elements in the Carousel
     *
     * @return number of elements
     */
    public int getCount() {
        if (mAdapter != null) {
            return mAdapter.count();
        }
        return 0;
    }

    /**
     * Returns the current index
     *
     * @return current index
     */
    public int getCurrentIndex() {
        return mIndex;
    }

    /**
     * Transition the carousel to the given index, animating until we reach it.
     *
     * @param index index of the element we want to reach
     * @param delay animation duration for each individual transition to the next item, in ms
     */
    public void transitionToIndex(int index, int delay) {
        mTargetIndex = Math.max(0, Math.min(getCount() - 1, index));
        mAnimateTargetDelay = Math.max(0, delay);
        mMotionLayout.setTransitionDuration(mAnimateTargetDelay);
        if (index < mIndex) {
            mMotionLayout.transitionToState(previousState, mAnimateTargetDelay);
        } else {
            mMotionLayout.transitionToState(nextState, mAnimateTargetDelay);
        }
    }

    /**
     * Jump to the given index without any animation
     *
     * @param index index of the element we want to reach
     */
    public void jumpToIndex(int index) {
        mIndex = Math.max(0, Math.min(getCount() - 1, index));
        refresh();
    }

    public void refresh() {
        final int count = mList.size();
        for (int i = 0; i < count; i++) {
            View view = mList.get(i);
            if (mAdapter.count() == 0) {
                updateViewVisibility(view, emptyViewBehavior);
            } else {
                updateViewVisibility(view, VISIBLE);
            }
        }
        mMotionLayout.rebuildScene();
        updateItems();
    }

    @Override
    public void onTransitionChange(MotionLayout motionLayout, int startId, int endId, float progress) {
        if (DEBUG) {
            System.out.println("onTransitionChange from " + startId + " to " + endId + " progress " + progress);
        }
        mLastStartId = startId;
    }

    int mLastStartId = -1;

    @Override
    public void onTransitionCompleted(MotionLayout motionLayout, int currentId) {
        mPreviousIndex = mIndex;
        if (currentId == nextState) {
            mIndex++;
        } else if (currentId == previousState) {
            mIndex--;
        }
        if (infiniteCarousel) {
            if (mIndex >= mAdapter.count()) {
                mIndex = 0;
            }
            if (mIndex < 0) {
                mIndex = mAdapter.count() - 1;
            }
        } else {
            if (mIndex >= mAdapter.count()) {
                mIndex = mAdapter.count() - 1;
            }
            if (mIndex < 0) {
                mIndex = 0;
            }
        }

        if (mPreviousIndex != mIndex) {
            mMotionLayout.post(mUpdateRunnable);
        }
    }

    private void enableAllTransitions(boolean enable) {
        ArrayList<MotionScene.Transition> transitions = mMotionLayout.getDefinedTransitions();
        for (MotionScene.Transition transition : transitions) {
            transition.setEnabled(enable);
        }
    }

    private boolean enableTransition(int transitionID, boolean enable) {
        if (transitionID == -1) {
            return false;
        }
        if (mMotionLayout == null) {
            return false;
        }
        MotionScene.Transition transition = mMotionLayout.getTransition(transitionID);
        if (transition == null) {
            return false;
        }
        if (enable == transition.isEnabled()) {
            return false;
        }
        transition.setEnabled(enable);
        return true;
    }

    Runnable mUpdateRunnable = new Runnable() {
        @Override
        public void run() {
            mMotionLayout.setProgress(0);
            updateItems();
            mAdapter.onNewItem(mIndex);
            float velocity = mMotionLayout.getVelocity();
            if (touchUpMode == TOUCH_UP_CARRY_ON && velocity > velocityThreshold && mIndex < mAdapter.count() - 1) {
                final float v = velocity * dampening;
                if (mIndex == 0 && mPreviousIndex > mIndex) {
                    // don't touch animate when reaching the first item
                    return;
                }
                if (mIndex == mAdapter.count() - 1 && mPreviousIndex < mIndex) {
                    // don't touch animate when reaching the last item
                    return;
                }
                mMotionLayout.post(new Runnable() {
                    @Override
                    public void run() {
                        mMotionLayout.touchAnimateTo(MotionLayout.TOUCH_UP_DECELERATE_AND_COMPLETE, 1, v);
                    }
                });
            }
        }
    };

    @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1)
    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        MotionLayout container = null;
        if (getParent() instanceof MotionLayout) {
            container = (MotionLayout) getParent();
        } else {
            return;
        }
        for (int i = 0; i < mCount; i++) {
            int id = mIds[i];
            View view = container.getViewById(id);
            if (firstViewReference == id) {
                startIndex = i;
            }
            mList.add(view);
        }
        mMotionLayout = container;
        // set up transitions if needed
        if (touchUpMode == TOUCH_UP_CARRY_ON) {
            MotionScene.Transition forward = mMotionLayout.getTransition(forwardTransition);
            if (forward != null) {
                forward.setOnTouchUp(MotionLayout.TOUCH_UP_DECELERATE_AND_COMPLETE);
            }
            MotionScene.Transition backward = mMotionLayout.getTransition(backwardTransition);
            if (backward != null) {
                backward.setOnTouchUp(MotionLayout.TOUCH_UP_DECELERATE_AND_COMPLETE);
            }
        }
        updateItems();
    }

    /**
     * Update the view visibility on the different ConstraintSets
     *
     * @param view
     * @param visibility
     * @return
     */
    private boolean updateViewVisibility(View view, int visibility) {
        if (mMotionLayout == null) {
            return false;
        }
        boolean needsMotionSceneRebuild = false;
        int[] constraintSets = mMotionLayout.getConstraintSetIds();
        for (int i = 0; i < constraintSets.length; i++) {
            needsMotionSceneRebuild |= updateViewVisibility(constraintSets[i], view, visibility);
        }
        return needsMotionSceneRebuild;
    }

    private boolean updateViewVisibility(int constraintSetId, View view, int visibility) {
        ConstraintSet constraintSet = mMotionLayout.getConstraintSet(constraintSetId);
        if (constraintSet == null) {
            return false;
        }
        ConstraintSet.Constraint constraint = constraintSet.getConstraint(view.getId());
        if (constraint == null) {
            return false;
        }
        constraint.propertySet.mVisibilityMode = ConstraintSet.VISIBILITY_MODE_IGNORE;
//        if (constraint.propertySet.visibility == visibility) {
//            return false;
//        }
//        constraint.propertySet.visibility = visibility;
        view.setVisibility(visibility);
        return true;
    }

    private void updateItems() {
        if (mAdapter == null) {
            return;
        }
        if (mMotionLayout == null) {
            return;
        }
        if (mAdapter.count() == 0) {
            return;
        }
        if (DEBUG) {
            System.out.println("Update items, index: " + mIndex);
        }
        final int viewCount = mList.size();
        for (int i = 0; i < viewCount; i++) {
            // mIndex should map to i == startIndex
            View view = mList.get(i);
            int index = mIndex + i - startIndex;
            if (infiniteCarousel) {
                if (index < 0) {
                    if (emptyViewBehavior != View.INVISIBLE) {
                        updateViewVisibility(view, emptyViewBehavior);
                    } else {
                        updateViewVisibility(view, VISIBLE);
                    }
                    if (index % mAdapter.count() == 0) {
                        mAdapter.populate(view, 0);
                    } else {
                        mAdapter.populate(view, mAdapter.count() + (index % mAdapter.count()));
                    }
                } else if (index >= mAdapter.count()) {
                    if (index == mAdapter.count()) {
                        index = 0;
                    } else if (index > mAdapter.count()) {
                        index = index % mAdapter.count();
                    }
                    if (emptyViewBehavior != View.INVISIBLE) {
                        updateViewVisibility(view, emptyViewBehavior);
                    } else {
                        updateViewVisibility(view, VISIBLE);
                    }
                    mAdapter.populate(view, index);
                } else {
                    updateViewVisibility(view, VISIBLE);
                    mAdapter.populate(view, index);
                }
            } else {
                if (index < 0) {
                    updateViewVisibility(view, emptyViewBehavior);
                } else if (index >= mAdapter.count()) {
                    updateViewVisibility(view, emptyViewBehavior);
                } else {
                    updateViewVisibility(view, VISIBLE);
                    mAdapter.populate(view, index);
                }
            }
        }

        if (mTargetIndex != -1 && mTargetIndex != mIndex) {
            mMotionLayout.post(() -> {
                mMotionLayout.setTransitionDuration(mAnimateTargetDelay);
                if (mTargetIndex < mIndex) {
                    mMotionLayout.transitionToState(previousState, mAnimateTargetDelay);
                } else {
                    mMotionLayout.transitionToState(nextState, mAnimateTargetDelay);
                }
            });
        } else if (mTargetIndex == mIndex) {
            mTargetIndex = -1;
        }

        if (backwardTransition == -1 || forwardTransition == -1) {
            Log.w(TAG, "No backward or forward transitions defined for Carousel!");
            return;
        }

        if (infiniteCarousel) {
            return;
        }

        final int count = mAdapter.count();
        if (mIndex == 0) {
            enableTransition(backwardTransition, false);
        } else {
            enableTransition(backwardTransition, true);
            mMotionLayout.setTransition(backwardTransition);
        }
        if (mIndex == count - 1) {
            enableTransition(forwardTransition, false);
        } else {
            enableTransition(forwardTransition, true);
            mMotionLayout.setTransition(forwardTransition);
        }
    }

}