ad78da873d
.toList() was only introduced to android in api level 34, which is newer than this module's min_sdk_version. Replace it with .collect(). This was found while updating android lint. Flag: EXEMPT refactor Bug: 394096385 Test: Presubmits Change-Id: I71fd1435b7645ea4f1f653c05fef1ddc47adbc14 Merged-In: Id8d1de1531b67a7daf448e45592b7ef78f685fc2
405 lines
17 KiB
Java
405 lines
17 KiB
Java
/*
|
|
* Copyright (C) 2024 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.android.launcher3.widget.picker;
|
|
|
|
import static com.android.launcher3.widget.util.WidgetsTableUtils.groupWidgetItemsUsingRowPxWithReordering;
|
|
|
|
import android.content.ComponentName;
|
|
import android.content.Context;
|
|
import android.os.Bundle;
|
|
import android.util.AttributeSet;
|
|
import android.view.LayoutInflater;
|
|
import android.view.View;
|
|
import android.view.ViewGroup;
|
|
import android.widget.TextView;
|
|
|
|
import androidx.annotation.Nullable;
|
|
import androidx.annotation.Px;
|
|
|
|
import com.android.launcher3.DeviceProfile;
|
|
import com.android.launcher3.PagedView;
|
|
import com.android.launcher3.R;
|
|
import com.android.launcher3.Utilities;
|
|
import com.android.launcher3.model.WidgetItem;
|
|
import com.android.launcher3.pageindicators.PageIndicatorDots;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Collection;
|
|
import java.util.Collections;
|
|
import java.util.HashSet;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.TreeMap;
|
|
import java.util.function.Consumer;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* A {@link PagedView} that displays widget recommendations in categories with dots as paged
|
|
* indicators.
|
|
*/
|
|
public final class WidgetRecommendationsView extends PagedView<PageIndicatorDots> {
|
|
private @Px float mAvailableHeight = Float.MAX_VALUE;
|
|
private @Px float mAvailableWidth = 0;
|
|
private static final String INITIALLY_DISPLAYED_WIDGETS_STATE_KEY =
|
|
"widgetRecommendationsView:mDisplayedWidgets";
|
|
private static final int MAX_CATEGORIES = 3;
|
|
|
|
// Whether to show all widgets in a full page without any limitation on height
|
|
private boolean mShowFullPageViewIfLowDensity = false;
|
|
// Number of items below which a category is considered low density.
|
|
private static final int IDEAL_ITEMS_PER_CATEGORY = 2;
|
|
|
|
private TextView mRecommendationPageTitle;
|
|
private final List<String> mCategoryTitles = new ArrayList<>();
|
|
|
|
/** Callbacks to run when page changes */
|
|
private final List<Consumer<Integer>> mPageSwitchListeners = new ArrayList<>();
|
|
|
|
@Nullable
|
|
private OnLongClickListener mWidgetCellOnLongClickListener;
|
|
@Nullable
|
|
private OnClickListener mWidgetCellOnClickListener;
|
|
private Set<ComponentName> mDisplayedWidgets = Collections.emptySet();
|
|
|
|
public WidgetRecommendationsView(Context context) {
|
|
this(context, /* attrs= */ null);
|
|
}
|
|
|
|
public WidgetRecommendationsView(Context context, AttributeSet attrs) {
|
|
this(context, attrs, /* defStyleAttr= */ 0);
|
|
}
|
|
|
|
public WidgetRecommendationsView(Context context, AttributeSet attrs, int defStyle) {
|
|
super(context, attrs, defStyle);
|
|
}
|
|
|
|
@Override
|
|
public void initParentViews(View parent) {
|
|
super.initParentViews(parent);
|
|
mRecommendationPageTitle = parent.findViewById(R.id.recommendations_page_title);
|
|
}
|
|
|
|
/**
|
|
* When there are less than 3 categories or when at least one category has less than 2 widgets,
|
|
* all widgets will be shown in a single page without being limited by the available height.
|
|
*/
|
|
public void enableFullPageViewIfLowDensity() {
|
|
mShowFullPageViewIfLowDensity = true;
|
|
}
|
|
|
|
/**
|
|
* Saves the necessary state in the provided bundle. To be called in case of orientation /
|
|
* other config changes.
|
|
*/
|
|
public void saveState(Bundle bundle) {
|
|
// Save the widgets that were displayed, so that, on rotation / fold / unfold, we can
|
|
// maintain the "initial" set of widgets that user first saw (if they fit).
|
|
bundle.putParcelableArrayList(INITIALLY_DISPLAYED_WIDGETS_STATE_KEY,
|
|
new ArrayList<>(mDisplayedWidgets));
|
|
}
|
|
|
|
/**
|
|
* Restores the state that was saved by the saveState method during orientation / other config
|
|
* changes.
|
|
*/
|
|
public void restoreState(Bundle bundle) {
|
|
ArrayList<ComponentName> componentList;
|
|
if (Utilities.ATLEAST_T) {
|
|
componentList = bundle.getParcelableArrayList(
|
|
INITIALLY_DISPLAYED_WIDGETS_STATE_KEY, ComponentName.class);
|
|
} else {
|
|
componentList = bundle.getParcelableArrayList(
|
|
INITIALLY_DISPLAYED_WIDGETS_STATE_KEY);
|
|
}
|
|
|
|
// Restore the "initial" set of widgets that were displayed, so that, on rotation / fold /
|
|
// unfold, we can maintain the set of widgets that user first saw (if they fit).
|
|
if (componentList != null) {
|
|
mDisplayedWidgets = new HashSet<>(componentList);
|
|
}
|
|
}
|
|
|
|
/** Sets a {@link android.view.View.OnLongClickListener} for all widget cells in this table. */
|
|
public void setWidgetCellLongClickListener(OnLongClickListener onLongClickListener) {
|
|
mWidgetCellOnLongClickListener = onLongClickListener;
|
|
}
|
|
|
|
/** Sets a {@link android.view.View.OnClickListener} for all widget cells in this table. */
|
|
public void setWidgetCellOnClickListener(OnClickListener widgetCellOnClickListener) {
|
|
mWidgetCellOnClickListener = widgetCellOnClickListener;
|
|
}
|
|
|
|
/**
|
|
* Add a callback to run when the current displayed page changes.
|
|
*/
|
|
public void addPageSwitchListener(Consumer<Integer> pageChangeListener) {
|
|
mPageSwitchListeners.add(pageChangeListener);
|
|
}
|
|
|
|
/**
|
|
* Displays all the provided recommendations in a single table if they fit.
|
|
*
|
|
* @param recommendedWidgets list of widgets to be displayed in recommendation section.
|
|
* @param deviceProfile the current {@link DeviceProfile}
|
|
* @param availableHeight height in px that can be used to display the recommendations;
|
|
* recommendations that don't fit in this height won't be shown
|
|
* @param availableWidth width in px that the recommendations should display in
|
|
* @param cellPadding padding in px that should be applied to each widget in the
|
|
* recommendations
|
|
* @return number of recommendations that could fit in the available space.
|
|
*/
|
|
public int setRecommendations(
|
|
List<WidgetItem> recommendedWidgets, DeviceProfile deviceProfile,
|
|
final @Px float availableHeight, final @Px int availableWidth,
|
|
final @Px int cellPadding) {
|
|
this.mAvailableHeight = availableHeight;
|
|
this.mAvailableWidth = availableWidth;
|
|
clear();
|
|
|
|
Set<ComponentName> displayedWidgets = maybeDisplayInTable(recommendedWidgets,
|
|
deviceProfile,
|
|
availableWidth, cellPadding);
|
|
|
|
if (mDisplayedWidgets.isEmpty()) {
|
|
// Save the widgets shown for the first time user opened the picker; so that, they can
|
|
// be maintained across orientation changes.
|
|
mDisplayedWidgets = displayedWidgets;
|
|
}
|
|
|
|
updateTitleAndIndicator(/* requestedPage= */ 0);
|
|
return displayedWidgets.size();
|
|
}
|
|
|
|
private boolean shouldShowFullPageView(
|
|
Map<WidgetRecommendationCategory, List<WidgetItem>> recommendations) {
|
|
if (mShowFullPageViewIfLowDensity) {
|
|
boolean hasLessCategories = recommendations.size() < MAX_CATEGORIES;
|
|
long lowDensityCategoriesCount = recommendations.values()
|
|
.stream()
|
|
.limit(MAX_CATEGORIES)
|
|
.filter(items -> items.size() < IDEAL_ITEMS_PER_CATEGORY).count();
|
|
|
|
// If there less number of categories or if there are at least 2 categorizes with less
|
|
// widgets, prefer showing single page view.
|
|
return hasLessCategories || lowDensityCategoriesCount > 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Displays the recommendations grouped by categories as pages.
|
|
* <p>In case of a single category, no title is displayed for it.</p>
|
|
*
|
|
* @param recommendations a map of widget items per recommendation category
|
|
* @param deviceProfile the current {@link DeviceProfile}
|
|
* @param availableHeight height in px that can be used to display the recommendations;
|
|
* recommendations that don't fit in this height won't be shown
|
|
* @param availableWidth width in px that the recommendations should display in
|
|
* @param cellPadding padding in px that should be applied to each widget in the
|
|
* recommendations
|
|
* @param requestedPage page number to display initially.
|
|
* @return number of recommendations that could fit in the available space.
|
|
*/
|
|
public int setRecommendations(
|
|
Map<WidgetRecommendationCategory, List<WidgetItem>> recommendations,
|
|
DeviceProfile deviceProfile, final @Px float availableHeight,
|
|
final @Px int availableWidth, final @Px int cellPadding, final int requestedPage) {
|
|
if (shouldShowFullPageView(recommendations)) {
|
|
// Show all widgets in single page with unlimited available height.
|
|
return setRecommendations(
|
|
recommendations.values().stream().flatMap(Collection::stream)
|
|
.collect(Collectors.toList()),
|
|
deviceProfile, /*availableHeight=*/ Float.MAX_VALUE, availableWidth,
|
|
cellPadding);
|
|
|
|
}
|
|
this.mAvailableHeight = availableHeight;
|
|
this.mAvailableWidth = availableWidth;
|
|
Context context = getContext();
|
|
// For purpose of recommendations section, we don't want paging dots to be halved in two
|
|
// pane display, so, we always provide isTwoPanels = "false".
|
|
mPageIndicator.setPauseScroll(/*pause=*/true, /*isTwoPanels=*/ false);
|
|
clear();
|
|
|
|
int displayedCategories = 0;
|
|
Set<ComponentName> allDisplayedWidgets = new HashSet<>();
|
|
|
|
// Render top MAX_CATEGORIES in separate tables. Each table becomes a page.
|
|
for (Map.Entry<WidgetRecommendationCategory, List<WidgetItem>> entry :
|
|
new TreeMap<>(recommendations).entrySet()) {
|
|
// If none of the recommendations for the category could fit in the mAvailableHeight, we
|
|
// don't want to add that category; and we look for the next one.
|
|
Set<ComponentName> displayedWidgetsForCategory = maybeDisplayInTable(entry.getValue(),
|
|
deviceProfile,
|
|
availableWidth, cellPadding);
|
|
if (!displayedWidgetsForCategory.isEmpty()) {
|
|
mCategoryTitles.add(
|
|
context.getResources().getString(entry.getKey().categoryTitleRes));
|
|
displayedCategories++;
|
|
allDisplayedWidgets.addAll(displayedWidgetsForCategory);
|
|
}
|
|
|
|
if (displayedCategories == MAX_CATEGORIES) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (mDisplayedWidgets.isEmpty()) {
|
|
// Save the widgets shown for the first time user opened the picker; so that, they can
|
|
// be maintained across orientation changes.
|
|
mDisplayedWidgets = allDisplayedWidgets;
|
|
}
|
|
|
|
updateTitleAndIndicator(requestedPage);
|
|
// For purpose of recommendations section, we don't want paging dots to be halved in two
|
|
// pane display, so, we always provide isTwoPanels = "false".
|
|
mPageIndicator.setPauseScroll(/*pause=*/false, /*isTwoPanels=*/false);
|
|
return allDisplayedWidgets.size();
|
|
}
|
|
|
|
private void clear() {
|
|
mCategoryTitles.clear();
|
|
removeAllViews();
|
|
setCurrentPage(0);
|
|
mPageIndicator.setActiveMarker(0);
|
|
}
|
|
|
|
/** Displays the page title and paging indicator if there are multiple pages. */
|
|
private void updateTitleAndIndicator(int requestedPage) {
|
|
boolean showPaginatedView = getPageCount() > 1;
|
|
int titleAndIndicatorVisibility = showPaginatedView ? View.VISIBLE : View.GONE;
|
|
mRecommendationPageTitle.setVisibility(titleAndIndicatorVisibility);
|
|
mPageIndicator.setVisibility(titleAndIndicatorVisibility);
|
|
if (showPaginatedView) {
|
|
if (requestedPage <= 0 || requestedPage >= getPageCount()) {
|
|
requestedPage = 0;
|
|
}
|
|
setCurrentPage(requestedPage);
|
|
mPageIndicator.setActiveMarker(requestedPage);
|
|
mRecommendationPageTitle.setText(mCategoryTitles.get(requestedPage));
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void notifyPageSwitchListener(int prevPage) {
|
|
if (getPageCount() > 1) {
|
|
// Since the title is outside the paging scroll, we update the title on page switch.
|
|
int nextPage = getNextPage();
|
|
mRecommendationPageTitle.setText(mCategoryTitles.get(nextPage));
|
|
mPageSwitchListeners.forEach(listener -> listener.accept(nextPage));
|
|
super.notifyPageSwitchListener(prevPage);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected boolean canScroll(float absVScroll, float absHScroll) {
|
|
// Allow only horizontal scroll.
|
|
return (absHScroll > absVScroll) && super.canScroll(absVScroll, absHScroll);
|
|
}
|
|
|
|
@Override
|
|
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
|
|
super.onScrollChanged(l, t, oldl, oldt);
|
|
mPageIndicator.setScroll(l, mMaxScroll);
|
|
}
|
|
|
|
@Override
|
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
|
boolean hasMultiplePages = getChildCount() > 0;
|
|
|
|
if (hasMultiplePages) {
|
|
int desiredHeight = 0;
|
|
int desiredWidth = Math.round(mAvailableWidth);
|
|
|
|
for (int i = 0; i < getChildCount(); i++) {
|
|
View child = getChildAt(i);
|
|
// Measure children based on available height and width.
|
|
measureChild(child,
|
|
MeasureSpec.makeMeasureSpec(desiredWidth, MeasureSpec.EXACTLY),
|
|
MeasureSpec.makeMeasureSpec(Math.round(mAvailableHeight),
|
|
MeasureSpec.AT_MOST));
|
|
// Use height of tallest child as we have limited height.
|
|
int childHeight = child.getMeasuredHeight();
|
|
desiredHeight = Math.max(desiredHeight, childHeight);
|
|
}
|
|
|
|
int finalHeight = resolveSizeAndState(desiredHeight, heightMeasureSpec, 0);
|
|
int finalWidth = resolveSizeAndState(desiredWidth, widthMeasureSpec, 0);
|
|
|
|
setMeasuredDimension(finalWidth, finalHeight);
|
|
} else {
|
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Groups the provided recommendations into rows and displays ones that fit in a table.
|
|
* <p>Returns the set of widgets that could fit.</p>
|
|
*/
|
|
private Set<ComponentName> maybeDisplayInTable(List<WidgetItem> recommendedWidgets,
|
|
DeviceProfile deviceProfile,
|
|
final @Px int availableWidth, final @Px int cellPadding) {
|
|
List<WidgetItem> filteredRecommendedWidgets = recommendedWidgets;
|
|
// Show only those widgets that were displayed when user first opened the picker.
|
|
if (!mDisplayedWidgets.isEmpty()) {
|
|
filteredRecommendedWidgets = recommendedWidgets.stream().filter(
|
|
w -> mDisplayedWidgets.contains(w.componentName)).collect(Collectors.toList());
|
|
}
|
|
Context context = getContext();
|
|
LayoutInflater inflater = LayoutInflater.from(context);
|
|
|
|
// Since we are limited by space, we don't sort recommendations - to show most relevant
|
|
// (if possible).
|
|
List<ArrayList<WidgetItem>> rows = groupWidgetItemsUsingRowPxWithReordering(
|
|
filteredRecommendedWidgets,
|
|
context,
|
|
deviceProfile,
|
|
availableWidth,
|
|
cellPadding);
|
|
|
|
WidgetsRecommendationTableLayout recommendationsTable =
|
|
(WidgetsRecommendationTableLayout) inflater.inflate(
|
|
R.layout.widget_recommendations_table,
|
|
/* root=*/ this,
|
|
/* attachToRoot=*/ false);
|
|
recommendationsTable.setWidgetCellOnClickListener(mWidgetCellOnClickListener);
|
|
recommendationsTable.setWidgetCellLongClickListener(mWidgetCellOnLongClickListener);
|
|
|
|
List<ArrayList<WidgetItem>> displayedItems = recommendationsTable.setRecommendedWidgets(
|
|
rows,
|
|
deviceProfile, mAvailableHeight);
|
|
|
|
if (!displayedItems.isEmpty()) {
|
|
addView(recommendationsTable);
|
|
}
|
|
|
|
return displayedItems.stream().flatMap(
|
|
items -> items.stream().map(w -> w.componentName))
|
|
.collect(Collectors.toSet());
|
|
}
|
|
|
|
/** Returns location of a widget cell for displaying the "touch and hold" education tip. */
|
|
public View getViewForEducationTip() {
|
|
if (getChildCount() > 0) {
|
|
// first page (a table layout) -> first item (a widget cell).
|
|
return ((ViewGroup) getChildAt(0)).getChildAt(0);
|
|
}
|
|
return null;
|
|
}
|
|
}
|