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