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