From 9d6dbd0a8d1aa3ebb88f852abcf94568f91cd30b Mon Sep 17 00:00:00 2001 From: Jeremy Sim Date: Fri, 1 Sep 2023 21:18:31 -0700 Subject: [PATCH] App Pairs: Implement app pairs icon [App Pairs 5/?] This patch implements the app pairs icon, which displays the two member apps and rotates with the device. Flag: ENABLE_APP_PAIRS (set to false) Bug: 274835596 Test: Manual Change-Id: I07085339d1e2d28f004c1661f0948c59e605c76a --- res/values/strings.xml | 4 + .../launcher3/apppairs/AppPairIcon.java | 143 +++++++++++++-- .../apppairs/AppPairIconBackground.java | 167 ++++++++++++++++++ 3 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 src/com/android/launcher3/apppairs/AppPairIconBackground.java diff --git a/res/values/strings.xml b/res/values/strings.xml index 37bd4f1950..57163ffa47 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -250,6 +250,10 @@ Folder: %1$s, %2$d or more items + + + App pair: %1$s and %2$s + Wallpaper & style diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java index 1dc4ad293a..8121245c3e 100644 --- a/src/com/android/launcher3/apppairs/AppPairIcon.java +++ b/src/com/android/launcher3/apppairs/AppPairIcon.java @@ -17,7 +17,9 @@ package com.android.launcher3.apppairs; import android.content.Context; +import android.graphics.Canvas; import android.graphics.Rect; +import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -26,6 +28,7 @@ import android.widget.FrameLayout; import androidx.annotation.Nullable; import com.android.launcher3.BubbleTextView; +import com.android.launcher3.DeviceProfile; import com.android.launcher3.R; import com.android.launcher3.dragndrop.DraggableView; import com.android.launcher3.model.data.FolderInfo; @@ -37,11 +40,41 @@ import java.util.Comparator; /** * A {@link android.widget.FrameLayout} used to represent an app pair icon on the workspace. + *
+ * The app pair icon is two parallel background rectangles with rounded corners. Icons of the two + * member apps are set into these rectangles. */ public class AppPairIcon extends FrameLayout implements DraggableView { + /** + * Design specs -- the below ratios are in relation to the size of a standard app icon. + */ + private static final float OUTER_PADDING_SCALE = 1 / 30f; + private static final float INNER_PADDING_SCALE = 1 / 24f; + private static final float MEMBER_ICON_SCALE = 11 / 30f; + private static final float CENTER_CHANNEL_SCALE = 1 / 30f; + private static final float BIG_RADIUS_SCALE = 1 / 5f; + private static final float SMALL_RADIUS_SCALE = 1 / 15f; + + // App pair icons are slightly smaller than regular icons, so we pad the icon by this much on + // each side. + float mOuterPadding; + // Inside of the icon, the two member apps are padded by this much. + float mInnerPadding; + // The two member apps have icons that are this big (in diameter). + float mMemberIconSize; + // The size of the center channel. + float mCenterChannelSize; + // The large outer radius of the background rectangles. + float mBigRadius; + // The small inner radius of the background rectangles. + float mSmallRadius; + // The app pairs icon appears differently in portrait and landscape. + boolean mIsLandscape; private ActivityContext mActivity; + // A view that holds the app pair's title. private BubbleTextView mAppPairName; + // The underlying ItemInfo that stores info about the app pair members, etc. private FolderInfo mInfo; public AppPairIcon(Context context, AttributeSet attrs) { @@ -53,11 +86,11 @@ public class AppPairIcon extends FrameLayout implements DraggableView { } /** - * Builds an AppPairIcon to be added to the Launcher + * Builds an AppPairIcon to be added to the Launcher. */ public static AppPairIcon inflateIcon(int resId, ActivityContext activity, @Nullable ViewGroup group, FolderInfo appPairInfo) { - + DeviceProfile grid = activity.getDeviceProfile(); LayoutInflater inflater = (group != null) ? LayoutInflater.from(group.getContext()) : activity.getLayoutInflater(); @@ -67,25 +100,113 @@ public class AppPairIcon extends FrameLayout implements DraggableView { Collections.sort(appPairInfo.contents, Comparator.comparingInt(a -> a.rank)); icon.setClipToPadding(false); - icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name); - - // TODO (jeremysim b/274189428): Replace this placeholder icon - WorkspaceItemInfo placeholder = new WorkspaceItemInfo(); - placeholder.newIcon(icon.getContext()); - icon.mAppPairName.applyFromWorkspaceItem(placeholder); - - icon.mAppPairName.setText(appPairInfo.title); - icon.setTag(appPairInfo); icon.setOnClickListener(activity.getItemOnClickListener()); icon.mInfo = appPairInfo; icon.mActivity = activity; + // Set up app pair title + icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name); + icon.mAppPairName.setCompoundDrawablePadding(0); + FrameLayout.LayoutParams lp = + (FrameLayout.LayoutParams) icon.mAppPairName.getLayoutParams(); + lp.topMargin = grid.iconSizePx + grid.iconDrawablePaddingPx; + icon.mAppPairName.setText(appPairInfo.title); + + // Set up accessibility + icon.setContentDescription(icon.getAccessibilityTitle( + appPairInfo.contents.get(0).title, appPairInfo.contents.get(1).title)); icon.setAccessibilityDelegate(activity.getAccessibilityDelegate()); return icon; } + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + + // Calculate device-specific measurements + DeviceProfile grid = mActivity.getDeviceProfile(); + int defaultIconSize = grid.iconSizePx; + mOuterPadding = OUTER_PADDING_SCALE * defaultIconSize; + mInnerPadding = INNER_PADDING_SCALE * defaultIconSize; + mMemberIconSize = MEMBER_ICON_SCALE * defaultIconSize; + mCenterChannelSize = CENTER_CHANNEL_SCALE * defaultIconSize; + mBigRadius = BIG_RADIUS_SCALE * defaultIconSize; + mSmallRadius = SMALL_RADIUS_SCALE * defaultIconSize; + mIsLandscape = grid.isLandscape; + + // Calculate drawable area position + float leftBound = (canvas.getWidth() / 2f) - (defaultIconSize / 2f); + float topBound = getPaddingTop(); + + // Prepare to draw app pair icon background + Drawable background = new AppPairIconBackground(getContext(), this); + background.setBounds(0, 0, defaultIconSize, defaultIconSize); + + // Draw background + canvas.save(); + canvas.translate(leftBound, topBound); + background.draw(canvas); + canvas.restore(); + + // Prepare to draw icons + WorkspaceItemInfo app1 = mInfo.contents.get(0); + WorkspaceItemInfo app2 = mInfo.contents.get(1); + Drawable app1Icon = app1.newIcon(getContext()); + Drawable app2Icon = app2.newIcon(getContext()); + app1Icon.setBounds(0, 0, defaultIconSize, defaultIconSize); + app2Icon.setBounds(0, 0, defaultIconSize, defaultIconSize); + + // Draw first icon + canvas.save(); + canvas.translate(leftBound, topBound); + // The app icons are placed differently depending on device orientation. + if (mIsLandscape) { + canvas.translate( + (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding + - mMemberIconSize, + (defaultIconSize / 2f) - (mMemberIconSize / 2f) + ); + } else { + canvas.translate( + (defaultIconSize / 2f) - (mMemberIconSize / 2f), + (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding + - mMemberIconSize + ); + + } + canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE); + app1Icon.draw(canvas); + canvas.restore(); + + // Draw second icon + canvas.save(); + canvas.translate(leftBound, topBound); + // The app icons are placed differently depending on device orientation. + if (mIsLandscape) { + canvas.translate( + (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding, + (defaultIconSize / 2f) - (mMemberIconSize / 2f) + ); + } else { + canvas.translate( + (defaultIconSize / 2f) - (mMemberIconSize / 2f), + (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding + ); + } + canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE); + app2Icon.draw(canvas); + canvas.restore(); + } + + /** + * Returns a formatted accessibility title for app pairs. + */ + public String getAccessibilityTitle(CharSequence app1, CharSequence app2) { + return getContext().getString(R.string.app_pair_name_format, app1, app2); + } + @Override public int getViewType() { return DRAGGABLE_ICON; diff --git a/src/com/android/launcher3/apppairs/AppPairIconBackground.java b/src/com/android/launcher3/apppairs/AppPairIconBackground.java new file mode 100644 index 0000000000..735c82f9d1 --- /dev/null +++ b/src/com/android/launcher3/apppairs/AppPairIconBackground.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2023 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.apppairs; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.os.Build; + +import com.android.launcher3.R; + +/** + * A Drawable for the background behind the twin app icons (looks like two rectangles). + */ +class AppPairIconBackground extends Drawable { + // The icon that we will draw this background on. + private final AppPairIcon icon; + private final Paint mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + /** + * Null values to use with + * {@link Canvas#drawDoubleRoundRect(RectF, float[], RectF, float[], Paint)}, since there + * doesn't seem to be any other API for drawing rectangles with 4 different corner radii. + */ + private static final RectF EMPTY_RECT = new RectF(); + private static final float[] ARRAY_OF_ZEROES = new float[8]; + + AppPairIconBackground(Context context, AppPairIcon appPairIcon) { + icon = appPairIcon; + // Set up background paint color + TypedArray ta = context.getTheme().obtainStyledAttributes(R.styleable.FolderIconPreview); + mBackgroundPaint.setStyle(Paint.Style.FILL); + mBackgroundPaint.setColor( + ta.getColor(R.styleable.FolderIconPreview_folderPreviewColor, 0)); + ta.recycle(); + } + + @Override + public void draw(Canvas canvas) { + if (icon.mIsLandscape) { + drawLeftRightSplit(canvas); + } else { + drawTopBottomSplit(canvas); + } + } + + /** + * When device is in landscape, we draw the rectangles with a left-right split. + */ + private void drawLeftRightSplit(Canvas canvas) { + // Get the bounds where we will draw the background image + int width = getBounds().width(); + int height = getBounds().height(); + + // The left half of the background image, excluding center channel + RectF leftSide = new RectF( + icon.mOuterPadding, + icon.mOuterPadding, + (width / 2f) - (icon.mCenterChannelSize / 2f), + height - icon.mOuterPadding + ); + // The right half of the background image, excluding center channel + RectF rightSide = new RectF( + (width / 2f) + (icon.mCenterChannelSize / 2f), + icon.mOuterPadding, + width - icon.mOuterPadding, + height - icon.mOuterPadding + ); + + drawCustomRoundedRect(canvas, leftSide, new float[]{ + icon.mBigRadius, icon.mBigRadius, + icon.mSmallRadius, icon.mSmallRadius, + icon.mSmallRadius, icon.mSmallRadius, + icon.mBigRadius, icon.mBigRadius}); + drawCustomRoundedRect(canvas, rightSide, new float[]{ + icon.mSmallRadius, icon.mSmallRadius, + icon.mBigRadius, icon.mBigRadius, + icon.mBigRadius, icon.mBigRadius, + icon.mSmallRadius, icon.mSmallRadius}); + } + + /** + * When device is in portrait, we draw the rectangles with a top-bottom split. + */ + private void drawTopBottomSplit(Canvas canvas) { + // Get the bounds where we will draw the background image + int width = getBounds().width(); + int height = getBounds().height(); + + // The top half of the background image, excluding center channel + RectF topSide = new RectF( + icon.mOuterPadding, + icon.mOuterPadding, + width - icon.mOuterPadding, + (height / 2f) - (icon.mCenterChannelSize / 2f) + ); + // The bottom half of the background image, excluding center channel + RectF bottomSide = new RectF( + icon.mOuterPadding, + (height / 2f) + (icon.mCenterChannelSize / 2f), + width - icon.mOuterPadding, + height - icon.mOuterPadding + ); + + drawCustomRoundedRect(canvas, topSide, new float[]{ + icon.mBigRadius, icon.mBigRadius, + icon.mBigRadius, icon.mBigRadius, + icon.mSmallRadius, icon.mSmallRadius, + icon.mSmallRadius, icon.mSmallRadius}); + drawCustomRoundedRect(canvas, bottomSide, new float[]{ + icon.mSmallRadius, icon.mSmallRadius, + icon.mSmallRadius, icon.mSmallRadius, + icon.mBigRadius, icon.mBigRadius, + icon.mBigRadius, icon.mBigRadius}); + } + + /** + * Draws a rectangle with custom rounded corners. + * @param c The Canvas to draw on. + * @param rect The bounds of the rectangle. + * @param radii An array of 8 radii for the corners: top left x, top left y, top right x, top + * right y, bottom right x, and so on. + */ + private void drawCustomRoundedRect(Canvas c, RectF rect, float[] radii) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Canvas.drawDoubleRoundRect is supported from Q onward + c.drawDoubleRoundRect(rect, radii, EMPTY_RECT, ARRAY_OF_ZEROES, mBackgroundPaint); + } else { + // Fallback rectangle with uniform rounded corners + c.drawRoundRect(rect, icon.mBigRadius, icon.mBigRadius, mBackgroundPaint); + } + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } + + @Override + public void setAlpha(int i) { + // Required by Drawable but not used. + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + // Required by Drawable but not used. + } +}