/* * Copyright (C) 2017 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.Flags.enableTieredWidgetsByDefaultInPicker; import static com.android.launcher3.allapps.ActivityAllAppsContainerView.AdapterHolder.SEARCH; import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_WIDGETSTRAY_EXPAND_PRESS; import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_WIDGETSTRAY_SEARCHED; import static com.android.launcher3.testing.shared.TestProtocol.NORMAL_STATE_ORDINAL; import static com.android.launcher3.views.RecyclerViewFastScroller.FastScrollerLocation.WIDGET_SCROLLER; import static java.lang.Math.abs; import static java.util.Collections.emptyList; import android.animation.Animator; import android.content.Context; import android.content.res.Resources; import android.graphics.Rect; import android.os.Bundle; import android.os.Parcelable; import android.os.Process; import android.os.UserHandle; import android.os.UserManager; import android.util.AttributeSet; import android.util.Pair; import android.util.SparseArray; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.ViewParent; import android.view.WindowInsets; import android.view.WindowInsetsController; import android.view.animation.AnimationUtils; import android.view.animation.Interpolator; import android.widget.Button; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.VisibleForTesting; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.RecyclerView; import com.android.launcher3.BaseActivity; import com.android.launcher3.DeviceProfile; import com.android.launcher3.R; import com.android.launcher3.anim.PendingAnimation; import com.android.launcher3.compat.AccessibilityManagerCompat; import com.android.launcher3.model.UserManagerState; import com.android.launcher3.model.WidgetItem; import com.android.launcher3.pm.UserCache; import com.android.launcher3.views.RecyclerViewFastScroller; import com.android.launcher3.views.SpringRelativeLayout; import com.android.launcher3.views.StickyHeaderLayout; import com.android.launcher3.widget.BaseWidgetSheet; import com.android.launcher3.widget.WidgetCell; import com.android.launcher3.widget.model.WidgetsListBaseEntry; import com.android.launcher3.widget.picker.model.data.WidgetPickerData; import com.android.launcher3.widget.picker.search.SearchModeListener; import com.android.launcher3.widget.picker.search.WidgetsSearchBar; import com.android.launcher3.widget.picker.search.WidgetsSearchBar.WidgetsSearchDataProvider; import com.android.launcher3.workprofile.PersonalWorkPagedView; import com.android.launcher3.workprofile.PersonalWorkSlidingTabStrip.OnActivePageChangedListener; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Predicate; import java.util.stream.IntStream; /** * Popup for showing the full list of available widgets */ public class WidgetsFullSheet extends BaseWidgetSheet implements OnActivePageChangedListener, WidgetsRecyclerView.HeaderViewDimensionsProvider, SearchModeListener, WidgetsListAdapter.ExpandButtonClickListener { private static final long FADE_IN_DURATION = 150; // The widget recommendation table can easily take over the entire screen on devices with small // resolution or landscape on phone. This ratio defines the max percentage of content area that // the table can display with respect to bottom sheet's height. private static final float RECOMMENDATION_TABLE_HEIGHT_RATIO = 0.45f; private static final String RECOMMENDATIONS_SAVED_STATE_KEY = "widgetsFullSheet:mRecommendationsCurrentPage"; private static final String SUPER_SAVED_STATE_KEY = "widgetsFullSheet:superHierarchyState"; private final UserCache mUserCache; private final UserManagerState mUserManagerState = new UserManagerState(); private final UserHandle mCurrentUser = Process.myUserHandle(); private final Predicate mPrimaryWidgetsFilter = entry -> mCurrentUser.equals(entry.mPkgItem.user); private final Predicate mWorkWidgetsFilter; protected final boolean mHasWorkProfile; // Number of recommendations displayed protected int mRecommendedWidgetsCount; private List mRecommendedWidgets = new ArrayList<>(); private Map> mRecommendedWidgetsMap = new HashMap<>(); protected int mRecommendationsCurrentPage = 0; protected final SparseArray mAdapters = new SparseArray(); // Helps with removing focus from searchbar by analyzing motion events. private final SearchClearFocusHelper mSearchClearFocusHelper = new SearchClearFocusHelper(); private final float mTouchSlop; // initialized in constructor private final OnAttachStateChangeListener mBindScrollbarInSearchMode = new OnAttachStateChangeListener() { @Override public void onViewAttachedToWindow(View view) { WidgetsRecyclerView searchRecyclerView = mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView; if (mIsInSearchMode && searchRecyclerView != null) { searchRecyclerView.bindFastScrollbar(mFastScroller, WIDGET_SCROLLER); } } @Override public void onViewDetachedFromWindow(View view) { } }; @Px private final int mTabsHeight; @Nullable private WidgetsRecyclerView mCurrentWidgetsRecyclerView; @Nullable private WidgetsRecyclerView mCurrentTouchEventRecyclerView; @Nullable PersonalWorkPagedView mViewPager; protected boolean mIsInSearchMode; private boolean mIsNoWidgetsViewNeeded; @Px protected int mMaxSpanPerRow; protected DeviceProfile mDeviceProfile; protected TextView mNoWidgetsView; protected LinearLayout mSearchScrollView; // Reference to the mSearchScrollView when it is is a sticky header. private @Nullable StickyHeaderLayout mStickyHeaderLayout; protected WidgetRecommendationsView mWidgetRecommendationsView; protected LinearLayout mWidgetRecommendationsContainer; protected View mTabBar; protected View mSearchBarContainer; protected WidgetsSearchBar mSearchBar; protected TextView mHeaderTitle; protected RecyclerViewFastScroller mFastScroller; protected int mBottomPadding; public WidgetsFullSheet(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mDeviceProfile = mActivityContext.getDeviceProfile(); mUserCache = UserCache.INSTANCE.get(context); mHasWorkProfile = mUserCache.getUserProfiles() .stream() .anyMatch(user -> mUserCache.getUserInfo(user).isWork()); mWorkWidgetsFilter = entry -> mHasWorkProfile && mUserCache.getUserInfo(entry.mPkgItem.user).isWork() && !mUserManagerState.isUserQuiet(entry.mPkgItem.user); mAdapters.put(AdapterHolder.PRIMARY, new AdapterHolder(AdapterHolder.PRIMARY)); mAdapters.put(AdapterHolder.WORK, new AdapterHolder(AdapterHolder.WORK)); mAdapters.put(AdapterHolder.SEARCH, new AdapterHolder(AdapterHolder.SEARCH)); Resources resources = getResources(); mUserManagerState.init(UserCache.INSTANCE.get(context), context.getSystemService(UserManager.class)); mTabsHeight = mHasWorkProfile ? resources.getDimensionPixelSize(R.dimen.all_apps_header_pill_height) : 0; } public WidgetsFullSheet(Context context, AttributeSet attrs) { this(context, attrs, 0); } @Override protected void onFinishInflate() { super.onFinishInflate(); mContent = findViewById(R.id.container); setContentBackgroundWithParent(getContext().getDrawable(R.drawable.bg_widgets_full_sheet), mContent); mContent.setOutlineProvider(mViewOutlineProvider); mContent.setClipToOutline(true); setupSheet(); } protected void setupSheet() { LayoutInflater layoutInflater = LayoutInflater.from(getContext()); int contentLayoutRes = mHasWorkProfile ? R.layout.widgets_full_sheet_paged_view : R.layout.widgets_full_sheet_recyclerview; layoutInflater.inflate(contentLayoutRes, mContent, true); setupViews(); mWidgetRecommendationsContainer = mSearchScrollView.findViewById( R.id.widget_recommendations_container); mWidgetRecommendationsView = mSearchScrollView.findViewById( R.id.widget_recommendations_view); // To save the currently displayed page, so that, it can be requested when rebinding // recommendations with different size constraints. mWidgetRecommendationsView.addPageSwitchListener( newPage -> mRecommendationsCurrentPage = newPage); mWidgetRecommendationsView.initParentViews(mWidgetRecommendationsContainer); mWidgetRecommendationsView.setWidgetCellLongClickListener(this); mWidgetRecommendationsView.setWidgetCellOnClickListener(this); mHeaderTitle = mSearchScrollView.findViewById(R.id.title); onWidgetsBound(); } protected void setupViews() { mSearchScrollView = findViewById(R.id.search_and_recommendations_container); if (mSearchScrollView instanceof StickyHeaderLayout) { mStickyHeaderLayout = (StickyHeaderLayout) mSearchScrollView; mStickyHeaderLayout.setCurrentRecyclerView( findViewById(R.id.primary_widgets_list_view)); } mNoWidgetsView = findViewById(R.id.no_widgets_text); mFastScroller = findViewById(R.id.fast_scroller); mFastScroller.setPopupView(findViewById(R.id.fast_scroller_popup)); mAdapters.get(AdapterHolder.PRIMARY).setup(findViewById(R.id.primary_widgets_list_view)); mAdapters.get(AdapterHolder.SEARCH).setup(findViewById(R.id.search_widgets_list_view)); if (mHasWorkProfile) { mViewPager = findViewById(R.id.widgets_view_pager); mViewPager.setOutlineProvider(mViewOutlineProvider); mViewPager.setClipToOutline(true); mViewPager.setClipChildren(false); mViewPager.initParentViews(this); mViewPager.getPageIndicator().setOnActivePageChangedListener(this); mViewPager.getPageIndicator().setActiveMarker(AdapterHolder.PRIMARY); findViewById(R.id.tab_personal) .setOnClickListener((View view) -> mViewPager.snapToPage(0)); findViewById(R.id.tab_work) .setOnClickListener((View view) -> mViewPager.snapToPage(1)); mAdapters.get(AdapterHolder.WORK).setup(findViewById(R.id.work_widgets_list_view)); setDeviceManagementResources(); } else { mViewPager = null; } mTabBar = mSearchScrollView.findViewById(R.id.tabs); mSearchBarContainer = mSearchScrollView.findViewById(R.id.search_bar_container); mSearchBar = mSearchScrollView.findViewById(R.id.widgets_search_bar); mSearchBar.initialize(new WidgetsSearchDataProvider() { @Override public List getWidgets() { if (enableTieredWidgetsByDefaultInPicker()) { // search all return mActivityContext.getWidgetPickerDataProvider().get().getAllWidgets(); } else { // Can be removed when inlining enableTieredWidgetsByDefaultInPicker flag return getWidgetsToDisplay(); } } }, /* searchModeListener= */ this); } private void setDeviceManagementResources() { if (mActivityContext.getStringCache() != null) { Button personalTab = findViewById(R.id.tab_personal); personalTab.setText(mActivityContext.getStringCache().widgetsPersonalTab); Button workTab = findViewById(R.id.tab_work); workTab.setText(mActivityContext.getStringCache().widgetsWorkTab); } } @Override public void onActivePageChanged(int currentActivePage) { AdapterHolder currentAdapterHolder = mAdapters.get(currentActivePage); WidgetsRecyclerView currentRecyclerView = mAdapters.get(currentActivePage).mWidgetsRecyclerView; updateRecyclerViewVisibility(currentAdapterHolder); attachScrollbarToRecyclerView(currentRecyclerView); } private void attachScrollbarToRecyclerView(WidgetsRecyclerView recyclerView) { if (mCurrentWidgetsRecyclerView != recyclerView) { // Bind scrollbar if changing the recycler view. If widgets list updates, since // scrollbar is already attached to the recycler view, it will automatically adjust as // needed with recycler view's onScrollListener. recyclerView.bindFastScrollbar(mFastScroller, WIDGET_SCROLLER); // Only reset the scroll position & expanded apps if the currently shown recycler view // has been updated. reset(); resetExpandedHeaders(); mCurrentWidgetsRecyclerView = recyclerView; if (mStickyHeaderLayout != null) { mStickyHeaderLayout.setCurrentRecyclerView(recyclerView); } } } protected void updateRecyclerViewVisibility(AdapterHolder adapterHolder) { // The first item is always an empty space entry. Look for any more items. boolean isWidgetAvailable = adapterHolder.mWidgetsListAdapter.hasVisibleEntries(); if (adapterHolder.mAdapterType == AdapterHolder.SEARCH) { mNoWidgetsView.setText(R.string.no_search_results); adapterHolder.mWidgetsRecyclerView.setVisibility(isWidgetAvailable ? VISIBLE : GONE); } else if (adapterHolder.mAdapterType == AdapterHolder.WORK && mUserCache.getUserProfiles().stream() .filter(userHandle -> mUserCache.getUserInfo(userHandle).isWork()) .anyMatch(mUserManagerState::isUserQuiet) && mActivityContext.getStringCache() != null) { mNoWidgetsView.setText(mActivityContext.getStringCache().workProfilePausedTitle); } else { mNoWidgetsView.setText(R.string.no_widgets_available); } mNoWidgetsView.setVisibility(isWidgetAvailable ? GONE : VISIBLE); } private void reset() { mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView.scrollToTop(); if (mHasWorkProfile) { mAdapters.get(AdapterHolder.WORK).mWidgetsRecyclerView.scrollToTop(); } mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView.scrollToTop(); if (mStickyHeaderLayout != null) { mStickyHeaderLayout.reset(/* animate= */ true); } } @VisibleForTesting public WidgetsRecyclerView getRecyclerView() { if (mIsInSearchMode) { return mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView; } if (!mHasWorkProfile || mViewPager.getCurrentPage() == AdapterHolder.PRIMARY) { return mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView; } return mAdapters.get(AdapterHolder.WORK).mWidgetsRecyclerView; } @Override protected Pair getAccessibilityTarget() { return Pair.create(getRecyclerView(), getContext().getString( mIsOpen ? R.string.widgets_list : R.string.widgets_list_closed)); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); onWidgetsBound(); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView .removeOnAttachStateChangeListener(mBindScrollbarInSearchMode); if (mHasWorkProfile) { mAdapters.get(AdapterHolder.WORK).mWidgetsRecyclerView .removeOnAttachStateChangeListener(mBindScrollbarInSearchMode); } } @Override public void setInsets(Rect insets) { super.setInsets(insets); mBottomPadding = Math.max(insets.bottom, mNavBarScrimHeight); setBottomPadding(mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView, mBottomPadding); setBottomPadding(mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView, mBottomPadding); if (mHasWorkProfile) { setBottomPadding(mAdapters.get(AdapterHolder.WORK) .mWidgetsRecyclerView, mBottomPadding); } ((MarginLayoutParams) mNoWidgetsView.getLayoutParams()).bottomMargin = mBottomPadding; if (mBottomPadding > 0) { setupNavBarColor(); } else { clearNavBarColor(); } requestLayout(); } @Override public WindowInsets onApplyWindowInsets(WindowInsets insets) { WindowInsets w = super.onApplyWindowInsets(insets); if (mInsets.bottom != mNavBarScrimHeight) { setInsets(mInsets); } return w; } private void setBottomPadding(RecyclerView recyclerView, int bottomPadding) { recyclerView.setPadding( recyclerView.getPaddingLeft(), recyclerView.getPaddingTop(), recyclerView.getPaddingRight(), bottomPadding); } @Override protected void onContentHorizontalMarginChanged(int contentHorizontalMarginInPx) { setContentViewChildHorizontalMargin(mSearchScrollView, contentHorizontalMarginInPx); if (mViewPager == null) { setContentViewChildHorizontalPadding( mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView, contentHorizontalMarginInPx); } else { setContentViewChildHorizontalPadding( mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView, contentHorizontalMarginInPx); setContentViewChildHorizontalPadding( mAdapters.get(AdapterHolder.WORK).mWidgetsRecyclerView, contentHorizontalMarginInPx); } setContentViewChildHorizontalPadding( mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView, contentHorizontalMarginInPx); } private static void setContentViewChildHorizontalMargin(View view, int horizontalMarginInPx) { ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); layoutParams.setMarginStart(horizontalMarginInPx); layoutParams.setMarginEnd(horizontalMarginInPx); } private static void setContentViewChildHorizontalPadding(View view, int horizontalPaddingInPx) { view.setPadding(horizontalPaddingInPx, view.getPaddingTop(), horizontalPaddingInPx, view.getPaddingBottom()); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int availableWidth = MeasureSpec.getSize(widthMeasureSpec); updateMaxSpansPerRow(availableWidth); doMeasure(widthMeasureSpec, heightMeasureSpec); } /** Returns {@code true} if the max spans have been updated. * * @param availableWidth Total width available within parent (includes insets). */ private void updateMaxSpansPerRow(int availableWidth) { @Px int maxHorizontalSpan = getAvailableWidthForSuggestions( availableWidth - getInsetsWidth()); if (mMaxSpanPerRow != maxHorizontalSpan) { mMaxSpanPerRow = maxHorizontalSpan; mAdapters.get(AdapterHolder.PRIMARY).mWidgetsListAdapter.setMaxHorizontalSpansPxPerRow( maxHorizontalSpan); mAdapters.get(AdapterHolder.SEARCH).mWidgetsListAdapter.setMaxHorizontalSpansPxPerRow( maxHorizontalSpan); if (mHasWorkProfile) { mAdapters.get(AdapterHolder.WORK).mWidgetsListAdapter.setMaxHorizontalSpansPxPerRow( maxHorizontalSpan); } post(this::onRecommendedWidgetsBound); } } /** * Returns the width available to display suggestions. */ protected int getAvailableWidthForSuggestions(int pickerAvailableWidth) { return pickerAvailableWidth - (2 * mContentHorizontalMargin); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int width = r - l; int height = b - t; // Content is laid out as center bottom aligned int contentWidth = mContent.getMeasuredWidth(); int contentLeft = (width - contentWidth - mInsets.left - mInsets.right) / 2 + mInsets.left; mContent.layout(contentLeft, height - mContent.getMeasuredHeight(), contentLeft + contentWidth, height); setTranslationShift(mTranslationShift); } /** * Returns all displayable widgets. */ // Used by the two pane sheet to show 3-dot menu to toggle between default lists and all lists // when enableTieredWidgetsByDefaultInPicker is OFF. This code path and the 3-dot menu can be // safely deleted when it's alternative "enableTieredWidgetsByDefaultInPicker" flag is inlined. protected List getWidgetsToDisplay() { return mActivityContext.getWidgetPickerDataProvider().get().getAllWidgets(); } @Override public void onWidgetsBound() { if (mIsInSearchMode) { return; } List widgets; List defaultWidgets = emptyList(); if (enableTieredWidgetsByDefaultInPicker()) { WidgetPickerData dataProvider = mActivityContext.getWidgetPickerDataProvider().get(); widgets = dataProvider.getAllWidgets(); defaultWidgets = dataProvider.getDefaultWidgets(); } else { // This code path can be deleted once enableTieredWidgetsByDefaultInPicker is inlined. widgets = getWidgetsToDisplay(); } AdapterHolder primaryUserAdapterHolder = mAdapters.get(AdapterHolder.PRIMARY); primaryUserAdapterHolder.mWidgetsListAdapter.setWidgets(widgets, defaultWidgets); if (mHasWorkProfile) { mViewPager.setVisibility(VISIBLE); mTabBar.setVisibility(VISIBLE); AdapterHolder workUserAdapterHolder = mAdapters.get(AdapterHolder.WORK); workUserAdapterHolder.mWidgetsListAdapter.setWidgets(widgets, defaultWidgets); onActivePageChanged(mViewPager.getCurrentPage()); } else { onActivePageChanged(0); } // Update recommended widgets section so that it occupies appropriate space on screen to // leave enough space for presence/absence of mNoWidgetsView. boolean isNoWidgetsViewNeeded = !mAdapters.get(AdapterHolder.PRIMARY).mWidgetsListAdapter.hasVisibleEntries() || (mHasWorkProfile && mAdapters.get(AdapterHolder.WORK) .mWidgetsListAdapter.hasVisibleEntries()); if (mIsNoWidgetsViewNeeded != isNoWidgetsViewNeeded) { mIsNoWidgetsViewNeeded = isNoWidgetsViewNeeded; post(this::onRecommendedWidgetsBound); } } @Override public void onWidgetsListExpandButtonClick(View v) { AdapterHolder currentAdapterHolder = mAdapters.get(getCurrentAdapterHolderType()); currentAdapterHolder.mWidgetsListAdapter.useExpandedList(); onWidgetsBound(); currentAdapterHolder.mWidgetsRecyclerView.announceForAccessibility( mActivityContext.getString(R.string.widgets_list_expanded)); mActivityContext.getStatsLogManager().logger().log(LAUNCHER_WIDGETSTRAY_EXPAND_PRESS); } @Override public void enterSearchMode(boolean shouldLog) { if (mIsInSearchMode) return; setViewVisibilityBasedOnSearch(/*isInSearchMode= */ true); attachScrollbarToRecyclerView(mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView); if (shouldLog) { mActivityContext.getStatsLogManager().logger().log(LAUNCHER_WIDGETSTRAY_SEARCHED); } } @Override public void exitSearchMode() { if (!mIsInSearchMode) return; onSearchResults(new ArrayList<>()); // Remove all views when exiting the search mode; this prevents animating from stale results // to new ones the next time we enter search mode. By the time recycler view is hidden, // layout may not have happened to clear up existing results. So, instead of waiting for it // to happen, we clear the views here. mAdapters.get(AdapterHolder.SEARCH).reset(); setViewVisibilityBasedOnSearch(/*isInSearchMode=*/ false); if (mHasWorkProfile) { mViewPager.snapToPage(AdapterHolder.PRIMARY); } attachScrollbarToRecyclerView(mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView); } @Override public void onSearchResults(List entries) { mAdapters.get(AdapterHolder.SEARCH).mWidgetsListAdapter.setWidgetsOnSearch(entries); updateRecyclerViewVisibility(mAdapters.get(AdapterHolder.SEARCH)); } protected void setViewVisibilityBasedOnSearch(boolean isInSearchMode) { mIsInSearchMode = isInSearchMode; if (isInSearchMode) { mWidgetRecommendationsContainer.setVisibility(GONE); if (mHasWorkProfile) { mViewPager.setVisibility(GONE); mTabBar.setVisibility(GONE); } else { mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView.setVisibility(GONE); } updateRecyclerViewVisibility(mAdapters.get(AdapterHolder.SEARCH)); // Hide no search results view to prevent it from flashing on enter search. mNoWidgetsView.setVisibility(GONE); } else { mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView.setVisibility(GONE); AdapterHolder currentAdapterHolder = mAdapters.get(getCurrentAdapterHolderType()); // Remove all views when exiting the search mode; this prevents animating / flashing old // list position / state. currentAdapterHolder.reset(); currentAdapterHolder.mWidgetsRecyclerView.setVisibility(VISIBLE); post(this::onRecommendedWidgetsBound); // Visibility of recycler views and headers are handled in methods below. onWidgetsBound(); } } protected void resetExpandedHeaders() { mAdapters.get(AdapterHolder.PRIMARY).mWidgetsListAdapter.resetExpandedHeader(); mAdapters.get(AdapterHolder.WORK).mWidgetsListAdapter.resetExpandedHeader(); } @Override public void onRecommendedWidgetsBound() { if (mIsInSearchMode) { return; } boolean forceUpdate = false; // We avoid applying new recommendations when some are already displayed. if (mRecommendedWidgetsMap.isEmpty()) { mRecommendedWidgetsMap = mActivityContext.getWidgetPickerDataProvider().get().getRecommendations(); forceUpdate = true; } mRecommendedWidgetsCount = mWidgetRecommendationsView.setRecommendations( mRecommendedWidgetsMap, mDeviceProfile, /* availableHeight= */ getMaxAvailableHeightForRecommendations(), /* availableWidth= */ mMaxSpanPerRow, /* cellPadding= */ mWidgetCellHorizontalPadding, /* requestedPage= */ mRecommendationsCurrentPage, /* forceUpdate= */ forceUpdate ); mWidgetRecommendationsContainer.setVisibility( mRecommendedWidgetsCount > 0 ? VISIBLE : GONE); } @Px protected float getMaxAvailableHeightForRecommendations() { // There isn't enough space to show recommendations in landscape orientation on phones with // a full sheet design. Tablets use a two pane picker. if (mDeviceProfile.isLandscape) { return 0f; } return (mDeviceProfile.heightPx - mDeviceProfile.bottomSheetTopPadding) * RECOMMENDATION_TABLE_HEIGHT_RATIO; } /** b/209579563: "Widgets" header should be focused first. */ @Override protected View getAccessibilityInitialFocusView() { return mHeaderTitle; } private void open(boolean animate) { if (animate) { if (getPopupContainer().getInsets().bottom > 0) { mContent.setAlpha(0); } setUpOpenAnimation(mActivityContext.getDeviceProfile().bottomSheetOpenDuration); Animator animator = mOpenCloseAnimation.getAnimationPlayer(); animator.setInterpolator(AnimationUtils.loadInterpolator( getContext(), android.R.interpolator.linear_out_slow_in)); post(() -> { animator.setDuration(mActivityContext.getDeviceProfile().bottomSheetOpenDuration) .start(); mContent.animate().alpha(1).setDuration(FADE_IN_DURATION); }); } else { setTranslationShift(TRANSLATION_SHIFT_OPENED); post(this::announceAccessibilityChanges); } } @Override protected void handleClose(boolean animate) { handleClose(animate, mActivityContext.getDeviceProfile().bottomSheetCloseDuration); } @Override protected boolean isOfType(int type) { return (type & TYPE_WIDGETS_FULL_SHEET) != 0; } @Override public boolean onControllerInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { mNoIntercept = shouldScroll(ev); } // Clear focus only if user touched outside of search area and handling focus out ourselves // was necessary (e.g. when it's not predictive back, but other user interaction). if (mSearchBar.isSearchBarFocused() && !getPopupContainer().isEventOverView(mSearchBarContainer, ev) && mSearchClearFocusHelper.shouldClearFocus(ev, mTouchSlop)) { mSearchBar.clearSearchBarFocus(); } return super.onControllerInterceptTouchEvent(ev); } protected boolean shouldScroll(MotionEvent ev) { boolean intercept = false; WidgetsRecyclerView recyclerView = getRecyclerView(); RecyclerViewFastScroller scroller = recyclerView.getScrollbar(); // Disable swipe down when recycler view is scrolling if (scroller.getThumbOffsetY() >= 0 && getPopupContainer().isEventOverView(scroller, ev)) { intercept = true; } else if (getPopupContainer().isEventOverView(recyclerView, ev)) { intercept = !recyclerView.shouldContainerScroll(ev, getPopupContainer()); } return intercept; } /** Shows the {@link WidgetsFullSheet} on the launcher. */ public static WidgetsFullSheet show(BaseActivity activity, boolean animate) { WidgetsFullSheet sheet = (WidgetsFullSheet) activity.getLayoutInflater().inflate( getWidgetSheetId(activity), activity.getDragLayer(), false); sheet.attachToContainer(); sheet.mIsOpen = true; sheet.open(animate); return sheet; } /** * Updates the widget picker's title and description in the header to the provided values (if * present). */ public void mayUpdateTitleAndDescription(@Nullable String title, @Nullable String descriptionRes) { if (title != null) { mHeaderTitle.setText(title); } // Full sheet doesn't support a description. } @Override public void saveHierarchyState(SparseArray sparseArray) { Bundle bundle = new Bundle(); // With widget picker open, when we open shade to switch theme, Launcher re-creates the // picker and calls save/restore hierarchy state. We save the state of recommendations // across those updates. bundle.putInt(RECOMMENDATIONS_SAVED_STATE_KEY, mRecommendationsCurrentPage); mWidgetRecommendationsView.saveState(bundle); SparseArray superState = new SparseArray<>(); super.saveHierarchyState(superState); bundle.putSparseParcelableArray(SUPER_SAVED_STATE_KEY, superState); sparseArray.put(0, bundle); } @Override public void restoreHierarchyState(SparseArray sparseArray) { Bundle state = (Bundle) sparseArray.get(0); mRecommendationsCurrentPage = state.getInt( RECOMMENDATIONS_SAVED_STATE_KEY, /*defaultValue=*/0); mWidgetRecommendationsView.restoreState(state); super.restoreHierarchyState(state.getSparseParcelableArray(SUPER_SAVED_STATE_KEY)); } private static int getWidgetSheetId(BaseActivity activity) { boolean isTwoPane = activity.getDeviceProfile().isTablet; return isTwoPane ? R.layout.widgets_two_pane_sheet : R.layout.widgets_full_sheet; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { return isTouchOnScrollbar(ev) || super.onInterceptTouchEvent(ev); } @Override public boolean onTouchEvent(MotionEvent ev) { return maybeHandleTouchEvent(ev) || super.onTouchEvent(ev); } private boolean maybeHandleTouchEvent(MotionEvent ev) { boolean isEventHandled = false; if (ev.getAction() == MotionEvent.ACTION_DOWN) { mCurrentTouchEventRecyclerView = isTouchOnScrollbar(ev) ? getRecyclerView() : null; } if (mCurrentTouchEventRecyclerView != null) { final float offsetX = mContent.getX(); final float offsetY = mContent.getY(); ev.offsetLocation(-offsetX, -offsetY); isEventHandled = mCurrentTouchEventRecyclerView.dispatchTouchEvent(ev); ev.offsetLocation(offsetX, offsetY); } if (ev.getAction() == MotionEvent.ACTION_UP || ev.getAction() == MotionEvent.ACTION_CANCEL) { mCurrentTouchEventRecyclerView = null; } return isEventHandled; } private boolean isTouchOnScrollbar(MotionEvent ev) { final float offsetX = mContent.getX(); final float offsetY = mContent.getY(); WidgetsRecyclerView rv = getRecyclerView(); ev.offsetLocation(-offsetX, -offsetY); boolean isOnScrollBar = rv != null && rv.getScrollbar() != null && rv.isHitOnScrollBar(ev); ev.offsetLocation(offsetX, offsetY); return isOnScrollBar; } /** Gets the {@link WidgetsRecyclerView} which shows all widgets in {@link WidgetsFullSheet}. */ @VisibleForTesting public static WidgetsRecyclerView getWidgetsView(BaseActivity launcher) { return launcher.findViewById(R.id.primary_widgets_list_view); } @Override public void addHintCloseAnim( float distanceToMove, Interpolator interpolator, PendingAnimation target) { target.addAnimatedFloat(mSwipeToDismissProgress, 0f, 1f, interpolator); } @Override protected void onCloseComplete() { super.onCloseComplete(); AccessibilityManagerCompat.sendStateEventToTest(getContext(), NORMAL_STATE_ORDINAL); } @Override public int getHeaderViewHeight() { return measureHeightWithVerticalMargins(mHeaderTitle) + measureHeightWithVerticalMargins(mSearchBarContainer); } /** private the height, in pixel, + the vertical margins of a given view. */ protected static int measureHeightWithVerticalMargins(View view) { if (view == null || view.getVisibility() != VISIBLE) { return 0; } MarginLayoutParams marginLayoutParams = (MarginLayoutParams) view.getLayoutParams(); return view.getMeasuredHeight() + marginLayoutParams.bottomMargin + marginLayoutParams.topMargin; } protected int getCurrentAdapterHolderType() { if (mIsInSearchMode) { return SEARCH; } else if (mViewPager != null) { return mViewPager.getCurrentPage(); } else { return AdapterHolder.PRIMARY; } } private void restorePreviousAdapterHolderType(int previousAdapterHolderType) { if (previousAdapterHolderType == AdapterHolder.WORK && mViewPager != null) { mViewPager.setCurrentPage(previousAdapterHolderType); } else if (previousAdapterHolderType == AdapterHolder.SEARCH) { enterSearchMode(false); } } @Override public void onDeviceProfileChanged(DeviceProfile dp) { super.onDeviceProfileChanged(dp); if (shouldRecreateLayout(/*oldDp=*/ mDeviceProfile, /*newDp=*/ dp)) { SparseArray widgetsState = new SparseArray<>(); saveHierarchyState(widgetsState); handleClose(false); WidgetsFullSheet sheet = show(BaseActivity.fromContext(getContext()), false); sheet.restoreRecommendations(mRecommendedWidgets, mRecommendedWidgetsMap); sheet.restoreHierarchyState(widgetsState); sheet.restoreAdapterStates(mAdapters); sheet.restorePreviousAdapterHolderType(getCurrentAdapterHolderType()); } else if (!isTwoPane()) { reset(); resetExpandedHeaders(); } mDeviceProfile = dp; } private void restoreRecommendations(List recommendedWidgets, Map> recommendedWidgetsMap) { mRecommendedWidgets = recommendedWidgets; mRecommendedWidgetsMap = recommendedWidgetsMap; } private void restoreAdapterStates(SparseArray adapters) { if (adapters.contains(AdapterHolder.WORK)) { mAdapters.get(AdapterHolder.WORK).mWidgetsListAdapter.restoreState( adapters.get(AdapterHolder.WORK).mWidgetsListAdapter); } mAdapters.get(AdapterHolder.PRIMARY).mWidgetsListAdapter.restoreState( adapters.get(AdapterHolder.PRIMARY).mWidgetsListAdapter); mAdapters.get(AdapterHolder.SEARCH).mWidgetsListAdapter.restoreState( adapters.get(AdapterHolder.SEARCH).mWidgetsListAdapter); } /** * Indicates if layout should be re-created on device profile change - so that a different * layout can be displayed. */ private static boolean shouldRecreateLayout(DeviceProfile oldDp, DeviceProfile newDp) { // When folding/unfolding the foldables, we need to switch between the regular widget picker // and the two pane picker, so we rebuild the picker with the correct layout. return oldDp.isTwoPanels != newDp.isTwoPanels; } /** * In widget search mode, we should scale down content inside widget bottom sheet, rather * than the whole bottom sheet, to indicate we will navigate back within the widget * bottom sheet. */ @Override public boolean shouldAnimateContentViewInBackSwipe() { return mIsInSearchMode; } @Override public void onBackInvoked() { if (mIsInSearchMode) { mSearchBar.reset(); // Posting animation to next frame will let widget sheet finish updating UI first, and // make animation smoother. post(this::animateSwipeToDismissProgressToStart); } else { super.onBackInvoked(); } } @Override public void onDragStart(boolean start, float startDisplacement) { super.onDragStart(start, startDisplacement); WindowInsetsController insetsController = getWindowInsetsController(); if (insetsController != null) { insetsController.hide(WindowInsets.Type.ime()); } } @Nullable private View getViewToShowEducationTip() { if (mWidgetRecommendationsContainer.getVisibility() == VISIBLE) { return mWidgetRecommendationsView.getViewForEducationTip(); } AdapterHolder adapterHolder = mAdapters.get(mIsInSearchMode ? AdapterHolder.SEARCH : mViewPager == null ? AdapterHolder.PRIMARY : mViewPager.getCurrentPage()); WidgetsRowViewHolder viewHolderForTip = (WidgetsRowViewHolder) IntStream.range( 0, adapterHolder.mWidgetsListAdapter.getItemCount()) .mapToObj(adapterHolder.mWidgetsRecyclerView:: findViewHolderForAdapterPosition) .filter(viewHolder -> viewHolder instanceof WidgetsRowViewHolder) .findFirst() .orElse(null); if (viewHolderForTip != null) { return ((ViewGroup) viewHolderForTip.tableContainer.getChildAt(0)).getChildAt(0); } return null; } protected boolean isTwoPane() { return false; } /** Gets the sheet for widget picker, which is used for testing. */ @VisibleForTesting public View getSheet() { return mContent; } /** Opens the first header in widget picker and scrolls to the top of the RecyclerView. */ @VisibleForTesting public void openFirstHeader() { mAdapters.get(AdapterHolder.PRIMARY).mWidgetsListAdapter.selectFirstHeaderEntry(); mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView.scrollToTop(); } @Override protected int getHeaderTopClip(@NonNull WidgetCell cell) { StickyHeaderLayout header = findViewById(R.id.search_and_recommendations_container); if (header == null) { return 0; } Rect cellRect = new Rect(); boolean cellIsPartiallyVisible = cell.getGlobalVisibleRect(cellRect); if (cellIsPartiallyVisible) { Rect occludingRect = new Rect(); for (View headerChild : header.getStickyChildren()) { Rect childRect = new Rect(); boolean childVisible = headerChild.getGlobalVisibleRect(childRect); if (childVisible && childRect.intersect(cellRect)) { occludingRect.union(childRect); } } if (!occludingRect.isEmpty() && cellRect.top < occludingRect.bottom) { return occludingRect.bottom - cellRect.top; } } return 0; } @Override protected void scrollCellContainerByY(WidgetCell wc, int scrollByY) { for (ViewParent parent = wc.getParent(); parent != null; parent = parent.getParent()) { if (parent instanceof WidgetsRecyclerView recyclerView) { // Scrollable container for main widget list. recyclerView.smoothScrollBy(0, scrollByY); return; } else if (parent instanceof StickyHeaderLayout header) { // Scrollable container for recommendations. We still scroll on the recycler (even // though the recommendations are not in the recycler view) because the // StickyHeaderLayout scroll is connected to the currently visible recycler view. WidgetsRecyclerView recyclerView = findVisibleRecyclerView(); if (recyclerView != null) { recyclerView.smoothScrollBy(0, scrollByY); } return; } else if (parent == this) { return; } } } @Nullable private WidgetsRecyclerView findVisibleRecyclerView() { if (mViewPager != null) { return (WidgetsRecyclerView) mViewPager.getPageAt(mViewPager.getCurrentPage()); } return findViewById(R.id.primary_widgets_list_view); } /** A holder class for holding adapters & their corresponding recycler view. */ final class AdapterHolder { static final int PRIMARY = 0; static final int WORK = 1; static final int SEARCH = 2; private final int mAdapterType; final WidgetsListAdapter mWidgetsListAdapter; private final DefaultItemAnimator mWidgetsListItemAnimator; WidgetsRecyclerView mWidgetsRecyclerView; AdapterHolder(int adapterType) { mAdapterType = adapterType; Context context = getContext(); mWidgetsListAdapter = new WidgetsListAdapter( context, LayoutInflater.from(context), this::getEmptySpaceHeight, /* iconClickListener= */ WidgetsFullSheet.this, /* iconLongClickListener= */ WidgetsFullSheet.this, /* expandButtonClickListener= */ WidgetsFullSheet.this, isTwoPane()); mWidgetsListAdapter.setHasStableIds(true); switch (mAdapterType) { case PRIMARY: mWidgetsListAdapter.setFilter(mPrimaryWidgetsFilter); break; case WORK: mWidgetsListAdapter.setFilter(mWorkWidgetsFilter); break; default: break; } mWidgetsListItemAnimator = new WidgetsListItemAnimator(); } /** * Swaps the adapter to existing adapter to prevent the recycler view from using stale view * to animate in the new visibility update. * *

For instance, when clearing search text and re-entering search with new list shouldn't * use stale results to animate in new results. Alternative is setting list animators to * null, but, we need animations with the default item animator. */ private void reset() { mWidgetsRecyclerView.swapAdapter( mWidgetsListAdapter, /*removeAndRecycleExistingViews=*/ true ); } private int getEmptySpaceHeight() { return mStickyHeaderLayout != null ? mStickyHeaderLayout.getHeaderHeight() : 0; } void setup(WidgetsRecyclerView recyclerView) { mWidgetsRecyclerView = recyclerView; mWidgetsRecyclerView.setOutlineProvider(mViewOutlineProvider); mWidgetsRecyclerView.setClipToOutline(true); mWidgetsRecyclerView.setClipChildren(false); mWidgetsRecyclerView.setAdapter(mWidgetsListAdapter); mWidgetsRecyclerView.bindFastScrollbar(mFastScroller, WIDGET_SCROLLER); mWidgetsRecyclerView.setItemAnimator(isTwoPane() ? null : mWidgetsListItemAnimator); mWidgetsRecyclerView.setHeaderViewDimensionsProvider(WidgetsFullSheet.this); if (!isTwoPane()) { mWidgetsRecyclerView.setEdgeEffectFactory( ((SpringRelativeLayout) mContent).createEdgeEffectFactory()); } // Recycler view binds to fast scroller when it is attached to screen. Make sure // search recycler view is bound to fast scroller if user is in search mode at the time // of attachment. if (mAdapterType == PRIMARY || mAdapterType == WORK) { mWidgetsRecyclerView.addOnAttachStateChangeListener(mBindScrollbarInSearchMode); } mWidgetsListAdapter.setMaxHorizontalSpansPxPerRow(mMaxSpanPerRow); } } /** * Helper to identify if searchbar's focus can be cleared when user performs an action * outside search. */ private static class SearchClearFocusHelper { private float mFirstInteractionX = -1f; private float mFirstInteractionY = -1f; /** * For a given [MotionEvent] indicates if we should clear focus from search (and hide IME). */ boolean shouldClearFocus(MotionEvent ev, float touchSlop) { int action = ev.getAction(); boolean clearFocus = false; if (action == MotionEvent.ACTION_DOWN) { mFirstInteractionX = ev.getX(); mFirstInteractionY = ev.getY(); } else if (action == MotionEvent.ACTION_CANCEL) { // This is when user performed a gesture e.g. predictive back // We don't handle it ourselves and let IME handle the close. mFirstInteractionY = -1; mFirstInteractionX = -1; } else if (action == MotionEvent.ACTION_UP) { // Its clear that user action wasn't predictive back - but press / scroll etc. that // should hide the keyboard. clearFocus = true; mFirstInteractionY = -1; mFirstInteractionX = -1; } else if (action == MotionEvent.ACTION_MOVE) { // Sometimes, on move, we may not receive ACTION_UP, but if the move was within // touch slop and we didn't know if its moved or cancelled, we can clear focus. // Example case: Apps list is small and you do a little scroll on list - in such, we // want to still hide the keyboard. if (mFirstInteractionX != -1 && mFirstInteractionY != -1) { float distY = abs(mFirstInteractionY - ev.getY()); float distX = abs(mFirstInteractionX - ev.getX()); if (distY >= touchSlop || distX >= touchSlop) { clearFocus = true; mFirstInteractionY = -1; mFirstInteractionX = -1; } } } return clearFocus; } } }