安卓开发- 安卓13 Launcher3文件夹预览图、文件夹展开后布局修改

文件夹预览图的改动

问题描述

在做Launcher3开发时,发现主页文件夹预览图的图标有溢出的情况,两个图标的时候,左右过于贴近边界了;三个图标时,上面的图标溢出了,下面两个图标也贴近左右边界了,如下图所示:

在这里插入图片描述

问题分析:出现这种情况通常是有更改了桌面图标的大小或者相关的边距值,导致计算预览图坐标不准确导致的,我们追看下源码:

计算绘制的调用流程分析

FolderIcon.java是Launcher3桌面绘制文件夹图标的一个关键的类,在文件夹图标绘制之前会调用dispatchDraw()方法,我们从这里开始分析,相关的代码如下,先瞄一眼FolderIcon的构造方法和init()方法里面做了什么东西,然后再分析dispatchDraw()方法:

// Launcher3/src/com/android/launcher3/folder/FolderIcon.java

public FolderIcon(Context context, AttributeSet attrs) {
    super(context, attrs);
    // 调用init()方法初始化
    init();
}

public FolderIcon(Context context) {
    super(context);
    init();
}

private void init() {
    mLongPressHelper = new CheckLongPressHelper(this);
    // 创建ClippedFolderIconLayoutRule和PreviewItemManager对象,这两个很重要,后面会说到
    mPreviewLayoutRule = new ClippedFolderIconLayoutRule();
    mPreviewItemManager = new PreviewItemManager(this);
    mDotParams = new DotRenderer.DrawParams();
}

这里主要是在init()方法中分别创建了ClippedFolderIconLayoutRule和PreviewItemManager对象,这两个对象很重要,后面会说到,这里留意一下,我们先分析dispatchDraw方法:

// Launcher3/src/com/android/launcher3/folder/FolderIcon.java

// 在绘制文件夹图标之前会调用这个方法,从这里开始分析
@Override
protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);

    if (!mBackgroundIsVisible) return;

    // 重新计算图标的各项参数
    mPreviewItemManager.recomputePreviewDrawingParams();

    if (!mBackground.drawingDelegated()) {
        mBackground.drawBackground(canvas);
    }
    if (mCurrentPreviewItems.isEmpty() && !mAnimating) return;

    // 调用PreviewItemManager.draw方法绘制文件夹预览图
    mPreviewItemManager.draw(canvas);

    if (!mBackground.drawingDelegated()) {
        mBackground.drawBackgroundStroke(canvas);
    }
    drawDot(canvas);
}

// ...其他代码

​ 在这个方法里面分别调用了PreviewItemManager的recomputePreviewDrawingParams()方法和draw()方法来继续计算和绘制预览项。

PreviewItemManager.java类是 用来管理FolderIcon(文件夹预览图)中PreviewItemDrawingParams(文件夹中应用预览项)绘图和动画参数的

看下PreviewItemManager的recomputePreviewDrawingParams()方法:

// Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java

public void recomputePreviewDrawingParams() {
    if (mReferenceDrawable != null) {
        // 调用computePreviewDrawingParams()方法
        computePreviewDrawingParams(mReferenceDrawable.getIntrinsicWidth(), mIcon.getMeasuredWidth());
    }
}

private void computePreviewDrawingParams(int drawableSize, int totalSize) {
    if (mIntrinsicIconSize != drawableSize || mTotalWidth != totalSize ||
            mPrevTopPadding != mIcon.getPaddingTop()) {
        mIntrinsicIconSize = drawableSize;
        mTotalWidth = totalSize;
        mPrevTopPadding = mIcon.getPaddingTop();

        mIcon.mBackground.setup(mIcon.getContext(), mIcon.mActivity, mIcon, mTotalWidth,
                mIcon.getPaddingTop());
        // 调用了FolderIcon.mPreviewLayoutRule.init方法做一些初始化工作(这个方法要留意下,后面会讲到)
        mIcon.mPreviewLayoutRule.init(mIcon.mBackground.previewSize, mIntrinsicIconSize,
                Utilities.isRtl(mIcon.getResources()));
		// 调用updatePreviewItems,注意参数传的是false
        updatePreviewItems(false);
    }
}

void updatePreviewItems(boolean animate) {
    int numOfPrevItemsAux = mFirstPageParams.size();
    // 调用buildParamsForPage方法,第三个参数是false
    buildParamsForPage(0, mFirstPageParams, animate);
    mNumOfPrevItems = numOfPrevItemsAux;
}

void buildParamsForPage(int page, ArrayList<PreviewItemDrawingParams> params, boolean animate) {
    // 这个items是用来存储当前应用图标预览项列表的
    List<WorkspaceItemInfo> items = mIcon.getPreviewItemsOnPage(page);

    // We adjust the size of the list to match the number of items in the preview.
    while (items.size() < params.size()) {
        // 先移除params中的items
        params.remove(params.size() - 1);
    }
    while (items.size() > params.size()) {
        // 再一个个添加进去
        params.add(new PreviewItemDrawingParams(0, 0, 0));
    }

    int numItemsInFirstPagePreview = page == 0 ? items.size() : MAX_NUM_ITEMS_IN_PREVIEW;
    for (int i = 0; i < params.size(); i++) {
        // 拿到单个PreviewItemDrawingParams对象,这里的PreviewItemDrawingParams就是单个应用图标预览项
        PreviewItemDrawingParams p = params.get(i);
        setDrawable(p, items.get(i));

        // 上面传递第三个参数animate为false,所以会进入这个if判断
        if (!animate) {
            if (p.anim != null) {
                p.anim.cancel();
            }
            // 调用computePreviewItemDrawingParams()方法
            computePreviewItemDrawingParams(i, numItemsInFirstPagePreview, p);
            if (mReferenceDrawable == null) {
                mReferenceDrawable = p.drawable;
            }
        } else {
            // ...
        }
    }
}

PreviewItemDrawingParams computePreviewItemDrawingParams(int index, int curNumItems,
        PreviewItemDrawingParams params) {
    // We use an index of -1 to represent an icon on the workspace for the destroy and create animations
    if (index == -1) {
        return getFinalIconParams(params);
    }
    // index为文件夹中应用预览项的下标,一般来说>=0, 所以走下面逻辑
    // 调用了FolderIcon.mPreviewLayoutRule.computePreviewItemDrawingParams方法
    return mIcon.mPreviewLayoutRule.computePreviewItemDrawingParams(index, curNumItems, params);
}

// mIcon类型是FolderIcon
private final FolderIcon mIcon;


// ...其他代码

​ 上面展示了PreviewItemManager中,在准备绘制图标预览项时的几个关键方法的调用逻辑,调用的顺序如下:

Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java#recomputePreviewDrawingParams()
Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java#computePreviewDrawingParams()
Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java#updatePreviewItems(false)
Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java#buildParamsForPage()
Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java#computePreviewItemDrawingParams()
mIcon.mPreviewLayoutRule.computePreviewItemDrawingParams

上面代码中注释也写得比较清楚,到最后是调用了FolderIcon.mPreviewLayoutRule.computePreviewItemDrawingParams方法,我们回到FolderIcon中看下:

// Launcher3/src/com/android/launcher3/folder/FolderIcon.java
ClippedFolderIconLayoutRule mPreviewLayoutRule;

private void init() {
    mLongPressHelper = new CheckLongPressHelper(this);
    mPreviewLayoutRule = new ClippedFolderIconLayoutRule();
    mPreviewItemManager = new PreviewItemManager(this);
    mDotParams = new DotRenderer.DrawParams();
}

​ 发现FolderIcon中的mPreviewLayoutRule就是在init方法中创建出来的ClippedFolderIconLayoutRule对象,这一点我们在最开始就有提到,所以这里直接到ClippedFolderIconLayoutRule类中查看computePreviewItemDrawingParams()方法的实现:

// Launcher3/src/com/android/launcher3/folder/ClippedFolderIconLayoutRule.java

private float[] mTmpPoint = new float[2];

/**
 * 这个方法实际上就是计算每个应用图标预览项的位置和大小的
 * index是应用图标在文件夹内的下标,从零开始
 * curNumItems是文件夹内应用图标的总数
 * params是对应应用图标的相关参数
 */
