/* * Copyright (C) 2015 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; import static android.view.View.MeasureSpec.makeMeasureSpec; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_WIDGETS_TRAY; import static com.android.launcher3.Utilities.ATLEAST_S; import android.content.Context; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.util.Log; import android.util.Size; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewPropertyAnimator; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.RemoteViews; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.launcher3.CheckLongPressHelper; import com.android.launcher3.DeviceProfile; import com.android.launcher3.Launcher; import com.android.launcher3.R; import com.android.launcher3.icons.BaseIconFactory; import com.android.launcher3.icons.FastBitmapDrawable; import com.android.launcher3.icons.RoundDrawableWrapper; import com.android.launcher3.icons.cache.HandlerRunnable; import com.android.launcher3.model.WidgetItem; import com.android.launcher3.views.ActivityContext; import com.android.launcher3.widget.util.WidgetSizes; import java.util.function.Consumer; /** * Represents the individual cell of the widget inside the widget tray. The preview is drawn * horizontally centered, and scaled down if needed. * * This view does not support padding. Since the image is scaled down to fit the view, padding will * further decrease the scaling factor. Drag-n-drop uses the view bounds for showing a smooth * transition from the view to drag view, so when adding padding support, DnD would need to * consider the appropriate scaling factor. */ public class WidgetCell extends LinearLayout { private static final String TAG = "WidgetCell"; private static final boolean DEBUG = false; private static final int FADE_IN_DURATION_MS = 90; /** Widget cell width is calculated by multiplying this factor to grid cell width. */ private static final float WIDTH_SCALE = 3f; /** Widget preview width is calculated by multiplying this factor to the widget cell width. */ private static final float PREVIEW_SCALE = 0.8f; /** * The maximum dimension that can be used as the size in * {@link android.view.View.MeasureSpec#makeMeasureSpec(int, int)}. * *

This is equal to (1 << MeasureSpec.MODE_SHIFT) - 1. */ private static final int MAX_MEASURE_SPEC_DIMENSION = (1 << 30) - 1; /** * The target preview width, in pixels, of a widget or a shortcut. * *

The actual preview width may be smaller than or equal to this value subjected to scaling. */ protected int mTargetPreviewWidth; /** * The target preview height, in pixels, of a widget or a shortcut. * *

The actual preview height may be smaller than or equal to this value subjected to scaling. */ protected int mTargetPreviewHeight; protected int mPresetPreviewSize; private int mCellSize; /** * The scale of the preview container. */ private float mPreviewContainerScale = 1f; private FrameLayout mWidgetImageContainer; private WidgetImageView mWidgetImage; private ImageView mWidgetBadge; private TextView mWidgetName; private TextView mWidgetDims; private TextView mWidgetDescription; protected WidgetItem mItem; private final DatabaseWidgetPreviewLoader mWidgetPreviewLoader; protected HandlerRunnable mActiveRequest; private boolean mAnimatePreview = true; protected final ActivityContext mActivity; private final CheckLongPressHelper mLongPressHelper; private final float mEnforcedCornerRadius; private RemoteViews mRemoteViewsPreview; private NavigableAppWidgetHostView mAppWidgetHostViewPreview; private float mAppWidgetHostViewScale = 1f; private int mSourceContainer = CONTAINER_WIDGETS_TRAY; public WidgetCell(Context context) { this(context, null); } public WidgetCell(Context context, AttributeSet attrs) { this(context, attrs, 0); } public WidgetCell(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mActivity = ActivityContext.lookupContext(context); mWidgetPreviewLoader = new DatabaseWidgetPreviewLoader(context); mLongPressHelper = new CheckLongPressHelper(this); mLongPressHelper.setLongPressTimeoutFactor(1); setContainerWidth(); setWillNotDraw(false); setClipToPadding(false); setAccessibilityDelegate(mActivity.getAccessibilityDelegate()); mEnforcedCornerRadius = RoundedCornerEnforcement.computeEnforcedRadius(context); } private void setContainerWidth() { mCellSize = (int) (mActivity.getDeviceProfile().allAppsIconSizePx * WIDTH_SCALE); mPresetPreviewSize = (int) (mCellSize * PREVIEW_SCALE); mTargetPreviewWidth = mTargetPreviewHeight = mPresetPreviewSize; } @Override protected void onFinishInflate() { super.onFinishInflate(); mWidgetImageContainer = findViewById(R.id.widget_preview_container); mWidgetImage = findViewById(R.id.widget_preview); mWidgetBadge = findViewById(R.id.widget_badge); mWidgetName = findViewById(R.id.widget_name); mWidgetDims = findViewById(R.id.widget_dims); mWidgetDescription = findViewById(R.id.widget_description); } public void setRemoteViewsPreview(RemoteViews view) { mRemoteViewsPreview = view; } @Nullable public RemoteViews getRemoteViewsPreview() { return mRemoteViewsPreview; } /** Returns the app widget host view scale, which is a value between [0f, 1f]. */ public float getAppWidgetHostViewScale() { return mAppWidgetHostViewScale; } /** * Called to clear the view and free attached resources. (e.g., {@link Bitmap} */ public void clear() { if (DEBUG) { Log.d(TAG, "reset called on:" + mWidgetName.getText()); } mWidgetImage.animate().cancel(); mWidgetImage.setDrawable(null); mWidgetImage.setVisibility(View.VISIBLE); mWidgetBadge.setImageDrawable(null); mWidgetBadge.setVisibility(View.GONE); mWidgetName.setText(null); mWidgetDims.setText(null); mWidgetDescription.setText(null); mWidgetDescription.setVisibility(GONE); mTargetPreviewWidth = mTargetPreviewHeight = mPresetPreviewSize; if (mActiveRequest != null) { mActiveRequest.cancel(); mActiveRequest = null; } mRemoteViewsPreview = null; if (mAppWidgetHostViewPreview != null) { mWidgetImageContainer.removeView(mAppWidgetHostViewPreview); } mAppWidgetHostViewPreview = null; mAppWidgetHostViewScale = 1f; mItem = null; } public void setSourceContainer(int sourceContainer) { this.mSourceContainer = sourceContainer; } /** * Applies the item to this view */ public void applyFromCellItem(WidgetItem item) { applyFromCellItem(item, 1f); } /** * Applies the item to this view */ public void applyFromCellItem(WidgetItem item, float previewScale) { applyFromCellItem(item, previewScale, this::applyPreview, null); } /** * Applies the item to this view * @param item item to apply * @param previewScale factor to scale the preview * @param callback callback when preview is loaded in case the preview is being loaded or cached * @param cachedPreview previously cached preview bitmap is present */ public void applyFromCellItem(WidgetItem item, float previewScale, @NonNull Consumer callback, @Nullable Bitmap cachedPreview) { // setPreviewSize DeviceProfile deviceProfile = mActivity.getDeviceProfile(); Size widgetSize = WidgetSizes.getWidgetItemSizePx(getContext(), deviceProfile, item); mTargetPreviewWidth = widgetSize.getWidth(); mTargetPreviewHeight = widgetSize.getHeight(); mPreviewContainerScale = previewScale; applyPreviewOnAppWidgetHostView(item); Context context = getContext(); mItem = item; mWidgetName.setText(mItem.label); mWidgetName.setContentDescription( context.getString(R.string.widget_preview_context_description, mItem.label)); mWidgetDims.setText(context.getString(R.string.widget_dims_format, mItem.spanX, mItem.spanY)); mWidgetDims.setContentDescription(context.getString( R.string.widget_accessible_dims_format, mItem.spanX, mItem.spanY)); if (ATLEAST_S && mItem.widgetInfo != null) { CharSequence description = mItem.widgetInfo.loadDescription(context); if (description != null && description.length() > 0) { mWidgetDescription.setText(description); mWidgetDescription.setVisibility(VISIBLE); } else { mWidgetDescription.setVisibility(GONE); } } if (item.activityInfo != null) { setTag(new PendingAddShortcutInfo(item.activityInfo)); } else { setTag(new PendingAddWidgetInfo(item.widgetInfo, mSourceContainer)); } ensurePreviewWithCallback(callback, cachedPreview); } private void applyPreviewOnAppWidgetHostView(WidgetItem item) { if (mRemoteViewsPreview != null) { mAppWidgetHostViewPreview = createAppWidgetHostView(getContext()); setAppWidgetHostViewPreview(mAppWidgetHostViewPreview, item.widgetInfo, mRemoteViewsPreview); return; } if (!item.hasPreviewLayout()) return; Context context = getContext(); // If the context is a Launcher activity, DragView will show mAppWidgetHostViewPreview as // a preview during drag & drop. And thus, we should use LauncherAppWidgetHostView, which // supports applying local color extraction during drag & drop. mAppWidgetHostViewPreview = isLauncherContext(context) ? new LauncherAppWidgetHostView(context) : createAppWidgetHostView(context); LauncherAppWidgetProviderInfo launcherAppWidgetProviderInfo = LauncherAppWidgetProviderInfo.fromProviderInfo(context, item.widgetInfo.clone()); // A hack to force the initial layout to be the preview layout since there is no API for // rendering a preview layout for work profile apps yet. For non-work profile layout, a // proper solution is to use RemoteViews(PackageName, LayoutId). launcherAppWidgetProviderInfo.initialLayout = item.widgetInfo.previewLayout; setAppWidgetHostViewPreview(mAppWidgetHostViewPreview, launcherAppWidgetProviderInfo, /* remoteViews= */ null); } private void setAppWidgetHostViewPreview( NavigableAppWidgetHostView appWidgetHostViewPreview, LauncherAppWidgetProviderInfo providerInfo, @Nullable RemoteViews remoteViews) { appWidgetHostViewPreview.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); appWidgetHostViewPreview.setAppWidget(/* appWidgetId= */ -1, providerInfo); appWidgetHostViewPreview.updateAppWidget(remoteViews); } public WidgetImageView getWidgetView() { return mWidgetImage; } @Nullable public NavigableAppWidgetHostView getAppWidgetHostViewPreview() { return mAppWidgetHostViewPreview; } public void setAnimatePreview(boolean shouldAnimate) { mAnimatePreview = shouldAnimate; } private void applyPreview(Bitmap bitmap) { if (bitmap != null) { Drawable drawable = new RoundDrawableWrapper( new FastBitmapDrawable(bitmap), mEnforcedCornerRadius); // Scale down the preview size if it's wider than the cell. float scale = 1f; if (mTargetPreviewWidth > 0) { float maxWidth = mTargetPreviewWidth; float previewWidth = drawable.getIntrinsicWidth() * mPreviewContainerScale; scale = Math.min(maxWidth / previewWidth, 1); } setContainerSize( Math.round(drawable.getIntrinsicWidth() * scale * mPreviewContainerScale), Math.round(drawable.getIntrinsicHeight() * scale * mPreviewContainerScale)); mWidgetImage.setDrawable(drawable); mWidgetImage.setVisibility(View.VISIBLE); if (mAppWidgetHostViewPreview != null) { removeView(mAppWidgetHostViewPreview); mAppWidgetHostViewPreview = null; } } if (mAnimatePreview) { mWidgetImageContainer.setAlpha(0f); ViewPropertyAnimator anim = mWidgetImageContainer.animate(); anim.alpha(1.0f).setDuration(FADE_IN_DURATION_MS); } else { mWidgetImageContainer.setAlpha(1f); } if (mActiveRequest != null) { mActiveRequest.cancel(); mActiveRequest = null; } } /** Used to show the badge when the widget is in the recommended section */ public void showBadge() { Drawable badge = mWidgetPreviewLoader.getBadgeForUser(mItem.user, BaseIconFactory.getBadgeSizeForIconSize( mActivity.getDeviceProfile().allAppsIconSizePx)); if (badge == null) { mWidgetBadge.setVisibility(View.GONE); } else { mWidgetBadge.setVisibility(View.VISIBLE); mWidgetBadge.setImageDrawable(badge); } } private void setContainerSize(int width, int height) { LayoutParams layoutParams = (LayoutParams) mWidgetImageContainer.getLayoutParams(); layoutParams.width = width; layoutParams.height = height; mWidgetImageContainer.setLayoutParams(layoutParams); } /** * Ensures that the preview is already loaded or being loaded. If the preview is not loaded, * it applies the provided cachedPreview. If that is null, it starts a loader and notifies the * callback on successful load. */ private void ensurePreviewWithCallback(Consumer callback, @Nullable Bitmap cachedPreview) { if (mAppWidgetHostViewPreview != null) { int containerWidth = (int) (mTargetPreviewWidth * mPreviewContainerScale); int containerHeight = (int) (mTargetPreviewHeight * mPreviewContainerScale); setContainerSize(containerWidth, containerHeight); if (mAppWidgetHostViewPreview.getChildCount() == 1) { View widgetContent = mAppWidgetHostViewPreview.getChildAt(0); ViewGroup.LayoutParams layoutParams = widgetContent.getLayoutParams(); // We only scale preview if both the width & height of the outermost view group are // not set to MATCH_PARENT. boolean shouldScale = layoutParams.width != MATCH_PARENT && layoutParams.height != MATCH_PARENT; if (shouldScale) { setNoClip(mWidgetImageContainer); setNoClip(mAppWidgetHostViewPreview); mAppWidgetHostViewScale = measureAndComputeWidgetPreviewScale(); mAppWidgetHostViewPreview.setScaleToFit(mAppWidgetHostViewScale); } } FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( containerWidth, containerHeight, Gravity.FILL); mAppWidgetHostViewPreview.setLayoutParams(params); mWidgetImageContainer.addView(mAppWidgetHostViewPreview, /* index= */ 0); mWidgetImage.setVisibility(View.GONE); applyPreview(null); return; } if (cachedPreview != null) { applyPreview(cachedPreview); return; } if (mActiveRequest != null) { return; } mActiveRequest = mWidgetPreviewLoader.loadPreview( mItem, new Size(mTargetPreviewWidth, mTargetPreviewHeight), callback); } @Override public boolean onTouchEvent(MotionEvent ev) { super.onTouchEvent(ev); mLongPressHelper.onTouchEvent(ev); return true; } @Override public void cancelLongPress() { super.cancelLongPress(); mLongPressHelper.cancelLongPress(); } private static NavigableAppWidgetHostView createAppWidgetHostView(Context context) { return new NavigableAppWidgetHostView(context) { @Override protected boolean shouldAllowDirectClick() { return false; } }; } private static boolean isLauncherContext(Context context) { return ActivityContext.lookupContext(context) instanceof Launcher; } @Override public CharSequence getAccessibilityClassName() { return WidgetCell.class.getName(); } @Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(info); info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK); } private static void setNoClip(ViewGroup view) { view.setClipChildren(false); view.setClipToPadding(false); } private float measureAndComputeWidgetPreviewScale() { if (mAppWidgetHostViewPreview.getChildCount() != 1) { return 1f; } // Measure the largest possible width & height that the app widget wants to display. mAppWidgetHostViewPreview.measure( makeMeasureSpec(MAX_MEASURE_SPEC_DIMENSION, MeasureSpec.UNSPECIFIED), makeMeasureSpec(MAX_MEASURE_SPEC_DIMENSION, MeasureSpec.UNSPECIFIED)); if (mRemoteViewsPreview != null) { // If RemoteViews contains multiple sizes, the best fit sized RemoteViews will be // selected in onLayout. To work out the right measurement, let's layout and then // measure again. mAppWidgetHostViewPreview.layout( /* left= */ 0, /* top= */ 0, /* right= */ mTargetPreviewWidth, /* bottom= */ mTargetPreviewHeight); mAppWidgetHostViewPreview.measure( makeMeasureSpec(mTargetPreviewWidth, MeasureSpec.UNSPECIFIED), makeMeasureSpec(mTargetPreviewHeight, MeasureSpec.UNSPECIFIED)); } View widgetContent = mAppWidgetHostViewPreview.getChildAt(0); int appWidgetContentWidth = widgetContent.getMeasuredWidth(); int appWidgetContentHeight = widgetContent.getMeasuredHeight(); if (appWidgetContentWidth == 0 || appWidgetContentHeight == 0) { return 1f; } // If the width / height of the widget content is set to wrap content, overrides the width / // height with the measured dimension. This avoids incorrect measurement after scaling. FrameLayout.LayoutParams layoutParam = (FrameLayout.LayoutParams) widgetContent.getLayoutParams(); if (layoutParam.width == WRAP_CONTENT) { layoutParam.width = widgetContent.getMeasuredWidth(); } if (layoutParam.height == WRAP_CONTENT) { layoutParam.height = widgetContent.getMeasuredHeight(); } widgetContent.setLayoutParams(layoutParam); int horizontalPadding = mAppWidgetHostViewPreview.getPaddingStart() + mAppWidgetHostViewPreview.getPaddingEnd(); int verticalPadding = mAppWidgetHostViewPreview.getPaddingTop() + mAppWidgetHostViewPreview.getPaddingBottom(); return Math.min( (mTargetPreviewWidth - horizontalPadding) * mPreviewContainerScale / appWidgetContentWidth, (mTargetPreviewHeight - verticalPadding) * mPreviewContainerScale / appWidgetContentHeight); } }