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