public PreviewItemDrawingParams computePreviewItemDrawingParams(int index, int curNumItems, PreviewItemDrawingParams params) {
    float totalScale = scaleForItem(curNumItems);
    float transX;
    float transY;

    if (index == EXIT_INDEX) {
        // 0 1 * <-- Exit position (row 0, col 2)
        // 2 3
        getGridPosition(0, 2, mTmpPoint);
    } else if (index == ENTER_INDEX) {
        // 0 1
        // 2 3 * <-- Enter position (row 1, col 2)
        getGridPosition(1, 2, mTmpPoint);
    } else if (index >= MAX_NUM_ITEMS_IN_PREVIEW) {
        // Items beyond those displayed in the preview are animated to the center
        mTmpPoint[0] = mTmpPoint[1] = mAvailableSpace / 2 - (mIconSize * totalScale) / 2;
    } else {
        // 0 <= index <= 3
        // 应用图标相关参数的计算,会走到这里,mTmpPoint是一个float数组,计算完成后会把transX、transY数据存在里面
        // 计算过程这里先不展开,等下再详细分析
        getPosition(index, curNumItems, mTmpPoint);
    }

    // 读取mTmpPoint中的数据
    transX = mTmpPoint[0];
    transY = mTmpPoint[1];

    // 更新数据到PreviewItemDrawingParams中
    if (params == null) {
        params = new PreviewItemDrawingParams(transX, transY, totalScale);
    } else {
        params.update(transX, transY, totalScale);
    }
    return params;
}

从上面代码可以看出,在computePreviewItemDrawingParams()方法中,先调用了getPosition()方法来计算各个预览项的排列顺序和位置,然后保存在名称为mTmpPoint的Float数组中,接着从数组总读取出数据,更新数据到各个PreviewItemDrawingParams中。

PreviewItemDrawingParams类可以理解为,是用来 存储 绘制文件夹预览项各项参数的,可以看下PreviewItemDrawingParams的定义:

// Launcher3/src/com/android/launcher3/folder/PreviewItemDrawingParams.java
class PreviewItemDrawingParams {
    float index;
    float transX;
    float transY;
    float scale;
    public FolderPreviewItemAnim anim;
    public boolean hidden;
    public Drawable drawable;
    public WorkspaceItemInfo item;

    PreviewItemDrawingParams(float transX, float transY, float scale) {
        this.transX = transX;
        this.transY = transY;
        this.scale = scale;
    }

    public void update(float transX, float transY, float scale) {
        // We ensure the update will not interfere with an animation on the layout params
        // If the final values differ, we cancel the animation.
        if (anim != null) {
            if (anim.finalState[1] == transX || anim.finalState[2] == transY
                    || anim.finalState[0] == scale) {
                return;
            }
            anim.cancel();
        }

        this.transX = transX;
        this.transY = transY;
        this.scale = scale;
    }
}

​ 可以看到在PreviewItemDrawingParams对象中定义了一些跟图标绘制相关的参数,比如index(图标索引)、tarnsX(X轴的偏移量)、scale(缩放系数)等;所以在computePreviewItemDrawingParams()方法中,更新数据到PreviewItemDrawingParams中之后,后面在绘制的时候就可以直接通过PreviewItemDrawingParams对象来获取相关数据来进行绘制了。

​ 现在,我们回到FolderIcon.java的dispatchDraw()方法中继续往下分析:

// Launcher3/src/com/android/launcher3/folder/FolderIcon.java

@Override
protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);

    if (!mBackgroundIsVisible) return;

    // 重新计算图标的各项参数
    mPreviewItemManager.recomputePreviewDrawingParams();

    if (!mBackground.drawingDelegated()) {
        mBackground.drawBackground(canvas);
    }

    if (mCurrentPreviewItems.isEmpty() && !mAnimating) return;

    // 调用PreviewItemManager.draw方法绘制文件夹预览图
    mPreviewItemManager.draw(canvas);

    if (!mBackground.drawingDelegated()) {
        mBackground.drawBackgroundStroke(canvas);
    }

    drawDot(canvas);
}

// ...其他代码

计算完图标的各项参数之后,调用PreviewItemManager.draw()方法将各应用图标预览项绘制出来:

// Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java

public void draw(Canvas canvas) {
    int saveCount = canvas.getSaveCount();
    // The items are drawn in coordinates relative to the preview offset
    PreviewBackground bg = mIcon.getFolderBackground();
    Path clipPath = bg.getClipPath();
    float firstPageItemsTransX = 0;
    if (mShouldSlideInFirstPage) {
        PointF firstPageOffset = new PointF(bg.basePreviewOffsetX + mCurrentPageItemsTransX,
                bg.basePreviewOffsetY);
        boolean shouldClip = mCurrentPageItemsTransX > mClipThreshold;
        drawParams(canvas, mCurrentPageParams, firstPageOffset, shouldClip, clipPath);
        firstPageItemsTransX = -ITEM_SLIDE_IN_OUT_DISTANCE_PX + mCurrentPageItemsTransX;
    }

    PointF firstPageOffset = new PointF(bg.basePreviewOffsetX + firstPageItemsTransX,
            bg.basePreviewOffsetY);
    boolean shouldClipFirstPage = firstPageItemsTransX < -mClipThreshold;
    // 调用drawParams()方法
    drawParams(canvas, mFirstPageParams, firstPageOffset, shouldClipFirstPage, clipPath);
    canvas.restoreToCount(saveCount);
}

public void drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params,
        PointF offset, boolean shouldClipPath, Path clipPath) {
    // 这里传进来的ArrayList<PreviewItemDrawingParams> params就是每个文件夹中应用图标的List
    // The first item should be drawn last (ie. on top of later items)
    for (int i = params.size() - 1; i >= 0; i--) {
        PreviewItemDrawingParams p = params.get(i);
        if (!p.hidden) {
            // Exiting param should always be clipped.
            boolean isExiting = p.index == EXIT_INDEX;
            // 遍历文件夹中每个图标,进行绘制
            drawPreviewItem(canvas, p, offset, isExiting | shouldClipPath, clipPath);
        }
    }
}

/**
 * Draws each preview item.
 * 关键方法:绘制文件夹预览图中每个图标
 * @param offset The offset needed to draw the preview items.
 * @param shouldClipPath Iff true, clip path using {@param clipPath}.
 * @param clipPath The clip path of the folder icon.
 */
private void drawPreviewItem(Canvas canvas, PreviewItemDrawingParams params, PointF offset,
        boolean shouldClipPath, Path clipPath) {
    canvas.save();
    if (shouldClipPath) {
        canvas.clipPath(clipPath);
    }
    canvas.translate(offset.x + params.transX, offset.y + params.transY);
    canvas.scale(params.scale, params.scale);
    Drawable d = params.drawable;
    if (d != null) {
        Rect bounds = d.getBounds();
        canvas.save();
        canvas.translate(-bounds.left, -bounds.top);
        canvas.scale(mIntrinsicIconSize / bounds.width(), mIntrinsicIconSize / bounds.height());
        d.draw(canvas);
        canvas.restore();
    }
    canvas.restore();
}

到这里,文件夹预览图的计算和绘制流程就走完了。由上面的分析过程可以知道,想要修改文件夹预览图的绘制效果,我们可以在两个地方入手:

  • 在预览图中各应用图标位置的计算时做处理
  • 在预览图各应用图标的绘制时做处理

下面分别将对这两部分的逻辑进行分析,并提供改动的示例。

绘制时的具体逻辑

为了方便理解,我们先分析绘制时的逻辑

// Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java

public void drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params,
        PointF offset, boolean shouldClipPath, Path clipPath) {
    // 这里传进来的ArrayList<PreviewItemDrawingParams> params,就是一个文件夹内部需要预览的应用图标列表
    // 正常来说,文件夹预览图中只有三种情况:两个应用、三个应用、四个及以上应用(默认的预览图最多就显示四个)
    for (int i = params.size() - 1; i >= 0; i--) {
        // 遍历文件夹预览图中需要显示的应用图标,调用drawPreviewItem()方法进行绘制
        PreviewItemDrawingParams p = params.get(i);
        if (!p.hidden) {
            // Exiting param should always be clipped.
            boolean isExiting = p.index == EXIT_INDEX;
             // 遍历文件夹中每个图标,进行绘制
            drawPreviewItem(canvas, p, offset, isExiting | shouldClipPath, clipPath);
        }
    }
}

