1 /* 2 * Copyright (C) 2024 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 static com.android.launcher3.widget.util.WidgetsTableUtils.groupWidgetItemsUsingRowPxWithoutReordering; 20 21 import android.content.ComponentName; 22 import android.content.Context; 23 import android.os.Bundle; 24 import android.util.AttributeSet; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.ViewGroup; 28 import android.widget.TextView; 29 30 import androidx.annotation.Nullable; 31 import androidx.annotation.Px; 32 33 import com.android.launcher3.DeviceProfile; 34 import com.android.launcher3.PagedView; 35 import com.android.launcher3.R; 36 import com.android.launcher3.Utilities; 37 import com.android.launcher3.model.WidgetItem; 38 import com.android.launcher3.pageindicators.PageIndicatorDots; 39 40 import java.util.ArrayList; 41 import java.util.Collections; 42 import java.util.HashSet; 43 import java.util.List; 44 import java.util.Map; 45 import java.util.Set; 46 import java.util.TreeMap; 47 import java.util.function.Consumer; 48 import java.util.stream.Collectors; 49 50 /** 51 * A {@link PagedView} that displays widget recommendations in categories with dots as paged 52 * indicators. 53 */ 54 public final class WidgetRecommendationsView extends PagedView<PageIndicatorDots> { 55 private @Px float mAvailableHeight = Float.MAX_VALUE; 56 private @Px float mAvailableWidth = 0; 57 private static final String INITIALLY_DISPLAYED_WIDGETS_STATE_KEY = 58 "widgetRecommendationsView:mDisplayedWidgets"; 59 private static final int MAX_CATEGORIES = 3; 60 private TextView mRecommendationPageTitle; 61 private final List<String> mCategoryTitles = new ArrayList<>(); 62 63 /** Callbacks to run when page changes */ 64 private final List<Consumer<Integer>> mPageSwitchListeners = new ArrayList<>(); 65 66 @Nullable 67 private OnLongClickListener mWidgetCellOnLongClickListener; 68 @Nullable 69 private OnClickListener mWidgetCellOnClickListener; 70 private Set<ComponentName> mDisplayedWidgets = Collections.emptySet(); 71 WidgetRecommendationsView(Context context)72 public WidgetRecommendationsView(Context context) { 73 this(context, /* attrs= */ null); 74 } 75 WidgetRecommendationsView(Context context, AttributeSet attrs)76 public WidgetRecommendationsView(Context context, AttributeSet attrs) { 77 this(context, attrs, /* defStyleAttr= */ 0); 78 } 79 WidgetRecommendationsView(Context context, AttributeSet attrs, int defStyle)80 public WidgetRecommendationsView(Context context, AttributeSet attrs, int defStyle) { 81 super(context, attrs, defStyle); 82 } 83 84 @Override initParentViews(View parent)85 public void initParentViews(View parent) { 86 super.initParentViews(parent); 87 mRecommendationPageTitle = parent.findViewById(R.id.recommendations_page_title); 88 } 89 90 /** 91 * Saves the necessary state in the provided bundle. To be called in case of orientation / 92 * other config changes. 93 */ saveState(Bundle bundle)94 public void saveState(Bundle bundle) { 95 // Save the widgets that were displayed, so that, on rotation / fold / unfold, we can 96 // maintain the "initial" set of widgets that user first saw (if they fit). 97 bundle.putParcelableArrayList(INITIALLY_DISPLAYED_WIDGETS_STATE_KEY, 98 new ArrayList<>(mDisplayedWidgets)); 99 } 100 101 /** 102 * Restores the state that was saved by the saveState method during orientation / other config 103 * changes. 104 */ restoreState(Bundle bundle)105 public void restoreState(Bundle bundle) { 106 ArrayList<ComponentName> componentList; 107 if (Utilities.ATLEAST_T) { 108 componentList = bundle.getParcelableArrayList( 109 INITIALLY_DISPLAYED_WIDGETS_STATE_KEY, ComponentName.class); 110 } else { 111 componentList = bundle.getParcelableArrayList( 112 INITIALLY_DISPLAYED_WIDGETS_STATE_KEY); 113 } 114 115 // Restore the "initial" set of widgets that were displayed, so that, on rotation / fold / 116 // unfold, we can maintain the set of widgets that user first saw (if they fit). 117 if (componentList != null) { 118 mDisplayedWidgets = new HashSet<>(componentList); 119 } 120 } 121 122 /** Sets a {@link android.view.View.OnLongClickListener} for all widget cells in this table. */ setWidgetCellLongClickListener(OnLongClickListener onLongClickListener)123 public void setWidgetCellLongClickListener(OnLongClickListener onLongClickListener) { 124 mWidgetCellOnLongClickListener = onLongClickListener; 125 } 126 127 /** Sets a {@link android.view.View.OnClickListener} for all widget cells in this table. */ setWidgetCellOnClickListener(OnClickListener widgetCellOnClickListener)128 public void setWidgetCellOnClickListener(OnClickListener widgetCellOnClickListener) { 129 mWidgetCellOnClickListener = widgetCellOnClickListener; 130 } 131 132 /** 133 * Add a callback to run when the current displayed page changes. 134 */ addPageSwitchListener(Consumer<Integer> pageChangeListener)135 public void addPageSwitchListener(Consumer<Integer> pageChangeListener) { 136 mPageSwitchListeners.add(pageChangeListener); 137 } 138 139 /** 140 * Displays all the provided recommendations in a single table if they fit. 141 * 142 * @param recommendedWidgets list of widgets to be displayed in recommendation section. 143 * @param deviceProfile the current {@link DeviceProfile} 144 * @param availableHeight height in px that can be used to display the recommendations; 145 * recommendations that don't fit in this height won't be shown 146 * @param availableWidth width in px that the recommendations should display in 147 * @param cellPadding padding in px that should be applied to each widget in the 148 * recommendations 149 * @return number of recommendations that could fit in the available space. 150 */ setRecommendations( List<WidgetItem> recommendedWidgets, DeviceProfile deviceProfile, final @Px float availableHeight, final @Px int availableWidth, final @Px int cellPadding)151 public int setRecommendations( 152 List<WidgetItem> recommendedWidgets, DeviceProfile deviceProfile, 153 final @Px float availableHeight, final @Px int availableWidth, 154 final @Px int cellPadding) { 155 this.mAvailableHeight = availableHeight; 156 this.mAvailableWidth = availableWidth; 157 clear(); 158 159 Set<ComponentName> displayedWidgets = maybeDisplayInTable(recommendedWidgets, 160 deviceProfile, 161 availableWidth, cellPadding); 162 163 if (mDisplayedWidgets.isEmpty()) { 164 // Save the widgets shown for the first time user opened the picker; so that, they can 165 // be maintained across orientation changes. 166 mDisplayedWidgets = displayedWidgets; 167 } 168 169 updateTitleAndIndicator(/* requestedPage= */ 0); 170 return displayedWidgets.size(); 171 } 172 173 /** 174 * Displays the recommendations grouped by categories as pages. 175 * <p>In case of a single category, no title is displayed for it.</p> 176 * 177 * @param recommendations a map of widget items per recommendation category 178 * @param deviceProfile the current {@link DeviceProfile} 179 * @param availableHeight height in px that can be used to display the recommendations; 180 * recommendations that don't fit in this height won't be shown 181 * @param availableWidth width in px that the recommendations should display in 182 * @param cellPadding padding in px that should be applied to each widget in the 183 * recommendations 184 * @param requestedPage page number to display initially. 185 * @return number of recommendations that could fit in the available space. 186 */ setRecommendations( Map<WidgetRecommendationCategory, List<WidgetItem>> recommendations, DeviceProfile deviceProfile, final @Px float availableHeight, final @Px int availableWidth, final @Px int cellPadding, final int requestedPage)187 public int setRecommendations( 188 Map<WidgetRecommendationCategory, List<WidgetItem>> recommendations, 189 DeviceProfile deviceProfile, final @Px float availableHeight, 190 final @Px int availableWidth, final @Px int cellPadding, final int requestedPage) { 191 this.mAvailableHeight = availableHeight; 192 this.mAvailableWidth = availableWidth; 193 Context context = getContext(); 194 // For purpose of recommendations section, we don't want paging dots to be halved in two 195 // pane display, so, we always provide isTwoPanels = "false". 196 mPageIndicator.setPauseScroll(/*pause=*/true, /*isTwoPanels=*/ false); 197 clear(); 198 199 int displayedCategories = 0; 200 Set<ComponentName> allDisplayedWidgets = new HashSet<>(); 201 202 // Render top MAX_CATEGORIES in separate tables. Each table becomes a page. 203 for (Map.Entry<WidgetRecommendationCategory, List<WidgetItem>> entry : 204 new TreeMap<>(recommendations).entrySet()) { 205 // If none of the recommendations for the category could fit in the mAvailableHeight, we 206 // don't want to add that category; and we look for the next one. 207 Set<ComponentName> displayedWidgetsForCategory = maybeDisplayInTable(entry.getValue(), 208 deviceProfile, 209 availableWidth, cellPadding); 210 if (!displayedWidgetsForCategory.isEmpty()) { 211 mCategoryTitles.add( 212 context.getResources().getString(entry.getKey().categoryTitleRes)); 213 displayedCategories++; 214 allDisplayedWidgets.addAll(displayedWidgetsForCategory); 215 } 216 217 if (displayedCategories == MAX_CATEGORIES) { 218 break; 219 } 220 } 221 222 if (mDisplayedWidgets.isEmpty()) { 223 // Save the widgets shown for the first time user opened the picker; so that, they can 224 // be maintained across orientation changes. 225 mDisplayedWidgets = allDisplayedWidgets; 226 } 227 228 updateTitleAndIndicator(requestedPage); 229 // For purpose of recommendations section, we don't want paging dots to be halved in two 230 // pane display, so, we always provide isTwoPanels = "false". 231 mPageIndicator.setPauseScroll(/*pause=*/false, /*isTwoPanels=*/false); 232 return allDisplayedWidgets.size(); 233 } 234 clear()235 private void clear() { 236 mCategoryTitles.clear(); 237 removeAllViews(); 238 setCurrentPage(0); 239 mPageIndicator.setActiveMarker(0); 240 } 241 242 /** Displays the page title and paging indicator if there are multiple pages. */ updateTitleAndIndicator(int requestedPage)243 private void updateTitleAndIndicator(int requestedPage) { 244 boolean showPaginatedView = getPageCount() > 1; 245 int titleAndIndicatorVisibility = showPaginatedView ? View.VISIBLE : View.GONE; 246 mRecommendationPageTitle.setVisibility(titleAndIndicatorVisibility); 247 mPageIndicator.setVisibility(titleAndIndicatorVisibility); 248 if (showPaginatedView) { 249 if (requestedPage <= 0 || requestedPage >= getPageCount()) { 250 requestedPage = 0; 251 } 252 setCurrentPage(requestedPage); 253 mPageIndicator.setActiveMarker(requestedPage); 254 mRecommendationPageTitle.setText(mCategoryTitles.get(requestedPage)); 255 } 256 } 257 258 @Override notifyPageSwitchListener(int prevPage)259 protected void notifyPageSwitchListener(int prevPage) { 260 if (getPageCount() > 1) { 261 // Since the title is outside the paging scroll, we update the title on page switch. 262 int nextPage = getNextPage(); 263 mRecommendationPageTitle.setText(mCategoryTitles.get(nextPage)); 264 mPageSwitchListeners.forEach(listener -> listener.accept(nextPage)); 265 super.notifyPageSwitchListener(prevPage); 266 } 267 } 268 269 @Override canScroll(float absVScroll, float absHScroll)270 protected boolean canScroll(float absVScroll, float absHScroll) { 271 // Allow only horizontal scroll. 272 return (absHScroll > absVScroll) && super.canScroll(absVScroll, absHScroll); 273 } 274 275 @Override onScrollChanged(int l, int t, int oldl, int oldt)276 protected void onScrollChanged(int l, int t, int oldl, int oldt) { 277 super.onScrollChanged(l, t, oldl, oldt); 278 mPageIndicator.setScroll(l, mMaxScroll); 279 } 280 281 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)282 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 283 boolean hasMultiplePages = getChildCount() > 0; 284 285 if (hasMultiplePages) { 286 int desiredHeight = 0; 287 int desiredWidth = Math.round(mAvailableWidth); 288 289 for (int i = 0; i < getChildCount(); i++) { 290 View child = getChildAt(i); 291 // Measure children based on available height and width. 292 measureChild(child, 293 MeasureSpec.makeMeasureSpec(desiredWidth, MeasureSpec.EXACTLY), 294 MeasureSpec.makeMeasureSpec(Math.round(mAvailableHeight), 295 MeasureSpec.AT_MOST)); 296 // Use height of tallest child as we have limited height. 297 int childHeight = child.getMeasuredHeight(); 298 desiredHeight = Math.max(desiredHeight, childHeight); 299 } 300 301 int finalHeight = resolveSizeAndState(desiredHeight, heightMeasureSpec, 0); 302 int finalWidth = resolveSizeAndState(desiredWidth, widthMeasureSpec, 0); 303 304 setMeasuredDimension(finalWidth, finalHeight); 305 } else { 306 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 307 } 308 } 309 310 /** 311 * Groups the provided recommendations into rows and displays ones that fit in a table. 312 * <p>Returns the set of widgets that could fit.</p> 313 */ maybeDisplayInTable(List<WidgetItem> recommendedWidgets, DeviceProfile deviceProfile, final @Px int availableWidth, final @Px int cellPadding)314 private Set<ComponentName> maybeDisplayInTable(List<WidgetItem> recommendedWidgets, 315 DeviceProfile deviceProfile, 316 final @Px int availableWidth, final @Px int cellPadding) { 317 List<WidgetItem> filteredRecommendedWidgets = recommendedWidgets; 318 // Show only those widgets that were displayed when user first opened the picker. 319 if (!mDisplayedWidgets.isEmpty()) { 320 filteredRecommendedWidgets = recommendedWidgets.stream().filter( 321 w -> mDisplayedWidgets.contains(w.componentName)).toList(); 322 } 323 Context context = getContext(); 324 LayoutInflater inflater = LayoutInflater.from(context); 325 326 // Since we are limited by space, we don't sort recommendations - to show most relevant 327 // (if possible). 328 List<ArrayList<WidgetItem>> rows = groupWidgetItemsUsingRowPxWithoutReordering( 329 filteredRecommendedWidgets, 330 context, 331 deviceProfile, 332 availableWidth, 333 cellPadding); 334 335 WidgetsRecommendationTableLayout recommendationsTable = 336 (WidgetsRecommendationTableLayout) inflater.inflate( 337 R.layout.widget_recommendations_table, 338 /* root=*/ this, 339 /* attachToRoot=*/ false); 340 recommendationsTable.setWidgetCellOnClickListener(mWidgetCellOnClickListener); 341 recommendationsTable.setWidgetCellLongClickListener(mWidgetCellOnLongClickListener); 342 343 List<ArrayList<WidgetItem>> displayedItems = recommendationsTable.setRecommendedWidgets( 344 rows, 345 deviceProfile, mAvailableHeight); 346 347 if (!displayedItems.isEmpty()) { 348 addView(recommendationsTable); 349 } 350 351 return displayedItems.stream().flatMap( 352 items -> items.stream().map(w -> w.componentName)) 353 .collect(Collectors.toSet()); 354 } 355 356 /** Returns location of a widget cell for displaying the "touch and hold" education tip. */ getViewForEducationTip()357 public View getViewForEducationTip() { 358 if (getChildCount() > 0) { 359 // first page (a table layout) -> first item (a widget cell). 360 return ((ViewGroup) getChildAt(0)).getChildAt(0); 361 } 362 return null; 363 } 364 } 365