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;
18 
19 import static com.android.car.carlauncher.AppGridConstants.PageOrientation;
20 import static com.android.car.carlauncher.AppGridConstants.isHorizontal;
21 
22 import android.content.Context;
23 import android.graphics.Rect;
24 import android.util.AttributeSet;
25 import android.view.View;
26 import android.view.ViewGroup;
27 
28 import androidx.annotation.VisibleForTesting;
29 import androidx.recyclerview.widget.GridLayoutManager;
30 import androidx.recyclerview.widget.RecyclerView;
31 
32 import com.android.car.carlauncher.pagination.PageIndexingHelper;
33 import com.android.car.carlauncher.pagination.PageMeasurementHelper.GridDimensions;
34 import com.android.car.carlauncher.pagination.PageMeasurementHelper.PageDimensions;
35 import com.android.car.carlauncher.pagination.PaginationController.DimensionUpdateListener;
36 import com.android.car.carlauncher.recyclerview.AppGridAdapter;
37 import com.android.car.carlauncher.recyclerview.PageMarginDecoration;
38 
39 /**
40  * The RecyclerView that holds all the apps as children in the main app grid.
41  */
42 public class AppGridRecyclerView extends RecyclerView implements DimensionUpdateListener {
43     // the previous rotary focus direction
44     private int mPrevRotaryPageScrollDirection = View.FOCUS_FORWARD;
45     private int mNumOfCols;
46     private int mNumOfRows;
47 
48     @PageOrientation
49     private final int mPageOrientation;
50     private AppGridAdapter mAdapter;
51     private PageMarginDecoration mPageMarginDecoration;
52     private PageIndexingHelper mPageIndexingHelper;
53     private static final String TAG = "AppGridRecyclerView";
54 
AppGridRecyclerView(Context context, AttributeSet attrs)55     public AppGridRecyclerView(Context context, AttributeSet attrs) {
56         super(context, attrs);
57         mPageOrientation = getResources().getBoolean(R.bool.use_vertical_app_grid)
58                 ? PageOrientation.VERTICAL : PageOrientation.HORIZONTAL;
59     }
60 
61     @Override
setAdapter(RecyclerView.Adapter adapter)62     public void setAdapter(RecyclerView.Adapter adapter) {
63         if (!(adapter instanceof AppGridAdapter)) {
64             throw new IllegalStateException("Expected Adapter of type AppGridAdapter");
65         }
66         // skip super.setAdapter() call. We create but do not attach the adapter to recyclerview
67         // until view tree layout is complete and the total size of the app grid is measurable.
68         // Check AppGridRecyclerView#onDimensionsUpdated
69         mAdapter = (AppGridAdapter) adapter;
70     }
71 
72     /**
73      * Finds the next focusable descendant given rotary input of either View.FOCUS_FORWARD or
74      * View.FOCUS_BACKWARD.
75      *
76      * This method could be called during a scroll event, or to initiate a scroll event when the
77      * intended viewHolder item is not on the screen.
78      */
79     @Override
focusSearch(View focused, int direction)80     public View focusSearch(View focused, int direction) {
81         ViewHolder viewHolder = findContainingViewHolder(focused);
82         AppGridAdapter adapter = (AppGridAdapter) getAdapter();
83 
84         if (viewHolder == null || getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
85             // user may input additional rotary rotations during a page sling, so we return the
86             // currently focused view.
87             return focused;
88         }
89 
90         int currentPosition = viewHolder.getAbsoluteAdapterPosition();
91         int nextPosition = adapter.getNextRotaryFocus(currentPosition, direction);
92 
93         int blockSize = mNumOfCols * mNumOfRows;
94         if ((currentPosition / blockSize) == (nextPosition / blockSize)) {
95             // if the views are on the same page, then RecyclerView#getChildAt will be able to find
96             // the child on screen.
97             return getChildAt(nextPosition % blockSize);
98         }
99 
100         // since the view is not on the screen and focusSearch cannot target a view that has not
101         // been recycled yet, we need to dispatch a scroll event and postpone focusing.
102         if (AppGridConstants.isHorizontal(mPageOrientation)) {
103             // TODO: fix rounding issue on last page with rotary
104             int pageWidth = getMeasuredWidth();
105             int dx = (direction == View.FOCUS_FORWARD) ? pageWidth : -pageWidth;
106             smoothScrollBy(dx, 0);
107         } else {
108             int pageHeight = getMeasuredHeight();
109             int dy = (direction == View.FOCUS_FORWARD) ? pageHeight : -pageHeight;
110             smoothScrollBy(0, dy);
111         }
112         mPrevRotaryPageScrollDirection = direction;
113 
114         // the focus should remain on current focused view until maybeHandleRotaryFocus is called
115         return focused;
116     }
117 
118     /**
119      * Handles the delayed rotary focus request. This method should only be called after rotary page
120      * scroll completed.
121      */
maybeHandleRotaryFocus()122     public void maybeHandleRotaryFocus() {
123         if (!isInTouchMode()) {
124             // if the recyclerview just settled, and it is using remote inputs, it must have been
125             // scrolled by focusSearch
126             if (mPrevRotaryPageScrollDirection == View.FOCUS_FORWARD) {
127                 getChildAt(0).requestFocus();
128                 return;
129             }
130             getChildAt(mNumOfCols * mNumOfRows - 1).requestFocus();
131         }
132     }
133 
getPageIndexingHelper()134     public PageIndexingHelper getPageIndexingHelper() {
135         return mPageIndexingHelper;
136     }
137 
getNumOfRows()138     public int getNumOfRows() {
139         return mNumOfRows;
140     }
141 
getNumOfCols()142     public int getNumOfCols() {
143         return mNumOfCols;
144     }
145 
146     /**
147      * Forces the adapter to be attached with the specified number of rows and columns.
148      *
149      * <p>This method is intended for testing purposes only.
150      */
151     @VisibleForTesting
forceAttachAdapter(int numOfRows, int numOfCols)152     protected void forceAttachAdapter(int numOfRows, int numOfCols) {
153         mNumOfRows = numOfRows;
154         mNumOfCols = numOfCols;
155         super.setAdapter(mAdapter);
156     }
157 
158     @Override
onDimensionsUpdated(PageDimensions pageDimens, GridDimensions gridDimens)159     public void onDimensionsUpdated(PageDimensions pageDimens, GridDimensions gridDimens) {
160         ViewGroup.LayoutParams layoutParams = getLayoutParams();
161         layoutParams.width = pageDimens.recyclerViewWidthPx;
162         layoutParams.height = pageDimens.recyclerViewHeightPx;
163         this.mNumOfRows = gridDimens.mNumOfRows;
164         this.mNumOfCols = gridDimens.mNumOfCols;
165         if (!(getLayoutManager() instanceof GridLayoutManager)) {
166             throw new IllegalStateException(
167                     "AppGridRecyclerView can only be used with GridLayoutManager.");
168         }
169         if (isHorizontal(mPageOrientation)) {
170             ((GridLayoutManager) getLayoutManager()).setSpanCount(mNumOfRows);
171         } else {
172             ((GridLayoutManager) getLayoutManager()).setSpanCount(mNumOfCols);
173         }
174 
175         Rect pageBounds = new Rect();
176         getGlobalVisibleRect(pageBounds);
177         mAdapter.updateViewHolderDimensions(pageBounds, gridDimens.cellWidthPx,
178                 gridDimens.cellHeightPx);
179         mAdapter.notifyDataSetChanged();
180 
181         if (mPageMarginDecoration != null) {
182             removeItemDecoration(mPageMarginDecoration);
183         }
184         mPageIndexingHelper = new PageIndexingHelper(mNumOfCols, mNumOfRows, mPageOrientation);
185         mPageMarginDecoration = new PageMarginDecoration(pageDimens.marginHorizontalPx,
186                 pageDimens.marginVerticalPx, mPageIndexingHelper);
187         addItemDecoration(mPageMarginDecoration);
188         // Now attach adapter to the recyclerView, after dimens are updated.
189         super.setAdapter(mAdapter);
190     }
191 }
192