| /* |
| * Copyright (C) 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.wallet.ui; |
| |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.graphics.Rect; |
| import android.util.AttributeSet; |
| import android.util.DisplayMetrics; |
| import android.view.HapticFeedbackConstants; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.widget.ImageView; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.cardview.widget.CardView; |
| import androidx.core.view.ViewCompat; |
| import androidx.recyclerview.widget.LinearLayoutManager; |
| import androidx.recyclerview.widget.LinearSmoothScroller; |
| import androidx.recyclerview.widget.PagerSnapHelper; |
| import androidx.recyclerview.widget.RecyclerView; |
| import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; |
| |
| import com.android.systemui.R; |
| |
| import java.util.Collections; |
| import java.util.List; |
| |
| /** |
| * Card Carousel for displaying Quick Access Wallet cards. |
| */ |
| public class WalletCardCarousel extends RecyclerView { |
| |
| // A negative card margin is required because card shrinkage pushes the cards too far apart |
| private static final float CARD_MARGIN_RATIO = -.03f; |
| // Size of the unselected card as a ratio to size of selected card. |
| private static final float UNSELECTED_CARD_SCALE = .83f; |
| private static final float CORNER_RADIUS_RATIO = 25f / 700f; |
| private static final float CARD_ASPECT_RATIO = 700f / 440f; |
| private static final float CARD_VIEW_WIDTH_RATIO = 0.69f; |
| |
| |
| static final int CARD_ANIM_ALPHA_DURATION = 100; |
| static final int CARD_ANIM_ALPHA_DELAY = 50; |
| |
| private final Rect mSystemGestureExclusionZone = new Rect(); |
| private final WalletCardCarouselAdapter mWalletCardCarouselAdapter; |
| private int mExpectedViewWidth; |
| private int mCardMarginPx; |
| private int mCardWidthPx; |
| private int mCardHeightPx; |
| private float mCornerRadiusPx; |
| private int mTotalCardWidth; |
| private float mCardEdgeToCenterDistance; |
| |
| private OnSelectionListener mSelectionListener; |
| private OnCardScrollListener mCardScrollListener; |
| // Adapter position of the child that is closest to the center of the recycler view, will also |
| // be used in DotIndicatorDecoration. |
| int mCenteredAdapterPosition = RecyclerView.NO_POSITION; |
| // Pixel distance, along y-axis, from the center of the recycler view to the nearest child, will |
| // also be used in DotIndicatorDecoration. |
| float mEdgeToCenterDistance = Float.MAX_VALUE; |
| private float mCardCenterToScreenCenterDistancePx = Float.MAX_VALUE; |
| |
| interface OnSelectionListener { |
| /** |
| * The card was moved to the center, thus selecting it. |
| */ |
| void onCardSelected(@NonNull WalletCardViewInfo card); |
| |
| /** |
| * The card was clicked. |
| */ |
| void onCardClicked(@NonNull WalletCardViewInfo card); |
| |
| /** |
| * Cards should be re-queried due to a layout change |
| */ |
| void queryWalletCards(); |
| } |
| |
| interface OnCardScrollListener { |
| void onCardScroll(WalletCardViewInfo centerCard, WalletCardViewInfo nextCard, |
| float percentDistanceFromCenter); |
| } |
| |
| public WalletCardCarousel(Context context) { |
| this(context, null); |
| } |
| |
| public WalletCardCarousel(Context context, @Nullable AttributeSet attributeSet) { |
| super(context, attributeSet); |
| |
| setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); |
| addOnScrollListener(new CardCarouselScrollListener()); |
| new CarouselSnapHelper().attachToRecyclerView(this); |
| mWalletCardCarouselAdapter = new WalletCardCarouselAdapter(); |
| mWalletCardCarouselAdapter.setHasStableIds(true); |
| setAdapter(mWalletCardCarouselAdapter); |
| ViewCompat.setAccessibilityDelegate(this, new CardCarouselAccessibilityDelegate(this)); |
| |
| addItemDecoration(new DotIndicatorDecoration(getContext())); |
| } |
| |
| /** |
| * We need to know the card width before we query cards. Card width depends on layout width. |
| * But the carousel isn't laid out until set to visible, which only happens after cards are |
| * returned. Setting the expected view width breaks the chicken-and-egg problem. |
| */ |
| void setExpectedViewWidth(int width) { |
| if (mExpectedViewWidth == width) { |
| return; |
| } |
| mExpectedViewWidth = width; |
| Resources res = getResources(); |
| DisplayMetrics metrics = res.getDisplayMetrics(); |
| int screenWidth = Math.min(metrics.widthPixels, metrics.heightPixels); |
| mCardWidthPx = Math.round(Math.min(width, screenWidth) * CARD_VIEW_WIDTH_RATIO); |
| mCardHeightPx = Math.round(mCardWidthPx / CARD_ASPECT_RATIO); |
| mCornerRadiusPx = mCardWidthPx * CORNER_RADIUS_RATIO; |
| mCardMarginPx = Math.round(mCardWidthPx * CARD_MARGIN_RATIO); |
| mTotalCardWidth = mCardWidthPx + res.getDimensionPixelSize(R.dimen.card_margin) * 2; |
| mCardEdgeToCenterDistance = mTotalCardWidth / 2f; |
| updatePadding(width); |
| if (mSelectionListener != null) { |
| mSelectionListener.queryWalletCards(); |
| } |
| } |
| |
| @Override |
| public void onViewAdded(View child) { |
| super.onViewAdded(child); |
| LayoutParams layoutParams = (LayoutParams) child.getLayoutParams(); |
| layoutParams.leftMargin = mCardMarginPx; |
| layoutParams.rightMargin = mCardMarginPx; |
| child.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> updateCardView(child)); |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| super.onLayout(changed, left, top, right, bottom); |
| int width = getWidth(); |
| if (mWalletCardCarouselAdapter.getItemCount() > 1 && width < mTotalCardWidth * 1.5) { |
| // When 2 or more cards are available but only one whole card can be shown on screen at |
| // a time, the entire carousel is opted out from system gesture to help users swipe |
| // between cards without accidentally performing the 'back' gesture. When there is only |
| // one card or when the carousel is large enough to accommodate several whole cards, |
| // there is no need to disable the back gesture since either the user can't swipe or has |
| // plenty of room with which to do so. |
| mSystemGestureExclusionZone.set(0, 0, width, getHeight()); |
| setSystemGestureExclusionRects(Collections.singletonList(mSystemGestureExclusionZone)); |
| } |
| if (width != mExpectedViewWidth) { |
| updatePadding(width); |
| } |
| } |
| |
| void setSelectionListener(OnSelectionListener selectionListener) { |
| mSelectionListener = selectionListener; |
| } |
| |
| void setCardScrollListener(OnCardScrollListener scrollListener) { |
| mCardScrollListener = scrollListener; |
| } |
| |
| int getCardWidthPx() { |
| return mCardWidthPx; |
| } |
| |
| int getCardHeightPx() { |
| return mCardHeightPx; |
| } |
| |
| /** |
| * Sets the adapter again in the RecyclerView, updating the ViewHolders children's layout. |
| * This is needed when changing the state of the device (eg fold/unfold) so the ViewHolders are |
| * recreated. |
| */ |
| void resetAdapter() { |
| setAdapter(mWalletCardCarouselAdapter); |
| } |
| |
| /** |
| * Returns true if the data set is changed. |
| */ |
| boolean setData(List<WalletCardViewInfo> data, int selectedIndex, boolean hasLockStateChanged) { |
| boolean hasDataChanged = mWalletCardCarouselAdapter.setData(data, hasLockStateChanged); |
| scrollToPosition(selectedIndex); |
| WalletCardViewInfo selectedCard = data.get(selectedIndex); |
| mCardScrollListener.onCardScroll(selectedCard, selectedCard, 0); |
| return hasDataChanged; |
| } |
| |
| @Override |
| public void scrollToPosition(int position) { |
| super.scrollToPosition(position); |
| mSelectionListener.onCardSelected(mWalletCardCarouselAdapter.mData.get(position)); |
| } |
| |
| /** |
| * The padding pushes the first and last cards in the list to the center when they are |
| * selected. |
| */ |
| private void updatePadding(int viewWidth) { |
| int paddingHorizontal = (viewWidth - mTotalCardWidth) / 2 - mCardMarginPx; |
| paddingHorizontal = Math.max(0, paddingHorizontal); // just in case |
| setPadding(paddingHorizontal, getPaddingTop(), paddingHorizontal, getPaddingBottom()); |
| |
| // re-center selected card after changing padding (if card is selected) |
| if (mWalletCardCarouselAdapter != null |
| && mWalletCardCarouselAdapter.getItemCount() > 0 |
| && mCenteredAdapterPosition != NO_POSITION) { |
| ViewHolder viewHolder = findViewHolderForAdapterPosition(mCenteredAdapterPosition); |
| if (viewHolder != null) { |
| View cardView = viewHolder.itemView; |
| int cardCenter = (cardView.getLeft() + cardView.getRight()) / 2; |
| int viewCenter = (getLeft() + getRight()) / 2; |
| int scrollX = cardCenter - viewCenter; |
| scrollBy(scrollX, 0); |
| } |
| } |
| } |
| |
| private void updateCardView(View view) { |
| WalletCardViewHolder viewHolder = (WalletCardViewHolder) view.getTag(); |
| CardView cardView = viewHolder.mCardView; |
| float center = (float) getWidth() / 2f; |
| float viewCenter = (view.getRight() + view.getLeft()) / 2f; |
| float viewWidth = view.getWidth(); |
| float position = (viewCenter - center) / viewWidth; |
| float scaleFactor = Math.max(UNSELECTED_CARD_SCALE, 1f - Math.abs(position)); |
| |
| cardView.setScaleX(scaleFactor); |
| cardView.setScaleY(scaleFactor); |
| |
| // a card is the "centered card" until its edge has moved past the center of the recycler |
| // view. note that we also need to factor in the negative margin. |
| // Find the edge that is closer to the center. |
| int edgePosition = |
| viewCenter < center ? view.getRight() + mCardMarginPx |
| : view.getLeft() - mCardMarginPx; |
| |
| if (Math.abs(viewCenter - center) < mCardCenterToScreenCenterDistancePx) { |
| int childAdapterPosition = getChildAdapterPosition(view); |
| if (childAdapterPosition == RecyclerView.NO_POSITION) { |
| return; |
| } |
| mCenteredAdapterPosition = getChildAdapterPosition(view); |
| mEdgeToCenterDistance = edgePosition - center; |
| mCardCenterToScreenCenterDistancePx = Math.abs(viewCenter - center); |
| } |
| } |
| |
| private class CardCarouselScrollListener extends OnScrollListener { |
| |
| private int mOldState = -1; |
| |
| @Override |
| public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { |
| if (newState == RecyclerView.SCROLL_STATE_IDLE && newState != mOldState) { |
| performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); |
| } |
| mOldState = newState; |
| } |
| |
| /** |
| * Callback method to be invoked when the RecyclerView has been scrolled. This will be |
| * called after the scroll has completed. |
| * |
| * <p>This callback will also be called if visible item range changes after a layout |
| * calculation. In that case, dx and dy will be 0. |
| * |
| * @param recyclerView The RecyclerView which scrolled. |
| * @param dx The amount of horizontal scroll. |
| * @param dy The amount of vertical scroll. |
| */ |
| @Override |
| public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { |
| mCenteredAdapterPosition = RecyclerView.NO_POSITION; |
| mEdgeToCenterDistance = Float.MAX_VALUE; |
| mCardCenterToScreenCenterDistancePx = Float.MAX_VALUE; |
| for (int i = 0; i < getChildCount(); i++) { |
| updateCardView(getChildAt(i)); |
| } |
| if (mCenteredAdapterPosition == RecyclerView.NO_POSITION || dx == 0) { |
| return; |
| } |
| |
| int nextAdapterPosition = |
| mCenteredAdapterPosition + (mEdgeToCenterDistance > 0 ? 1 : -1); |
| if (nextAdapterPosition < 0 |
| || nextAdapterPosition >= mWalletCardCarouselAdapter.mData.size()) { |
| return; |
| } |
| |
| // Update the label text based on the currently selected card and the next one |
| WalletCardViewInfo centerCard = |
| mWalletCardCarouselAdapter.mData.get(mCenteredAdapterPosition); |
| WalletCardViewInfo nextCard = mWalletCardCarouselAdapter.mData.get(nextAdapterPosition); |
| float percentDistanceFromCenter = |
| Math.abs(mEdgeToCenterDistance) / mCardEdgeToCenterDistance; |
| mCardScrollListener.onCardScroll(centerCard, nextCard, percentDistanceFromCenter); |
| } |
| } |
| |
| private class CarouselSnapHelper extends PagerSnapHelper { |
| |
| private static final float MILLISECONDS_PER_INCH = 200.0F; |
| private static final int MAX_SCROLL_ON_FLING_DURATION = 80; // ms |
| |
| @Override |
| public View findSnapView(LayoutManager layoutManager) { |
| View view = super.findSnapView(layoutManager); |
| if (view == null) { |
| // implementation decides not to snap |
| return null; |
| } |
| WalletCardViewHolder viewHolder = (WalletCardViewHolder) view.getTag(); |
| WalletCardViewInfo card = viewHolder.mCardViewInfo; |
| mSelectionListener.onCardSelected(card); |
| mCardScrollListener.onCardScroll(card, card, 0); |
| return view; |
| } |
| |
| /** |
| * The default SnapScroller is a little sluggish |
| */ |
| @Override |
| protected LinearSmoothScroller createScroller(LayoutManager layoutManager) { |
| return new LinearSmoothScroller(getContext()) { |
| @Override |
| protected void onTargetFound(View targetView, State state, Action action) { |
| int[] snapDistances = calculateDistanceToFinalSnap(layoutManager, targetView); |
| final int dx = snapDistances[0]; |
| final int dy = snapDistances[1]; |
| final int time = calculateTimeForDeceleration( |
| Math.max(Math.abs(dx), Math.abs(dy))); |
| if (time > 0) { |
| action.update(dx, dy, time, mDecelerateInterpolator); |
| } |
| } |
| |
| @Override |
| protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { |
| return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; |
| } |
| |
| @Override |
| protected int calculateTimeForScrolling(int dx) { |
| return Math.min(MAX_SCROLL_ON_FLING_DURATION, |
| super.calculateTimeForScrolling(dx)); |
| } |
| }; |
| } |
| } |
| |
| private class WalletCardCarouselAdapter extends Adapter<WalletCardViewHolder> { |
| |
| private List<WalletCardViewInfo> mData = Collections.EMPTY_LIST; |
| |
| @NonNull |
| @Override |
| public WalletCardViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { |
| LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext()); |
| View view = inflater.inflate(R.layout.wallet_card_view, viewGroup, false); |
| WalletCardViewHolder viewHolder = new WalletCardViewHolder(view); |
| CardView cardView = viewHolder.mCardView; |
| cardView.setRadius(mCornerRadiusPx); |
| ViewGroup.LayoutParams layoutParams = cardView.getLayoutParams(); |
| layoutParams.width = getCardWidthPx(); |
| layoutParams.height = getCardHeightPx(); |
| view.setTag(viewHolder); |
| return viewHolder; |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull WalletCardViewHolder viewHolder, int position) { |
| WalletCardViewInfo cardViewInfo = mData.get(position); |
| viewHolder.mCardViewInfo = cardViewInfo; |
| if (cardViewInfo.getCardId().isEmpty()) { |
| viewHolder.mImageView.setScaleType(ImageView.ScaleType.CENTER); |
| } |
| viewHolder.mImageView.setImageDrawable(cardViewInfo.getCardDrawable()); |
| viewHolder.mCardView.setContentDescription(cardViewInfo.getContentDescription()); |
| viewHolder.mCardView.setOnClickListener( |
| v -> { |
| if (position != mCenteredAdapterPosition) { |
| smoothScrollToPosition(position); |
| } else { |
| mSelectionListener.onCardClicked(cardViewInfo); |
| } |
| }); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return mData.size(); |
| } |
| |
| @Override |
| public long getItemId(int position) { |
| return mData.get(position).getCardId().hashCode(); |
| } |
| |
| private boolean setData(List<WalletCardViewInfo> data, boolean hasLockedStateChanged) { |
| List<WalletCardViewInfo> oldData = mData; |
| mData = data; |
| if (hasLockedStateChanged || !isUiEquivalent(oldData, data)) { |
| notifyDataSetChanged(); |
| return true; |
| } |
| return false; |
| } |
| |
| private boolean isUiEquivalent( |
| List<WalletCardViewInfo> oldData, List<WalletCardViewInfo> newData) { |
| if (oldData.size() != newData.size()) { |
| return false; |
| } |
| for (int i = 0; i < newData.size(); i++) { |
| WalletCardViewInfo oldItem = oldData.get(i); |
| WalletCardViewInfo newItem = newData.get(i); |
| if (!oldItem.isUiEquivalent(newItem)) { |
| return false; |
| } |
| } |
| return true; |
| } |
| } |
| |
| private class CardCarouselAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { |
| |
| private CardCarouselAccessibilityDelegate(@NonNull RecyclerView recyclerView) { |
| super(recyclerView); |
| } |
| |
| @Override |
| public boolean onRequestSendAccessibilityEvent( |
| ViewGroup viewGroup, View view, AccessibilityEvent accessibilityEvent) { |
| int eventType = accessibilityEvent.getEventType(); |
| if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) { |
| scrollToPosition(getChildAdapterPosition(view)); |
| } |
| return super.onRequestSendAccessibilityEvent(viewGroup, view, accessibilityEvent); |
| } |
| } |
| } |