安卓13 Launcher3开发实战:如何优雅调整文件夹预览图布局(附完整源码解析)

Android 13/14 Launcher3图标白边问题终极解决方案:修改BaseIconFactory.java实战 本文深入解析Android 13/14中Launcher3图标白边问题的根源,并提供通过修改BaseIconFactory.java的终极解决方案。详细介绍了多版本适配方案、进阶优化策略及验证方法,帮助开发者彻底解决非自适应图标在Launcher3中显示白边的问题,提升应用图标视觉效果。 阅读详情

安卓13 Launcher3深度定制:从源码到实战,打造优雅的文件夹预览图布局方案

最近在做一个平板Launcher的深度定制项目,客户反馈文件夹预览图的图标布局有点别扭——两个图标时贴边太紧,三个图标时顶部图标直接“越狱”了。这问题看似不大,但实际体验很影响质感。我花了几天时间把Launcher3的文件夹绘制逻辑彻底梳理了一遍,发现这背后其实是一套相当精巧的几何布局算法。

如果你也在做Launcher定制,特别是安卓13的版本,这篇文章应该能帮你少走不少弯路。我会从源码分析入手,给出两种不同层级的解决方案,最后还会分享一些参数化设计的思路,让你的修改更加健壮。

1. 理解Launcher3文件夹预览图的绘制架构

要调整文件夹预览图的布局,首先得搞清楚它是怎么画出来的。Launcher3的文件夹图标绘制是个典型的分层架构,理解这个架构比直接改代码更重要。

1.1 核心类的作用与协作关系

Launcher3中负责文件夹图标绘制的核心类主要有三个:FolderIcon、PreviewItemManager和ClippedFolderIconLayoutRule。它们的分工很明确:

  • FolderIcon:文件夹图标的View容器,负责整体的绘制调度
  • PreviewItemManager:管理预览项(就是那些小图标)的绘制参数和动画
  • ClippedFolderIconLayoutRule:计算每个预览项的位置和大小

这个架构的设计思路很清晰——计算与绘制分离。ClippedFolderIconLayoutRule负责“怎么摆”,PreviewItemManager负责“怎么画”,FolderIcon负责“什么时候画”。

// FolderIcon的初始化过程
private void init() {
    mLongPressHelper = new CheckLongPressHelper(this);
    // 创建布局规则和预览项管理器
    mPreviewLayoutRule = new ClippedFolderIconLayoutRule();
    mPreviewItemManager = new PreviewItemManager(this);
    mDotParams = new DotRenderer.DrawParams();
}

这里有个关键点:ClippedFolderIconLayoutRule在初始化时就被创建,但它的参数配置是在PreviewItemManager.recomputePreviewDrawingParams()中完成的。这种延迟初始化的设计是为了适应不同设备尺寸的动态调整。

1.2 绘制流程的完整调用链

当文件夹需要绘制时,整个调用链是这样的:

FolderIcon.dispatchDraw()
    ↓
PreviewItemManager.recomputePreviewDrawingParams()
    ↓
PreviewItemManager.computePreviewDrawingParams()
    ↓
ClippedFolderIconLayoutRule.init()  // 初始化布局参数
    ↓
PreviewItemManager.updatePreviewItems()
    ↓
PreviewItemManager.buildParamsForPage()
    ↓
PreviewItemManager.computePreviewItemDrawingParams()
    ↓
ClippedFolderIconLayoutRule.computePreviewItemDrawingParams()  // 核心计算
    ↓
PreviewItemManager.draw()  // 实际绘制

这个流程中,计算位置和实际绘制是两个独立的阶段。理解这一点很重要,因为我们的修改方案也会基于这两个不同的切入点。

提示:在调试时,可以在FolderIcon.dispatchDraw()方法开头加Log,观察绘制频率。你会发现文件夹图标的绘制是相当频繁的,特别是在拖拽、动画过程中。

1.3 预览项的数据结构

每个预览项的信息都封装在PreviewItemDrawingParams对象中:

class PreviewItemDrawingParams {
    float transX;      // X轴平移量
    float transY;      // Y轴平移量
    float scale;       // 缩放比例
    Drawable drawable; // 图标Drawable
    WorkspaceItemInfo item; // 应用信息
    
    // 构造函数和更新方法
    PreviewItemDrawingParams(float transX, float transY, float scale) {
        this.transX = transX;
        this.transY = transY;
        this.scale = scale;
    }
}

这个类的设计体现了Android图形系统的典型模式:变换参数(transX, transY, scale)与绘制内容(drawable)分离。调整布局本质上就是调整这些变换参数。

2. 布局计算的核心算法解析

现在进入最核心的部分——ClippedFolderIconLayoutRule如何计算每个图标的位置。这部分涉及一些几何知识,但我会用尽量直观的方式解释。

2.1 圆形布局的基本原理

Launcher3的文件夹预览图采用了一种圆形布局算法。简单说,就是把图标均匀分布在一个虚拟的圆环上。这个设计的巧妙之处在于,无论图标数量是2个、3个还是4个,都能保持视觉上的平衡。

关键参数定义:

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;
private static final float ITEM_RADIUS_SCALE_FACTOR = 1.15f;

这些常量决定了布局的基本行为:

  • MAX_NUM_ITEMS_IN_PREVIEW:最多显示4个预览图标
  • MIN_SCALE/MAX_SCALE:图标的缩放范围
  • ITEM_RADIUS_SCALE_FACTOR:控制图标分布半径的系数

2.2 getPosition()方法的数学逻辑

getPosition()方法是整个布局计算的核心,它的计算过程可以分为几个步骤:

第一步:确定起始角度

