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 package com.android.launcher3.widget.picker; 17 18 import static com.android.launcher3.Flags.enableCategorizedWidgetSuggestions; 19 import static com.android.launcher3.Flags.enableUnfoldedTwoPanePicker; 20 import static com.android.launcher3.UtilitiesKt.CLIP_CHILDREN_FALSE_MODIFIER; 21 import static com.android.launcher3.UtilitiesKt.CLIP_TO_PADDING_FALSE_MODIFIER; 22 import static com.android.launcher3.UtilitiesKt.modifyAttributesOnViewTree; 23 import static com.android.launcher3.UtilitiesKt.restoreAttributesOnViewTree; 24 25 import android.content.Context; 26 import android.graphics.Rect; 27 import android.os.Process; 28 import android.util.AttributeSet; 29 import android.view.LayoutInflater; 30 import android.view.MotionEvent; 31 import android.view.View; 32 import android.view.ViewGroup; 33 import android.view.ViewParent; 34 import android.widget.FrameLayout; 35 import android.widget.LinearLayout; 36 import android.widget.ScrollView; 37 38 import androidx.annotation.NonNull; 39 import androidx.annotation.Nullable; 40 import androidx.annotation.Px; 41 42 import com.android.launcher3.DeviceProfile; 43 import com.android.launcher3.R; 44 import com.android.launcher3.Utilities; 45 import com.android.launcher3.model.WidgetItem; 46 import com.android.launcher3.model.data.PackageItemInfo; 47 import com.android.launcher3.recyclerview.ViewHolderBinder; 48 import com.android.launcher3.util.PackageUserKey; 49 import com.android.launcher3.widget.WidgetCell; 50 import com.android.launcher3.widget.model.WidgetsListBaseEntry; 51 import com.android.launcher3.widget.model.WidgetsListContentEntry; 52 import com.android.launcher3.widget.model.WidgetsListHeaderEntry; 53 54 import java.util.Collections; 55 import java.util.List; 56 57 /** 58 * Popup for showing the full list of available widgets with a two-pane layout. 59 */ 60 public class WidgetsTwoPaneSheet extends WidgetsFullSheet { 61 62 private static final int PERSONAL_TAB = 0; 63 private static final int WORK_TAB = 1; 64 private static final int MINIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP = 268; 65 private static final int MAXIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP = 395; 66 private static final String SUGGESTIONS_PACKAGE_NAME = "widgets_list_suggestions_entry"; 67 68 // This ratio defines the max percentage of content area that the recommendations can display 69 // with respect to the bottom sheet's height. 70 private static final float RECOMMENDATION_SECTION_HEIGHT_RATIO_TWO_PANE = 0.70f; 71 private FrameLayout mSuggestedWidgetsContainer; 72 private WidgetsListHeader mSuggestedWidgetsHeader; 73 private PackageUserKey mSuggestedWidgetsPackageUserKey; 74 private View mPrimaryWidgetListView; 75 private LinearLayout mRightPane; 76 77 private ScrollView mRightPaneScrollView; 78 private WidgetsListTableViewHolderBinder mWidgetsListTableViewHolderBinder; 79 80 private boolean mOldIsSwipeToDismissInProgress; 81 private int mActivePage = -1; 82 @Nullable 83 private PackageUserKey mSelectedHeader; 84 WidgetsTwoPaneSheet(Context context, AttributeSet attrs, int defStyleAttr)85 public WidgetsTwoPaneSheet(Context context, AttributeSet attrs, int defStyleAttr) { 86 super(context, attrs, defStyleAttr); 87 } 88 WidgetsTwoPaneSheet(Context context, AttributeSet attrs)89 public WidgetsTwoPaneSheet(Context context, AttributeSet attrs) { 90 super(context, attrs); 91 } 92 93 @Override setupSheet()94 protected void setupSheet() { 95 // Set the header change listener in the adapter 96 mAdapters.get(AdapterHolder.PRIMARY) 97 .mWidgetsListAdapter.setHeaderChangeListener(getHeaderChangeListener()); 98 mAdapters.get(AdapterHolder.WORK) 99 .mWidgetsListAdapter.setHeaderChangeListener(getHeaderChangeListener()); 100 mAdapters.get(AdapterHolder.SEARCH) 101 .mWidgetsListAdapter.setHeaderChangeListener(getHeaderChangeListener()); 102 103 LayoutInflater layoutInflater = LayoutInflater.from(getContext()); 104 105 int contentLayoutRes = mHasWorkProfile ? R.layout.widgets_two_pane_sheet_paged_view 106 : R.layout.widgets_two_pane_sheet_recyclerview; 107 layoutInflater.inflate(contentLayoutRes, findViewById(R.id.recycler_view_container), true); 108 109 setupViews(); 110 111 mWidgetsListTableViewHolderBinder = 112 new WidgetsListTableViewHolderBinder(mActivityContext, layoutInflater, this, this); 113 114 mWidgetRecommendationsContainer = mContent.findViewById( 115 R.id.widget_recommendations_container); 116 mWidgetRecommendationsView = mContent.findViewById( 117 R.id.widget_recommendations_view); 118 mWidgetRecommendationsView.initParentViews(mWidgetRecommendationsContainer); 119 mWidgetRecommendationsView.setWidgetCellLongClickListener(this); 120 mWidgetRecommendationsView.setWidgetCellOnClickListener(this); 121 // To save the currently displayed page, so that, it can be requested when rebinding 122 // recommendations with different size constraints. 123 mWidgetRecommendationsView.addPageSwitchListener( 124 newPage -> mRecommendationsCurrentPage = newPage); 125 126 mHeaderTitle = mContent.findViewById(R.id.title); 127 mRightPane = mContent.findViewById(R.id.right_pane); 128 mRightPaneScrollView = mContent.findViewById(R.id.right_pane_scroll_view); 129 mRightPaneScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); 130 131 mPrimaryWidgetListView = findViewById(R.id.primary_widgets_list_view); 132 mPrimaryWidgetListView.setOutlineProvider(mViewOutlineProvider); 133 mPrimaryWidgetListView.setClipToOutline(true); 134 135 onWidgetsBound(); 136 137 // Set the fast scroller as not visible for two pane layout. 138 mFastScroller.setVisibility(GONE); 139 } 140 141 @Override getTabletHorizontalMargin(DeviceProfile deviceProfile)142 protected int getTabletHorizontalMargin(DeviceProfile deviceProfile) { 143 if (enableCategorizedWidgetSuggestions()) { 144 // two pane picker is full width for fold as well as tablet. 145 return getResources().getDimensionPixelSize( 146 R.dimen.widget_picker_two_panels_left_right_margin); 147 } 148 if (deviceProfile.isTwoPanels && enableUnfoldedTwoPanePicker()) { 149 // enableUnfoldedTwoPanePicker made two pane picker full-width for fold only. 150 return getResources().getDimensionPixelSize( 151 R.dimen.widget_picker_two_panels_left_right_margin); 152 } 153 if (deviceProfile.isLandscape && !deviceProfile.isTwoPanels) { 154 // non-fold tablet landscape margins (ag/22163531) 155 return getResources().getDimensionPixelSize( 156 R.dimen.widget_picker_landscape_tablet_left_right_margin); 157 } 158 return deviceProfile.allAppsLeftRightMargin; 159 } 160 161 @Override onUserSwipeToDismissProgressChanged()162 protected void onUserSwipeToDismissProgressChanged() { 163 super.onUserSwipeToDismissProgressChanged(); 164 boolean isSwipeToDismissInProgress = mSwipeToDismissProgress.value > 0; 165 if (isSwipeToDismissInProgress == mOldIsSwipeToDismissInProgress) { 166 return; 167 } 168 mOldIsSwipeToDismissInProgress = isSwipeToDismissInProgress; 169 if (isSwipeToDismissInProgress) { 170 modifyAttributesOnViewTree(mPrimaryWidgetListView, (ViewParent) mContent, 171 CLIP_CHILDREN_FALSE_MODIFIER); 172 modifyAttributesOnViewTree(mRightPaneScrollView, (ViewParent) mContent, 173 CLIP_CHILDREN_FALSE_MODIFIER, CLIP_TO_PADDING_FALSE_MODIFIER); 174 } else { 175 restoreAttributesOnViewTree(mPrimaryWidgetListView, mContent, 176 CLIP_CHILDREN_FALSE_MODIFIER); 177 restoreAttributesOnViewTree(mRightPaneScrollView, mContent, 178 CLIP_CHILDREN_FALSE_MODIFIER, CLIP_TO_PADDING_FALSE_MODIFIER); 179 } 180 } 181 182 @Override onLayout(boolean changed, int l, int t, int r, int b)183 protected void onLayout(boolean changed, int l, int t, int r, int b) { 184 super.onLayout(changed, l, t, r, b); 185 if (changed && mDeviceProfile.isTwoPanels && enableUnfoldedTwoPanePicker()) { 186 LinearLayout layout = mContent.findViewById(R.id.linear_layout_container); 187 FrameLayout leftPane = layout.findViewById(R.id.recycler_view_container); 188 LinearLayout.LayoutParams layoutParams = (LayoutParams) leftPane.getLayoutParams(); 189 // Width is 1/3 of the sheet unless it's less than min width or max width 190 int leftPaneWidth = layout.getMeasuredWidth() / 3; 191 @Px int minLeftPaneWidthPx = Utilities.dpToPx(MINIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP); 192 @Px int maxLeftPaneWidthPx = Utilities.dpToPx(MAXIMUM_WIDTH_LEFT_PANE_FOLDABLE_DP); 193 if (leftPaneWidth < minLeftPaneWidthPx) { 194 layoutParams.width = minLeftPaneWidthPx; 195 } else if (leftPaneWidth > maxLeftPaneWidthPx) { 196 layoutParams.width = maxLeftPaneWidthPx; 197 } else { 198 layoutParams.width = 0; 199 } 200 layoutParams.weight = layoutParams.width == 0 ? 0.33F : 0; 201 202 post(() -> { 203 // The following calls all trigger requestLayout, so we post them to avoid 204 // calling requestLayout during a layout pass. This also fixes the related warnings 205 // in logcat. 206 leftPane.setLayoutParams(layoutParams); 207 requestApplyInsets(); 208 if (mSelectedHeader != null) { 209 if (mSelectedHeader.equals(mSuggestedWidgetsPackageUserKey)) { 210 mSuggestedWidgetsHeader.callOnClick(); 211 } else { 212 getHeaderChangeListener().onHeaderChanged(mSelectedHeader); 213 } 214 } 215 }); 216 } 217 } 218 219 @Override onWidgetsBound()220 public void onWidgetsBound() { 221 super.onWidgetsBound(); 222 if (mRecommendedWidgetsCount == 0 && mSelectedHeader == null) { 223 mAdapters.get(mActivePage).mWidgetsListAdapter.selectFirstHeaderEntry(); 224 mAdapters.get(mActivePage).mWidgetsRecyclerView.scrollToTop(); 225 } 226 } 227 228 @Override onRecommendedWidgetsBound()229 public void onRecommendedWidgetsBound() { 230 super.onRecommendedWidgetsBound(); 231 232 if (mSuggestedWidgetsContainer == null && mRecommendedWidgetsCount > 0) { 233 setupSuggestedWidgets(LayoutInflater.from(getContext())); 234 mSuggestedWidgetsHeader.callOnClick(); 235 } else if (mSelectedHeader != null 236 && mSelectedHeader.equals(mSuggestedWidgetsPackageUserKey)) { 237 // Reselect widget if we are reloading recommendations while it is currently showing. 238 selectWidgetCell(mWidgetRecommendationsContainer, getLastSelectedWidgetItem()); 239 } 240 } 241 setupSuggestedWidgets(LayoutInflater layoutInflater)242 private void setupSuggestedWidgets(LayoutInflater layoutInflater) { 243 // Add suggested widgets. 244 mSuggestedWidgetsContainer = mSearchScrollView.findViewById(R.id.suggestions_header); 245 246 // Inflate the suggestions header. 247 mSuggestedWidgetsHeader = (WidgetsListHeader) layoutInflater.inflate( 248 R.layout.widgets_list_row_header_two_pane, 249 mSuggestedWidgetsContainer, 250 false); 251 mSuggestedWidgetsHeader.setExpanded(true); 252 253 PackageItemInfo packageItemInfo = new PackageItemInfo( 254 /* packageName= */ SUGGESTIONS_PACKAGE_NAME, 255 Process.myUserHandle()) { 256 @Override 257 public boolean usingLowResIcon() { 258 return false; 259 } 260 }; 261 String suggestionsHeaderTitle = getContext().getString( 262 R.string.suggested_widgets_header_title); 263 String suggestionsRightPaneTitle = getContext().getString( 264 R.string.widget_picker_right_pane_accessibility_title, suggestionsHeaderTitle); 265 packageItemInfo.title = suggestionsHeaderTitle; 266 // Suggestions may update at run time. The widgets count on suggestions doesn't add any 267 // value, so, we don't show the count. 268 WidgetsListHeaderEntry widgetsListHeaderEntry = WidgetsListHeaderEntry.create( 269 packageItemInfo, 270 /*titleSectionName=*/ suggestionsHeaderTitle, 271 /*items=*/ mActivityContext.getPopupDataProvider().getRecommendedWidgets(), 272 /*visibleWidgetsCount=*/ 0) 273 .withWidgetListShown(); 274 275 mSuggestedWidgetsHeader.applyFromItemInfoWithIcon(widgetsListHeaderEntry); 276 mSuggestedWidgetsHeader.setIcon( 277 getContext().getDrawable(R.drawable.widget_suggestions_icon)); 278 mSuggestedWidgetsHeader.setOnClickListener(view -> { 279 mSuggestedWidgetsHeader.setExpanded(true); 280 resetExpandedHeaders(); 281 mRightPane.removeAllViews(); 282 mRightPane.addView(mWidgetRecommendationsContainer); 283 mRightPaneScrollView.setScrollY(0); 284 mRightPane.setAccessibilityPaneTitle(suggestionsRightPaneTitle); 285 mSuggestedWidgetsPackageUserKey = PackageUserKey.fromPackageItemInfo(packageItemInfo); 286 final boolean isChangingHeaders = mSelectedHeader == null 287 || !mSelectedHeader.equals(mSuggestedWidgetsPackageUserKey); 288 if (isChangingHeaders) { 289 // If switching from another header, unselect any WidgetCells. This is necessary 290 // because we do not clear/recycle the WidgetCells in the recommendations container 291 // when the header is clicked, only when onRecommendationsBound is called. That 292 // means a WidgetCell in the recommendations container may still be selected from 293 // the last time the recommendations were shown. 294 unselectWidgetCell(mWidgetRecommendationsContainer, getLastSelectedWidgetItem()); 295 } 296 mSelectedHeader = mSuggestedWidgetsPackageUserKey; 297 }); 298 mSuggestedWidgetsContainer.addView(mSuggestedWidgetsHeader); 299 mRightPane.setAccessibilityPaneTitle(suggestionsRightPaneTitle); 300 } 301 302 @Override 303 @Px getMaxAvailableHeightForRecommendations()304 protected float getMaxAvailableHeightForRecommendations() { 305 if (mRecommendedWidgetsCount > 0) { 306 // If widgets were already selected for display, we show them all on orientation change 307 // in a two pane picker 308 return Float.MAX_VALUE; 309 } 310 311 return (mDeviceProfile.heightPx - mDeviceProfile.bottomSheetTopPadding) 312 * RECOMMENDATION_SECTION_HEIGHT_RATIO_TWO_PANE; 313 } 314 315 @Override onActivePageChanged(int currentActivePage)316 public void onActivePageChanged(int currentActivePage) { 317 super.onActivePageChanged(currentActivePage); 318 319 // If active page didn't change then we don't want to update the header. 320 if (mActivePage == currentActivePage) { 321 return; 322 } 323 324 mActivePage = currentActivePage; 325 326 if (mSuggestedWidgetsHeader == null) { 327 mAdapters.get(currentActivePage).mWidgetsListAdapter.selectFirstHeaderEntry(); 328 mAdapters.get(currentActivePage).mWidgetsRecyclerView.scrollToTop(); 329 } else if (currentActivePage == PERSONAL_TAB || currentActivePage == WORK_TAB) { 330 mSuggestedWidgetsHeader.callOnClick(); 331 } 332 } 333 334 @Override updateRecyclerViewVisibility(AdapterHolder adapterHolder)335 protected void updateRecyclerViewVisibility(AdapterHolder adapterHolder) { 336 // The first item is always an empty space entry. Look for any more items. 337 boolean isWidgetAvailable = adapterHolder.mWidgetsListAdapter.hasVisibleEntries(); 338 339 mRightPane.setVisibility(isWidgetAvailable ? VISIBLE : GONE); 340 341 super.updateRecyclerViewVisibility(adapterHolder); 342 } 343 344 @Override onSearchResults(List<WidgetsListBaseEntry> entries)345 public void onSearchResults(List<WidgetsListBaseEntry> entries) { 346 super.onSearchResults(entries); 347 mAdapters.get(AdapterHolder.SEARCH).mWidgetsListAdapter.selectFirstHeaderEntry(); 348 mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView.scrollToTop(); 349 } 350 351 @Override shouldScroll(MotionEvent ev)352 protected boolean shouldScroll(MotionEvent ev) { 353 return getPopupContainer().isEventOverView(mRightPaneScrollView, ev) 354 ? mRightPaneScrollView.canScrollVertically(-1) 355 : super.shouldScroll(ev); 356 } 357 358 @Override setViewVisibilityBasedOnSearch(boolean isInSearchMode)359 protected void setViewVisibilityBasedOnSearch(boolean isInSearchMode) { 360 super.setViewVisibilityBasedOnSearch(isInSearchMode); 361 362 if (mSuggestedWidgetsHeader != null && mSuggestedWidgetsContainer != null) { 363 if (!isInSearchMode) { 364 mSuggestedWidgetsContainer.setVisibility(VISIBLE); 365 mSuggestedWidgetsHeader.callOnClick(); 366 } else { 367 mSuggestedWidgetsContainer.setVisibility(GONE); 368 } 369 } else if (!isInSearchMode) { 370 mAdapters.get(mActivePage).mWidgetsListAdapter.selectFirstHeaderEntry(); 371 } 372 373 } 374 375 @Override getContentView()376 protected View getContentView() { 377 return mRightPane; 378 } 379 getHeaderChangeListener()380 private HeaderChangeListener getHeaderChangeListener() { 381 return new HeaderChangeListener() { 382 @Override 383 public void onHeaderChanged(@NonNull PackageUserKey selectedHeader) { 384 final boolean isSameHeader = mSelectedHeader != null 385 && mSelectedHeader.equals(selectedHeader); 386 mSelectedHeader = selectedHeader; 387 WidgetsListContentEntry contentEntry = mActivityContext.getPopupDataProvider() 388 .getSelectedAppWidgets(selectedHeader); 389 390 if (contentEntry == null || mRightPane == null) { 391 return; 392 } 393 394 if (mSuggestedWidgetsHeader != null) { 395 mSuggestedWidgetsHeader.setExpanded(false); 396 } 397 398 WidgetsListContentEntry contentEntryToBind; 399 if (enableCategorizedWidgetSuggestions()) { 400 // Setting max span size enables row to understand how to fit more than one item 401 // in a row. 402 contentEntryToBind = contentEntry.withMaxSpanSize(mMaxSpanPerRow); 403 } else { 404 contentEntryToBind = contentEntry; 405 } 406 407 WidgetsRowViewHolder widgetsRowViewHolder = 408 mWidgetsListTableViewHolderBinder.newViewHolder(mRightPane); 409 mWidgetsListTableViewHolderBinder.bindViewHolder(widgetsRowViewHolder, 410 contentEntryToBind, 411 ViewHolderBinder.POSITION_FIRST | ViewHolderBinder.POSITION_LAST, 412 Collections.EMPTY_LIST); 413 if (isSameHeader) { 414 // Reselect the last selected widget if we are reloading the same header. 415 selectWidgetCell(widgetsRowViewHolder.tableContainer, 416 getLastSelectedWidgetItem()); 417 } 418 widgetsRowViewHolder.mDataCallback = data -> { 419 mWidgetsListTableViewHolderBinder.bindViewHolder(widgetsRowViewHolder, 420 contentEntryToBind, 421 ViewHolderBinder.POSITION_FIRST | ViewHolderBinder.POSITION_LAST, 422 Collections.singletonList(data)); 423 if (isSameHeader) { 424 selectWidgetCell(widgetsRowViewHolder.tableContainer, 425 getLastSelectedWidgetItem()); 426 } 427 }; 428 mRightPane.removeAllViews(); 429 mRightPane.addView(widgetsRowViewHolder.itemView); 430 mRightPaneScrollView.setScrollY(0); 431 mRightPane.setAccessibilityPaneTitle( 432 getContext().getString( 433 R.string.widget_picker_right_pane_accessibility_title, 434 contentEntry.mPkgItem.title)); 435 } 436 }; 437 } 438 439 private static void selectWidgetCell(ViewGroup parent, WidgetItem item) { 440 if (parent == null || item == null) return; 441 WidgetCell cell = Utilities.findViewByPredicate(parent, v -> v instanceof WidgetCell wc 442 && wc.matchesItem(item)); 443 if (cell != null && !cell.isShowingAddButton()) { 444 cell.callOnClick(); 445 } 446 } 447 448 private static void unselectWidgetCell(ViewGroup parent, WidgetItem item) { 449 if (parent == null || item == null) return; 450 WidgetCell cell = Utilities.findViewByPredicate(parent, v -> v instanceof WidgetCell wc 451 && wc.matchesItem(item)); 452 if (cell != null && cell.isShowingAddButton()) { 453 cell.hideAddButton(/* animate= */ false); 454 } 455 } 456 457 @Override 458 public void setInsets(Rect insets) { 459 super.setInsets(insets); 460 FrameLayout rightPaneContainer = mContent.findViewById(R.id.right_pane_container); 461 rightPaneContainer.setPadding( 462 rightPaneContainer.getPaddingLeft(), 463 rightPaneContainer.getPaddingTop(), 464 rightPaneContainer.getPaddingRight(), 465 mBottomPadding); 466 requestLayout(); 467 } 468 469 @Override 470 protected int getWidgetListHorizontalMargin() { 471 return getResources().getDimensionPixelSize( 472 R.dimen.widget_list_left_pane_horizontal_margin); 473 } 474 475 @Override 476 protected boolean isTwoPane() { 477 return true; 478 } 479 480 @Override 481 protected int getHeaderTopClip(@NonNull WidgetCell cell) { 482 return 0; 483 } 484 485 @Override 486 protected void scrollCellContainerByY(WidgetCell wc, int scrollByY) { 487 for (ViewParent parent = wc.getParent(); parent != null; parent = parent.getParent()) { 488 if (parent instanceof ScrollView scrollView) { 489 scrollView.smoothScrollBy(0, scrollByY); 490 return; 491 } else if (parent == this) { 492 return; 493 } 494 } 495 } 496 497 /** 498 * This is a listener for when the selected header gets changed in the left pane. 499 */ 500 public interface HeaderChangeListener { 501 /** 502 * Sets the right pane to have the widgets for the currently selected header from 503 * the left pane. 504 */ 505 void onHeaderChanged(@NonNull PackageUserKey selectedHeader); 506 } 507 } 508