Merge "App Pairs: Implement app pairs icon" into main
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 37bd4f1..57163ff 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -250,6 +250,10 @@
<!-- Folder name format when folder has 4 or more items shown in preview-->
<string name="folder_name_format_overflow">Folder: <xliff:g id="name" example="Games">%1$s</xliff:g>, <xliff:g id="size" example="2">%2$d</xliff:g> or more items</string>
+ <!-- App pair accessibility -->
+ <!-- App pair name -->
+ <string name="app_pair_name_format">App pair: <xliff:g id="app1" example="Chrome">%1$s</xliff:g> and <xliff:g id="app2" example="YouTube">%2$s</xliff:g></string>
+
<!-- Strings for the customization mode -->
<!-- Text for wallpaper change button [CHAR LIMIT=30]-->
<string name="styles_wallpaper_button_text">Wallpaper & style</string>
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
index 1dc4ad2..8121245 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 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 @@
/**
* A {@link android.widget.FrameLayout} used to represent an app pair icon on the workspace.
+ * <br>
+ * 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 @@
}
/**
- * 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,26 +100,114 @@
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 0000000..735c82f
--- /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.
+ }
+}