// 从左往右布局时,起始角度为π(180度)
double theta0 = mIsRtl ? 0 : Math.PI;
int direction = mIsRtl ? 1 : -1;
double thetaShift = 0;

// 根据图标数量调整起始角度
if (curNumItems == 3) {
    thetaShift = Math.PI / 2;  // 90度
} else if (curNumItems == 4) {
    thetaShift = Math.PI / 4;  // 45度
}
theta0 += direction * thetaShift;

这个调整是为了让布局在不同数量下都看起来自然。比如3个图标时,起始角度设为90度,这样第一个图标就在正上方。

第二步:计算每个图标的角度

// 将圆环等分,计算每个图标的角度
double theta = theta0 + index * (2 * Math.PI / curNumItems) * direction;

这里用到了极坐标的思想。如果curNumItems=3,那么每个图标间隔120度(2π/3)。

第三步:计算分布半径

// 半径随图标数量线性增加
float radius = mRadius * (1 + MAX_RADIUS_DILATION * 
    (curNumItems - MIN_NUM_ITEMS_IN_PREVIEW) / 
    (MAX_NUM_ITEMS_IN_PREVIEW - MIN_NUM_ITEMS_IN_PREVIEW));

半径的计算公式很有意思:图标越多,半径越大。这是为了给更多图标腾出空间。

第四步:转换为直角坐标

// 计算图标左上角坐标
float halfIconSize = (mIconSize * scaleForItem(curNumItems)) / 2;
result[0] = mAvailableSpace / 2 + (float) (radius * Math.cos(theta) / 2) - halfIconSize;
result[1] = mAvailableSpace / 2 + (float) (-radius * Math.sin(theta) / 2) - halfIconSize;

这里有几个关键点:

  1. radius * Math.cos(theta) 得到的是图标中心相对于圆心的X坐标
  2. 除以2是为了让图标更靠近中心,避免贴边
  3. 减去halfIconSize是因为Canvas绘制时用的是左上角坐标

2.3 缩放系数的动态调整

图标的大小不是固定的,而是根据数量动态调整:

public float scaleForItem(int numItems) {
    final float scale;
    if (numItems <= 3) {
        scale = MAX_SCALE;  // 2-3个图标用最大缩放
    } else {
        scale = MIN_SCALE;  // 4个及以上用最小缩放
    }
    return scale * mBaselineIconScale;
}

这个设计很合理:图标少时可以大一些,图标多时就得小一些,否则放不下。

2.4 问题定位:为什么会出现布局异常

通过分析源码,我发现布局问题的根源在于几何约束条件被破坏。以3个图标为例,理想情况下它们应该构成一个等边三角形,且都在文件夹边界内。

但实际计算时,如果图标尺寸(mIconSize * scale)过大,或者分布半径(radius)不合适,就会导致图标超出边界。具体来说:

  1. 顶部图标溢出:Y坐标result[1]为负值,说明图标跑到了文件夹上方
  2. 左右图标贴边:X坐标result[0]接近0或接近mAvailableSpace - iconWidth

用数学公式表示约束条件:

图标半径 = (mIconSize * scale) / 2
最大允许半径 = mAvailableSpace / 2 - 图标半径 - 边距

当计算出的radius大于最大允许半径时,就会出问题。

3. 方案一:绘制时的动态调整(快速修复)

如果你需要快速解决问题,或者改动范围要尽量小,可以在绘制阶段进行调整。这种方法的优点是改动集中、风险可控,但缺点是治标不治本。

3.1 理解绘制阶段的坐标变换

在PreviewItemManager.drawPreviewItem()方法中,Canvas经历了多次变换:

private void drawPreviewItem(Canvas canvas, PreviewItemDrawingParams params, 
                             PointF offset, boolean shouldClipPath, Path clipPath) {
    canvas.save();
    
    // 第一次平移:offset是文件夹背景的偏移,params.transX/Y是图标相对偏移
    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();
        
        // 第二次平移和缩放:调整Drawable的绘制位置
        canvas.translate(-bounds.left, -bounds.top);
        canvas.scale(mIntrinsicIconSize / bounds.width(), 
                    mIntrinsicIconSize / bounds.height());
        
        d.draw(canvas);
        canvas.restore();
    }
    canvas.restore();
}

关键点:offset是固定的文件夹背景偏移,params.transX/Y才是决定图标位置的关键。

3.2 实现绘制时的自适应调整

我创建了一个新的绘制方法,专门处理2个和3个图标的特殊情况:

/**
 * 自定义绘制方法,处理2-3个图标时的边界问题
 * @param size 当前文件夹中的图标数量
 */
private void drawPreviewItemAdjusted(Canvas canvas, PreviewItemDrawingParams params, 
                                     PointF offset, boolean shouldClipPath, 
                                     Path clipPath, int size) {
    canvas.save();
    if (shouldClipPath) {
        canvas.clipPath(clipPath);
    }
    
    float adjustedX = params.transX;
    float adjustedY = params.transY;
    
    // 根据图标数量和应用位置进行调整
    switch (size) {
        case 2:
            // 两个图标:向中心靠拢
            if (params.transX > 0) {
                adjustedX = params.transX - 8f;  // 右边图标左移
            } else {
                adjustedX = params.transX + 8f;  // 左边图标右移
            }
            break;
            
        case 3:
            // 三个图标:重新分布
            if (params.transY < 0) {
                // 顶部图标下移
                adjustedY = params.transY + 15f;
            } else {
                // 底部图标整体调整
                adjustedY = params.transY + 5f;
                if (params.transX > 0) {
                    adjustedX = params.transX - 6f;  // 右下图标左移
                } else {
                    adjustedX = params.transX + 6f;  // 左下图标右移
                }
            }
            break;
    }
    
    // 应用调整后的坐标
    canvas.translate(offset.x + adjustedX, offset.y + adjustedY);
    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();
}

