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 com.android.systemui.plugin.globalactions.wallet; 18 19 import android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.content.Context; 22 import android.graphics.Rect; 23 import android.util.AttributeSet; 24 import android.util.DisplayMetrics; 25 import android.util.TypedValue; 26 import android.view.HapticFeedbackConstants; 27 import android.view.LayoutInflater; 28 import android.view.View; 29 import android.view.ViewGroup; 30 import android.view.accessibility.AccessibilityEvent; 31 import android.widget.ImageView; 32 33 import androidx.cardview.widget.CardView; 34 import androidx.core.view.ViewCompat; 35 import androidx.recyclerview.widget.LinearLayoutManager; 36 import androidx.recyclerview.widget.LinearSmoothScroller; 37 import androidx.recyclerview.widget.PagerSnapHelper; 38 import androidx.recyclerview.widget.RecyclerView; 39 import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; 40 41 import java.util.Collections; 42 import java.util.List; 43 44 /** 45 * Card Carousel for displaying Quick Access Wallet cards. 46 */ 47 class WalletCardCarousel extends RecyclerView { 48 49 // A negative card margin is required because card shrinkage pushes the cards too far apart 50 private static final float CARD_MARGIN_RATIO = -.03f; 51 // Size of the unselected card as a ratio to size of selected card. 52 private static final float UNSELECTED_CARD_SCALE = .83f; 53 private static final float CORNER_RADIUS_RATIO = 25f / 700f; 54 private static final float CARD_ASPECT_RATIO = 700f / 440f; 55 static final int CARD_ANIM_ALPHA_DURATION = 100; 56 static final int CARD_ANIM_ALPHA_DELAY = 50; 57 58 private final int mScreenWidth; 59 private final int mCardMarginPx; 60 private final Rect mSystemGestureExclusionZone = new Rect(); 61 private final WalletCardAdapter mWalletCardAdapter; 62 private final int mCardWidthPx; 63 private final int mCardHeightPx; 64 private final float mCornerRadiusPx; 65 private final int mTotalCardWidth; 66 private final float mCardEdgeToCenterDistance; 67 68 private OnSelectionListener mSelectionListener; 69 private OnCardScrollListener mCardScrollListener; 70 // Adapter position of the child that is closest to the center of the recycler view. 71 private int mCenteredAdapterPosition = RecyclerView.NO_POSITION; 72 // Pixel distance, along y-axis, from the center of the recycler view to the nearest child. 73 private float mEdgeToCenterDistance = Float.MAX_VALUE; 74 private float mCardCenterToScreenCenterDistancePx = Float.MAX_VALUE; 75 // When card data is loaded, this many cards should be animated as data is bound to them. 76 private int mNumCardsToAnimate; 77 // When card data is loaded, this is the position of the leftmost card to be animated. 78 private int mCardAnimationStartPosition; 79 // When card data is loaded, the animations may be delayed so that other animations can complete 80 private int mExtraAnimationDelay; 81 82 interface OnSelectionListener { 83 /** 84 * The card was moved to the center, thus selecting it. 85 */ onCardSelected(@onNull WalletCardViewInfo card)86 void onCardSelected(@NonNull WalletCardViewInfo card); 87 88 /** 89 * The card was clicked. 90 */ onCardClicked(@onNull WalletCardViewInfo card)91 void onCardClicked(@NonNull WalletCardViewInfo card); 92 } 93 94 interface OnCardScrollListener { onCardScroll(WalletCardViewInfo centerCard, WalletCardViewInfo nextCard, float percentDistanceFromCenter)95 void onCardScroll(WalletCardViewInfo centerCard, WalletCardViewInfo nextCard, 96 float percentDistanceFromCenter); 97 } 98 WalletCardCarousel(Context context)99 public WalletCardCarousel(Context context) { 100 this(context, null); 101 } 102 WalletCardCarousel(Context context, @Nullable AttributeSet attributeSet)103 public WalletCardCarousel(Context context, @Nullable AttributeSet attributeSet) { 104 super(context, attributeSet); 105 TypedValue outValue = new TypedValue(); 106 getResources().getValue(R.dimen.card_screen_width_ratio, outValue, true); 107 final float cardScreenWidthRatio = outValue.getFloat(); 108 109 setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); 110 DisplayMetrics metrics = getResources().getDisplayMetrics(); 111 mScreenWidth = Math.min(metrics.widthPixels, metrics.heightPixels); 112 mCardWidthPx = Math.round(mScreenWidth * cardScreenWidthRatio); 113 mCardHeightPx = Math.round(mCardWidthPx / CARD_ASPECT_RATIO); 114 mCornerRadiusPx = mCardWidthPx * CORNER_RADIUS_RATIO; 115 mCardMarginPx = Math.round(mScreenWidth * CARD_MARGIN_RATIO); 116 mTotalCardWidth = 117 mCardWidthPx + getResources().getDimensionPixelSize(R.dimen.card_margin) * 2; 118 mCardEdgeToCenterDistance = mTotalCardWidth / 2f; 119 addOnScrollListener(new CardCarouselScrollListener()); 120 new CarouselSnapHelper().attachToRecyclerView(this); 121 mWalletCardAdapter = new WalletCardAdapter(); 122 mWalletCardAdapter.setHasStableIds(true); 123 setAdapter(mWalletCardAdapter); 124 ViewCompat.setAccessibilityDelegate(this, new CardCarouselAccessibilityDelegate(this)); 125 updatePadding(mScreenWidth); 126 } 127 128 @Override onViewAdded(View child)129 public void onViewAdded(View child) { 130 super.onViewAdded(child); 131 LayoutParams layoutParams = (LayoutParams) child.getLayoutParams(); 132 layoutParams.leftMargin = mCardMarginPx; 133 layoutParams.rightMargin = mCardMarginPx; 134 child.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> updateCardView(child)); 135 } 136 137 @Override onLayout(boolean changed, int left, int top, int right, int bottom)138 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 139 super.onLayout(changed, left, top, right, bottom); 140 int width = getWidth(); 141 if (mWalletCardAdapter.getItemCount() > 1) { 142 // Whole carousel is opted out from system gesture. 143 mSystemGestureExclusionZone.set(0, 0, width, getHeight()); 144 setSystemGestureExclusionRects(Collections.singletonList(mSystemGestureExclusionZone)); 145 } 146 if (width != mScreenWidth) { 147 updatePadding(width); 148 } 149 } 150 151 /** 152 * The padding pushes the first and last cards in the list to the center when they are 153 * selected. 154 */ updatePadding(int viewWidth)155 private void updatePadding(int viewWidth) { 156 int paddingHorizontal = (viewWidth - mTotalCardWidth) / 2 - mCardMarginPx; 157 paddingHorizontal = Math.max(0, paddingHorizontal); // just in case 158 setPadding(paddingHorizontal, getPaddingTop(), paddingHorizontal, getPaddingBottom()); 159 160 // re-center selected card after changing padding (if card is selected) 161 if (mWalletCardAdapter != null 162 && mWalletCardAdapter.getItemCount() > 0 163 && mCenteredAdapterPosition != NO_POSITION) { 164 ViewHolder viewHolder = findViewHolderForAdapterPosition(mCenteredAdapterPosition); 165 if (viewHolder != null) { 166 View cardView = viewHolder.itemView; 167 int cardCenter = (cardView.getLeft() + cardView.getRight()) / 2; 168 int viewCenter = (getLeft() + getRight()) / 2; 169 int scrollX = cardCenter - viewCenter; 170 scrollBy(scrollX, 0); 171 } 172 } 173 } 174 setSelectionListener(OnSelectionListener selectionListener)175 void setSelectionListener(OnSelectionListener selectionListener) { 176 mSelectionListener = selectionListener; 177 } 178 setCardScrollListener(OnCardScrollListener scrollListener)179 void setCardScrollListener(OnCardScrollListener scrollListener) { 180 mCardScrollListener = scrollListener; 181 } 182 getCardWidthPx()183 int getCardWidthPx() { 184 return mCardWidthPx; 185 } 186 getCardHeightPx()187 int getCardHeightPx() { 188 return mCardHeightPx; 189 } 190 191 /** 192 * Set card data. Returns true if carousel was empty, indicating that views will be animated 193 */ setData(List<WalletCardViewInfo> data, int selectedIndex)194 boolean setData(List<WalletCardViewInfo> data, int selectedIndex) { 195 boolean wasEmpty = mWalletCardAdapter.getItemCount() == 0; 196 mWalletCardAdapter.setData(data); 197 if (wasEmpty) { 198 scrollToPosition(selectedIndex); 199 mNumCardsToAnimate = numCardsOnScreen(data.size(), selectedIndex); 200 mCardAnimationStartPosition = Math.max(selectedIndex - 1, 0); 201 } 202 WalletCardViewInfo selectedCard = data.get(selectedIndex); 203 mCardScrollListener.onCardScroll(selectedCard, selectedCard, 0); 204 return wasEmpty; 205 } 206 207 @Override scrollToPosition(int position)208 public void scrollToPosition(int position) { 209 super.scrollToPosition(position); 210 mSelectionListener.onCardSelected(mWalletCardAdapter.mData.get(position)); 211 } 212 213 /** 214 * The number of cards shown on screen when one of the cards is position in the center. This is 215 * also the num 216 */ numCardsOnScreen(int numCards, int selectedIndex)217 private static int numCardsOnScreen(int numCards, int selectedIndex) { 218 if (numCards <= 2) { 219 return numCards; 220 } 221 // When there are 3 or more cards, 3 cards will be shown unless the first or last card is 222 // centered on screen. 223 return selectedIndex > 0 && selectedIndex < (numCards - 1) ? 3 : 2; 224 } 225 updateCardView(View view)226 private void updateCardView(View view) { 227 WalletCardViewHolder viewHolder = (WalletCardViewHolder) view.getTag(); 228 CardView cardView = viewHolder.cardView; 229 float center = (float) getWidth() / 2f; 230 float viewCenter = (view.getRight() + view.getLeft()) / 2f; 231 float viewWidth = view.getWidth(); 232 float position = (viewCenter - center) / viewWidth; 233 float scaleFactor = Math.max(UNSELECTED_CARD_SCALE, 1f - Math.abs(position)); 234 235 cardView.setScaleX(scaleFactor); 236 cardView.setScaleY(scaleFactor); 237 238 // a card is the "centered card" until its edge has moved past the center of the recycler 239 // view. note that we also need to factor in the negative margin. 240 // Find the edge that is closer to the center. 241 int edgePosition = 242 viewCenter < center ? view.getRight() + mCardMarginPx 243 : view.getLeft() - mCardMarginPx; 244 245 if (Math.abs(viewCenter - center) < mCardCenterToScreenCenterDistancePx) { 246 int childAdapterPosition = getChildAdapterPosition(view); 247 if (childAdapterPosition == RecyclerView.NO_POSITION) { 248 return; 249 } 250 mCenteredAdapterPosition = getChildAdapterPosition(view); 251 mEdgeToCenterDistance = edgePosition - center; 252 mCardCenterToScreenCenterDistancePx = Math.abs(viewCenter - center); 253 } 254 } 255 256 void setExtraAnimationDelay(int extraAnimationDelay) { 257 mExtraAnimationDelay = extraAnimationDelay; 258 } 259 260 private class CardCarouselScrollListener extends OnScrollListener { 261 262 private int oldState = -1; 263 264 @Override 265 public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { 266 if (newState == RecyclerView.SCROLL_STATE_IDLE && newState != oldState) { 267 performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); 268 } 269 oldState = newState; 270 } 271 272 /** 273 * Callback method to be invoked when the RecyclerView has been scrolled. This will be 274 * called after the scroll has completed. 275 * 276 * <p>This callback will also be called if visible item range changes after a layout 277 * calculation. In that case, dx and dy will be 0. 278 * 279 * @param recyclerView The RecyclerView which scrolled. 280 * @param dx The amount of horizontal scroll. 281 * @param dy The amount of vertical scroll. 282 */ 283 @Override 284 public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { 285 mCenteredAdapterPosition = RecyclerView.NO_POSITION; 286 mEdgeToCenterDistance = Float.MAX_VALUE; 287 mCardCenterToScreenCenterDistancePx = Float.MAX_VALUE; 288 for (int i = 0; i < getChildCount(); i++) { 289 updateCardView(getChildAt(i)); 290 } 291 if (mCenteredAdapterPosition == RecyclerView.NO_POSITION || dx == 0) { 292 return; 293 } 294 295 int nextAdapterPosition = 296 mCenteredAdapterPosition + (mEdgeToCenterDistance > 0 ? 1 : -1); 297 if (nextAdapterPosition < 0 || nextAdapterPosition >= mWalletCardAdapter.mData.size()) { 298 return; 299 } 300 301 // Update the label text based on the currently selected card and the next one 302 WalletCardViewInfo centerCard = mWalletCardAdapter.mData.get(mCenteredAdapterPosition); 303 WalletCardViewInfo nextCard = mWalletCardAdapter.mData.get(nextAdapterPosition); 304 float percentDistanceFromCenter = 305 Math.abs(mEdgeToCenterDistance) / mCardEdgeToCenterDistance; 306 mCardScrollListener.onCardScroll(centerCard, nextCard, percentDistanceFromCenter); 307 } 308 } 309 310 private class CarouselSnapHelper extends PagerSnapHelper { 311 312 private static final float MILLISECONDS_PER_INCH = 200.0F; 313 private static final int MAX_SCROLL_ON_FLING_DURATION = 80; // ms 314 315 @Override 316 public View findSnapView(LayoutManager layoutManager) { 317 View view = super.findSnapView(layoutManager); 318 if (view == null) { 319 // implementation decides not to snap 320 return null; 321 } 322 WalletCardViewHolder viewHolder = (WalletCardViewHolder) view.getTag(); 323 WalletCardViewInfo card = viewHolder.info; 324 mSelectionListener.onCardSelected(card); 325 mCardScrollListener.onCardScroll(card, card, 0); 326 return view; 327 } 328 329 /** 330 * The default SnapScroller is a little sluggish 331 */ 332 @Override 333 protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) { 334 return new LinearSmoothScroller(getContext()) { 335 @Override 336 protected void onTargetFound(View targetView, State state, Action action) { 337 int[] snapDistances = calculateDistanceToFinalSnap(layoutManager, targetView); 338 final int dx = snapDistances[0]; 339 final int dy = snapDistances[1]; 340 final int time = calculateTimeForDeceleration( 341 Math.max(Math.abs(dx), Math.abs(dy))); 342 if (time > 0) { 343 action.update(dx, dy, time, mDecelerateInterpolator); 344 } 345 } 346 347 @Override 348 protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { 349 return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; 350 } 351 352 @Override 353 protected int calculateTimeForScrolling(int dx) { 354 return Math.min(MAX_SCROLL_ON_FLING_DURATION, 355 super.calculateTimeForScrolling(dx)); 356 } 357 }; 358 } 359 } 360 361 private static class WalletCardViewHolder extends ViewHolder { 362 363 private final CardView cardView; 364 private final ImageView imageView; 365 private WalletCardViewInfo info; 366 367 WalletCardViewHolder(View view) { 368 super(view); 369 cardView = view.requireViewById(R.id.card); 370 imageView = cardView.requireViewById(R.id.image); 371 } 372 } 373 374 private class WalletCardAdapter extends Adapter<WalletCardViewHolder> { 375 376 private List<WalletCardViewInfo> mData = Collections.emptyList(); 377 378 @Override 379 public int getItemViewType(int position) { 380 return 0; 381 } 382 383 @NonNull 384 @Override 385 public WalletCardViewHolder onCreateViewHolder( 386 @NonNull ViewGroup parent, int itemViewType) { 387 LayoutInflater inflater = LayoutInflater.from(getContext()); 388 View view = inflater.inflate(R.layout.wallet_card_view, parent, false); 389 WalletCardViewHolder viewHolder = new WalletCardViewHolder(view); 390 CardView cardView = viewHolder.cardView; 391 cardView.setRadius(mCornerRadiusPx); 392 ViewGroup.LayoutParams layoutParams = cardView.getLayoutParams(); 393 layoutParams.width = mCardWidthPx; 394 layoutParams.height = mCardHeightPx; 395 view.setTag(viewHolder); 396 return viewHolder; 397 } 398 399 @Override 400 public void onBindViewHolder(WalletCardViewHolder viewHolder, int position) { 401 WalletCardViewInfo info = mData.get(position); 402 viewHolder.info = info; 403 viewHolder.imageView.setImageDrawable(info.getCardDrawable()); 404 viewHolder.cardView.setContentDescription(info.getContentDescription()); 405 viewHolder.cardView.setOnClickListener( 406 v -> { 407 if (position != mCenteredAdapterPosition) { 408 smoothScrollToPosition(position); 409 } else { 410 mSelectionListener.onCardClicked(info); 411 } 412 }); 413 if (mNumCardsToAnimate > 0 && (position - mCardAnimationStartPosition < 2)) { 414 mNumCardsToAnimate--; 415 // Animation of cards is progressively delayed from left to right in 50ms increments 416 // Additional delay may be added if the empty state view needs to be animated first. 417 int startDelay = (position - mCardAnimationStartPosition) * CARD_ANIM_ALPHA_DELAY 418 + mExtraAnimationDelay; 419 viewHolder.itemView.setAlpha(0f); 420 viewHolder.itemView.animate().alpha(1f) 421 .setStartDelay(Math.max(0, startDelay)) 422 .setDuration(CARD_ANIM_ALPHA_DURATION).start(); 423 } 424 } 425 426 @Override getItemId(int position)427 public long getItemId(int position) { 428 return mData.get(position).getCardId().hashCode(); 429 } 430 431 @Override getItemCount()432 public int getItemCount() { 433 return mData.size(); 434 } 435 setData(List<WalletCardViewInfo> data)436 private void setData(List<WalletCardViewInfo> data) { 437 this.mData = data; 438 notifyDataSetChanged(); 439 } 440 } 441 442 private class CardCarouselAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { 443 CardCarouselAccessibilityDelegate(@onNull RecyclerView recyclerView)444 private CardCarouselAccessibilityDelegate(@NonNull RecyclerView recyclerView) { 445 super(recyclerView); 446 } 447 448 @Override onRequestSendAccessibilityEvent( ViewGroup viewGroup, View view, AccessibilityEvent accessibilityEvent)449 public boolean onRequestSendAccessibilityEvent( 450 ViewGroup viewGroup, View view, AccessibilityEvent accessibilityEvent) { 451 int eventType = accessibilityEvent.getEventType(); 452 if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) { 453 scrollToPosition(getChildAdapterPosition(view)); 454 } 455 return super.onRequestSendAccessibilityEvent(viewGroup, view, accessibilityEvent); 456 } 457 } 458 } 459