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