然后在drawParams()方法中根据图标数量选择绘制方式:

public void drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params,
                       PointF offset, boolean shouldClipPath, Path clipPath) {
    int size = params.size();
    
    // 只有2-3个图标时使用调整后的绘制
    if (size == 2 || size == 3) {
        for (int i = params.size() - 1; i >= 0; i--) {
            PreviewItemDrawingParams p = params.get(i);
            if (!p.hidden) {
                boolean isExiting = p.index == EXIT_INDEX;
                drawPreviewItemAdjusted(canvas, p, offset, 
                                      isExiting | shouldClipPath, clipPath, size);
            }
        }
    } else {
        // 4个及以上图标使用原逻辑
        for (int i = params.size() - 1; i >= 0; i--) {
            PreviewItemDrawingParams p = params.get(i);
            if (!p.hidden) {
                boolean isExiting = p.index == EXIT_INDEX;
                drawPreviewItem(canvas, p, offset, 
                              isExiting | shouldClipPath, clipPath);
            }
        }
    }
}

3.3 这种方案的优缺点

优点:

  • 改动范围小,只影响绘制逻辑
  • 可以快速验证效果
  • 对原有计算逻辑无侵入

缺点:

  • 硬编码的调整值可能不适用于所有分辨率
  • 如果图标尺寸或边距改变,需要重新调整
  • 没有从根本上解决问题

注意:这种方案中的调整值(8f、15f、6f等)需要根据实际UI效果微调。建议通过资源文件配置,方便不同设备适配。

4. 方案二:计算时的参数化优化(推荐方案)

更优雅的解决方案是在计算阶段就修正问题。这需要修改ClippedFolderIconLayoutRule的计算逻辑,但好处是一劳永逸。

4.1 重新设计布局参数

首先,我们需要定义一套可配置的参数:

// 在ClippedFolderIconLayoutRule中添加配置参数
public class ClippedFolderIconLayoutRule {
    // 原有的常量
    private static final float MIN_SCALE = 0.44f;
    private static final float MAX_SCALE = 0.51f;
    
    // 新增:针对不同图标数量的配置
    private static final float RADIUS_FACTOR_2_ITEMS = 0.85f;  // 2个图标时的半径系数
    private static final float RADIUS_FACTOR_3_ITEMS = 0.90f;  // 3个图标时的半径系数
    private static final float RADIUS_FACTOR_4_ITEMS = 1.00f;  // 4个图标时的半径系数
    
    private static final float SCALE_FACTOR_2_ITEMS = 0.48f;   // 2个图标时的缩放系数
    private static final float SCALE_FACTOR_3_ITEMS = 0.46f;   // 3个图标时的缩放系数
    private static final float SCALE_FACTOR_4_ITEMS = 0.44f;   // 4个图标时的缩放系数
    
    // 边距安全系数,确保图标不贴边
    private static final float MARGIN_SAFETY_FACTOR = 0.1f;
}

4.2 优化getPosition()计算方法

修改getPosition()方法,加入边界检查和自适应调整:

private void getPosition(int index, int curNumItems, float[] result) {
    curNumItems = Math.max(curNumItems, 2);
    
    // 原有的角度计算逻辑保持不变
    double theta0 = mIsRtl ? 0 : Math.PI;
    int direction = mIsRtl ? 1 : -1;
    double thetaShift = 0;
    
    if (curNumItems == 3) {
        thetaShift = Math.PI / 2;
    } else if (curNumItems == 4) {
        thetaShift = Math.PI / 4;
    }
    theta0 += direction * thetaShift;
    
    if (curNumItems == 4 && index == 3) {
        index = 2;
    } else if (curNumItems == 4 && index == 2) {
        index = 3;
    }
    
    double theta = theta0 + index * (2 * Math.PI / curNumItems) * direction;
    
    // 优化1:根据图标数量使用不同的半径系数
    float radiusFactor;
    switch (curNumItems) {
        case 2:
            radiusFactor = RADIUS_FACTOR_2_ITEMS;
            break;
        case 3:
            radiusFactor = RADIUS_FACTOR_3_ITEMS;
            break;
        default:
            radiusFactor = RADIUS_FACTOR_4_ITEMS;
    }
    
    // 优化2:动态计算半径,考虑图标大小
    float baseRadius = mAvailableSpace / 2f;
    float iconSize = mIconSize * scaleForItem(curNumItems);
    float maxRadius = baseRadius - iconSize / 2 - (mAvailableSpace * MARGIN_SAFETY_FACTOR);
    
    float radius = Math.min(baseRadius * radiusFactor, maxRadius);
    
    // 优化3:重新计算坐标,确保在边界内
    float halfIconSize = iconSize / 2;
    float centerX = mAvailableSpace / 2;
    float centerY = mAvailableSpace / 2;
    
    // 计算理论坐标
    float theoreticalX = centerX + (float) (radius * Math.cos(theta) / 2);
    float theoreticalY = centerY + (float) (-radius * Math.sin(theta) / 2);
    
    // 边界检查与修正
    float minX = halfIconSize + mAvailableSpace * MARGIN_SAFETY_FACTOR;
    float maxX = mAvailableSpace - halfIconSize - mAvailableSpace * MARGIN_SAFETY_FACTOR;
    float minY = halfIconSize + mAvailableSpace * MARGIN_SAFETY_FACTOR;
    float maxY = mAvailableSpace - halfIconSize - mAvailableSpace * MARGIN_SAFETY_FACTOR;
    
    float finalX = Math.max(minX, Math.min(maxX, theoreticalX));
    float finalY = Math.max(minY, Math.min(maxY, theoreticalY));
    
    result[0] = finalX - halfIconSize;
    result[1] = finalY - halfIconSize;
}