/**
 * Draws each preview item. 绘制每个预览项
 *
 * @param offset The offset needed to draw the preview items.
 * @param shouldClipPath Iff true, clip path using {@param clipPath}.
 * @param clipPath The clip path of the folder icon.
 */
private void drawPreviewItem(Canvas canvas, PreviewItemDrawingParams params, PointF offset,
        boolean shouldClipPath, Path clipPath) {
    // 创建一个保存点,保存当前的Canvas状态
    canvas.save();
    if (shouldClipPath) {
        // 如果有必要,裁剪出一个新区域clipPath作为新的canvas对象绘制的区域
        canvas.clipPath(clipPath);
    }
    // 将canvas的原点移动到指定位置,由offset和params的x、y坐标共同决定
    canvas.translate(offset.x + params.transX, offset.y + params.transY);
    // 缩放canvas的x、y轴比例
    canvas.scale(params.scale, params.scale);
    // 获取预览项的Drawable对象
    Drawable d = params.drawable;
    if (d != null) {
        // 获取预览项绘制的边界
        Rect bounds = d.getBounds();
        // 在前面进行平移和缩放基础上,再次创建一个保存点
        canvas.save();
        // 再次进行平移,平移的坐标由bounds.left和bounds.top
        canvas.translate(-bounds.left, -bounds.top);
        // 再次进行缩放,由mIntrinsicIconSize、bounds.width()和bounds.height()
        canvas.scale(mIntrinsicIconSize / bounds.width(), mIntrinsicIconSize / bounds.height());
        // 进行绘制
        d.draw(canvas);
        // 回到第二次保存点之前的状态
        canvas.restore();
    }
    // 回到最初始的状态
    canvas.restore();
}

​ 上面注释写得比较清楚,在drawParams()方法中可以拿到每个文件夹的预览项列表,通过for循环,调用drawPreviewItem()方法绘制每一个预览项。

​ 在drawPreviewItem()方法中,canvas根据一系列的平移和缩放,最终调用预览项的Drawable.draw(canvas)来进行绘制。这里为了方便分析,我们加一些打印来看看相关的数据:

private void drawPreviewItem(Canvas canvas, PreviewItemDrawingParams params, PointF offset,
        boolean shouldClipPath, Path clipPath) {
    canvas.save();
    if (shouldClipPath) {
        canvas.clipPath(clipPath);
    }
    android.util.Log.d(TAG, "drawPreviewItem: ------------------------");
    android.util.Log.d(TAG, "drawPreviewItem: offset.x=" + offset.x + "; offset.y=" + offset.y);
    android.util.Log.d(TAG, "drawPreviewItem: params.transX=" + params.transX + "; params.transY=" + params.transY);
    android.util.Log.d(TAG, "drawPreviewItem: params.scale=" + params.scale);

    canvas.translate(offset.x + params.transX, offset.y + params.transY);
    canvas.scale(params.scale, params.scale);
    Drawable d = params.drawable;
    if (d != null) {
        Rect bounds = d.getBounds();
        canvas.save();
        canvas.translate(-bounds.left, -bounds.top);
        canvas.scale(mIntrinsicIconSize / bounds.width(), mIntrinsicIconSize / bounds.height());
        android.util.Log.d(TAG, "drawPreviewItem: getIntrinsicWidth=" + d.getIntrinsicWidth());
        android.util.Log.d(TAG, "drawPreviewItem: bounds=" + bounds);
        d.draw(canvas);
        canvas.restore();
    }
    canvas.restore();
}

我这里只是两个应用和三个应用的情况下出现异常,四个或以上应用时显示是正常的,所以这里只分析这两种情况。查看文件夹中包含两个和三个应用时的相关打印如下:

#2个应用
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: ------------------------
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: offset.x=75.0; offset.y=15.0
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: params.transX=87.8625; params.transY=40.425
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: params.scale=0.4675
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: getIntrinsicWidth=180
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: bounds=Rect(0, 0 - 180, 180)
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: ------------------------
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: offset.x=75.0; offset.y=15.0
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: params.transX=-7.012501; params.transY=40.425
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: params.scale=0.4675
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: getIntrinsicWidth=180
07-17 16:03:10.361  1646  1646 D PreviewItemManager: drawPreviewItem: bounds=Rect(0, 0 - 180, 180)

#3个应用
07-17 16:33:47.445 18720 18720 D PreviewItemManager: drawPreviewItem: ------------------------
07-17 16:33:47.446 18720 18720 D PreviewItemManager: drawPreviewItem: offset.x=75.0; offset.y=15.0
07-17 16:33:47.446 18720 18720 D PreviewItemManager: drawPreviewItem: params.transX=-5.7923393; params.transY=67.1086
07-17 16:33:47.446 18720 18720 D PreviewItemManager: drawPreviewItem: params.scale=0.4675
07-17 16:33:47.446 18720 18720 D PreviewItemManager: drawPreviewItem: getIntrinsicWidth=180
07-17 16:33:47.447 18720 18720 D PreviewItemManager: drawPreviewItem: bounds=Rect(0, 0 - 180, 180)
07-17 16:33:47.447 18720 18720 D PreviewItemManager: drawPreviewItem: ------------------------
07-17 16:33:47.448 18720 18720 D PreviewItemManager: drawPreviewItem: offset.x=75.0; offset.y=15.0
07-17 16:33:47.448 18720 18720 D PreviewItemManager: drawPreviewItem: params.transX=86.64235; params.transY=67.1086
07-17 16:33:47.448 18720 18720 D PreviewItemManager: drawPreviewItem: params.scale=0.4675
07-17 16:33:47.449 18720 18720 D PreviewItemManager: drawPreviewItem: getIntrinsicWidth=180
07-17 16:33:47.449 18720 18720 D PreviewItemManager: drawPreviewItem: bounds=Rect(0, 0 - 180, 180)
07-17 16:33:47.449 18720 18720 D PreviewItemManager: drawPreviewItem: ------------------------
07-17 16:33:47.449 18720 18720 D PreviewItemManager: drawPreviewItem: offset.x=75.0; offset.y=15.0
07-17 16:33:47.449 18720 18720 D PreviewItemManager: drawPreviewItem: params.transX=40.425; params.transY=-12.942188
07-17 16:33:47.449 18720 18720 D PreviewItemManager: drawPreviewItem: params.scale=0.4675
07-17 16:33:47.450 18720 18720 D PreviewItemManager: drawPreviewItem: getIntrinsicWidth=180
07-17 16:33:47.450 18720 18720 D PreviewItemManager: drawPreviewItem: bounds=Rect(0, 0 - 180, 180)

在分析打印之前先看下谷歌桌面文件夹预览图的结构及相关说明:

在这里插入图片描述

结合上面内容分析,可以知道,在文件夹中包含两个和三个应用预览项时,下面这几项的数据都是一直不变的:

// 本项目中设定的FolderIcon的宽高:316x271

// 文件夹预览图在FolderIcon中的偏移量
offset.x=75.0; offset.y=15.0
    
// 预览项默认的缩放比例
params.scale=0.4675
    
// 预览项Drawable对象的固有宽度(这里应该是该项目设定了普通应用图标的size=60dp,且dip=480,所以获取到的固有宽度是(480/160)*60=180px)
getIntrinsicWidth=180
    
// 预览项Deawable对象的边界,这里可以得知bounds.left和bounds.top都是0
bounds=Rect(0, 0 - 180, 180)

回头看下绘制的关键代码:

// 第一次平移canvas,因为offset.x和offset.y是固定的,所以移动位置取决于params.transX和params.transY
canvas.translate(offset.x + params.transX, offset.y + params.transY);
// 第一次缩放,这个值也是固定的
canvas.scale(params.scale, params.scale);
Drawable d = params.drawable;
Rect bounds = d.getBounds();
canvas.save();

