• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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