4.3 优化scaleForItem()方法

同时调整缩放系数的计算:

public float scaleForItem(int numItems) {
    final float scale;
    switch (numItems) {
        case 2:
            scale = SCALE_FACTOR_2_ITEMS;
            break;
        case 3:
            scale = SCALE_FACTOR_3_ITEMS;
            break;
        default:
            scale = SCALE_FACTOR_4_ITEMS;
    }
    
    // 确保缩放后图标不会太大
    float maxScale = (mAvailableSpace * 0.8f) / mIconSize;
    return Math.min(scale * mBaselineIconScale, maxScale);
}

4.4 添加参数验证和日志

为了方便调试和验证,可以添加一些日志输出:

private void getPosition(int index, int curNumItems, float[] result) {
    // ... 前面的计算逻辑
    
    // 验证计算结果
    if (BuildConfig.DEBUG) {
        Log.d("FolderLayout", String.format(
            "Item %d/%d: radius=%.2f, scale=%.3f, pos=(%.1f, %.1f), iconSize=%.1f",
            index, curNumItems, radius, 
            scaleForItem(curNumItems),
            result[0], result[1],
            mIconSize * scaleForItem(curNumItems)
        ));
        
        // 检查是否越界
        if (result[0] < 0 || result[0] + iconSize > mAvailableSpace ||
            result[1] < 0 || result[1] + iconSize > mAvailableSpace) {
            Log.w("FolderLayout", "Icon out of bounds!");
        }
    }
    
    // ... 返回结果
}

4.5 配置化的参数管理

为了让布局参数更容易调整,可以创建一个配置类:

public class FolderLayoutConfig {
    // 不同图标数量的配置
    public static class ItemCountConfig {
        public final float radiusFactor;    // 半径系数
        public final float scaleFactor;     // 缩放系数
        public final float marginFactor;    // 边距系数
        
        public ItemCountConfig(float radiusFactor, float scaleFactor, float marginFactor) {
            this.radiusFactor = radiusFactor;
            this.scaleFactor = scaleFactor;
            this.marginFactor = marginFactor;
        }
    }
    
    private static final SparseArray<ItemCountConfig> CONFIGS = new SparseArray<>();
    
    static {
        // 2个图标的配置
        CONFIGS.put(2, new ItemCountConfig(0.85f, 0.48f, 0.12f));
        // 3个图标的配置
        CONFIGS.put(3, new ItemCountConfig(0.90f, 0.46f, 0.10f));
        // 4个图标的配置
        CONFIGS.put(4, new ItemCountConfig(1.00f, 0.44f, 0.08f));
    }
    
    public static ItemCountConfig getConfig(int itemCount) {
        ItemCountConfig config = CONFIGS.get(itemCount);
        if (config == null) {
            // 默认使用4个图标的配置
            config = CONFIGS.get(4);
        }
        return config;
    }
}

然后在ClippedFolderIconLayoutRule中使用这个配置:

private void getPosition(int index, int curNumItems, float[] result) {
    // 获取配置
    FolderLayoutConfig.ItemCountConfig config = 
        FolderLayoutConfig.getConfig(curNumItems);
    
    // 使用配置参数进行计算
    float radius = mAvailableSpace / 2f * config.radiusFactor;
    float scale = config.scaleFactor * mBaselineIconScale;
    float margin = mAvailableSpace * config.marginFactor;
    
    // ... 后续计算
}

5. 实战:处理文件夹展开后的内部布局

文件夹预览图只是问题的一部分,文件夹展开后的内部布局同样重要。在Launcher3中,这部分由DeviceProfile类管理。

5.1 DeviceProfile的布局计算

DeviceProfile负责计算所有与设备相关的布局参数,包括文件夹内部格子的尺寸:

private void updateFolderCellSize(float scale, Resources res) {
    // 获取基础图标尺寸(来自device_profiles.xml配置)
    float invIconSizeDp = inv.iconSize[mTypeIndex];
    
    // 计算图标像素尺寸
    folderChildIconSizePx = Math.max(1, pxFromDp(invIconSizeDp, mMetrics, scale));
    
    // 计算文字尺寸
    folderChildTextSizePx = pxFromSp(inv.iconTextSize[mTypeIndex], mMetrics, scale);
    
    // 计算文字高度
    int textHeight = Utilities.calculateTextHeight(folderChildTextSizePx);
    
    // 计算单元格尺寸
    if (isScalableGrid) {
        folderCellWidthPx = pxFromDp(getCellSize().x, mMetrics, scale);
        folderCellHeightPx = pxFromDp(getCellSize().y, mMetrics, scale);
    } 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;
    }
    
    // 计算图标与文字的间距
    folderChildDrawablePaddingPx = Math.max(0, 
        (folderCellHeightPx - folderChildIconSizePx - textHeight) / 3);
}

5.2 常见问题与解决方案

在实际项目中,我遇到过几种典型的布局问题:

问题1:图标太小,留白太多

// 解决方案:调整图标尺寸和单元格尺寸的比例
private void updateFolderCellSize(float scale, Resources res) {
    // 原逻辑...
    
    // 自定义调整:增大图标,减小单元格
    float iconScaleFactor = 1.2f;  // 图标放大20%
    float cellScaleFactor = 0.9f;   // 单元格缩小10%
    
    folderChildIconSizePx *= iconScaleFactor;
    folderCellWidthPx *= cellScaleFactor;
    folderCellHeightPx *= cellScaleFactor;
    
    // 重新计算间距
    folderChildDrawablePaddingPx = Math.max(0,
        (folderCellHeightPx - folderChildIconSizePx - textHeight) / 3);
}