// 第二次平移,因为bounds的left和top都是0,所以这里的操作没有实际效果 
canvas.translate(-bounds.left, -bounds.top);
// 第二次缩放,缩放比例是1
canvas.scale(mIntrinsicIconSize / bounds.width(), mIntrinsicIconSize / bounds.height());
// 绘制
d.draw(canvas);

由上面分析可以知道,对预览项绘制的位置真正有影响的是params.transX和params.transY:

在绘制时修改

看下三个应用预览项时的params.transX和params.transY:

# 前面两个Y轴偏移量一样,在同一水平线上,证明是底下的两个预览项应用
07-17 16:33:47.446 18720 18720 D PreviewItemManager: drawPreviewItem: params.transX=-5.7923393; params.transY=67.1086
07-17 16:33:47.448 18720 18720 D PreviewItemManager: drawPreviewItem: params.transX=86.64235; params.transY=67.1086
# 这个对应的是顶部溢出的预览项应用,我们重点分析这个
07-17 16:33:47.449 18720 18720 D PreviewItemManager: drawPreviewItem: params.transX=40.425; params.transY=-12.942188

可以看到顶部溢出的预览项的 params.transY=-12.942188,这里Y轴偏移量是一个负数,

// 第一次平移canvas,
canvas.translate(offset.x + params.transX, offset.y + params.transY);

因为offset.y是固定为15,params.transY < 0 ,所以 offset.y + params.transY < offset.y。而offset.y又是文件夹预览区域(灰白色背景)的Y轴起始坐标,所以canvas.translate()绘制起点的Y坐标会位于文件夹预览区域的上方,所以导致了预览项绘制溢出的问题。同理可以知道:

  • 对于文件夹图标的left边界,如果预览项的params.transX是负数,就可能引发越界;
  • 对于文件夹图标的top边界,如果预览项的params.transY是负数,就可能引发越界;
  • 对于文件夹图标的right边界,如果右侧预览项的params.transX过大,可能引发越界问题;
  • 对于文件夹图标的bottom边界,如果底部预览项params.transY过大,可能引发越界问题。

问题修改:这里知道是由params.transX和params.transY引起的位置偏移,所以改动方式也很简单,就是哪个地方溢出,就针对那个参数做修改:

​ 以三个应用的情况来说:上面的图标顶部溢出,是由于params.transY为负数引起的,所以可以在绘制前判断一下,如果params.transY为负数,就将其设为0或者取反,让它在绘制时往下移动,同时为了避免与下面的图标出现重叠,下面的两个图标也要跟着改变params.transY的值(与第一个应用偏移量相同)。
​ 第二行的两个应用,与文件夹左右两边距离文件夹边框太近,是因为左边图标的params.transX为负数(向左偏移),而右边图标的params.transX又过大(向右偏移)导致的,这种情况下就可以增加判断,如果params.transX是负数(说明是第二行的左边应用),那么可以将params.transX设为0或者取反;如果params.transX>0(说明是第二行的右边应用),则将params.transX的值适当减少一些,这样就可以使三个应用比较均匀地分布在文件夹图标中了。

​ (两个应用时出现距离左右边框太近的也是类似处理)

​ 接下来还有个问题,绘制PreviewItemDrawingParams时都是单独绘制自己的,那么怎么知道当前是两个或者三个应用的情况且需要修改呢?可以看下drawParams方法:

// Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java

public void drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params,
        PointF offset, boolean shouldClipPath, Path clipPath) {
    // 这里传进来的ArrayList<PreviewItemDrawingParams> params,就是一个文件夹内部需要预览的应用图标列表
    // 正常来说,文件夹预览图中只有三种情况:两个应用、三个应用、四个及以上应用(默认的预览图最多就显示四个)
    for (int i = params.size() - 1; i >= 0; i--) {
        // 遍历文件夹预览图中需要显示的应用图标,调用drawPreviewItem()方法进行绘制
        PreviewItemDrawingParams p = params.get(i);
        if (!p.hidden) {
            // Exiting param should always be clipped.
            boolean isExiting = p.index == EXIT_INDEX;
             // 遍历文件夹中每个图标,进行绘制
            drawPreviewItem(canvas, p, offset, isExiting | shouldClipPath, clipPath);
        }
    }
}

在这个方法中,其实是有一个for循环在遍历ArrayList<PreviewItemDrawingParams> params的内容,这个params就是一个文件夹中所需要绘制的预览项,而在这个类里面,drawPreviewItem()方法又只有这一个入口(没有其他地方会调用,所以是唯一的入口),所以我们可以在这里,自定义一个方法(或者在原来的drawPreviewItem方法中增加多一个参数),将params的size传递进去,根据params的size值来处理不同情况:

// Launcher3/src/com/android/launcher3/folder/PreviewItemManager.java

public void drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params,
        PointF offset, boolean shouldClipPath, Path clipPath) {

    // Solves the problem of icon boundary overflow when the number of ICONS in the folder is 2 or 3.
    // add by 20240717
    int size = params.size();
    if (size <= 3){
        // 当文件夹中的应用预览项为2或者3的时候,调用自定义的方法
        for (int i = params.size() - 1; i >= 0; i--) {
            PreviewItemDrawingParams p = params.get(i);
            if (!p.hidden) {
                boolean isExiting = p.index == EXIT_INDEX;
                drawPreviewItemOfCustomer(canvas, p, offset, isExiting | shouldClipPath, clipPath, size);
            }
        }
    } else {
        // 其他情况不变,还是调用之前的方法
        for (int i = params.size() - 1; i >= 0; i--) {
            PreviewItemDrawingParams p = params.get(i);
            if (!p.hidden) {
                // Exiting param should always be clipped.
                boolean isExiting = p.index == EXIT_INDEX;
                drawPreviewItem(canvas, p, offset, isExiting | shouldClipPath, clipPath);
            }
        }
    }
}

/**
 * 自定义的方法,逻辑与drawPreviewItem()方法类似,只是在params.size <= 3时,对params.transX和params.transY做一些调整
 * Solves the problem of icon boundary overflow when the number of ICONS in the folder is 2 or 3.
 * add by 20240717
 */
private void drawPreviewItemOfCustomer(Canvas canvas, PreviewItemDrawingParams params, PointF offset,
                             boolean shouldClipPath, Path clipPath, int size) {
    canvas.save();
    if (shouldClipPath) {
        canvas.clipPath(clipPath);
    }

    // Recalculate the starting point of the drawing
    if (size == 2){
        // 当文件夹内有两个应用时,只需要调整两个应用的params.transX即可
        float finallyX = 0;
        if (params.transX > 0) {
            // 右边的应用就向左偏移7
            finallyX = params.transX - 7;
        }
        // 左边的params.transX原本为负数,这里直接处理变成0
        android.util.Log.d(TAG, "drawPreviewItem: finallyX=" + finallyX);
        canvas.translate(offset.x + finallyX, offset.y + params.transY);
    } else if (size == 3) {
        float finallyY = 0;
        if (params.transY > 0) {
            // 第二行的应用,就往下偏移12
            finallyY = params.transY + 12;
        }
        // 第一行应用的params.transY原本为负数,这里直接处理变成0
        
        float finallyX = 0;
        if (params.transX > 0 && params.transY > 0) {
            // 第二行右边的应用, 向左偏移
            finallyX = params.transX - 6;
        } else if (params.transX > 0 && params.transY < 0) {
            // 第一行的应用,已经居中了,不需要处理
            finallyX = params.transX;
        }
        // 第二行左边应用的params.transX原本为负数,这里直接处理变成0
        android.util.Log.d(TAG, "drawPreviewItem: finallyX=" + finallyX);
        android.util.Log.d(TAG, "drawPreviewItem: finallyY=" + finallyY);
        canvas.translate(offset.x + finallyX, offset.y + finallyY);
    } else {
        // size不等于2或者3的情况,就正常处理
        canvas.translate(offset.x + params.transX, offset.y + params.transY);
    }

    // 下面的缩放和绘制流程不变
    canvas.scale(params.scale, params.scale);
    Drawable d = params.drawable;
    if (d != null) {
        Rect bounds = d.getBounds();
        canvas.save();
        canvas.translate(-bounds.left, -bounds.top);
        canvas.scale(mIntrinsicIconSize / bounds.width(), mIntrinsicIconSize / bounds.height());
        d.draw(canvas);
        canvas.restore();
    }
    canvas.restore();
}

