1 /* 2 * Copyright (C) 2017 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; 17 18 import static com.android.app.animation.Interpolators.EMPHASIZED; 19 import static com.android.launcher3.Flags.enableWidgetTapToAdd; 20 import static com.android.launcher3.LauncherState.NORMAL; 21 import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback; 22 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_WIDGET_ADD_BUTTON_TAP; 23 24 import android.content.Context; 25 import android.graphics.Canvas; 26 import android.graphics.Paint; 27 import android.graphics.Rect; 28 import android.util.AttributeSet; 29 import android.util.Log; 30 import android.view.View; 31 import android.view.View.OnClickListener; 32 import android.view.View.OnLongClickListener; 33 import android.view.WindowInsets; 34 import android.view.animation.Interpolator; 35 36 import androidx.annotation.NonNull; 37 import androidx.annotation.Nullable; 38 import androidx.annotation.Px; 39 40 import com.android.launcher3.BaseActivity; 41 import com.android.launcher3.DeviceProfile; 42 import com.android.launcher3.DeviceProfile.OnDeviceProfileChangeListener; 43 import com.android.launcher3.Insettable; 44 import com.android.launcher3.Launcher; 45 import com.android.launcher3.PendingAddItemInfo; 46 import com.android.launcher3.R; 47 import com.android.launcher3.model.WidgetItem; 48 import com.android.launcher3.popup.PopupDataProvider; 49 import com.android.launcher3.testing.TestLogging; 50 import com.android.launcher3.testing.shared.TestProtocol; 51 import com.android.launcher3.util.SystemUiController; 52 import com.android.launcher3.util.Themes; 53 import com.android.launcher3.util.window.WindowManagerProxy; 54 import com.android.launcher3.views.AbstractSlideInView; 55 56 import java.util.concurrent.atomic.AtomicBoolean; 57 58 /** 59 * Base class for various widgets popup 60 */ 61 public abstract class BaseWidgetSheet extends AbstractSlideInView<BaseActivity> 62 implements OnClickListener, OnLongClickListener, 63 PopupDataProvider.PopupDataChangeListener, Insettable, OnDeviceProfileChangeListener { 64 /** The default number of cells that can fit horizontally in a widget sheet. */ 65 public static final int DEFAULT_MAX_HORIZONTAL_SPANS = 4; 66 67 protected final Rect mInsets = new Rect(); 68 69 @Px 70 protected int mContentHorizontalMargin; 71 @Px 72 protected int mWidgetCellHorizontalPadding; 73 74 protected int mNavBarScrimHeight; 75 private final Paint mNavBarScrimPaint; 76 77 private boolean mDisableNavBarScrim = false; 78 79 @Nullable private WidgetCell mWidgetCellWithAddButton = null; 80 @Nullable private WidgetItem mLastSelectedWidgetItem = null; 81 BaseWidgetSheet(Context context, AttributeSet attrs, int defStyleAttr)82 public BaseWidgetSheet(Context context, AttributeSet attrs, int defStyleAttr) { 83 super(context, attrs, defStyleAttr); 84 mContentHorizontalMargin = getWidgetListHorizontalMargin(); 85 mWidgetCellHorizontalPadding = getResources().getDimensionPixelSize( 86 R.dimen.widget_cell_horizontal_padding); 87 mNavBarScrimPaint = new Paint(); 88 mNavBarScrimPaint.setColor(Themes.getNavBarScrimColor(mActivityContext)); 89 } 90 91 /** 92 * Returns the margins to be applied to the left and right of the widget apps list. 93 */ getWidgetListHorizontalMargin()94 protected int getWidgetListHorizontalMargin() { 95 return getResources().getDimensionPixelSize( 96 R.dimen.widget_list_horizontal_margin); 97 } 98 getScrimColor(Context context)99 protected int getScrimColor(Context context) { 100 return context.getResources().getColor(R.color.widgets_picker_scrim); 101 } 102 103 @Override onAttachedToWindow()104 protected void onAttachedToWindow() { 105 super.onAttachedToWindow(); 106 WindowInsets windowInsets = WindowManagerProxy.INSTANCE.get(getContext()) 107 .normalizeWindowInsets(getContext(), getRootWindowInsets(), new Rect()); 108 mNavBarScrimHeight = getNavBarScrimHeight(windowInsets); 109 mActivityContext.getPopupDataProvider().setChangeListener(this); 110 mActivityContext.addOnDeviceProfileChangeListener(this); 111 } 112 113 @Override onDetachedFromWindow()114 protected void onDetachedFromWindow() { 115 super.onDetachedFromWindow(); 116 mActivityContext.getPopupDataProvider().setChangeListener(null); 117 mActivityContext.removeOnDeviceProfileChangeListener(this); 118 } 119 120 @Override onDeviceProfileChanged(DeviceProfile dp)121 public void onDeviceProfileChanged(DeviceProfile dp) { 122 int navBarScrimColor = Themes.getNavBarScrimColor(mActivityContext); 123 if (mNavBarScrimPaint.getColor() != navBarScrimColor) { 124 mNavBarScrimPaint.setColor(navBarScrimColor); 125 invalidate(); 126 } 127 setupNavBarColor(); 128 } 129 130 @Override onClick(View v)131 public final void onClick(View v) { 132 WidgetCell wc; 133 if (v instanceof WidgetCell view) { 134 wc = view; 135 } else if (v.getParent() instanceof WidgetCell parent) { 136 wc = parent; 137 } else { 138 return; 139 } 140 141 if (enableWidgetTapToAdd()) { 142 scrollToWidgetCell(wc); 143 144 if (mWidgetCellWithAddButton != null) { 145 if (mWidgetCellWithAddButton.isShowingAddButton()) { 146 // If there is a add button currently showing, hide it. 147 mWidgetCellWithAddButton.hideAddButton(/* animate= */ true); 148 } else { 149 // The last recorded widget cell to show an add button is no longer showing it, 150 // likely because the widget cell has been recycled or lost focus. If this is 151 // the cell that has been clicked, we will show it below. 152 mWidgetCellWithAddButton = null; 153 } 154 } 155 156 if (mWidgetCellWithAddButton != wc) { 157 // If click is on a cell not showing an add button, show it now. 158 final PendingAddItemInfo info = (PendingAddItemInfo) wc.getTag(); 159 if (mActivityContext instanceof Launcher) { 160 wc.showAddButton((view) -> addWidget(info)); 161 } else { 162 wc.showAddButton((view) -> mActivityContext.getItemOnClickListener() 163 .onClick(wc)); 164 } 165 } 166 167 mWidgetCellWithAddButton = mWidgetCellWithAddButton != wc ? wc : null; 168 if (mWidgetCellWithAddButton != null) { 169 mLastSelectedWidgetItem = mWidgetCellWithAddButton.getWidgetItem(); 170 } else { 171 mLastSelectedWidgetItem = null; 172 } 173 } else { 174 mActivityContext.getItemOnClickListener().onClick(wc); 175 } 176 } 177 178 @Override getShiftRange()179 protected float getShiftRange() { 180 // We add the extra height added during predictive back / swipe up to the shift range, so 181 // that the idle interpolator knows to animate the view off fully. 182 return mContent.getHeight() + getBottomOffsetPx(); 183 } 184 185 /** 186 * Click handler for tap to add button. This handler assumes we are in the Launcher activity and 187 * should not be used when the widget sheet is displayed elsewhere. 188 */ addWidget(@onNull PendingAddItemInfo info)189 private void addWidget(@NonNull PendingAddItemInfo info) { 190 // Using a boolean flag here to make sure the callback is only run once. This should never 191 // happen because we close the sheet and it will be reconstructed the next time it is 192 // needed. 193 final AtomicBoolean hasRun = new AtomicBoolean(false); 194 addOnCloseListener(() -> { 195 if (hasRun.get()) return; 196 hasRun.set(true); 197 198 // Going to NORMAL state will also dismiss the All Apps view if it is showing. 199 Launcher launcher = Launcher.getLauncher(mActivityContext); 200 launcher.getStateManager().goToState(NORMAL, forSuccessCallback(() -> { 201 launcher.getAccessibilityDelegate().addToWorkspace(info, 202 /*accessibility=*/ false, 203 /*finishCallback=*/ (success) -> { 204 mActivityContext.getStatsLogManager() 205 .logger() 206 .withItemInfo(info) 207 .log(LAUNCHER_WIDGET_ADD_BUTTON_TAP); 208 }); 209 })); 210 }); 211 close(/* animate= */ true); 212 } 213 214 /** 215 * Scroll to show the widget cell. If both the bottom and top of the cell are clipped, this will 216 * prioritize showing the bottom of the cell (where the add button is). 217 */ scrollToWidgetCell(@onNull WidgetCell wc)218 private void scrollToWidgetCell(@NonNull WidgetCell wc) { 219 final int headerTopClip = getHeaderTopClip(wc); 220 final Rect visibleRect = new Rect(); 221 final boolean isPartiallyVisible = wc.getLocalVisibleRect(visibleRect); 222 int scrollByY = 0; 223 if (isPartiallyVisible) { 224 final int scrollPadding = getResources() 225 .getDimensionPixelSize(R.dimen.widget_cell_add_button_scroll_padding); 226 final int topClip = visibleRect.top + headerTopClip; 227 final int bottomClip = wc.getHeight() - visibleRect.bottom; 228 if (bottomClip != 0) { 229 scrollByY = bottomClip + scrollPadding; 230 } else if (topClip != 0) { 231 scrollByY = -topClip - scrollPadding; 232 } 233 } 234 235 if (isPartiallyVisible && scrollByY == 0) { 236 // Widget is fully visible. 237 return; 238 } else if (!isPartiallyVisible) { 239 Log.e("BaseWidgetSheet", "click on invisible WidgetCell should not be possible"); 240 return; 241 } 242 243 scrollCellContainerByY(wc, scrollByY); 244 } 245 246 /** 247 * Find the nearest scrollable container of the given WidgetCell, and scroll by the given 248 * amount. 249 */ scrollCellContainerByY(WidgetCell wc, int scrollByY)250 protected abstract void scrollCellContainerByY(WidgetCell wc, int scrollByY); 251 252 253 /** 254 * Return the top clip of any sticky headers over the given cell. 255 */ getHeaderTopClip(@onNull WidgetCell cell)256 protected int getHeaderTopClip(@NonNull WidgetCell cell) { 257 return 0; 258 } 259 260 /** 261 * Returns the component of the widget that is currently showing an add button, if any. 262 */ 263 @Nullable getLastSelectedWidgetItem()264 protected WidgetItem getLastSelectedWidgetItem() { 265 return mLastSelectedWidgetItem; 266 } 267 268 @Override onLongClick(View v)269 public boolean onLongClick(View v) { 270 TestLogging.recordEvent(TestProtocol.SEQUENCE_MAIN, "Widgets.onLongClick"); 271 v.cancelLongPress(); 272 273 boolean result; 274 if (v instanceof WidgetCell) { 275 result = mActivityContext.getAllAppsItemLongClickListener().onLongClick(v); 276 } else if (v.getParent() instanceof WidgetCell wc) { 277 result = mActivityContext.getAllAppsItemLongClickListener().onLongClick(wc); 278 } else { 279 return true; 280 } 281 if (result) { 282 close(true); 283 } 284 return result; 285 } 286 287 @Override setInsets(Rect insets)288 public void setInsets(Rect insets) { 289 mInsets.set(insets); 290 @Px int contentHorizontalMargin = getWidgetListHorizontalMargin(); 291 if (contentHorizontalMargin != mContentHorizontalMargin) { 292 onContentHorizontalMarginChanged(contentHorizontalMargin); 293 mContentHorizontalMargin = contentHorizontalMargin; 294 } 295 } 296 297 /** Enables or disables the sheet's nav bar scrim. */ disableNavBarScrim(boolean disable)298 public void disableNavBarScrim(boolean disable) { 299 mDisableNavBarScrim = disable; 300 } 301 getNavBarScrimHeight(WindowInsets insets)302 private int getNavBarScrimHeight(WindowInsets insets) { 303 if (mDisableNavBarScrim) { 304 return 0; 305 } else { 306 return insets.getTappableElementInsets().bottom; 307 } 308 } 309 310 @Override onApplyWindowInsets(WindowInsets insets)311 public WindowInsets onApplyWindowInsets(WindowInsets insets) { 312 mNavBarScrimHeight = getNavBarScrimHeight(insets); 313 return super.onApplyWindowInsets(insets); 314 } 315 316 @Override dispatchDraw(Canvas canvas)317 protected void dispatchDraw(Canvas canvas) { 318 super.dispatchDraw(canvas); 319 320 if (mNavBarScrimHeight > 0) { 321 canvas.drawRect(0, getHeight() - mNavBarScrimHeight, getWidth(), getHeight(), 322 mNavBarScrimPaint); 323 } 324 } 325 326 /** Called when the horizontal margin of the content view has changed. */ onContentHorizontalMarginChanged(int contentHorizontalMarginInPx)327 protected abstract void onContentHorizontalMarginChanged(int contentHorizontalMarginInPx); 328 329 /** 330 * Measures the dimension of this view and its children by taking system insets, navigation bar, 331 * status bar, into account. 332 */ doMeasure(int widthMeasureSpec, int heightMeasureSpec)333 protected void doMeasure(int widthMeasureSpec, int heightMeasureSpec) { 334 DeviceProfile deviceProfile = mActivityContext.getDeviceProfile(); 335 int widthUsed; 336 if (deviceProfile.isTablet) { 337 widthUsed = Math.max(2 * getTabletHorizontalMargin(deviceProfile), 338 2 * (mInsets.left + mInsets.right)); 339 } else if (mInsets.bottom > 0) { 340 widthUsed = mInsets.left + mInsets.right; 341 } else { 342 Rect padding = deviceProfile.workspacePadding; 343 widthUsed = Math.max(padding.left + padding.right, 344 2 * (mInsets.left + mInsets.right)); 345 } 346 347 measureChildWithMargins(mContent, widthMeasureSpec, 348 widthUsed, heightMeasureSpec, deviceProfile.bottomSheetTopPadding); 349 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), 350 MeasureSpec.getSize(heightMeasureSpec)); 351 } 352 353 /** Returns the horizontal margins to be applied to the widget sheet. **/ getTabletHorizontalMargin(DeviceProfile deviceProfile)354 protected int getTabletHorizontalMargin(DeviceProfile deviceProfile) { 355 return deviceProfile.allAppsLeftRightMargin; 356 } 357 358 @Override getIdleInterpolator()359 protected Interpolator getIdleInterpolator() { 360 return mActivityContext.getDeviceProfile().isTablet 361 ? EMPHASIZED : super.getIdleInterpolator(); 362 } 363 onCloseComplete()364 protected void onCloseComplete() { 365 super.onCloseComplete(); 366 clearNavBarColor(); 367 } 368 clearNavBarColor()369 protected void clearNavBarColor() { 370 getSystemUiController().updateUiState( 371 SystemUiController.UI_STATE_WIDGET_BOTTOM_SHEET, 0); 372 } 373 setupNavBarColor()374 protected void setupNavBarColor() { 375 boolean isNavBarDark = Themes.getAttrBoolean(getContext(), R.attr.isMainColorDark); 376 377 // In light mode, landscape reverses navbar background color. 378 boolean isPhoneLandscape = 379 !mActivityContext.getDeviceProfile().isTablet && mInsets.bottom == 0; 380 if (!isNavBarDark && isPhoneLandscape) { 381 isNavBarDark = true; 382 } 383 384 getSystemUiController().updateUiState(SystemUiController.UI_STATE_WIDGET_BOTTOM_SHEET, 385 isNavBarDark ? SystemUiController.FLAG_DARK_NAV 386 : SystemUiController.FLAG_LIGHT_NAV); 387 } 388 getSystemUiController()389 protected SystemUiController getSystemUiController() { 390 return mActivityContext.getSystemUiController(); 391 } 392 393 @Override setTranslationShift(float translationShift)394 protected void setTranslationShift(float translationShift) { 395 super.setTranslationShift(translationShift); 396 if (mActivityContext instanceof Launcher ls) { 397 ls.onWidgetsTransition(1 - translationShift); 398 } 399 } 400 } 401