1 /*
2  * Copyright (C) 2023 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.car.carlauncher.recents.view;
18 
19 import android.app.Activity;
20 import android.content.Context;
21 import android.util.AttributeSet;
22 import android.view.View;
23 import android.view.WindowMetrics;
24 
25 import androidx.annotation.NonNull;
26 import androidx.annotation.Nullable;
27 import androidx.annotation.Px;
28 import androidx.recyclerview.widget.RecyclerView;
29 
30 import com.android.car.carlauncher.R;
31 import com.android.car.carlauncher.recents.RecentTasksViewModel;
32 import com.android.car.carlauncher.recents.RecentsUtils;
33 import com.android.internal.annotations.VisibleForTesting;
34 
35 import org.jetbrains.annotations.NotNull;
36 
37 /**
38  * RecyclerView that centers the first and last elements of the Recent task list by adding
39  * appropriate padding.
40  */
41 public class RecentsRecyclerView extends RecyclerView {
42     private final int mFirstItemWidth;
43     private final int mColSpacing;
44     private final int mItemWidth;
45     private RecentTasksViewModel mRecentTasksViewModel;
46     private WindowMetrics mWindowMetrics;
47 
RecentsRecyclerView(@onNull Context context)48     public RecentsRecyclerView(@NonNull Context context) {
49         this(context, /* attrs= */ null);
50     }
51 
RecentsRecyclerView(@onNull Context context, @Nullable AttributeSet attrs)52     public RecentsRecyclerView(@NonNull Context context,
53             @Nullable AttributeSet attrs) {
54         this(context, attrs, androidx.recyclerview.R.attr.recyclerViewStyle);
55     }
56 
57 
RecentsRecyclerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr)58     public RecentsRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs,
59             int defStyleAttr) {
60         super(context, attrs, defStyleAttr);
61         mRecentTasksViewModel = RecentTasksViewModel.getInstance();
62         if (context instanceof Activity) {
63             // needed for testing when using a mock context
64             mWindowMetrics = ((Activity) context).getWindowManager().getCurrentWindowMetrics();
65         }
66         mFirstItemWidth = getResources().getDimensionPixelSize(R.dimen.recent_task_width_first);
67         mItemWidth = getResources().getDimensionPixelSize(R.dimen.recent_task_width);
68         mColSpacing = getResources().getDimensionPixelSize(R.dimen.recent_task_col_space);
69     }
70 
71     @VisibleForTesting
RecentsRecyclerView(@onNull Context context, RecentTasksViewModel recentTasksViewModel, WindowMetrics windowMetrics)72     public RecentsRecyclerView(@NonNull Context context, RecentTasksViewModel recentTasksViewModel,
73             WindowMetrics windowMetrics) {
74         this(context);
75         mRecentTasksViewModel = recentTasksViewModel;
76         mWindowMetrics = windowMetrics;
77     }
78 
79     /**
80      * Handles {@link View.FOCUS_FORWARD} and {@link View.FOCUS_BACKWARD} events.
81      * For the 2 elements, A and B, the focus should go forward from A's dismiss button to
82      * A's thumbnail to B's dismiss button to B's thumbnail and backward in the inverted order.
83      */
84     @Override
focusSearch(View focused, int direction)85     public View focusSearch(View focused, int direction) {
86         if (direction != View.FOCUS_FORWARD && direction != View.FOCUS_BACKWARD) {
87             return super.focusSearch(focused, direction);
88         }
89         boolean shouldGoToNextView = shouldGoToNextView(direction);
90         ViewHolder focusedViewHolder = findContainingViewHolder(focused);
91         if (focusedViewHolder == null) {
92             return null;
93         }
94         if (focusedViewHolder instanceof BaseTaskViewHolder) {
95             View taskDismissButton = focusedViewHolder.itemView.findViewById(
96                     R.id.task_dismiss_button);
97             View taskThumbnail = focusedViewHolder.itemView.findViewById(R.id.task_thumbnail);
98             if (focused == taskDismissButton && shouldGoToNextView) {
99                 return taskThumbnail;
100             }
101             if (focused == taskThumbnail && !shouldGoToNextView) {
102                 return taskDismissButton;
103             }
104         }
105         int position = focusedViewHolder.getAbsoluteAdapterPosition();
106         if (position == NO_POSITION) {
107             return null;
108         }
109 
110         return getNextFocusView(direction,
111                 shouldGoToNextView ? ++position : --position, /* tryScrolling= */ true);
112     }
113 
114     /**
115      * Should be called to find the view in the next viewHolder to take focus.
116      *
117      * @param nextPosition next view holder position to be focused.
118      * @param tryScrolling should try to scroll to find the next view holder.
119      *                     This would only happen if view holder at {@code nextPosition} is null.
120      * @return the next view to be focused.
121      */
122     @Nullable
getNextFocusView(int direction, int nextPosition, boolean tryScrolling)123     private View getNextFocusView(int direction, int nextPosition, boolean tryScrolling) {
124         ViewHolder nextFocusViewHolder = findViewHolderForAdapterPosition(nextPosition);
125 
126         if (nextFocusViewHolder == null) {
127             if (tryScrolling) {
128                 this.smoothScrollBy(direction == View.FOCUS_FORWARD ? mItemWidth : -mItemWidth, 0);
129                 this.addOnScrollListener(new OnScrollListener() {
130                     @Override
131                     public void onScrollStateChanged(@NotNull RecyclerView recyclerView,
132                             int newState) {
133                         super.onScrollStateChanged(recyclerView, newState);
134                         if (newState == SCROLL_STATE_IDLE) {
135                             RecentsRecyclerView.this.removeOnScrollListener(this);
136                             View nextFocusedView = getNextFocusView(direction, nextPosition,
137                                     /* tryScrolling= */ false);
138                             if (nextFocusedView != null) {
139                                 nextFocusedView.requestFocus();
140                             }
141                         }
142                     }
143                 });
144             }
145             return null;
146         }
147         if (nextFocusViewHolder instanceof BaseTaskViewHolder) {
148             return shouldGoToNextView(direction)
149                     ? nextFocusViewHolder.itemView.findViewById(R.id.task_dismiss_button)
150                     : nextFocusViewHolder.itemView.findViewById(R.id.task_thumbnail);
151         }
152         if (nextFocusViewHolder instanceof ClearAllViewHolder) {
153             return nextFocusViewHolder.itemView.findViewById(R.id.recents_clear_all_button);
154         }
155         return nextFocusViewHolder.itemView;
156 
157     }
158 
159     /**
160      * @return {@code true} if the {@code direction} is meant to move the focus to next
161      * view {@code false} for previous view.
162      */
shouldGoToNextView(int direction)163     private boolean shouldGoToNextView(int direction) {
164         return (direction == View.FOCUS_FORWARD)
165                 == !RecentsUtils.areItemsRightToLeft(this);
166     }
167 
168     /**
169      * Resets the RecyclerView's start and end padding based on the Task list size,
170      * recent task view width and window width where Recents activity is drawn.
171      */
resetPadding()172     public void resetPadding() {
173         if (mRecentTasksViewModel.getRecentTasksSize() == 0) {
174             setPadding(/* firstItemPadding= */ 0, /* lastItemPadding= */ 0);
175             return;
176         }
177         setPadding(/* firstItemPadding= */ calculateFirstItemPadding(
178                         mWindowMetrics.getBounds().width()),
179                 /* lastItemPadding= */ calculateLastItemPadding());
180     }
181 
182     @Px
183     @VisibleForTesting
calculateFirstItemPadding(@x int windowWidth)184     int calculateFirstItemPadding(@Px int windowWidth) {
185         // This assumes that RecyclerView's width is same as the windowWidth. This is to add padding
186         // before RecyclerView or its children is drawn.
187         return Math.max(0, (windowWidth - (mFirstItemWidth + mColSpacing)) / 2);
188     }
189 
190     @Px
191     @VisibleForTesting
calculateLastItemPadding()192     int calculateLastItemPadding() {
193         // no-op
194         return 0;
195     }
196 
197     /**
198      * @param firstItemPadding padding set to recyclerView to fit the first item.
199      * @param lastItemPadding  padding set to recyclerView to fit the last item.
200      */
setPadding(@x int firstItemPadding, @Px int lastItemPadding)201     private void setPadding(@Px int firstItemPadding, @Px int lastItemPadding) {
202         boolean shouldBeReversed = RecentsUtils.areItemsRightToLeft(this);
203         setPaddingRelative(
204                 /* start= */ shouldBeReversed ? lastItemPadding : firstItemPadding,
205                 getPaddingTop(),
206                 /* end= */ shouldBeReversed ? firstItemPadding : lastItemPadding,
207                 getPaddingBottom());
208     }
209 }
210