修改之后的效果:

在这里插入图片描述

可以看到,修改完之后,预览图标都比较均匀地分布在文件夹中了。

注意:虽然这样修改是起作用了,但是这只是项目紧急情况下的一种临时应对策略。这种在绘制最后阶段去手动改动数据的方式是存在风险的,因为一旦项目需求发生更改,比如图标UI的大小这些发生改变,计算出来的params.transX和params.transY就会发生改变,从而使上面的对策失效。为了从源头解决问题,可以使用下面的方法来做处理:

计算时的具体逻辑

​ 前面有分析到,预览项的相关参数(宽高、位置)需要先计算,然后再在draw()中进行绘制,我们现在回过头来,到计算相关的地方(也就是ClippedFolderIconLayoutRule的computePreviewItemDrawingParams()方法中)查看代码:

// Launcher3/src/com/android/launcher3/folder/ClippedFolderIconLayoutRule.java

private float[] mTmpPoint = new float[2];

/**
 * 这个方法实际上就是计算每个应用图标预览项的位置和大小的
 * index是应用图标在文件夹内的下标,从零开始
 * curNumItems是文件夹内应用图标的总数
 * params是对应应用图标的相关参数
 */
public PreviewItemDrawingParams computePreviewItemDrawingParams(int index, int curNumItems, PreviewItemDrawingParams params) {
    float totalScale = scaleForItem(curNumItems);
    float transX;
    float transY;

    if (index == EXIT_INDEX) {
        // ...代码省略
    } else {
        // 0 <= index <= 3
        // 应用图标相关参数的计算,会走到这里,mTmpPoint是一个float数组,计算完成后会把transX、transY数据存在里面
        getPosition(index, curNumItems, mTmpPoint);
    }

    // 读取mTmpPoint中的数据
    transX = mTmpPoint[0];
    transY = mTmpPoint[1];

    // 更新数据到PreviewItemDrawingParams中
    if (params == null) {
        params = new PreviewItemDrawingParams(transX, transY, totalScale);
    } else {
        params.update(transX, transY, totalScale);
    }
    return params;
}

computePreviewItemDrawingParams()方法中,计算预览项相关参数的关键方法是getPosition(),跟进查看getPosition方法的实现:

// Launcher3/src/com/android/launcher3/folder/ClippedFolderIconLayoutRule.java

public static final int MAX_NUM_ITEMS_IN_PREVIEW = 4;
private static final int MIN_NUM_ITEMS_IN_PREVIEW = 2;
private static final float MIN_SCALE = 0.44f;
private static final float MAX_SCALE = 0.51f;
private static final float MAX_RADIUS_DILATION = 0.25f;
// ICON_OVERLAP_FACTOR预览项的最大重叠量可以超出背景边界.
public static final float ICON_OVERLAP_FACTOR = 1 + (MAX_RADIUS_DILATION / 2f);
// ITEM_RADIUS_SCALE_FACTOR用于控制预览图标分布的初始半径大小
private static final float ITEM_RADIUS_SCALE_FACTOR = 1.15f;
private float[] mTmpPoint = new float[2];

/**
 * 初始化部分数据,这个方法是在PreviewItemManager.java#computePreviewDrawingParams()方法中调用的
 */
public void init(int availableSpace, float intrinsicIconSize, boolean rtl) {
    // mAvailableSpace文件夹图标内部可用的空间
    mAvailableSpace = availableSpace;
    // 基础半径,mRadius=availableSpace*1.15/2   (这里不太明白为什么不直接取availableSpace的一半,而是要*1.15)
    mRadius = ITEM_RADIUS_SCALE_FACTOR * availableSpace / 2f;
    // mIconSize一般与app设定的大小一致,mIconSize=60dp*3=180px
    mIconSize = intrinsicIconSize;
    mIsRtl = rtl;
    // 基准缩放比例,我这里是165/180≈0.917
    mBaselineIconScale = availableSpace / (intrinsicIconSize * 1f);
}

/**
 * 关键方法,计算文件夹预览图内应用的排列方式,各个应用图标的参数等
 */
private void getPosition(int index, int curNumItems, float[] result) {
    curNumItems = Math.max(curNumItems, 2);

    // 从左往右的布局,theta0=PI
    double theta0 = mIsRtl ? 0 : Math.PI;
    // 从左往右的布局,direction=-1
    int direction = mIsRtl ? 1 : -1;

    double thetaShift = 0;
    if (curNumItems == 3) {
        thetaShift = Math.PI / 2;
    } else if (curNumItems == 4) {
        thetaShift = Math.PI / 4;
    }
    
    /** 计算各应用图标的初始角坐标
     *  theta0初始值是PI,根据上面thetaShift的计算,可以得知:
     *  curNumItems=2时,theta0=PI
     *  curNumItems=3时,theta0=PI/2
     *  curNumItems=4时,theta0=PI*3/4
     * */
    theta0 += direction * thetaShift;

    // 当curNumItems=4时,调整index的值,用于计算各个index应用的排列方式和角坐标
    if (curNumItems == 4 && index == 3) {
        index = 2;
    } else if (curNumItems == 4 && index == 2) {
        index = 3;
    }

    /** 根据theta0(初始的角坐标)计算出,在不同curNumItems时,各个index图标的排列方式和角坐标
     *  theta是各个图标中心点相对文件夹中心点的角坐标。direction==-1(默认是顺时针旋转)
     *  2*PI 是一整个圆(360°),分成curNumItems等份,一份就是两个图标中心点角坐标之差
     *  curNumItems=2时,两个图标在同一水平线上,角坐标分别是 PI 和 0
     *  curNumItems=3时,三个图标按品字排列,第一个在顶部,顺时针排列,角坐标分别是 PI/2 (即90°) 、-PI/6(-30°)、-PI*5/6(-150°)
     *  curNumItems=4时,四个图标按2x2排列,四个图标坐标分别为 PI*3/4、PI/4、-PI/4、-PI*3/4
     * */
    double theta = theta0 + index * (2 * Math.PI / curNumItems) * direction;
    
    /** 计算各应用图标中心距离文件夹图标中心的半径
     * 这里使用了一个线性插值公式,根据当前预览图标数量(curNumItems)相对于预览图标的最小和最大数量(MIN_NUM_ITEMS_IN_PREVIEW和MAX_NUM_ITEMS_IN_PREVIEW)的比例,来调整radius的大小。
     *  curNumItems == 2时, radius = mRadius
     *  curNumItems == 3时, radius = 1.125*mRadius
     *  curNumItems == 4时, radius = 1.25*mRadius
     * */
    float radius = mRadius * (1 + MAX_RADIUS_DILATION * (curNumItems -
            MIN_NUM_ITEMS_IN_PREVIEW) / (MAX_NUM_ITEMS_IN_PREVIEW - MIN_NUM_ITEMS_IN_PREVIEW));
    
    // halfIconSize存储了图标尺寸的一半,因为图标是围绕文件夹中心点分布的,为了使图标完全落在文件夹内并且不超出边界,我们需要从计算出的坐标值中减去图标尺寸的一半。这样即使在图标边缘,也不会超出文件夹的边界。这里调用了scaleForItem方法来获取一个缩放系数(后面实际绘制的缩放倍数)
    float halfIconSize = (mIconSize * scaleForItem(curNumItems)) / 2;
    
    /** 计算各应用图标左上角的x、y坐标
     *  以文件夹图标中心为圆点,沿着圆圈,映射各个应用图标位置(左上角图标)。
     *  mAvailableSpace / 2:作为基础偏移,确保图标布局是以文件夹中心为基准的
     *  radius * Math.cos(theta) :图标中心x坐标与文件夹中心x坐标的距离
     *  radius * Math.cos(theta)/2: 除以2可以理解为是在原半径的基础上进一步减小分布范围,以确保图标之间有足够的空间。也可以避免图标相互重叠或者触碰到文件夹边框
     *  radius * Math.sin(theta) :图标中心y坐标与文件夹中心y坐标的距离,除以2的含义同上
     *  halfIconSize:图标尺寸的一半
     *  result[0]:应用图标左上角的x坐标
     *  result[1]:应用图标左上角的y坐标
     * */
    result[0] = mAvailableSpace / 2 + (float) (radius * Math.cos(theta) / 2) - halfIconSize;
    result[1] = mAvailableSpace / 2 + (float) (- radius * Math.sin(theta) / 2) - halfIconSize;
}

