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