Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9d6dbd0a authored by Jeremy Sim's avatar Jeremy Sim
Browse files

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
parent 9acb884c
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -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 &amp; style</string>
+132 −11
Original line number Diff line number Diff line
@@ -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.
 * <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 @@ 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;
+167 −0
Original line number Diff line number Diff line
/*
 * 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.
    }
}