public float scaleForItem(int numItems) {
    // Scale is determined by the number of items in the preview.
    final float scale;
    if (numItems <= 3) {
        // 文件夹内图标为2或者3时,使用缩放系数MAX_SCALE
        scale = MAX_SCALE;
    } else {
        // 文件夹内图标>=4时,使用MIN_SCALE
        scale = MIN_SCALE;
    }
    return scale * mBaselineIconScale;
}

getPosition()方法中的计算逻辑稍微有些复杂,我在上面也写了比较详细的注释,我这里不做过多解析了,我总结下计算的思路:

(1)在文件夹预览图标布局中,图标不是随机分布的,而是围绕文件夹图标中心均匀排列的。实现方法就是:以中心点为圆心绘制一个圆,各个应用的图标中心都在圆上,且各个图标之间的距离(角度)相等即可;

(2)计算 应用图标中心 距离 文件夹图标中心 的半径:图标数量不同时,绘制的大小和区域也不一样,这里使用一个线性插值公式,根据当前预览图标数量(curNumItems)相对于预览图标的最小和最大数量(2和4)的比例,来调整半径radius的大小:

mRadius = 1.15 * availableSpace / 2f;
curNumItems == 2时, radius = mRadius
curNumItems == 3时, radius = 1.125*mRadius
curNumItems == 4时, radius = 1.25*mRadius

(3)根据当前预览图标数量(curNumItems)将这个圆平均分成curNumItems份,并根据角坐标系计算每个应用图标中心点的角坐标:

当curNumItems=2时,两个应用在中心点的两侧,两个图标在同一水平线上,角坐标分别是 PI (180°)和 0;
当curNumItems=3时,三个图标按品字排列,第一个在顶部,顺时针排列,角坐标分别是 PI/2 (即90°) 、-PI/6(-30°)-PI*5/6(-150°);
当curNumItems=4时,四个图标按2x2排列,四个图标坐标分别为 PI * 3/4、PI/4-PI/4、-PI*3/4

(4)知道角坐标以及半径之后,就可以根据cos和sin函数获取应用图标中心在文件夹角坐标系中的x和y坐标了,最后计算应用图标左上角的x、y坐标:

    /** 计算各应用图标左上角的x、y坐标
     *  以文件夹图标中心为圆点,沿着圆圈,映射各个应用图标位置(左上角图标)。
     *  mAvailableSpace / 2:作为基础偏移,确保图标布局是以文件夹中心为基准的
     *  radius * Math.cos(theta) :图标中心x坐标与文件夹中心x坐标的距离
     *  radius * Math.cos(theta)/2: 除以2可以理解为是在原半径的基础上进一步减小分布范围,以确保图标之间有足够的空间。也可以避免图标相互重叠或者触碰到文件夹边框。其实就是把整个图标往中心点移动。
     *  radius * Math.sin(theta) :图标中心y坐标与文件夹中心y坐标的距离,除以2的含义同上
     *  halfIconSize:图标尺寸的一半
     *  result[0]:应用图标左上角的x坐标
     *  result[1]:应用图标左上角的y坐标
     * */
    result[0] = mAvailableSpace / 2 + (float) (radius * Math.cos(theta) / 2) - halfIconSize;
    result[1] = mAvailableSpace / 2 + (float) (- radius * Math.sin(theta) / 2) - halfIconSize;

这里选取两个图标时,左边图标左上角x坐标的计算示意图:

在这里插入图片描述

​ 这里(radius * Math.cos(theta) / 2) 中的除以2,可以理解为把整个图标往中心点移动,在原半径的基础上进一步减小分布范围,以确保图标之间有足够的空间。也可以避免图标相互重叠或者触碰到文件夹边框。其他的都比较好理解,设图标左上角x轴坐标为x,则由图示有以下数学关系:

mAvailableSpace / 2 = x + mIconSize / 2  + |(radius * Math.cos(theta) / 2)|
// 即x = mAvailableSpace / 2 - mIconSize / 2 - | (radius * Math.cos(theta) / 2)|, 因为图标在左侧时Math.cos(theta)是负值
// 所以转化过来,就对应了上面的result[0] = mAvailableSpace / 2 + (float) (radius * Math.cos(theta) / 2) - halfIconSize;

同理,如果图标是在右侧,Math.cos(theta)值是正数,那么位置关系就会变成

x = mAvailableSpace / 2 +  (radius * Math.cos(theta) / 2) - mIconSize / 2
// 与代码中的逻辑是一样的

到这里计算的具体逻辑就分析完了。

在计算时的改动

​ 由上面的分析可以知道,影响计算结果的有这些参数:mAvailableSpace(这个是文件夹图标大小,一般不做改动)、mIconSize(其实就是缩放系数)、radius(半径,影响应用图标离中心点的距离)、theta(角坐标),我们可以根据自己的需求来做不同的改动,我这里以我遇到的问题来做处理:

(1)两个应用时,左右两边距离文件夹边距太近:这个比较好处理,左右两边距离文件夹边距太近,反过来说就是应用离中心位置太远,所以我们通过调整两个应用时的radius值即可,应用图标大小角度这些都不用调整。(效果图在下面,和三个改动的一起展示)

/**
 *  curNumItems == 2时, radius = mRadius = mAvailableSpace * 1.15 / 2
 *  curNumItems == 3时, radius = 1.125*mRadius
 *  curNumItems == 4时, radius = 1.25*mRadius
 * */
float radius = mRadius * (1 + MAX_RADIUS_DILATION * (curNumItems -
        MIN_NUM_ITEMS_IN_PREVIEW) / (MAX_NUM_ITEMS_IN_PREVIEW - MIN_NUM_ITEMS_IN_PREVIEW));

if (curNumItems == 2) {
    // 原本是mAvailableSpace * 1.15 / 2,这里根据自己的情况调整小一点就可以了
    radius = mAvailableSpace/2f;
} 

(2)三个应用时,三个应用图标整体都向上偏移了,且顶部应用溢出边界了;同时第二行两个应用的左右离文件夹的边距太近了。这里的情况就比较复杂了:

​ 三个应用图标放在文件夹图标内,按照Launcher3源码的思路:这三个图标(小正方形)需要沿着文件夹图标(大正方形)的中心点呈120度角分布,形成一个等边三角形的顶点布局。从数学角度来讨论做到这样需要满足什么条件:

  • 这样的布局要求三个小正方形的中心点位于一个以大正方形中心为圆心、特定半径的圆周上,这个半径就是每个小正方形中心到大正方形中心的距离。
  • 由于小正方形要放在大正方形内,且三个小正方形呈等边三角形分布,这意味着小正方形不能太大,否则它们会互相重叠或超出大正方形的边界。我们假设大正方形的边长为L,小正方形的边长为l。

在这里插入图片描述

这里增加一些打印来看各个数据

# 三个应用时,顶部应用预览项图标的相关打印
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: mAvailableSpace=165.0
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: mBaselineIconScale=0.9166667
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: mIconSize=180.0
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: curNumItems=3index=0
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: scale=0.4675
07-20 16:41:13.347  4896  4896 D lClippedFolderIconLayout: getPosition: mRadius=94.875
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: radius=106.734375
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: theta=1.5707963267948966
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: halfIconSize=42.075
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: Math.cos(theta)=6.123233995736766E-17
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: Math.sin(theta)=1.0
07-20 16:41:13.347  4896  4896 D ClippedFolderIconLayout: getPosition: result[0]=40.425; result[1]=-12.942188