问题2:不同分辨率适配

// 解决方案:根据屏幕密度动态调整
private void updateFolderCellSize(float scale, Resources res) {
    // 原逻辑...
    
    // 根据DPI调整参数
    float density = mMetrics.density;
    float adjustedIconSize;
    
    if (density >= 3.0f) {
        // 高DPI设备(如4K平板)
        adjustedIconSize = folderChildIconSizePx * 0.9f;
    } else if (density >= 2.0f) {
        // 中DPI设备
        adjustedIconSize = folderChildIconSizePx;
    } else {
        // 低DPI设备
        adjustedIconSize = folderChildIconSizePx * 1.1f;
    }
    
    // 确保调整后的尺寸合理
    float maxIconSize = folderCellWidthPx * 0.8f;  // 图标不超过单元格宽度的80%
    folderChildIconSizePx = Math.min(adjustedIconSize, maxIconSize);
}

5.3 配置文件的优化建议

除了代码修改,还可以通过device_profiles.xml配置文件进行调整:

<grid-option 
    launcher:name="custom_6_by_5"
    launcher:numFolderRows="3"
    launcher:numFolderColumns="3">
    
    <display-option
        launcher:name="Tablet"
        launcher:iconImageSize="72"          <!-- 增大图标尺寸 -->
        launcher:iconTextSize="16"           <!-- 增大文字尺寸 -->
        launcher:minCellWidth="90"           <!-- 减小单元格宽度 -->
        launcher:minCellHeight="90"          <!-- 减小单元格高度 -->
        launcher:borderSpaceHorizontal="12"  <!-- 调整水平边距 -->
        launcher:borderSpaceVertical="12"    <!-- 调整垂直边距 -->
        />
</grid-option>

关键参数说明:

参数说明建议值
iconImageSize图标尺寸(dp)根据屏幕密度调整,60-80dp
iconTextSize文字尺寸(sp)14-18sp,确保可读性
minCellWidth最小单元格宽度图标宽度 + 适当边距
minCellHeight最小单元格高度图标高度 + 文字高度 + 间距
borderSpaceHorizontal水平边距8-16dp,避免太拥挤
borderSpaceVertical垂直边距8-16dp,保持视觉平衡

5.4 测试与验证策略

修改布局参数后,需要进行全面的测试:

  1. 不同图标数量测试:2个、3个、4个、更多图标
  2. 不同屏幕密度测试:mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi
  3. 不同屏幕方向测试:横屏、竖屏
  4. 动态变化测试:拖拽图标进出文件夹时的动画
  5. 边界情况测试:文件夹在屏幕边缘时的显示

可以创建一个测试工具类:

public class FolderLayoutTester {
    public static void testAllConfigurations(Context context) {
        int[] iconCounts = {2, 3, 4, 5, 6};
        float[] densities = {1.0f, 1.5f, 2.0f, 3.0f, 4.0f};
        
        for (int count : iconCounts) {
            for (float density : densities) {
                testConfiguration(context, count, density);
            }
        }
    }
    
    private static void testConfiguration(Context context, int iconCount, float density) {
        // 模拟不同配置下的布局计算
        DisplayMetrics metrics = new DisplayMetrics();
        metrics.density = density;
        metrics.scaledDensity = density;
        
        // 创建测试用的布局规则
        ClippedFolderIconLayoutRule rule = new ClippedFolderIconLayoutRule();
        rule.init(165, 180, false);  // 假设可用空间165px,图标尺寸180px
        
        // 计算每个图标的位置
        for (int i = 0; i < iconCount; i++) {
            float[] position = new float[2];
            rule.getPositionForTest(i, iconCount, position);
            
            // 验证位置是否在边界内
            if (position[0] < 0 || position[0] > 165 ||
                position[1] < 0 || position[1] > 165) {
                Log.e("LayoutTest", String.format(
                    "FAIL: %d icons, density=%.1f, icon%d at (%.1f, %.1f)",
                    iconCount, density, i, position[0], position[1]
                ));
            } else {
                Log.i("LayoutTest", String.format(
                    "PASS: %d icons, density=%.1f, icon%d at (%.1f, %.1f)",
                    iconCount, density, i, position[0], position[1]
                ));
            }
        }
    }
}

6. 高级技巧:创建自适应布局系统

对于需要高度定制的项目,可以考虑实现一个完整的自适应布局系统。

6.1 定义布局策略接口

首先定义一个布局策略接口,支持不同的布局算法:

public interface FolderLayoutStrategy {
    /**
     * 初始化布局参数
     */
    void init(int availableSpace, float iconSize, boolean isRtl);
    
    /**
     * 计算图标位置
     */
    void getPosition(int index, int totalItems, float[] result);
    
    /**
     * 获取图标缩放比例
     */
    float getScale(int totalItems);
    
    /**
     * 验证布局是否有效
     */
    boolean validateLayout(int totalItems);
}

6.2 实现不同的布局策略

圆形布局策略(默认):

public class CircularLayoutStrategy implements FolderLayoutStrategy {
    private float mRadius;
    private float mAvailableSpace;
    private float mIconSize;
    private boolean mIsRtl;
    
    @Override
    public void init(int availableSpace, float iconSize, boolean isRtl) {
        mAvailableSpace = availableSpace;
        mIconSize = iconSize;
        mIsRtl = isRtl;
        mRadius = availableSpace * 0.4f;  // 经验值
    }
    
    @Override
    public void getPosition(int index, int totalItems, float[] result) {
        // 圆形布局算法
        double angle = 2 * Math.PI * index / totalItems;
        float center = mAvailableSpace / 2;
        float iconRadius = mIconSize * getScale(totalItems) / 2;
        
        result[0] = center + (float) (mRadius * Math.cos(angle)) - iconRadius;
        result[1] = center + (float) (mRadius * Math.sin(angle)) - iconRadius;
    }
    
