• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 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 package com.android.launcher3.widget.picker;
17 
18 import android.animation.ValueAnimator;
19 import android.graphics.Point;
20 import android.view.MotionEvent;
21 import android.view.View;
22 import android.view.ViewGroup.MarginLayoutParams;
23 import android.widget.RelativeLayout;
24 import android.widget.TextView;
25 
26 import androidx.annotation.Nullable;
27 
28 import com.android.launcher3.views.RecyclerViewFastScroller;
29 import com.android.launcher3.widget.picker.WidgetsFullSheet.SearchAndRecommendationViewHolder;
30 import com.android.launcher3.workprofile.PersonalWorkPagedView;
31 
32 /**
33  * A controller which measures & updates {@link WidgetsFullSheet}'s views padding, margin and
34  * vertical displacement upon scrolling.
35  */
36 final class SearchAndRecommendationsScrollController implements
37         RecyclerViewFastScroller.OnFastScrollChangeListener, ValueAnimator.AnimatorUpdateListener {
38     private final boolean mHasWorkProfile;
39     private final SearchAndRecommendationViewHolder mViewHolder;
40     private final View mSearchAndRecommendationViewParent;
41     private final WidgetsRecyclerView mPrimaryRecyclerView;
42     private final WidgetsRecyclerView mSearchRecyclerView;
43     private final TextView mNoWidgetsView;
44     private final int mTabsHeight;
45     private final ValueAnimator mAnimator = ValueAnimator.ofInt(0, 0);
46     private final Point mTempOffset = new Point();
47     private int mBottomInset;
48 
49     // The following are only non null if mHasWorkProfile is true.
50     @Nullable private final WidgetsRecyclerView mWorkRecyclerView;
51     @Nullable private final View mPrimaryWorkTabsView;
52     @Nullable private final PersonalWorkPagedView mPrimaryWorkViewPager;
53 
54     private WidgetsRecyclerView mCurrentRecyclerView;
55     private int mCurrentRecyclerViewScrollY = 0;
56 
57     private OnContentChangeListener mOnContentChangeListener = () -> onScrollChanged();
58 
59     /**
60      * The vertical distance, in pixels, until the search is pinned at the top of the screen when
61      * the user scrolls down the recycler view.
62      */
63     private int mCollapsibleHeightForSearch = 0;
64     /**
65      * The vertical distance, in pixels, until the recommendation table disappears from the top of
66      * the screen when the user scrolls down the recycler view.
67      */
68     private int mCollapsibleHeightForRecommendation = 0;
69     /**
70      * The vertical distance, in pixels, until the tabs is pinned at the top of the screen when the
71      * user scrolls down the recycler view.
72      *
73      * <p>Always 0 if there is no work profile.
74      */
75     private int mCollapsibleHeightForTabs = 0;
76 
77     private boolean mShouldForwardToRecyclerView = false;
78 
SearchAndRecommendationsScrollController( boolean hasWorkProfile, int tabsHeight, SearchAndRecommendationViewHolder viewHolder, WidgetsRecyclerView primaryRecyclerView, @Nullable WidgetsRecyclerView workRecyclerView, WidgetsRecyclerView searchRecyclerView, @Nullable View personalWorkTabsView, @Nullable PersonalWorkPagedView primaryWorkViewPager, TextView noWidgetsView)79     SearchAndRecommendationsScrollController(
80             boolean hasWorkProfile,
81             int tabsHeight,
82             SearchAndRecommendationViewHolder viewHolder,
83             WidgetsRecyclerView primaryRecyclerView,
84             @Nullable WidgetsRecyclerView workRecyclerView,
85             WidgetsRecyclerView searchRecyclerView,
86             @Nullable View personalWorkTabsView,
87             @Nullable PersonalWorkPagedView primaryWorkViewPager,
88             TextView noWidgetsView) {
89         mHasWorkProfile = hasWorkProfile;
90         mViewHolder = viewHolder;
91         mViewHolder.mContainer.setSearchAndRecommendationScrollController(this);
92         mSearchAndRecommendationViewParent = (View) mViewHolder.mContainer.getParent();
93         mPrimaryRecyclerView = primaryRecyclerView;
94         mWorkRecyclerView = workRecyclerView;
95         mSearchRecyclerView = searchRecyclerView;
96         mPrimaryWorkTabsView = personalWorkTabsView;
97         mPrimaryWorkViewPager = primaryWorkViewPager;
98         mTabsHeight = tabsHeight;
99         mNoWidgetsView = noWidgetsView;
100         setCurrentRecyclerView(mPrimaryRecyclerView, /* animateReset= */ false);
101     }
102 
setCurrentRecyclerView(WidgetsRecyclerView currentRecyclerView)103     public void setCurrentRecyclerView(WidgetsRecyclerView currentRecyclerView) {
104         setCurrentRecyclerView(currentRecyclerView, /* animateReset= */ true);
105     }
106 
107     /** Sets the current active {@link WidgetsRecyclerView}. */
setCurrentRecyclerView(WidgetsRecyclerView currentRecyclerView, boolean animateReset)108     private void setCurrentRecyclerView(WidgetsRecyclerView currentRecyclerView,
109             boolean animateReset) {
110         if (mCurrentRecyclerView == currentRecyclerView) {
111             return;
112         }
113         if (mCurrentRecyclerView != null) {
114             mCurrentRecyclerView.setOnContentChangeListener(null);
115         }
116         mCurrentRecyclerView = currentRecyclerView;
117         mCurrentRecyclerView.setOnContentChangeListener(mOnContentChangeListener);
118         reset(animateReset);
119     }
120 
121     /**
122      * Updates padding of {@link WidgetsFullSheet} contents to include {@code bottomInset} wherever
123      * necessary.
124      */
updateBottomInset(int bottomInset)125     public boolean updateBottomInset(int bottomInset) {
126         mBottomInset = bottomInset;
127         return updateMarginAndPadding();
128     }
129 
130     /**
131      * Updates the margin and padding of {@link WidgetsFullSheet} to accumulate collapsible views.
132      *
133      * @return {@code true} if margins or/and padding of views in the search and recommendations
134      * container have been updated.
135      */
updateMarginAndPadding()136     public boolean updateMarginAndPadding() {
137         boolean hasMarginOrPaddingUpdated = false;
138         mCollapsibleHeightForSearch = measureHeightWithVerticalMargins(mViewHolder.mHeaderTitle);
139         mCollapsibleHeightForRecommendation =
140                 measureHeightWithVerticalMargins(mViewHolder.mHeaderTitle)
141                         + measureHeightWithVerticalMargins(mViewHolder.mCollapseHandle)
142                         + measureHeightWithVerticalMargins((View) mViewHolder.mSearchBarContainer)
143                         + measureHeightWithVerticalMargins(mViewHolder.mRecommendedWidgetsTable);
144 
145         int topContainerHeight = measureHeightWithVerticalMargins(mViewHolder.mContainer);
146         int noWidgetsViewHeight =  topContainerHeight - mBottomInset;
147 
148         if (mHasWorkProfile) {
149             mCollapsibleHeightForTabs = measureHeightWithVerticalMargins(mViewHolder.mHeaderTitle)
150                     + measureHeightWithVerticalMargins(mViewHolder.mRecommendedWidgetsTable);
151             // In a work profile setup, the full widget sheet contains the following views:
152             //           ------- (pinned)           -|
153             //          Widgets (collapsible)       -|---> LinearLayout for search & recommendations
154             //          Search bar (pinned)         -|
155             //  Widgets recommendation (collapsible)-|
156             //      Personal | Work (pinned)
157             //           View Pager
158             //
159             // Views after the search & recommendations are not bound by RelativelyLayout param.
160             // To position them on the expected location, padding & margin are added to these views
161 
162             // Tabs should have a padding of the height of the search & recommendations container.
163             RelativeLayout.LayoutParams tabsLayoutParams =
164                     (RelativeLayout.LayoutParams) mPrimaryWorkTabsView.getLayoutParams();
165             tabsLayoutParams.topMargin = topContainerHeight;
166             mPrimaryWorkTabsView.setLayoutParams(tabsLayoutParams);
167 
168             // Instead of setting the top offset directly, we split the top offset into two values:
169             // 1. topOffsetAfterAllViewsCollapsed: this is the top offset after all collapsible
170             //    views are no longer visible on the screen.
171             //    This value is set as the margin for the view pager.
172             // 2. mMaxCollapsibleDistance
173             //    This value is set as the padding for the recycler views in order to work with
174             //    clipToPadding="false", which is an attribute for not showing top / bottom padding
175             //    when a recycler view has not reached the top or bottom of the list.
176             //    e.g. a list of 10 entries, only 3 entries are visible at a time.
177             //         case 1: recycler view is scrolled to the top. Top padding is visible/
178             //         (top padding)
179             //         item 1
180             //         item 2
181             //         item 3
182             //
183             //         case 2: recycler view is scrolled to the middle. No padding is visible.
184             //         item 4
185             //         item 5
186             //         item 6
187             //
188             //         case 3: recycler view is scrolled to the end. bottom padding is visible.
189             //         item 8
190             //         item 9
191             //         item 10
192             //         (bottom padding): not set in this case.
193             //
194             // When the views are first inflated, the sum of topOffsetAfterAllViewsCollapsed and
195             // mMaxCollapsibleDistance should equal to the top container height.
196             int topOffsetAfterAllViewsCollapsed =
197                     topContainerHeight + mTabsHeight - mCollapsibleHeightForTabs;
198 
199             if (mPrimaryWorkTabsView.getVisibility() == View.VISIBLE) {
200                 noWidgetsViewHeight += mTabsHeight;
201             }
202 
203             RelativeLayout.LayoutParams viewPagerLayoutParams =
204                     (RelativeLayout.LayoutParams) mPrimaryWorkViewPager.getLayoutParams();
205             if (viewPagerLayoutParams.topMargin != topOffsetAfterAllViewsCollapsed) {
206                 viewPagerLayoutParams.topMargin = topOffsetAfterAllViewsCollapsed;
207                 mPrimaryWorkViewPager.setLayoutParams(viewPagerLayoutParams);
208                 hasMarginOrPaddingUpdated = true;
209             }
210 
211             if (mPrimaryRecyclerView.getPaddingTop() != mCollapsibleHeightForTabs) {
212                 mPrimaryRecyclerView.setPadding(
213                         mPrimaryRecyclerView.getPaddingLeft(),
214                         mCollapsibleHeightForTabs,
215                         mPrimaryRecyclerView.getPaddingRight(),
216                         mPrimaryRecyclerView.getPaddingBottom());
217                 hasMarginOrPaddingUpdated = true;
218             }
219             if (mWorkRecyclerView.getPaddingTop() != mCollapsibleHeightForTabs) {
220                 mWorkRecyclerView.setPadding(
221                         mWorkRecyclerView.getPaddingLeft(),
222                         mCollapsibleHeightForTabs,
223                         mWorkRecyclerView.getPaddingRight(),
224                         mWorkRecyclerView.getPaddingBottom());
225                 hasMarginOrPaddingUpdated = true;
226             }
227         } else {
228             if (mPrimaryRecyclerView.getPaddingTop() != topContainerHeight) {
229                 mPrimaryRecyclerView.setPadding(
230                         mPrimaryRecyclerView.getPaddingLeft(),
231                         topContainerHeight,
232                         mPrimaryRecyclerView.getPaddingRight(),
233                         mPrimaryRecyclerView.getPaddingBottom());
234                 hasMarginOrPaddingUpdated = true;
235             }
236         }
237         if (mSearchRecyclerView.getPaddingTop() != topContainerHeight) {
238             mSearchRecyclerView.setPadding(
239                     mSearchRecyclerView.getPaddingLeft(),
240                     topContainerHeight,
241                     mSearchRecyclerView.getPaddingRight(),
242                     mSearchRecyclerView.getPaddingBottom());
243             hasMarginOrPaddingUpdated = true;
244         }
245         if (mNoWidgetsView.getPaddingTop() != noWidgetsViewHeight) {
246             mNoWidgetsView.setPadding(
247                     mNoWidgetsView.getPaddingLeft(),
248                     noWidgetsViewHeight,
249                     mNoWidgetsView.getPaddingRight(),
250                     mNoWidgetsView.getPaddingBottom());
251             hasMarginOrPaddingUpdated = true;
252         }
253         return hasMarginOrPaddingUpdated;
254     }
255 
256     @Override
onScrollChanged()257     public void onScrollChanged() {
258         int recyclerViewYOffset = mCurrentRecyclerView.getCurrentScrollY();
259         if (recyclerViewYOffset < 0) return;
260         mCurrentRecyclerViewScrollY = recyclerViewYOffset;
261         if (mAnimator.isStarted()) {
262             mAnimator.cancel();
263         }
264         applyVerticalTransition();
265     }
266 
267     /**
268      * Changes the displacement of collapsible views (e.g. title & widget recommendations) and fixed
269      * views (e.g. recycler views, tabs) upon scrolling / content changes in the recycler view.
270      */
applyVerticalTransition()271     private void applyVerticalTransition() {
272         if (mCollapsibleHeightForRecommendation > 0) {
273             int yDisplacement = Math.max(-mCurrentRecyclerViewScrollY,
274                     -mCollapsibleHeightForRecommendation);
275             mViewHolder.mHeaderTitle.setTranslationY(yDisplacement);
276             mViewHolder.mRecommendedWidgetsTable.setTranslationY(yDisplacement);
277         }
278 
279         if (mCollapsibleHeightForSearch > 0) {
280             int searchYDisplacement = Math.max(-mCurrentRecyclerViewScrollY,
281                     -mCollapsibleHeightForSearch);
282             mViewHolder.mSearchBarContainer.setTranslationY(searchYDisplacement);
283         }
284 
285         if (mHasWorkProfile && mCollapsibleHeightForTabs > 0) {
286             int yDisplacementForTabs = Math.max(-mCurrentRecyclerViewScrollY,
287                     -mCollapsibleHeightForTabs);
288             mPrimaryWorkTabsView.setTranslationY(yDisplacementForTabs);
289         }
290     }
291 
292     /** Resets any previous view translation. */
reset(boolean animate)293     public void reset(boolean animate) {
294         if (mCurrentRecyclerViewScrollY == 0) {
295             return;
296         }
297         if (mAnimator.isStarted()) {
298             mAnimator.cancel();
299         }
300 
301         if (animate) {
302             mAnimator.setIntValues(mCurrentRecyclerViewScrollY, 0);
303             mAnimator.addUpdateListener(this);
304             mAnimator.setDuration(300);
305             mAnimator.start();
306         } else {
307             mCurrentRecyclerViewScrollY = 0;
308             applyVerticalTransition();
309         }
310     }
311 
312     /**
313      * Returns {@code true} if a touch event should be intercepted by this controller.
314      */
onInterceptTouchEvent(MotionEvent event)315     public boolean onInterceptTouchEvent(MotionEvent event) {
316         calculateMotionEventOffset(mTempOffset);
317         event.offsetLocation(mTempOffset.x, mTempOffset.y);
318         try {
319             mShouldForwardToRecyclerView = mCurrentRecyclerView.onInterceptTouchEvent(event);
320             return mShouldForwardToRecyclerView;
321         } finally {
322             event.offsetLocation(-mTempOffset.x, -mTempOffset.y);
323         }
324     }
325 
326     /**
327      * Returns {@code true} if this controller has intercepted and consumed a touch event.
328      */
onTouchEvent(MotionEvent event)329     public boolean onTouchEvent(MotionEvent event) {
330         if (mShouldForwardToRecyclerView) {
331             calculateMotionEventOffset(mTempOffset);
332             event.offsetLocation(mTempOffset.x, mTempOffset.y);
333             try {
334                 return mCurrentRecyclerView.onTouchEvent(event);
335             } finally {
336                 event.offsetLocation(-mTempOffset.x, -mTempOffset.y);
337             }
338         }
339         return false;
340     }
341 
calculateMotionEventOffset(Point p)342     private void calculateMotionEventOffset(Point p) {
343         p.x = mViewHolder.mContainer.getLeft() - mCurrentRecyclerView.getLeft()
344                 - mSearchAndRecommendationViewParent.getLeft();
345         p.y = mViewHolder.mContainer.getTop() - mCurrentRecyclerView.getTop()
346                 - mSearchAndRecommendationViewParent.getTop();
347     }
348 
349     /** private the height, in pixel, + the vertical margins of a given view. */
measureHeightWithVerticalMargins(View view)350     private static int measureHeightWithVerticalMargins(View view) {
351         if (view.getVisibility() != View.VISIBLE) {
352             return 0;
353         }
354         MarginLayoutParams marginLayoutParams = (MarginLayoutParams) view.getLayoutParams();
355         return view.getMeasuredHeight() + marginLayoutParams.bottomMargin
356                 + marginLayoutParams.topMargin;
357     }
358 
359     @Override
onAnimationUpdate(ValueAnimator animation)360     public void onAnimationUpdate(ValueAnimator animation) {
361         mCurrentRecyclerViewScrollY = (Integer) animation.getAnimatedValue();
362         applyVerticalTransition();
363     }
364 
365     /**
366      * A listener to be notified when there is a content change in the recycler view that may affect
367      * the relative position of the search and recommendation container.
368      */
369     public interface OnContentChangeListener {
370         /** Notifies a content change in the recycler view. */
onContentChanged()371         void onContentChanged();
372     }
373 }
374