由上面的打印可以知道,L即文件夹图标可用大小为165,l 为2*halfIconSize=84.15,L 和 l 的关系是不满足上面条件的,正常算出来需要满足 l <= 77.78,且需要应用图标之间要有一些间隔,即 l 的值要比77.78更小才行。查看halfIconSize的计算公式:

// mIconSize固定是180
float halfIconSize = (mIconSize * scaleForItem(curNumItems)) / 2;
 mBaselineIconScale = availableSpace / (intrinsicIconSize * 1f);

public float scaleForItem(int numItems) {
    // Scale is determined by the number of items in the preview.
    final float scale;
    if (numItems == 2) {
        scale = MAX_SCALE;
    } else if (numItems == 3) {
        scale = MAX_SCALE;
    } else {
        scale = MIN_SCALE;
    }
    return scale * mBaselineIconScale;
}

​ 由上面内容可以知道halfIconSize = availableSpace * MAX_SCALE / 2;所以调整MAX_SCALE 让halfIconSize *2 满足 <= 77.78即可。这里修改下,使用scale = MIN_SCALE = 0.44,计算出来的图标大小为 72.6 ,可以满足需求。同时radius也需要更改为availableSpace/2,将图标向中心靠拢:

if (curNumItems == 2) {
    radius = mAvailableSpace/2f;
} else if (curNumItems == 3) {
    // 原本是 radius = 1.125*mAvailableSpace/2
    // 三个应用时,使用的radius=mAvailableSpace/2
    radius = mAvailableSpace/2f;
}

public float scaleForItem(int numItems) {
    // Scale is determined by the number of items in the preview.
    final float scale;
    if (numItems == 2) {
        scale = MAX_SCALE;
    } else if (numItems == 3) {
        // 使用缩放系数为 0.44
        scale = MIN_SCALE;
    } else {
        scale = MIN_SCALE;
    }
    return scale * mBaselineIconScale;
}

效果图如下:

在这里插入图片描述

由上面效果图可以看到,三个图标的改动已经将图标都收纳到文件夹图标内部了,由于三个图标都需要和中心点保持等距,所以图标之间的间隔相距比较近。根据这一情况,我们可以换下思路,计算第一行应用数据的时候,使用的是中心点radius=availableSpace/2,计算下面两个应用的时候,可以适当地调高一点radius,让其离中心位置远一些,从而达到三个图标均匀分布的效果:

if (curNumItems == 2) {
    radius = mAvailableSpace/2f;
} else if (curNumItems == 3) {
    if (index == 0) {
        radius = mAvailableSpace/2f;
    } else {
        radius = 1.25f * mAvailableSpace/2f;
    }
}

改动效果如下:

在这里插入图片描述

对比上面的效果,可以看到三个图标的分布情况是有了优化的。

文件夹展开后内部图标的大小以及图标之间的间距

问题描述

出现问题的效果:

在这里插入图片描述

由上面效果图可以看到,桌面的应用文件夹展开之后,每个应用所占据的空间比较大,而应用的图标尺寸比较小,留白较多,视觉效果不太好。
需求:将图标尺寸调大,并将每个格子占用的尺寸调小。

计算文件夹Cell尺寸分析

在分析前,我们先介绍两个相关的文件:

  • Launcher3/res/xml/device_profiles.xml

    device_profiles.xml主要用于定义不同设备配置下的布局参数和特性。这个文件通常包含了一系列的<grid-option>标签,每个标签定义了一种设备配置的属性,例如屏幕尺寸、方向、网格布局等。可以通过device_profiles.xml文件设定桌面的布局、设定一些参数(如主页的布局、图标的尺寸等)。

  • Launcher3/src/com/android/launcher3/DeviceProfile.java

    DeviceProfile.java 是 Launcher3 中的一个关键类,它主要负责管理与设备相关的信息以及布局参数。这个类提供了很多方法来获取设备屏幕的尺寸、方向、分辨率、网格布局的信息以及其他与用户界面相关的配置。当Launcher启动时,DeviceProfile 会被初始化并用来设置初始布局。

这两个文件对主页布局、绘制各控件的尺寸等起了至关重要的作用,我这里贴一下我项目中device_profiles.xml的配置:

<?xml version="1.0" encoding="utf-8"?>
<profiles xmlns:launcher="http://schemas.android.com/apk/res-auto" >

	<!--省略其他grid-option标签内容,本项目只使用6x5的布局-->
    <grid-option
        launcher:name="6_by_5"
        launcher:numRows="6"
        launcher:numColumns="7"
        launcher:numSearchContainerColumns="5"
        launcher:numFolderRows="3"
        launcher:numFolderColumns="3"
        launcher:numHotseatIcons="0"
        launcher:hotseatColumnSpanLandscape="2"
        launcher:numAllAppsColumns="6"
        launcher:isScalable="true"
        launcher:inlineNavButtonsEndSpacing="@dimen/taskbar_button_margin_6_5"
        launcher:devicePaddingId="@xml/paddings_6x5"
        launcher:dbFile="launcher_6_by_5.db"
        launcher:defaultLayoutId="@xml/default_workspace_6x5"
        launcher:deviceCategory="tablet" >

        <display-option
            launcher:name="Tablet"
            launcher:minWidthDps="900"
            launcher:minHeightDps="820"
            launcher:minCellHeight="120"
            launcher:minCellWidth="102"
            launcher:minCellHeightLandscape="104"
            launcher:minCellWidthLandscape="120"
            launcher:iconImageSize="60"
            launcher:iconTextSize="14"
            launcher:borderSpaceHorizontal="16"
            launcher:borderSpaceVertical="64"
            launcher:borderSpaceLandscapeHorizontal="64"
            launcher:borderSpaceLandscapeVertical="16"
            launcher:horizontalMargin="54"
            launcher:horizontalMarginLandscape="120"
            launcher:allAppsCellWidth="96"
            launcher:allAppsCellHeight="142"
            launcher:allAppsCellWidthLandscape="126"
            launcher:allAppsCellHeightLandscape="126"
            launcher:allAppsIconSize="60"
            launcher:allAppsIconTextSize="14"
            launcher:allAppsBorderSpaceHorizontal="8"
            launcher:allAppsBorderSpaceVertical="16"
            launcher:allAppsBorderSpaceLandscape="16"
            launcher:hotseatBarBottomSpace="30"
            launcher:hotseatBarBottomSpaceLandscape="40"
            launcher:canBeDefault="true" />
    </grid-option>
</profiles>

从上面文件的配置,我们可以得到一些关键的信息:

  • 屏幕的布局是6x7(六行七列)
  • 文件夹的布局是3x3(三行三列)
  • 主页图标尺寸是60dp
  • 主页应用的名称尺寸是14dp
  • 另外,本项目的分辨率是3840x2160px

当Launcher启动时,DeviceProfile 会被初始化并用来设置初始布局。这里就不再介绍DeviceProfile的加载过程了,在构造方法中调用了updateAvailableDimensions方法,然后在updateAvailableDimensions方法中又调用了updateFolderCellSize方法去计算文件夹内部格子的尺寸,看下源码:

// Launcher3/src/com/android/launcher3/DeviceProfile.java

DeviceProfile(Context context, InvariantDeviceProfile inv, Info info, WindowBounds windowBounds,
        SparseArray<DotRenderer> dotRendererCache, boolean isMultiWindowMode,
        boolean transposeLayoutWithOrientation, boolean isMultiDisplay, boolean isGestureMode,
        @NonNull final ViewScaleProvider viewScaleProvider) {

    // ...
    // Calculate all of the remaining variables.
    extraSpace = updateAvailableDimensions(res);

    // ...
}