    // ... 其他方法实现
}

网格布局策略:

public class GridLayoutStrategy implements FolderLayoutStrategy {
    @Override
    public void getPosition(int index, int totalItems, float[] result) {
        // 网格布局算法
        int cols = (int) Math.ceil(Math.sqrt(totalItems));
        int rows = (int) Math.ceil((float) totalItems / cols);
        
        int col = index % cols;
        int row = index / cols;
        
        float cellWidth = mAvailableSpace / cols;
        float cellHeight = mAvailableSpace / rows;
        float iconSize = mIconSize * getScale(totalItems);
        
        result[0] = col * cellWidth + (cellWidth - iconSize) / 2;
        result[1] = row * cellHeight + (cellHeight - iconSize) / 2;
    }
    
    // ... 其他方法实现
}

6.3 动态策略选择

可以根据图标数量动态选择布局策略:

public class AdaptiveFolderLayout {
    private FolderLayoutStrategy mStrategy;
    
    public void updateStrategy(int itemCount) {
        if (itemCount <= 1) {
            mStrategy = new SingleItemStrategy();
        } else if (itemCount == 2) {
            mStrategy = new TwoItemStrategy();
        } else if (itemCount == 3) {
            mStrategy = new TriangularStrategy();
        } else if (itemCount <= 6) {
            mStrategy = new CircularLayoutStrategy();
        } else {
            mStrategy = new GridLayoutStrategy();
        }
    }
    
    // 代理方法
    public void getPosition(int index, int totalItems, float[] result) {
        mStrategy.getPosition(index, totalItems, result);
    }
}

6.4 性能优化考虑

布局计算虽然不复杂,但在快速滑动或动画时可能会频繁调用。可以考虑以下优化:

  1. 缓存计算结果:对于相同的参数组合,缓存位置计算结果
  2. 预计算常用布局:在初始化时预计算2-6个图标的布局
  3. 使用对象池:避免频繁创建float[]数组
public class LayoutCalculator {
    private static final int MAX_CACHE_SIZE = 20;
    private final LruCache<String, float[]> mPositionCache;
    
    public LayoutCalculator() {
        mPositionCache = new LruCache<>(MAX_CACHE_SIZE);
    }
    
    public float[] getPosition(int index, int totalItems, 
                               int availableSpace, float iconSize) {
        String key = String.format("%d-%d-%d-%.1f", 
            index, totalItems, availableSpace, iconSize);
        
        float[] position = mPositionCache.get(key);
        if (position == null) {
            position = calculatePosition(index, totalItems, availableSpace, iconSize);
            mPositionCache.put(key, position);
        }
        
        return position.clone();  // 返回副本,避免修改缓存
    }
    
    private float[] calculatePosition(int index, int totalItems, 
                                      int availableSpace, float iconSize) {
        // 实际计算逻辑
        float[] result = new float[2];
        // ... 计算过程
        return result;
    }
}

6.5 可视化调试工具

开发一个简单的调试界面,实时查看布局效果:

public class FolderLayoutDebugView extends View {
    private FolderLayoutStrategy mStrategy;
    private int mItemCount = 3;
    private Paint mPaint;
    
    public FolderLayoutDebugView(Context context) {
        super(context);
        init();
    }
    
    private void init() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(Color.BLUE);
        
        mStrategy = new CircularLayoutStrategy();
        mStrategy.init(300, 60, false);
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        // 绘制背景
        canvas.drawColor(Color.LTGRAY);
        
        // 绘制每个图标
        for (int i = 0; i < mItemCount; i++) {
            float[] pos = new float[2];
            mStrategy.getPosition(i, mItemCount, pos);
            
            float scale = mStrategy.getScale(mItemCount);
            float radius = 30 * scale;
            
            canvas.drawCircle(pos[0], pos[1], radius, mPaint);
            
            // 绘制编号
            mPaint.setColor(Color.WHITE);
            mPaint.setTextSize(20);
            canvas.drawText(String.valueOf(i), pos[0] - 5, pos[1] + 5, mPaint);
            mPaint.setColor(Color.BLUE);
        }
    }
    
    public void setItemCount(int count) {
        mItemCount = count;
        invalidate();
    }
}

这个调试工具可以帮助你直观地看到不同参数下的布局效果,快速调整到最佳状态。

7. 实际项目中的经验总结

在多个Launcher定制项目中,我积累了一些实用的经验:

经验1:优先修改计算逻辑,而非绘制逻辑

  • 计算阶段的修改更彻底,适配性更好
  • 绘制阶段的修改适合快速修复或A/B测试

经验2:参数化配置优于硬编码

  • 将关键参数提取到配置类或资源文件中
  • 便于不同设备、不同分辨率的适配
  • 方便产品经理或设计师调整视觉效果

经验3:考虑动画效果

  • 修改布局时要注意图标进出文件夹的动画
  • 确保位置变化是连续的,避免跳跃
  • 可以适当调整动画插值器,使移动更自然

经验4:测试要充分

  • 测试不同图标数量(1-9个)
  • 测试不同屏幕密度和尺寸
  • 测试横竖屏切换
  • 测试拖拽、长按等交互操作

经验5:性能监控

  • 在getPosition()方法中添加性能日志
  • 监控布局计算是否成为性能瓶颈
  • 对于频繁调用的方法,考虑缓存优化

最后,记住一个原则:好的布局应该是自适应的。不要为了某个特定情况而过度优化,要确保在各种条件下都能正常工作。Launcher3的源码虽然复杂,但它的设计思路很值得学习——通过参数化的计算来实现灵活的布局,而不是写死的坐标值。

