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