private void updateAvailableFolderCellDimensions(Resources res) {
    // 关键方法-计算文件夹内部格子的尺寸
    updateFolderCellSize(1f, res);

    // 不要让文件夹太靠近屏幕的边缘
    int folderMargin = edgeMarginPx * 2;
    Point totalWorkspacePadding = getTotalWorkspacePadding();

    // 检查图标是否符合可用的高度
    float contentUsedHeight = folderCellHeightPx * inv.numFolderRows
            + ((inv.numFolderRows - 1) * folderCellLayoutBorderSpacePx);
    int contentMaxHeight = availableHeightPx - totalWorkspacePadding.y - folderFooterHeightPx
            - folderMargin - folderContentPaddingTop;
    // 计算y轴上的缩放比例
    float scaleY = contentMaxHeight / contentUsedHeight;

    // 检查图标是否符合可用的宽度
    float contentUsedWidth = folderCellWidthPx * inv.numFolderColumns
            + ((inv.numFolderColumns - 1) * folderCellLayoutBorderSpacePx);
    int contentMaxWidth = availableWidthPx - totalWorkspacePadding.x - folderMargin
            - folderContentPaddingLeftRight * 2;
    // 计算x轴上的缩放比例
    float scaleX = contentMaxWidth / contentUsedWidth;

    float scale = Math.min(scaleX, scaleY);
    if (scale < 1f) {
        // 关键方法-计算文件夹内部格子的尺寸
        updateFolderCellSize(scale, res);
    }
}

直接跟进到updateFolderCellSize()方法中查看(这里添加一些Log打印的代码):

// Launcher3/src/com/android/launcher3/DeviceProfile.java

private void updateFolderCellSize(float scale, Resources res) {
    android.util.Log.d(TAG, "updateFolderCellSize: ------------------scale=" + scale);
    float invIconSizeDp = inv.iconSize[mTypeIndex];
    // 获取文件夹中图标的尺寸
    folderChildIconSizePx = Math.max(1, pxFromDp(invIconSizeDp, mMetrics, scale));
    // 获取文件夹中,图标应用名Text的尺寸
    folderChildTextSizePx = pxFromSp(inv.iconTextSize[mTypeIndex], mMetrics, scale);
    // 文件夹名称的字符尺寸
    folderLabelTextSizePx = Math.max(pxFromSp(MIN_FOLDER_TEXT_SIZE_SP, mMetrics), (int) (folderChildTextSizePx * folderLabelTextScale));
    int textHeight = Utilities.calculateTextHeight(folderChildTextSizePx);

    // 通过下面的逻辑获取folderCellWidthPx和folderCellHeightPx(文件夹中Cell的宽高)
    if (isScalableGrid) {
        android.util.Log.d(TAG, "updateFolderCellSize:--isScalableGrid-- ");
        if (inv.folderStyle == INVALID_RESOURCE_HANDLE) {
            folderCellWidthPx = pxFromDp(getCellSize().x, mMetrics, scale);
            folderCellHeightPx = pxFromDp(getCellSize().y, mMetrics, scale);
            android.util.Log.d(TAG, "updateFolderCellSize: getCellSize().x=" + getCellSize().x + "; getCellSize().y=" + getCellSize().y);
        }
        folderContentPaddingLeftRight = folderCellLayoutBorderSpacePx;
    } else {
        android.util.Log.d(TAG, "updateFolderCellSize: customer");
        int cellPaddingX = (int) (res.getDimensionPixelSize(R.dimen.folder_cell_x_padding) * scale);
        int cellPaddingY = (int) (res.getDimensionPixelSize(R.dimen.folder_cell_y_padding) * scale);
        folderCellWidthPx = folderChildIconSizePx + 2 * cellPaddingX;
        folderCellHeightPx = folderChildIconSizePx + 2 * cellPaddingY + textHeight;
        folderContentPaddingLeftRight = res.getDimensionPixelSize(R.dimen.folder_content_padding_left_right);
        folderFooterHeightPx = res.getDimensionPixelSize(R.dimen.folder_footer_height_default);
    }
    
    // 计算文件夹内Cell的Padding
    folderChildDrawablePaddingPx = Math.max(0, (folderCellHeightPx - folderChildIconSizePx - textHeight) / 3);
    android.util.Log.d(TAG, "updateFolderCellSize: invIconSizeDp=" + invIconSizeDp);
    android.util.Log.d(TAG, "updateFolderCellSize: mMetrics=" + mMetrics);
    android.util.Log.d(TAG, "updateFolderCellSize: folderCellWidthPx=" + folderCellWidthPx);
    android.util.Log.d(TAG, "updateFolderCellSize: folderCellHeightPx=" + folderCellHeightPx);
    android.util.Log.d(TAG, "updateFolderCellSize: folderChildIconSizePx=" + folderChildIconSizePx);
    android.util.Log.d(TAG, "updateFolderCellSize: folderChildTextSizePx=" + folderChildTextSizePx);
    android.util.Log.d(TAG, "updateFolderCellSize: textHeight=" + textHeight);
    android.util.Log.d(TAG, "updateFolderCellSize: folderChildDrawablePaddingPx=" + folderChildDrawablePaddingPx);
}

由上面代码可以看出,在updateFolderCellSize()方法中会计算获取文件夹中图标的尺寸、文件夹中Cell的宽高等信息;这里看下展开关闭文件夹时的打印:

07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: ------------------scale=0.6691267
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize:--isScalableGrid-- 
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: getCellSize().x=302; getCellSize().y=271
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: invIconSizeDp=60.0
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: mMetrics=DisplayMetrics{density=3.0, width=3840, height=2160, scaledDensity=3.0, xdpi=76.8, ydpi=76.094}
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: folderCellWidthPx=606
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: folderCellHeightPx=544
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: folderChildIconSizePx=120
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: folderChildTextSizePx=28
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: textHeight=38
07-29 15:37:11.130  1641  1641 D DeviceProfile: updateFolderCellSize: folderChildDrawablePaddingPx=128

问题修改

通过上面的打印可以看出,文件夹展开后,每个格子占用的宽高是606x544,但是应用图标的宽高只有120px,我们可以在方法的后面部分,重新设定我们需要的相关参数,从而达到想要的效果:

// Launcher3/src/com/android/launcher3/DeviceProfile.java

private void updateFolderCellSize(float scale, Resources res) {
    float invIconSizeDp = inv.iconSize[mTypeIndex];
//        folderChildIconSizePx = Math.max(1, pxFromDp(invIconSizeDp, mMetrics, scale));
//        folderChildTextSizePx = pxFromSp(inv.iconTextSize[mTypeIndex], mMetrics, scale);
    // 将文件夹内的应用图标尺寸和文字尺寸固定为180和42
    folderChildIconSizePx = 180;
    folderChildTextSizePx = 42;
    folderLabelTextSizePx = Math.max(pxFromSp(MIN_FOLDER_TEXT_SIZE_SP, mMetrics), (int) (folderChildTextSizePx * folderLabelTextScale));
    int textHeight = Utilities.calculateTextHeight(folderChildTextSizePx);
    if (isScalableGrid) {
        if (inv.folderStyle == INVALID_RESOURCE_HANDLE) {
            folderCellWidthPx = pxFromDp(getCellSize().x, mMetrics, scale);
            folderCellHeightPx = pxFromDp(getCellSize().y, mMetrics, scale);
        }
        folderContentPaddingLeftRight = folderCellLayoutBorderSpacePx;
    } else {
        int cellPaddingX = (int) (res.getDimensionPixelSize(R.dimen.folder_cell_x_padding) * scale);
        int cellPaddingY = (int) (res.getDimensionPixelSize(R.dimen.folder_cell_y_padding) * scale);
        folderCellWidthPx = folderChildIconSizePx + 2 * cellPaddingX;
        folderCellHeightPx = folderChildIconSizePx + 2 * cellPaddingY + textHeight;
        folderContentPaddingLeftRight = res.getDimensionPixelSize(R.dimen.folder_content_padding_left_right);
        folderFooterHeightPx = res.getDimensionPixelSize(R.dimen.folder_footer_height_default);
    }
    // 将文件夹的Cell宽高固定为450和408
    folderCellWidthPx = 450;
    folderCellHeightPx = 408;

    folderChildDrawablePaddingPx = Math.max(0, (folderCellHeightPx - folderChildIconSizePx - textHeight) / 3);
}

修改后的效果(同时在device_profiles.xml文件设定了文件夹展开后的布局为3x3,这里不贴代码了):

在这里插入图片描述

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值