Android12——Launcher3文件夹布局修改调整 亲测有效android12中桌面布局Launcher3 阅读详情

相关推荐

Android 10.0 Launcher3中workspaces桌面去掉下拉状态栏功能

在10.0的系统rom定制化开发中,在原生系统Launcher3中是可以通过下滑workspace来下拉出状态栏的,然而在禁用systemui下拉状态栏的时候,是可以通过Launcher3下滑 workspace页面下拉出状态栏,也是不合理的,所以也同时需要禁用Launcher3中的下拉状态栏的功能,接下来就来分析下Launcher3中在workspace中通过下滑下拉出 状态栏的相关源码,来实现禁用workspace中通过下滑下拉状态栏功能

安卓兼职framework和app工程师的博客 1571

Launcher3最近任务理解分析

最近任务是Launcher中一个常见且非常重要的功能,本文从最近任务界面、最近任务启动、最近任务数据加载与绑定三个角度出发,结合源码(基于Android13的版本)分析相关的过程。

rophy___的博客 2905

android12.0(S) Launcher3 导入 AndroidStudio 调试编译

android12.0(S) Launcher3 导入 AndroidStudio 调试编译

cczhengv 1万+

Launcher3自定义桌面应用【Android13版本】

在android13中自定义Launcher的桌面应用

rophy___的博客 4709

android 13.0 Launcher3长按app弹窗设置为圆角背景功能实现二

在13.0的系统ROM定制化开发中,在进行一些Launcher3的定制化开发中,在使用app的弹窗的功能时,会弹出应用信息和 微件之类的内容,所以在定制需求中,需要默认设置为圆角背景,接下来就来分析下相关功能的实现

安卓兼职framework和app工程师的博客 644

修改Launcher3首屏应用位置并且添加应用文件夹

launcher:screen="0"位于首页,launcher:x="1"从左向右第2个格子,launcher:y="4"从上到下第5个格子。这里x和y都可以写0,0表示首个格子。修改为:mTaskbarActivityContext.setUIController(TaskbarUIController.DEFAULT);屏蔽掉原生的,因为原生的写法带有launcher:container="-101",表示始终在taskbar里面,没法自定义修改。修改完成,更多复杂的效果需要根据需求修改。

tzj1058756809的博客 488

【Android】Android Framework系列--Launcher3各启动场景源码分析

Launcher3的启动场景主要包括: 开机后启动:开机时,android ams服务拉起Launcher 按键启动:比如短压home键,android wms中的PhoneWindowManager拉起Launcher 异常崩溃后启动:Launcher异常崩溃后,android ams再次拉起Launcher

林多 3109

ldpc编解码器设计与仿真

计算校验节点到变量节点的消息 m_{c \to v}=2 \cdot atanh(\prod_{v’ \in N©\setminus v}tanh(\frac{m_{v’ \to c}}{2}))。计算变量节点到校验节点的消息 m_{v \to c}=L_{v,0}+\sum_{c’ \in M(v)\setminus c}m_{c’ \to v}。◦ 对于每个变量节点 v,计算最终 LLR 值 L_{v,f}=L_{v,0}+\sum_{c \in M(v)}m_{c \to v}。

youngerwang的博客 811

在Launcher3中增加主题切换功能

1 \packages\apps\Launcher3\res\layout\theme_picker.xml         android:layout_width="match_parent"     android:layout_height="match_parent" >   xmlns:android="http://schemas.android.com/apk/res/

xuxinyl的博客 8186

agv调度系统3.0技术介绍

agv调度系统,穿梭车调度系统,调度系统技术介绍,教你做调度

持续分享调度系统开发过程 4869

Android Studio安装常见问题详解——Android Emulator Hypervisor Driver for AMD Processors installation failed.

目录一、问题描述二、解决方案2.1 开启CPU虚拟化2.2 禁用Hyper-V2.3 使用管理员命令行在驱动程序包中执行“silent_install.bat” 一、问题描述 新买的AMD轻薄本,在安装Android Studio时遇到问题: Android Emulator Hypervisor Driver for AMD Processors installation failed. 笔记本配置为: 二、解决方案 2.1 开启CPU虚拟化 设置虚拟化的步骤这里不进行详细介绍了。如果需要,可以自行百

Wendy的博客 4万+

系统定制-Launch3抽屉内APP排序与默认桌面显示指定APP

抽屉内APP排序与默认桌面显示指定APP抽屉内APP排序默认桌面显示指定APP修改不生效桌面APP名称不显示源码查阅地址 抽屉内APP排序 首先,顺着源码往上捋,很轻松就能找到有用列表封装类,packages/apps/Launcher3/src/com/android/launcher3/allapps.AlphabeticalAppsList。它的注释是 The alphabetically sorted list of applications. 这个类总共才五百行代码,阅读很轻松。可以找到函数o

RenoY3的博客 2046

FPGA—ZYNQ_BD Block Design学习(一)

1.第一步:打开Vivado软件,创建或打开一个工程。2.第二步:在Block Design中添加arm核心,并将其配置为IP核。3.第三步:配置arm核心的外设信息,如DDR接口、时钟频率、UART接口等。4.第四步:完成Block Design中的模块互联,生成PL端的比特流。 5.第五步:导出PS端的硬件信息,包括arm IP核的外设、速度、引脚和地址等信息。1创建bd 需要有ddr以及uart和时钟配置外设io口,这里就是zynq的管脚规定好的,根据原理图用哪个个勾选哪一个。 对外设uart的配

m0_58129662的博客 1029

【安卓13】谷歌桌面Launcher3屏蔽全部应用里面的部分app

