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