1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.constraintlayout.helper.widget;
18 
19 import android.content.Context;
20 import android.content.res.TypedArray;
21 import android.util.AttributeSet;
22 import android.util.Log;
23 import android.view.View;
24 
25 import androidx.constraintlayout.motion.widget.MotionHelper;
26 import androidx.constraintlayout.motion.widget.MotionLayout;
27 import androidx.constraintlayout.motion.widget.MotionScene;
28 import androidx.constraintlayout.widget.ConstraintSet;
29 import androidx.constraintlayout.widget.R;
30 
31 import java.util.ArrayList;
32 
33 /**
34  * Carousel works within a MotionLayout to provide a simple recycler like pattern.
35  * Based on a series of Transitions and callback to give you the ability to swap views.
36  */
37 public class Carousel extends MotionHelper {
38     private static final boolean DEBUG = false;
39     private static final String TAG = "Carousel";
40     private Adapter mAdapter = null;
41     private final ArrayList<View> mList = new ArrayList<>();
42     private int mPreviousIndex = 0;
43     private int mIndex = 0;
44     private MotionLayout mMotionLayout;
45     private int mFirstViewReference = -1;
46     private boolean mInfiniteCarousel = false;
47     private int mBackwardTransition = -1;
48     private int mForwardTransition = -1;
49     private int mPreviousState = -1;
50     private int mNextState = -1;
51     private float mDampening = 0.9f;
52     private int mStartIndex = 0;
53     private int mEmptyViewBehavior = INVISIBLE;
54 
55     public static final int TOUCH_UP_IMMEDIATE_STOP = 1;
56     public static final int TOUCH_UP_CARRY_ON = 2;
57 
58     private int mTouchUpMode = TOUCH_UP_IMMEDIATE_STOP;
59     private float mVelocityThreshold = 2f;
60     private int mTargetIndex = -1;
61     private int mAnimateTargetDelay = 200;
62 
63     /**
64      * Adapter for a Carousel
65      */
66     public interface Adapter {
67         /**
68          * Number of items you want to display in the Carousel
69          * @return number of items
70          */
count()71         int count();
72 
73         /**
74          * Callback to populate the view for the given index
75          *
76          * @param view
77          * @param index
78          */
populate(View view, int index)79         void populate(View view, int index);
80 
81         /**
82          * Callback when we reach a new index
83          * @param index
84          */
onNewItem(int index)85         void onNewItem(int index);
86     }
87 
Carousel(Context context)88     public Carousel(Context context) {
89         super(context);
90     }
91 
Carousel(Context context, AttributeSet attrs)92     public Carousel(Context context, AttributeSet attrs) {
93         super(context, attrs);
94         init(context, attrs);
95     }
96 
Carousel(Context context, AttributeSet attrs, int defStyleAttr)97     public Carousel(Context context, AttributeSet attrs, int defStyleAttr) {
98         super(context, attrs, defStyleAttr);
99         init(context, attrs);
100     }
101 
init(Context context, AttributeSet attrs)102     private void init(Context context, AttributeSet attrs) {
103         if (attrs != null) {
104             TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Carousel);
105             final int n = a.getIndexCount();
106             for (int i = 0; i < n; i++) {
107                 int attr = a.getIndex(i);
108                 if (attr == R.styleable.Carousel_carousel_firstView) {
109                     mFirstViewReference = a.getResourceId(attr, mFirstViewReference);
110                 } else if (attr == R.styleable.Carousel_carousel_backwardTransition) {
111                     mBackwardTransition = a.getResourceId(attr, mBackwardTransition);
112                 } else if (attr == R.styleable.Carousel_carousel_forwardTransition) {
113                     mForwardTransition = a.getResourceId(attr, mForwardTransition);
114                 } else if (attr == R.styleable.Carousel_carousel_emptyViewsBehavior) {
115                     mEmptyViewBehavior = a.getInt(attr, mEmptyViewBehavior);
116                 } else if (attr == R.styleable.Carousel_carousel_previousState) {
117                     mPreviousState = a.getResourceId(attr, mPreviousState);
118                 } else if (attr == R.styleable.Carousel_carousel_nextState) {
119                     mNextState = a.getResourceId(attr, mNextState);
120                 } else if (attr == R.styleable.Carousel_carousel_touchUp_dampeningFactor) {
121                     mDampening = a.getFloat(attr, mDampening);
122                 } else if (attr == R.styleable.Carousel_carousel_touchUpMode) {
123                     mTouchUpMode = a.getInt(attr, mTouchUpMode);
124                 } else if (attr == R.styleable.Carousel_carousel_touchUp_velocityThreshold) {
125                     mVelocityThreshold = a.getFloat(attr, mVelocityThreshold);
126                 } else if (attr == R.styleable.Carousel_carousel_infinite) {
127                     mInfiniteCarousel = a.getBoolean(attr, mInfiniteCarousel);
128                 }
129             }
130             a.recycle();
131         }
132     }
133 
setAdapter(Adapter adapter)134     public void setAdapter(Adapter adapter) {
135         mAdapter = adapter;
136     }
137 
138     /**
139      * A setter method for whether it should be a infinite Carousel.
140      * Remember to call {@link #refresh} after calling this method.
141      *
142      * @param infiniteCarousel true if it should be a infinite Carousel, otherwise, false
143      */
setInfinite(boolean infiniteCarousel)144     public void setInfinite(boolean infiniteCarousel) {
145         this.mInfiniteCarousel = infiniteCarousel;
146     }
147 
148     /**
149      * Returns whether it's a infinite Carousel
150      *
151      * @return true if it's a infinite Carousel, otherwise, false.
152      */
isInfinite()153     public boolean isInfinite() {
154         return this.mInfiniteCarousel;
155     }
156 
157     /**
158      * Returns the number of elements in the Carousel
159      *
160      * @return number of elements
161      */
getCount()162     public int getCount() {
163         if (mAdapter != null) {
164             return mAdapter.count();
165         }
166         return 0;
167     }
168 
169     /**
170      * Returns the current index
171      *
172      * @return current index
173      */
getCurrentIndex()174     public int getCurrentIndex() {
175         return mIndex;
176     }
177 
178     /**
179      * Transition the carousel to the given index, animating until we reach it.
180      *
181      * @param index index of the element we want to reach
182      * @param delay animation duration for each individual transition to the next item, in ms
183      */
transitionToIndex(int index, int delay)184     public void transitionToIndex(int index, int delay) {
185         mTargetIndex = Math.max(0, Math.min(getCount() - 1, index));
186         mAnimateTargetDelay = Math.max(0, delay);
187         mMotionLayout.setTransitionDuration(mAnimateTargetDelay);
188         if (index < mIndex) {
189             mMotionLayout.transitionToState(mPreviousState, mAnimateTargetDelay);
190         } else {
191             mMotionLayout.transitionToState(mNextState, mAnimateTargetDelay);
192         }
193     }
194 
195     /**
196      * Jump to the given index without any animation
197      *
198      * @param index index of the element we want to reach
199      */
jumpToIndex(int index)200     public void jumpToIndex(int index) {
201         mIndex = Math.max(0, Math.min(getCount() - 1, index));
202         refresh();
203     }
204 
205     /**
206      * Rebuilds the scene
207      */
refresh()208     public void refresh() {
209         final int count = mList.size();
210         for (int i = 0; i < count; i++) {
211             View view = mList.get(i);
212             if (mAdapter.count() == 0) {
213                 updateViewVisibility(view, mEmptyViewBehavior);
214             } else {
215                 updateViewVisibility(view, VISIBLE);
216             }
217         }
218         mMotionLayout.rebuildScene();
219         updateItems();
220     }
221 
222     @Override
onTransitionChange(MotionLayout motionLayout, int startId, int endId, float progress)223     public void onTransitionChange(MotionLayout motionLayout,
224                                    int startId,
225                                    int endId,
226                                    float progress) {
227         if (DEBUG) {
228             System.out.println("onTransitionChange from " + startId
229                     + " to " + endId + " progress " + progress);
230         }
231         mLastStartId = startId;
232     }
233 
234     int mLastStartId = -1;
235 
236     @Override
onTransitionCompleted(MotionLayout motionLayout, int currentId)237     public void onTransitionCompleted(MotionLayout motionLayout, int currentId) {
238         mPreviousIndex = mIndex;
239         if (currentId == mNextState) {
240             mIndex++;
241         } else if (currentId == mPreviousState) {
242             mIndex--;
243         }
244         if (mInfiniteCarousel) {
245             if (mIndex >= mAdapter.count()) {
246                 mIndex = 0;
247             }
248             if (mIndex < 0) {
249                 mIndex = mAdapter.count() - 1;
250             }
251         } else {
252             if (mIndex >= mAdapter.count()) {
253                 mIndex = mAdapter.count() - 1;
254             }
255             if (mIndex < 0) {
256                 mIndex = 0;
257             }
258         }
259 
260         if (mPreviousIndex != mIndex) {
261             mMotionLayout.post(mUpdateRunnable);
262         }
263     }
264 
265     @SuppressWarnings("unused")
enableAllTransitions(boolean enable)266     private void enableAllTransitions(boolean enable) {
267         ArrayList<MotionScene.Transition> transitions = mMotionLayout.getDefinedTransitions();
268         for (MotionScene.Transition transition : transitions) {
269             transition.setEnabled(enable);
270         }
271     }
272 
enableTransition(int transitionID, boolean enable)273     private boolean enableTransition(int transitionID, boolean enable) {
274         if (transitionID == -1) {
275             return false;
276         }
277         if (mMotionLayout == null) {
278             return false;
279         }
280         MotionScene.Transition transition = mMotionLayout.getTransition(transitionID);
281         if (transition == null) {
282             return false;
283         }
284         if (enable == transition.isEnabled()) {
285             return false;
286         }
287         transition.setEnabled(enable);
288         return true;
289     }
290 
291     Runnable mUpdateRunnable = new Runnable() {
292         @Override
293         public void run() {
294             mMotionLayout.setProgress(0);
295             updateItems();
296             mAdapter.onNewItem(mIndex);
297             float velocity = mMotionLayout.getVelocity();
298             if (mTouchUpMode == TOUCH_UP_CARRY_ON && velocity > mVelocityThreshold
299                     && mIndex < mAdapter.count() - 1) {
300                 final float v = velocity * mDampening;
301                 if (mIndex == 0 && mPreviousIndex > mIndex) {
302                     // don't touch animate when reaching the first item
303                     return;
304                 }
305                 if (mIndex == mAdapter.count() - 1 && mPreviousIndex < mIndex) {
306                     // don't touch animate when reaching the last item
307                     return;
308                 }
309                 mMotionLayout.post(new Runnable() {
310                     @Override
311                     public void run() {
312                         mMotionLayout.touchAnimateTo(MotionLayout.TOUCH_UP_DECELERATE_AND_COMPLETE,
313                                 1, v);
314                     }
315                 });
316             }
317         }
318     };
319 
320     @Override
onDetachedFromWindow()321     protected void onDetachedFromWindow() {
322         super.onDetachedFromWindow();
323         mList.clear();
324     }
325 
326     @Override
onAttachedToWindow()327     protected void onAttachedToWindow() {
328         super.onAttachedToWindow();
329         MotionLayout container = null;
330         if (getParent() instanceof MotionLayout) {
331             container = (MotionLayout) getParent();
332         } else {
333             return;
334         }
335         mList.clear();
336         for (int i = 0; i < mCount; i++) {
337             int id = mIds[i];
338             View view = container.getViewById(id);
339             if (mFirstViewReference == id) {
340                 mStartIndex = i;
341             }
342             mList.add(view);
343         }
344         mMotionLayout = container;
345         // set up transitions if needed
346         if (mTouchUpMode == TOUCH_UP_CARRY_ON) {
347             MotionScene.Transition forward = mMotionLayout.getTransition(mForwardTransition);
348             if (forward != null) {
349                 forward.setOnTouchUp(MotionLayout.TOUCH_UP_DECELERATE_AND_COMPLETE);
350             }
351             MotionScene.Transition backward = mMotionLayout.getTransition(mBackwardTransition);
352             if (backward != null) {
353                 backward.setOnTouchUp(MotionLayout.TOUCH_UP_DECELERATE_AND_COMPLETE);
354             }
355         }
356         updateItems();
357     }
358 
359     /**
360      * Update the view visibility on the different ConstraintSets
361      *
362      * @param view
363      * @param visibility
364      * @return
365      */
updateViewVisibility(View view, int visibility)366     private boolean updateViewVisibility(View view, int visibility) {
367         if (mMotionLayout == null) {
368             return false;
369         }
370         boolean needsMotionSceneRebuild = false;
371         int[] constraintSets = mMotionLayout.getConstraintSetIds();
372         for (int i = 0; i < constraintSets.length; i++) {
373             needsMotionSceneRebuild |= updateViewVisibility(constraintSets[i], view, visibility);
374         }
375         return needsMotionSceneRebuild;
376     }
377 
updateViewVisibility(int constraintSetId, View view, int visibility)378     private boolean updateViewVisibility(int constraintSetId, View view, int visibility) {
379         ConstraintSet constraintSet = mMotionLayout.getConstraintSet(constraintSetId);
380         if (constraintSet == null) {
381             return false;
382         }
383         ConstraintSet.Constraint constraint = constraintSet.getConstraint(view.getId());
384         if (constraint == null) {
385             return false;
386         }
387         constraint.propertySet.mVisibilityMode = ConstraintSet.VISIBILITY_MODE_IGNORE;
388 //        if (constraint.propertySet.visibility == visibility) {
389 //            return false;
390 //        }
391 //        constraint.propertySet.visibility = visibility;
392         view.setVisibility(visibility);
393         return true;
394     }
395 
updateItems()396     private void updateItems() {
397         if (mAdapter == null) {
398             return;
399         }
400         if (mMotionLayout == null) {
401             return;
402         }
403         if (mAdapter.count() == 0) {
404             return;
405         }
406         if (DEBUG) {
407             System.out.println("Update items, index: " + mIndex);
408         }
409         final int viewCount = mList.size();
410         for (int i = 0; i < viewCount; i++) {
411             // mIndex should map to i == startIndex
412             View view = mList.get(i);
413             int index = mIndex + i - mStartIndex;
414             if (mInfiniteCarousel) {
415                 if (index < 0) {
416                     if (mEmptyViewBehavior != View.INVISIBLE) {
417                         updateViewVisibility(view, mEmptyViewBehavior);
418                     } else {
419                         updateViewVisibility(view, VISIBLE);
420                     }
421                     if (index % mAdapter.count() == 0) {
422                         mAdapter.populate(view, 0);
423                     } else {
424                         mAdapter.populate(view, mAdapter.count() + (index % mAdapter.count()));
425                     }
426                 } else if (index >= mAdapter.count()) {
427                     if (index == mAdapter.count()) {
428                         index = 0;
429                     } else if (index > mAdapter.count()) {
430                         index = index % mAdapter.count();
431                     }
432                     if (mEmptyViewBehavior != View.INVISIBLE) {
433                         updateViewVisibility(view, mEmptyViewBehavior);
434                     } else {
435                         updateViewVisibility(view, VISIBLE);
436                     }
437                     mAdapter.populate(view, index);
438                 } else {
439                     updateViewVisibility(view, VISIBLE);
440                     mAdapter.populate(view, index);
441                 }
442             } else {
443                 if (index < 0) {
444                     updateViewVisibility(view, mEmptyViewBehavior);
445                 } else if (index >= mAdapter.count()) {
446                     updateViewVisibility(view, mEmptyViewBehavior);
447                 } else {
448                     updateViewVisibility(view, VISIBLE);
449                     mAdapter.populate(view, index);
450                 }
451             }
452         }
453 
454         if (mTargetIndex != -1 && mTargetIndex != mIndex) {
455             mMotionLayout.post(() -> {
456                 mMotionLayout.setTransitionDuration(mAnimateTargetDelay);
457                 if (mTargetIndex < mIndex) {
458                     mMotionLayout.transitionToState(mPreviousState, mAnimateTargetDelay);
459                 } else {
460                     mMotionLayout.transitionToState(mNextState, mAnimateTargetDelay);
461                 }
462             });
463         } else if (mTargetIndex == mIndex) {
464             mTargetIndex = -1;
465         }
466 
467         if (mBackwardTransition == -1 || mForwardTransition == -1) {
468             Log.w(TAG, "No backward or forward transitions defined for Carousel!");
469             return;
470         }
471 
472         if (mInfiniteCarousel) {
473             return;
474         }
475 
476         final int count = mAdapter.count();
477         if (mIndex == 0) {
478             enableTransition(mBackwardTransition, false);
479         } else {
480             enableTransition(mBackwardTransition, true);
481             mMotionLayout.setTransition(mBackwardTransition);
482         }
483         if (mIndex == count - 1) {
484             enableTransition(mForwardTransition, false);
485         } else {
486             enableTransition(mForwardTransition, true);
487             mMotionLayout.setTransition(mForwardTransition);
488         }
489     }
490 
491 }
492