这是常用的几种方法,第二种简单明了,直接在类里面过滤,还有什么疑问可留言沟通。

小李不背锅的博客 1217

1553B通信项目开发笔记(一)协议概述

最近接了个项目,需要用1553B协议通信,仅作为RT端口进行通信,控制器采用ARM 使用芯片为国产的BU61580芯片,功能和引脚和DDC的61580完全一致。

u010396127的博客 2万+

高频功率放大器的设计实现

摘 要 随着无线通信技术的高速发展,市场对射频电路的需求越来越大,同时对射频电路的性能要求也越来越高。高频功率放大器是位于无线发射级末端的重要部件,为了弥补信号在无线传输过程中的衰耗,要求发射机具有较大功率的输出,通信距离越远,要求的输出功率越大。在高频范围内,为了获得足够大的高频输出功率,就要采用高频功率放大器。 本次课程设计的高频功率放大器的技术指标包括输出功率、效率、功率增益、由于高频频功率放大器的输出功率比较大,耗能比较多,所以工作效率就显得比较重要。本课程设计的高频功率放大器电路由两极功率放大器

qq_43543515的博客 3万+

Launcher3体系化之路

摘要:本文探讨了车机Launcher开发面临的挑战与解决方案。车企对智能座舱桌面有特殊需求,如手车互联定制、音源切换、媒体卡片、Widget编辑和复杂应用列表等。

Chuck Chan的专栏 751

从零构建基于ZYNQ与AD9361的SDR信号源:FPGA逻辑与射频前端的协同设计

本文详细介绍了从零开始构建基于ZYNQ与AD9361的SDR信号源的完整流程。通过解析FPGA逻辑与射频前端的协同设计,重点阐述了如何利用FPGA生成基带信号,并通过ARM处理器配置AD9361,最终实现一个可编程的2.4GHz载波信号发生器。内容涵盖硬件选型、Vivado环境搭建、AXI总线通信、LVDS接口设计及软件驱动开发,为SDR开发者提供了实用的实战指南。

weixin_29200485的博客 215

Launcher默认支持旋转

launcher界面默认开启旋转 1、默认情况 launcher默认没有开启旋转,长按桌面–》home Settings中有开关可以打开是否允许旋转 <SwitchPreference android:key="pref_allowRotation" android:title="@string/allow_rotation_title" android:summary="@string/allow_rotation_desc" andr

纵容的博客 1808

AI快速变现之路,AI视频创作

初期投入$3000(工具订阅+样片制作),重点攻克跨境电商和本地生活服务市场。关注2025年AIGC视频工具的API降价趋势(如Pika企业版折扣可达50%),建议与云服务商签订长期合作协议。

qq_29896351的博客 1983

IEM模型,地表粗糙度模拟

利用微波反演土壤水分时候,利用IEM模型可以实现对随机地表的模拟

手把手教你定制Android14启动动画:基于Launcher3源码的修改指南

本文提供了一份基于Android 14 AOSP源码深度定制启动动画的实战指南。通过剖析Launcher3中的RemoteAnimationRunnerCompat框架与QuickstepTransitionManager核心类,详细讲解了如何修改动画时长、插值器,并操控SurfaceControl.Transaction以实现从图标到全屏应用的个性化过渡效果,适合ROM开发者和高级定制爱好者参考。

tgb34567890的博客 543

最新的sdio 4.0 spec 协议标准 带书签 高清完整PDF版

Part-E1-SDIO-Specification-Ver4.00-Final-120220.pdf:最新的sdio 4.0 spec 协议标准 带书签 高清完整PDF版,包括SDIO 接口的外设,SDIO 接口官方协议等。

【安卓13】谷歌原生桌面launcher3源码修改,修改桌面布局(首屏应用、小部件、导航栏、大屏设备任务栏)

近期接到一个关于谷歌EDLA认证的需求,我负责的是谷歌原生桌面布局的修改,通过研究源码,将涉及到了一些修改思路发出来,大家可以参考一下有没有对你有用的信息。

小李不背锅的博客 1万+

TMS_WebGMaps_1.9.8.0_Full_Source

TMS_WebGMaps_1.9.8.0_Full_Source 全源码 delphi cbuilder

Data-Structures-and-Algorithm-Analysis-in-Java.pdf

作者: Weiss, Mark A. 出版社: Addison Wesley Data Structures and Algorithm Analysis in Java is an “advanced algorithms” book that fits between traditional CS2 and Algorithms Analysis courses. In the old ACM Curriculum Guidelines, this course was known as CS7. This text is for readers who want to learn good programming and algorithm analysis skills simultaneously so that they can develop such programs with the maximum amo...

Launcher3图标布局原理解析【原创】

Launcher3是Android原生的系统APP,相当于PC的桌面,管理各个应用/小部件/文件夹。本文基于Android13重点讲解图标的布局流程和数据保存方法。

dengtonglong的专栏 2227

一步步客制化Android桌面(Launcher3)图标

由于最近在负责MTK5.1的Android系统开发,所以本文就以5.1的代码为参考。其它版本虽然会略有不同,但是修改思路是大致相同的。在市面上很多手机都会对原生桌面进行一些修改,比如把时钟、日历修改成动态显示,或者对整个界面风格进行修改。那么我们就来模仿一下,简单地修改原生应用的图标显示,从而达到修改主题样式的目的。5.1上的Android桌面,其实也就是Launcher3,位置是 alps\pa

mikechenmj的博客 6774
上一篇: 从零构建LangChain自定义Retriever:手把手实现电商评论语义搜索
下一篇: 手把手教你用CanApe搭建XCP标定工程(附VN1630A硬件配置指南)
Python
博客等级 码龄26年 1973粉丝 1567原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值