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