1 /*
2  * Copyright (C) 2015 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.launcher3.widget.picker;
18 
19 import android.content.Context;
20 import android.graphics.Point;
21 import android.util.AttributeSet;
22 import android.view.MotionEvent;
23 import android.view.View;
24 import android.widget.TableLayout;
25 
26 import androidx.recyclerview.widget.LinearLayoutManager;
27 import androidx.recyclerview.widget.RecyclerView;
28 import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
29 
30 import com.android.launcher3.BaseRecyclerView;
31 import com.android.launcher3.DeviceProfile;
32 import com.android.launcher3.R;
33 import com.android.launcher3.views.ActivityContext;
34 import com.android.launcher3.widget.model.WidgetListSpaceEntry;
35 import com.android.launcher3.widget.model.WidgetsListBaseEntry;
36 import com.android.launcher3.widget.model.WidgetsListContentEntry;
37 import com.android.launcher3.widget.model.WidgetsListHeaderEntry;
38 import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry;
39 import com.android.launcher3.widget.picker.WidgetsSpaceViewHolderBinder.EmptySpaceView;
40 
41 /**
42  * The widgets recycler view.
43  */
44 public class WidgetsRecyclerView extends BaseRecyclerView implements OnItemTouchListener {
45 
46     private WidgetsListAdapter mAdapter;
47 
48     private final int mScrollbarTop;
49 
50     private final Point mFastScrollerOffset = new Point();
51     private boolean mTouchDownOnScroller;
52     private HeaderViewDimensionsProvider mHeaderViewDimensionsProvider;
53 
54     // Cached sizes
55     private int mLastVisibleWidgetContentTableHeight = 0;
56     private int mWidgetHeaderHeight = 0;
57     private int mWidgetEmptySpaceHeight = 0;
58 
59     private final int mSpacingBetweenEntries;
60 
WidgetsRecyclerView(Context context)61     public WidgetsRecyclerView(Context context) {
62         this(context, null);
63     }
64 
WidgetsRecyclerView(Context context, AttributeSet attrs)65     public WidgetsRecyclerView(Context context, AttributeSet attrs) {
66         this(context, attrs, 0);
67     }
68 
WidgetsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr)69     public WidgetsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) {
70         // API 21 and below only support 3 parameter ctor.
71         super(context, attrs, defStyleAttr);
72         mScrollbarTop = getResources().getDimensionPixelSize(R.dimen.dynamic_grid_edge_margin);
73         addOnItemTouchListener(this);
74 
75         ActivityContext activity = ActivityContext.lookupContext(getContext());
76         DeviceProfile grid = activity.getDeviceProfile();
77 
78         // The spacing used between entries.
79         mSpacingBetweenEntries =
80                 getResources().getDimensionPixelSize(R.dimen.widget_list_entry_spacing);
81     }
82 
83     @Override
onFinishInflate()84     protected void onFinishInflate() {
85         super.onFinishInflate();
86         // create a layout manager with Launcher's context so that scroll position
87         // can be preserved during screen rotation.
88         setLayoutManager(new LinearLayoutManager(getContext()));
89     }
90 
91     @Override
setAdapter(Adapter adapter)92     public void setAdapter(Adapter adapter) {
93         super.setAdapter(adapter);
94         mAdapter = (WidgetsListAdapter) adapter;
95     }
96 
97     /**
98      * Maps the touch (from 0..1) to the adapter position that should be visible.
99      */
100     @Override
scrollToPositionAtProgress(float touchFraction)101     public String scrollToPositionAtProgress(float touchFraction) {
102         // Skip early if widgets are not bound.
103         if (isModelNotReady()) {
104             return "";
105         }
106 
107         // Stop the scroller if it is scrolling
108         stopScroll();
109 
110         int rowCount = mAdapter.getItemCount();
111         float pos = rowCount * touchFraction;
112         int availableScrollHeight = getAvailableScrollHeight();
113         LinearLayoutManager layoutManager = ((LinearLayoutManager) getLayoutManager());
114         layoutManager.scrollToPositionWithOffset(0, (int) -(availableScrollHeight * touchFraction));
115 
116         int posInt = (int) ((touchFraction == 1) ? pos - 1 : pos);
117         return mAdapter.getSectionName(posInt);
118     }
119 
120     /**
121      * Updates the bounds for the scrollbar.
122      */
123     @Override
onUpdateScrollbar(int dy)124     public void onUpdateScrollbar(int dy) {
125         // Skip early if widgets are not bound.
126         if (isModelNotReady()) {
127             mScrollbar.setThumbOffsetY(-1);
128             return;
129         }
130 
131         // Skip early if, there no child laid out in the container.
132         int scrollY = getCurrentScrollY();
133         if (scrollY < 0) {
134             mScrollbar.setThumbOffsetY(-1);
135             return;
136         }
137 
138         synchronizeScrollBarThumbOffsetToViewScroll(scrollY, getAvailableScrollHeight());
139     }
140 
141     @Override
getCurrentScrollY()142     public int getCurrentScrollY() {
143         // Skip early if widgets are not bound.
144         if (isModelNotReady() || getChildCount() == 0) {
145             return -1;
146         }
147 
148         int rowIndex = -1;
149         View child = null;
150 
151         LayoutManager layoutManager = getLayoutManager();
152         if (layoutManager instanceof LinearLayoutManager) {
153             // Use the LayoutManager as the source of truth for visible positions. During
154             // animations, the view group child may not correspond to the visible views that appear
155             // at the top.
156             rowIndex = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
157             child = layoutManager.findViewByPosition(rowIndex);
158         }
159 
160         if (child == null) {
161             // If the layout manager returns null for any reason, which can happen before layout
162             // has occurred for the position, then look at the child of this view as a ViewGroup.
163             child = getChildAt(0);
164             rowIndex = getChildPosition(child);
165         }
166 
167         for (int i = 0; i < getChildCount(); i++) {
168             View view = getChildAt(i);
169             if (view instanceof TableLayout) {
170                 // This assumes there is ever only one content shown in this recycler view.
171                 mLastVisibleWidgetContentTableHeight = view.getMeasuredHeight();
172             } else if (view instanceof WidgetsListHeader
173                     && mWidgetHeaderHeight == 0
174                     && view.getMeasuredHeight() > 0) {
175                 // This assumes all header views are of the same height.
176                 mWidgetHeaderHeight = view.getMeasuredHeight();
177             } else if (view instanceof EmptySpaceView && view.getMeasuredHeight() > 0) {
178                 mWidgetEmptySpaceHeight = view.getMeasuredHeight();
179             }
180         }
181 
182         int scrollPosition = getItemsHeight(rowIndex);
183         int offset = getLayoutManager().getDecoratedTop(child);
184 
185         return getPaddingTop() + scrollPosition - offset;
186     }
187 
188     /**
189      * Returns the available scroll height, in pixel.
190      *
191      * <p>If the recycler view can't be scrolled, returns 0.
192      */
193     @Override
getAvailableScrollHeight()194     protected int getAvailableScrollHeight() {
195         // AvailableScrollHeight = Total height of the all items - first page height
196         int firstPageHeight = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
197         int totalHeightOfAllItems = getItemsHeight(/* untilIndex= */ mAdapter.getItemCount());
198         int availableScrollHeight = totalHeightOfAllItems - firstPageHeight;
199         return Math.max(0, availableScrollHeight);
200     }
201 
isModelNotReady()202     private boolean isModelNotReady() {
203         return mAdapter.getItemCount() == 0;
204     }
205 
206     @Override
getScrollBarTop()207     public int getScrollBarTop() {
208         return mHeaderViewDimensionsProvider == null
209                 ? mScrollbarTop
210                 : mHeaderViewDimensionsProvider.getHeaderViewHeight() + mScrollbarTop;
211     }
212 
213     @Override
onInterceptTouchEvent(RecyclerView rv, MotionEvent e)214     public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
215         if (e.getAction() == MotionEvent.ACTION_DOWN) {
216             mTouchDownOnScroller =
217                     mScrollbar.isHitInParent(e.getX(), e.getY(), mFastScrollerOffset);
218         }
219         if (mTouchDownOnScroller) {
220             final boolean result = mScrollbar.handleTouchEvent(e, mFastScrollerOffset);
221             return result;
222         }
223         return false;
224     }
225 
226     @Override
onTouchEvent(RecyclerView rv, MotionEvent e)227     public void onTouchEvent(RecyclerView rv, MotionEvent e) {
228         if (mTouchDownOnScroller) {
229             mScrollbar.handleTouchEvent(e, mFastScrollerOffset);
230         }
231     }
232 
233     @Override
onRequestDisallowInterceptTouchEvent(boolean disallowIntercept)234     public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
235     }
236 
setHeaderViewDimensionsProvider( HeaderViewDimensionsProvider headerViewDimensionsProvider)237     public void setHeaderViewDimensionsProvider(
238             HeaderViewDimensionsProvider headerViewDimensionsProvider) {
239         mHeaderViewDimensionsProvider = headerViewDimensionsProvider;
240     }
241 
242     @Override
scrollToTop()243     public void scrollToTop() {
244         if (mScrollbar != null) {
245             mScrollbar.reattachThumbToScroll();
246         }
247 
248         if (getLayoutManager() instanceof LinearLayoutManager) {
249             if (getCurrentScrollY() == 0) {
250                 // We are at the top, so don't scrollToPosition (would cause unnecessary relayout).
251                 return;
252             }
253         }
254         scrollToPosition(0);
255     }
256 
257     /**
258      * Returns the sum of the height, in pixels, of this list adapter's items from index 0 until
259      * {@code untilIndex}.
260      *
261      * <p>If the untilIndex is larger than the total number of items in this adapter, returns the
262      * sum of all items' height.
263      */
getItemsHeight(int untilIndex)264     private int getItemsHeight(int untilIndex) {
265         if (untilIndex > mAdapter.getItems().size()) {
266             untilIndex = mAdapter.getItems().size();
267         }
268         int totalItemsHeight = 0;
269         for (int i = 0; i < untilIndex; i++) {
270             WidgetsListBaseEntry entry = mAdapter.getItems().get(i);
271             if (entry instanceof WidgetsListHeaderEntry
272                     || entry instanceof WidgetsListSearchHeaderEntry) {
273                 totalItemsHeight += mWidgetHeaderHeight;
274                 if (i > 0) {
275                     // Each header contains the spacing between entries as top decoration, except
276                     // the first one.
277                     totalItemsHeight += mSpacingBetweenEntries;
278                 }
279             } else if (entry instanceof WidgetsListContentEntry) {
280                 totalItemsHeight += mLastVisibleWidgetContentTableHeight;
281             } else if (entry instanceof WidgetListSpaceEntry) {
282                 totalItemsHeight += mWidgetEmptySpaceHeight;
283             } else {
284                 throw new UnsupportedOperationException("Can't estimate height for " + entry);
285             }
286         }
287         return totalItemsHeight;
288     }
289 
290     /**
291      * Provides dimensions of the header view that is shown at the top of a
292      * {@link WidgetsRecyclerView}.
293      */
294     public interface HeaderViewDimensionsProvider {
295         /**
296          * Returns the height, in pixels, of the header view that is shown at the top of a
297          * {@link WidgetsRecyclerView}.
298          */
getHeaderViewHeight()299         int getHeaderViewHeight();
300     }
301 }
302