AOSP Launcher3 CellLayout 深度解析
一、总体定位
Launcher3 视图层级:
DragLayer
└── Workspace (PagedView)
├── CellLayout (Page 0 — 主屏)
│ └── ShortcutAndWidgetContainer
│ ├── BubbleTextView (App Icon)
│ ├── BubbleTextView (Shortcut)
│ ├── AppWidgetHostView (Widget)
│ ├── FolderIcon
│ └── ...
├── CellLayout (Page 1)
│ └── ShortcutAndWidgetContainer
├── CellLayout (Page 2)
│ └── ShortcutAndWidgetContainer
└── ...
Hotseat
└── CellLayout (底部快捷栏)
└── ShortcutAndWidgetContainer
CellLayout 是 Launcher 桌面每一页的核心容器,负责将屏幕划分为 N×M 的网格,管理图标/Widget 在网格中的放置、拖拽、重排、动画。
二、核心类图
CellLayout (ViewGroup)
│
├── 内部容器
│ └── ShortcutAndWidgetContainer (实际持有子 View)
│
├── 核心数据结构
│ ├── boolean[][] mOccupied ── 占用矩阵
│ ├── boolean[][] mTmpOccupied ── 临时占用矩阵
│ ├── int[] mTempLocation ── 临时坐标
│ ├── GridOccupancy mOccupied ── 封装后的占用矩阵
│ └── ItemConfiguration ── 重排配置快照
│
├── 布局参数
│ ├── mCountX, mCountY ── 网格列/行数
│ ├── mCellWidth, mCellHeight ── 单元格像素尺寸
│ ├── mWidthGap, mHeightGap ── 单元格间距
│ ├── mBorderSpaceX, mBorderSpaceY ── 边框间距
│ └── mFixedCellWidth/Height ── 固定单元格尺寸
│
├── 拖拽支持
│ ├── DragEnforcer ── 拖拽规则
│ ├── mDragCenter ── 拖拽中心坐标
│ ├── mDragCell ── 拖拽源单元格
│ ├── mReorderAnimators ── 重排动画 Map
│ └── CellLayoutLayoutParams ── 子 View 布局参数
│
├── 视觉反馈
│ ├── CellLayoutContainer ── 背景容器
│ ├── mDragOutlinePaint ── 拖拽轮廓画笔
│ ├── mFolderLeaveBehindCell ── 文件夹拖出残影
│ └── mItemPlacementDirty ── 标记需要刷新
│
└── 辅助内部类
├── CellInfo ── 单元格信息
├── LayoutParams (CellLayoutLayoutParams)
├── ItemConfiguration ── 重排方案
└── GridOccupancy ── 占用矩阵封装
三、源码结构分析
3.1 关键成员变量
/**
* CellLayout.java (简化版 — 基于 AOSP Android 14)
*/
public class CellLayout extends ViewGroup {
// ============================================================
// 网格配置
// ============================================================
/** 网格列数和行数 (典型值: 4×5 或 5×5) */
private int mCountX; // 列数
private int mCountY; // 行数
/** 单元格像素尺寸 */
private int mCellWidth;
private int mCellHeight;
/**
* 单元格之间的间距
* 在新版 Launcher3 中被 mBorderSpace 取代
*/
private int mBorderSpaceX;
private int mBorderSpaceY;
/** 内边距 (网格区域到 CellLayout 边缘的距离) */
private int mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom;
// ============================================================
// 核心数据结构 — 占用矩阵
// ============================================================
/**
* ★ 占用矩阵: mCountX × mCountY 的 boolean 二维数组
*
* mOccupied[x][y] = true 表示该单元格已被占用
* mOccupied[x][y] = false 表示空闲
*
* 这是 CellLayout 最核心的数据结构, 所有放置/移动/查找
* 算法都基于此矩阵
*/
final GridOccupancy mOccupied;
/** 临时占用矩阵 (拖拽计算时使用, 避免修改原矩阵) */
final GridOccupancy mTmpOccupied;
// ============================================================
// 子 View 容器
// ============================================================
/**
* ★ 真正持有子 View 的容器
*
* CellLayout 自身不直接 addView 子元素,
* 而是将 Icon/Widget 添加到此内部容器中
*/
private ShortcutAndWidgetContainer mShortcutsAndWidgets;
// ============================================================
// 拖拽相关
// ============================================================
/** 拖拽时的目标单元格 */
private final int[] mDragCell = new int[2];
/** 拖拽时的目标单元格中心像素坐标 */
private final int[] mTmpPoint = new int[2];
private final int[] mTempLocation = new int[2];
/** 拖拽预览: 轮廓动画 */
private final CellLayoutContainer mContainer;
private Drawable mDragOutline = null;
/** 是否正在拖拽中 */
private boolean mDragging;
/**
* ★ 重排动画映射: 每个被挤走的 View 对应一个动画
* Key = 被重排的 View
* Value = 该 View 的位移动画
*/
private final HashMap<CellLayoutLayoutParams, Animator> mReorderAnimators = new HashMap<>();
/** 已占用的 ItemInfo 列表 (用于重排算法) */
private final ArrayList<ItemInfo> mIntersectingViews = new ArrayList<>();
// ============================================================
// 文件夹
// ============================================================
/** 文件夹拖出时的"残影"位置 */
private int[] mFolderLeaveBehindCell = {-1, -1};
// ============================================================
// 常量
// ============================================================
/** 拖拽重排动画时长 */
private static final int REORDER_ANIMATION_DURATION = 150;
/** 图标放置模式 */
public static final int MODE_SHOW_REORDER_HINT = 0;
public static final int MODE_DRAG_OVER = 1;
public static final int MODE_ON_DROP = 2;
public static final int MODE_ON_DROP_EXTERNAL = 3;
public static final int MODE_ACCEPT_DROP = 4;
}
3.2 GridOccupancy — 占用矩阵
/**
* GridOccupancy — CellLayout 的核心数据结构
*
* 封装 boolean[][] 矩阵, 提供查找空位、标记占用等操作
*
* 示例 (4列 × 5行 桌面):
*
* 列→ 0 1 2 3
* 行↓ ┌─────┬─────┬─────┬─────┐
* 0 │ 📱 │ 📷 │ │ │
* ├─────┼─────┼─────┼─────┤
* 1 │ 🎵 │ │ ┌─────────┐│
* ├─────┼─────┤ │ Widget ││
* 2 │ │ │ │ 2×2 ││
* ├─────┼─────┤ └─────────┘│
* 3 │ 📁 │ │ │ │
* ├─────┼─────┼─────┼─────┤
* 4 │ │ │ │ │
* └─────┴─────┴─────┴─────┘
*
* 对应矩阵:
* cells[0][0]=T cells[1][0]=T cells[2][0]=F cells[3][0]=F
* cells[0][1]=T cells[1][1]=F cells[2][1]=T cells[3][1]=T
* cells[0][2]=F cells[1][2]=F cells[2][2]=T cells[3][2]=T
* cells[0][3]=T cells[1][3]=F cells[2][3]=F cells[3][3]=F
* cells[0][4]=F cells[1][4]=F cells[2][4]=F cells[3][4]=F
*/
public class GridOccupancy {
/** 底层矩阵: cells[x][y] */
public boolean[][] cells;
private final int mCountX;
private final int mCountY;
public GridOccupancy(int countX, int countY) {
mCountX = countX;
mCountY = countY;
cells = new boolean[countX][countY];
}
/**
* ★ 标记一个区域为已占用
*
* @param cellX 起始列
* @param cellY 起始行
* @param spanX 横跨列数 (1=单格, 2=两列宽)
* @param spanY 纵跨行数
* @param value true=占用, false=释放
*/
public void markCells(int cellX, int cellY, int spanX, int spanY, boolean value) {
if (cellX < 0 || cellY < 0) return;
for (int x = cellX; x < cellX + spanX && x < mCountX; x++) {
for (int y = cellY; y < cellY + spanY && y < mCountY; y++) {
cells[x][y] = value;
}
}
}
/** 标记 CellInfo 对应的区域 */
public void markCells(CellInfo info, boolean value) {
markCells(info.cellX, info.cellY, info.spanX, info.spanY, value);
}
/**
* ★ 检查一个区域是否完全空闲
*
* @return true = 所有单元格都空闲, 可以放置
*/
public boolean isRegionVacant(int cellX, int cellY, int spanX, int spanY) {
for (int x = cellX; x < cellX + spanX && x < mCountX; x++) {
for (int y = cellY; y < cellY + spanY && y < mCountY; y++) {
if (x < 0 || y < 0 || cells[x][y]) {
return false;
}
}
}
return true;
}
/** 清空所有占用标记 */
public void clear() {
for (int x = 0; x < mCountX; x++) {
for (int y = 0; y < mCountY; y++) {
cells[x][y] = false;
}
}
}
/** 从另一个占用矩阵复制 */
public void copyTo(GridOccupancy dest) {
for (int x = 0; x < mCountX; x++) {
System.arraycopy(cells[x], 0, dest.cells[x], 0, mCountY);
}
}
/**
* ★ 查找能容纳 spanX×spanY 的最近空位
*
* 搜索策略: 从给定位置开始, 按距离由近到远搜索
*
* @param cellXY 起始位置 (输入) / 找到的位置 (输出)
* @param spanX 需要的列数
* @param spanY 需要的行数
* @return true = 找到空位
*/
public boolean findNearestVacantArea(int[] cellXY, int spanX, int spanY) {
// ... 螺旋搜索或距离排序搜索
return false; // simplified
}
}
四、布局系统详解
4.1 坐标系统
CellLayout 坐标系统:
(0,0) 像素坐标起点
┌─────────────────────────────────────────────┐
│ paddingLeft paddingRight │
│ ┌───────────────────────────────────────┐ │
│ │ Cell(0,0) Cell(1,0) Cell(2,0) │ │ paddingTop
│ │ ┌───┐gap┌───┐gap┌───┐ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └───┘ └───┘ └───┘ │ │
│ │ gap(Y) │ │
│ │ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └───┘ └───┘ └───┘ │ │
│ │ Cell(0,1) │ │
│ └───────────────────────────────────────┘ │
│ paddingBottom │
└─────────────────────────────────────────────┘
两种坐标:
1. 网格坐标 (cellX, cellY) — 整数, 表示第几列第几行
2. 像素坐标 (pixelX, pixelY) — 相对于 CellLayout 左上角的像素位置
核心转换公式:
pixelX = paddingLeft + cellX * (cellWidth + borderSpaceX)
pixelY = paddingTop + cellY * (cellHeight + borderSpaceY)
反向转换:
cellX = (pixelX - paddingLeft) / (cellWidth + borderSpaceX)
cellY = (pixelY - paddingTop) / (cellHeight + borderSpaceY)
4.2 单元格尺寸计算
/**
* ★ 核心方法: 根据 DeviceProfile 计算网格参数
*
* 调用链: DeviceProfile → InvariantDeviceProfile → CellLayout.setGridSize()
*/
public void setGridSize(int countX, int countY) {
mCountX = countX;
mCountY = countY;
mOccupied = new GridOccupancy(countX, countY);
mTmpOccupied = new GridOccupancy(countX, countY);
requestLayout();
}
/**
* ★ onMeasure — 计算单元格尺寸
*
* 典型计算逻辑 (简化版):
*
* 可用宽度 = MeasureSpec 宽度 - paddingLeft - paddingRight
* 可用高度 = MeasureSpec 高度 - paddingTop - paddingBottom
*
* cellWidth = (可用宽度 - (mCountX - 1) * borderSpaceX) / mCountX
* cellHeight = (可用高度 - (mCountY - 1) * borderSpaceY) / mCountY
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 从 DeviceProfile 获取配置 (而非自己计算)
DeviceProfile dp = mActivity.getDeviceProfile();
mCellWidth = dp.cellWidthPx;
mCellHeight = dp.cellHeightPx;
mBorderSpaceX = dp.cellLayoutBorderSpacePx.x;
mBorderSpaceY = dp.cellLayoutBorderSpacePx.y;
mShortcutsAndWidgets.setCellDimensions(
mCellWidth, mCellHeight,
mBorderSpaceX, mBorderSpaceY,
mCountX, mCountY);
int newWidth = widthSize;
int newHeight = heightSize;
setMeasuredDimension(newWidth, newHeight);
// 测量子 View
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(
newWidth - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY);
int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
newHeight - getPaddingTop() - getPaddingBottom(),
MeasureSpec.EXACTLY);
mShortcutsAndWidgets.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
4.3 子 View 布局 (onLayout)
/**
* ★ CellLayout.onLayout — 将子 View 放到正确位置
*
* CellLayout 只有一个直接子 View: mShortcutsAndWidgets
* 实际的 Icon/Widget 布局由 ShortcutAndWidgetContainer.onLayout 完成
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int left = getPaddingLeft();
int top = getPaddingTop();
int right = r - l - getPaddingRight();
int bottom = b - t - getPaddingBottom();
mShortcutsAndWidgets.layout(left, top, right, bottom);
}
/**
* ShortcutAndWidgetContainer.onLayout — 真正的子 View 定位
*/
// 在 ShortcutAndWidgetContainer 中:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
if (child.getVisibility() == GONE) continue;
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) child.getLayoutParams();
// ★ 根据 cellX, cellY 计算像素坐标
int childLeft = lp.x; // 已在 setupLp 中计算好
int childTop = lp.y;
child.layout(childLeft, childTop,
childLeft + lp.width,
childTop + lp.height);
}
}
4.4 CellLayoutLayoutParams — 布局参数
/**
* ★ CellLayout.LayoutParams (CellLayoutLayoutParams)
*
* 每个放在 CellLayout 中的子 View 都有此参数
* 它记录了 View 在网格中的位置和跨度
*/
public static class CellLayoutLayoutParams extends MarginLayoutParams {
/**
* 网格坐标
* cellX = 起始列 (0-based)
* cellY = 起始行 (0-based)
*/
public int cellX;
public int cellY;
/**
* 临时网格坐标 (拖拽过程中使用)
* 拖拽结束后会赋值给 cellX/cellY
*/
public int tmpCellX;
public int tmpCellY;
/**
* 跨度
* spanX = 横跨列数 (图标=1, 2×2 Widget=2)
* spanY = 纵跨行数
*/
public int cellHSpan;
public int cellVSpan;
/** 是否可以被重排挤走 */
public boolean canReorder = true;
/**
* 像素坐标 (由 setup() 计算)
* 在 ShortcutAndWidgetContainer.onLayout 中使用
*/
public int x;
public int y;
/** 是否被拖拽中 */
public boolean dropped;
/**
* ★ setup — 从网格坐标计算像素坐标
*
* 核心公式:
* x = paddingLeft + cellX * (cellWidth + borderSpaceX)
* y = paddingTop + cellY * (cellHeight + borderSpaceY)
* width = cellHSpan * cellWidth + (cellHSpan - 1) * borderSpaceX
* height = cellVSpan * cellHeight + (cellVSpan - 1) * borderSpaceY
*/
public void setup(int cellWidth, int cellHeight,
boolean invertX, int colCount,
int borderSpaceX, int borderSpaceY,
Point borderSpace) {
// RTL 支持
int myCellX = invertX ? (colCount - cellX - cellHSpan) : cellX;
// 计算像素位置
x = myCellX * (cellWidth + borderSpaceX);
y = cellY * (cellHeight + borderSpaceY);
// 计算占用的总像素尺寸
width = cellHSpan * cellWidth + (cellHSpan - 1) * borderSpaceX;
height = cellVSpan * cellHeight + (cellVSpan - 1) * borderSpaceY;
}
}
五、放置算法 ⭐⭐⭐
5.1 查找空位
/**
* ★★★ findNearestArea — CellLayout 最核心的算法
*
* 在网格中查找最接近 (pixelX, pixelY) 的空位,
* 要求能容纳 spanX × spanY 的区域
*
* 用途:
* 1. 拖拽时预览放置位置
* 2. 安装新应用时自动找位置
* 3. 旋转屏幕后重新布局
*
* @param pixelX 目标像素 X
* @param pixelY 目标像素 Y
* @param spanX 需要的列跨度
* @param spanY 需要的行跨度
* @param result 输出: 找到的 (cellX, cellY)
* @param resultSpan 输出: 实际使用的跨度 (Widget 可能缩放)
* @return 找到的 (cellX, cellY), 未找到返回 null
*/
int[] findNearestArea(int pixelX, int pixelY,
int spanX, int spanY,
int[] result, int[] resultSpan) {
// 将像素坐标转换为浮点网格坐标
double bestDistance = Double.MAX_VALUE;
int bestX = -1, bestY = -1;
// 遍历所有可能的放置位置
for (int x = 0; x <= mCountX - spanX; x++) {
for (int y = 0; y <= mCountY - spanY; y++) {
// 检查该区域是否完全空闲
if (!mOccupied.isRegionVacant(x, y, spanX, spanY)) {
continue;
}
// 计算该位置中心到目标的距离
// 使用像素距离, 考虑 span 的中心偏移
double cellCenterX = x * (mCellWidth + mBorderSpaceX)
+ spanX * mCellWidth / 2.0;
double cellCenterY = y * (mCellHeight + mBorderSpaceY)
+ spanY * mCellHeight / 2.0;
double distance = Math.hypot(pixelX - cellCenterX,
pixelY - cellCenterY);
if (distance < bestDistance) {
bestDistance = distance;
bestX = x;
bestY = y;
}
}
}
if (bestX >= 0) {
result[0] = bestX;
result[1] = bestY;
return result;
}
return null; // 没有空位
}
5.2 精确像素→网格转换
/**
* 将像素坐标转换为网格坐标 (最近的单元格)
*
* 不考虑占用状态, 纯粹的坐标转换
*/
void pointToCellExact(int x, int y, int[] result) {
int hStartPadding = getPaddingLeft();
int vStartPadding = getPaddingTop();
result[0] = (x - hStartPadding) / (mCellWidth + mBorderSpaceX);
result[1] = (y - vStartPadding) / (mCellHeight + mBorderSpaceY);
// 边界钳制
result[0] = Math.max(0, Math.min(result[0], mCountX - 1));
result[1] = Math.max(0, Math.min(result[1], mCountY - 1));
}
/**
* 网格坐标转换为像素坐标 (单元格左上角)
*/
void cellToPoint(int cellX, int cellY, int[] result) {
int hStartPadding = getPaddingLeft();
int vStartPadding = getPaddingTop();
result[0] = hStartPadding + cellX * (mCellWidth + mBorderSpaceX);
result[1] = vStartPadding + cellY * (mCellHeight + mBorderSpaceY);
}
/**
* 网格坐标转换为像素坐标 (单元格中心)
*/
void cellToCenterPoint(int cellX, int cellY, int[] result) {
cellToPoint(cellX, cellY, result);
result[0] += mCellWidth / 2;
result[1] += mCellHeight / 2;
}
/**
* ★ 根据像素坐标计算离哪个单元格中心最近
*
* 用于拖拽时确定放置位置
*/
void pointToCellRounded(int x, int y, int[] result) {
pointToCellExact(x, y, result);
// 四舍五入到最近的格子
}
六、拖拽系统 ⭐⭐⭐
6.1 拖拽流程总览
拖拽放置流程:
用户长按图标
│
▼
┌────────────────┐
│ DragController │ 捕获 touch 事件
│ .startDrag() │
└────────┬───────┘
│
▼
┌────────────────┐
│ Workspace │ 确定拖到了哪个 CellLayout
│ .onDragOver() │
└────────┬───────┘
│
▼
┌────────────────────────────────────────────────┐
│ CellLayout.onDragOver() │
│ │
│ 1. pointToCellRounded() → 目标网格坐标 │
│ 2. findNearestArea() → 最近空位 │
│ 3. 如果有空位: │
│ └─ 显示放置预览 (高亮目标格子) │
│ 4. 如果无空位: │
│ └─ attemptReorder() → 尝试挤走其他图标 │
│ ├─ simulateReorder() → 模拟重排 │
│ └─ animateReorder() → 播放重排动画 │
└────────────────────┬───────────────────────────┘
│
▼ 用户松手
┌────────────────────────────────────────────────┐
│ CellLayout.onDrop() │
│ │
│ 1. 最终确定放置位置 │
│ 2. addViewToCellLayout() → 添加 View │
│ 3. markCellsForView() → 更新占用矩阵 │
│ 4. 通知 Launcher 持久化到数据库 │
└────────────────────────────────────────────────┘
6.2 拖拽核心方法
/**
* ★ onDragEnter — 拖拽进入此 CellLayout
*/
void onDragEnter() {
mDragging = true;
// 清除之前的拖拽状态
mDragCell[0] = mDragCell[1] = -1;
}
/**
* ★ onDragExit — 拖拽离开此 CellLayout
*/
void onDragExit() {
mDragging = false;
// 清除拖拽预览
clearDragOutlines();
// 恢复所有被重排的 View 到原位
revertTempState();
}
/**
* ★★★ createAreaForResize — 为 Widget 调整大小创建空间
*
* 当用户拖拽 Widget 边缘调整大小时,
* 需要把占用区域内的图标挤走
*/
public void createAreaForResize(int cellX, int cellY,
int spanX, int spanY,
View dragView, int[] direction,
boolean commit) {
// 1. 标记需要的区域
// 2. 查找该区域内的已有 View
// 3. 尝试将它们推到周围空位
// 4. 如果 commit=true, 永久保存新位置
}
/**
* ★ addViewToCellLayout — 向网格中添加一个 View
*
* @param child 要添加的 View (BubbleTextView / AppWidgetHostView 等)
* @param index 插入顺序 (-1 = 末尾)
* @param childId View 的 ID (对应数据库中的 ItemInfo._id)
* @param lp 布局参数 (包含 cellX, cellY, spanX, spanY)
* @param markCells 是否标记占用矩阵
*/
public boolean addViewToCellLayout(View child, int index, int childId,
CellLayoutLayoutParams lp,
boolean markCells) {
// 1. 检查目标区域是否空闲 (如果不允许重叠)
if (markCells) {
if (!mOccupied.isRegionVacant(lp.cellX, lp.cellY,
lp.cellHSpan, lp.cellVSpan)) {
return false;
}
}
// 2. 将 View 添加到 ShortcutsAndWidgets 容器
// (不是直接 addView 到 CellLayout!)
mShortcutsAndWidgets.addView(child, index, lp);
// 3. 标记占用矩阵
if (markCells) {
markCellsAsOccupiedForView(child);
}
return true;
}
/**
* 从网格中移除 View
*/
public void removeView(View view) {
// 1. 释放占用矩阵
markCellsAsUnoccupiedForView(view);
// 2. 从容器移除
mShortcutsAndWidgets.removeView(view);
}
/**
* 标记 View 占用的网格区域
*/
public void markCellsAsOccupiedForView(View view) {
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) view.getLayoutParams();
mOccupied.markCells(lp.cellX, lp.cellY, lp.cellHSpan, lp.cellVSpan, true);
}
public void markCellsAsUnoccupiedForView(View view) {
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) view.getLayoutParams();
mOccupied.markCells(lp.cellX, lp.cellY, lp.cellHSpan, lp.cellVSpan, false);
}
七、重排算法 ⭐⭐⭐⭐⭐
重排算法是 CellLayout 中最复杂的部分。当拖拽一个图标到已有图标的位置时,需要将已有图标"推开"。
7.1 重排策略
重排方向策略:
当拖拽 📱 到 📷 的位置:
方案1: 向右推
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ 📱│ 📷│ │ │ ──→ │ │ 📱│ 📷│ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
方案2: 向下推
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ 📱│ 📷│ │ │ ──→ │ 📱│ │ │ │
│ │ │ │ │ │ │ 📷│ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
方案3: 交换位置
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ 📱│ 📷│ │ │ ──→ │ 📷│ 📱│ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
算法选择最小移动代价的方案。
7.2 核心重排实现
/**
* ★★★ performReorder — 执行重排算法
*
* 尝试在目标位置放置一个 spanX×spanY 的项目,
* 并将冲突的项目推到合适的位置
*
* @param pixelX, pixelY 目标像素坐标
* @param spanX, spanY 需要的跨度
* @param dragView 被拖拽的 View
* @param mode 模式 (预览/确认)
* @return 最终放置的单元格坐标
*/
int[] performReorder(int pixelX, int pixelY,
int minSpanX, int minSpanY,
int spanX, int spanY,
View dragView,
int[] result, int[] resultSpan,
int mode) {
// 1. 转换像素坐标到最近的网格坐标
int[] targetCell = findNearestArea(pixelX, pixelY, spanX, spanY,
mTmpOccupied, result, resultSpan);
// 2. 如果找到空位, 直接使用
if (targetCell != null && mTmpOccupied.isRegionVacant(
targetCell[0], targetCell[1], spanX, spanY)) {
if (mode == MODE_ON_DROP) {
return targetCell; // 直接放置
}
// MODE_DRAG_OVER: 显示预览
showDragOutline(targetCell[0], targetCell[1], spanX, spanY);
return targetCell;
}
// 3. ★ 没有空位 → 尝试重排
// 使用 ItemConfiguration 模拟不同重排方案
ItemConfiguration bestSolution = findBestSolution(
pixelX, pixelY, spanX, spanY, dragView);
if (bestSolution == null) {
return null; // 完全放不下
}
// 4. 应用最佳方案
if (mode == MODE_ON_DROP || mode == MODE_ON_DROP_EXTERNAL) {
commitReorder(bestSolution);
} else {
// 预览模式: 动画展示
animateReorder(bestSolution);
}
result[0] = bestSolution.cellX;
result[1] = bestSolution.cellY;
return result;
}
/**
* ★★★ 内部类: ItemConfiguration
*
* 表示一种重排方案的完整快照
* 包含每个被移动的 View 的新位置
*/
private class ItemConfiguration {
/** 被拖拽项的目标位置 */
int cellX, cellY;
int spanX, spanY;
/**
* ★ 被重排的 View → 新位置 的映射
* Key = 需要移动的 View
* Value = 该 View 的新 CellAndSpan
*/
final HashMap<View, CellAndSpan> map = new HashMap<>();
/** 该方案的占用矩阵 (模拟结果) */
final GridOccupancy mOccupied;
/** 是否有效 (所有项目都找到了位置) */
boolean isSolution = false;
/** 方案的总移动代价 (越小越好) */
float cost = 0f;
}
/**
* ★ CellAndSpan — 单元格坐标 + 跨度
*/
private static class CellAndSpan {
int cellX, cellY;
int spanX, spanY;
CellAndSpan(int x, int y, int sx, int sy) {
this.cellX = x; this.cellY = y;
this.spanX = sx; this.spanY = sy;
}
}
/**
* ★★★ findBestSolution — 搜索最佳重排方案
*
* 尝试 4 个方向的推移, 选择代价最小的方案
*/
private ItemConfiguration findBestSolution(
int pixelX, int pixelY, int spanX, int spanY, View dragView) {
// 4 个推移方向: 左, 右, 上, 下
int[][] directions = {
{-1, 0}, // 左
{ 1, 0}, // 右
{ 0,-1}, // 上
{ 0, 1}, // 下
};
ItemConfiguration bestConfig = null;
float bestCost = Float.MAX_VALUE;
for (int[] direction : directions) {
// 创建临时配置
ItemConfiguration config = new ItemConfiguration();
config.cellX = /* 目标 cellX */;
config.cellY = /* 目标 cellY */;
config.spanX = spanX;
config.spanY = spanY;
// ★ 核心: 模拟推移
if (pushViewsToTempLocation(
config, direction, dragView, spanX, spanY)) {
// 计算代价 = 所有被移动 View 的距离之和
float cost = computeCost(config);
if (cost < bestCost) {
bestCost = cost;
bestConfig = config;
}
}
}
return bestConfig;
}
/**
* ★★★ pushViewsToTempLocation — 递归推移算法
*
* 将占据目标区域的 View 向指定方向推移,
* 如果推移后又与其他 View 冲突, 则递归推移
*
* @param config 当前方案配置
* @param direction 推移方向 {dx, dy}
* @param dragView 正在拖拽的 View (不参与被推)
* @return true = 成功找到方案, false = 推不动
*/
private boolean pushViewsToTempLocation(
ItemConfiguration config,
int[] direction,
View dragView,
int spanX, int spanY) {
// 1. 找出目标区域内所有被冲突的 View
ArrayList<View> conflicting = findConflictingViews(
config.cellX, config.cellY, spanX, spanY, dragView);
if (conflicting.isEmpty()) {
return true; // 没有冲突, 直接放置
}
// 2. 逐个尝试推移冲突的 View
for (View view : conflicting) {
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) view.getLayoutParams();
// 尝试在推移方向找空位
int newX = lp.cellX + direction[0];
int newY = lp.cellY + direction[1];
// 边界检查
while (newX >= 0 && newX + lp.cellHSpan <= mCountX &&
newY >= 0 && newY + lp.cellVSpan <= mCountY) {
// 检查新位置是否空闲 (在模拟矩阵中)
if (config.mOccupied.isRegionVacant(
newX, newY, lp.cellHSpan, lp.cellVSpan)) {
// 找到空位! 记录到方案中
config.map.put(view, new CellAndSpan(
newX, newY, lp.cellHSpan, lp.cellVSpan));
config.mOccupied.markCells(
newX, newY, lp.cellHSpan, lp.cellVSpan, true);
break;
}
// 继续沿方向移动
newX += direction[0];
newY += direction[1];
}
// 如果某个 View 推不动, 该方案失败
if (!config.map.containsKey(view)) {
return false;
}
}
return true;
}
/**
* 计算重排方案的代价
*
* 代价 = 所有被移动 View 的曼哈顿距离之和
* 距离越小, 方案越优
*/
private float computeCost(ItemConfiguration config) {
float totalCost = 0f;
for (Map.Entry<View, CellAndSpan> entry : config.map.entrySet()) {
View view = entry.getKey();
CellAndSpan newPos = entry.getValue();
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) view.getLayoutParams();
// 曼哈顿距离
float dist = Math.abs(newPos.cellX - lp.cellX)
+ Math.abs(newPos.cellY - lp.cellY);
totalCost += dist;
}
return totalCost;
}
/**
* ★ commitReorder — 确认重排方案, 将 View 移动到新位置
*/
private void commitReorder(ItemConfiguration config) {
for (Map.Entry<View, CellAndSpan> entry : config.map.entrySet()) {
View view = entry.getKey();
CellAndSpan newPos = entry.getValue();
// 更新布局参数
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) view.getLayoutParams();
lp.cellX = newPos.cellX;
lp.cellY = newPos.cellY;
lp.cellHSpan = newPos.spanX;
lp.cellVSpan = newPos.spanY;
// 重新布局
view.requestLayout();
// 更新数据库 (通过 ItemInfo)
Object tag = view.getTag();
if (tag instanceof ItemInfo) {
ItemInfo info = (ItemInfo) tag;
info.cellX = newPos.cellX;
info.cellY = newPos.cellY;
// LauncherModel.updateItemInDatabase(...)
}
}
// 重建占用矩阵
markCellsForAllChildren();
}
/**
* ★ animateReorder — 播放重排动画
*
* 被推移的 View 从原位置平滑动画到新位置
*/
private void animateReorder(ItemConfiguration config) {
for (Map.Entry<View, CellAndSpan> entry : config.map.entrySet()) {
View view = entry.getKey();
CellAndSpan newPos = entry.getValue();
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) view.getLayoutParams();
// 计算动画起止像素坐标
int[] oldPixel = new int[2];
int[] newPixel = new int[2];
cellToPoint(lp.cellX, lp.cellY, oldPixel);
cellToPoint(newPos.cellX, newPos.cellY, newPixel);
// 取消之前的动画
Animator oldAnim = mReorderAnimators.get(lp);
if (oldAnim != null) oldAnim.cancel();
// 创建平移动画
ObjectAnimator animX = ObjectAnimator.ofFloat(
view, "translationX",
oldPixel[0] - newPixel[0], 0f);
ObjectAnimator animY = ObjectAnimator.ofFloat(
view, "translationY",
oldPixel[1] - newPixel[1], 0f);
AnimatorSet set = new AnimatorSet();
set.playTogether(animX, animY);
set.setDuration(REORDER_ANIMATION_DURATION);
set.setInterpolator(new DecelerateInterpolator(1.5f));
mReorderAnimators.put(lp, set);
// 先更新布局参数到新位置
lp.tmpCellX = newPos.cellX;
lp.tmpCellY = newPos.cellY;
set.start();
}
}
八、绘制系统
8.1 onDraw
/**
* ★ CellLayout.onDraw — 绘制网格视觉元素
*
* CellLayout 自身绘制:
* 1. 拖拽目标区域高亮
* 2. 拖拽轮廓预览
* 3. 文件夹拖出残影
* 4. 调试模式下的网格线
*
* 子 View (图标/Widget) 的绘制由 ShortcutsAndWidgets 处理
*/
@Override
protected void onDraw(Canvas canvas) {
// 1. 绘制拖拽轮廓 (Drop target outline)
if (mDragging && mDragOutline != null) {
int[] loc = new int[2];
cellToPoint(mDragCell[0], mDragCell[1], loc);
// 拖拽轮廓带半透明效果
mDragOutline.setAlpha((int)(mDragOutlineAlpha * 255));
mDragOutline.setBounds(
loc[0], loc[1],
loc[0] + mDragOutline.getIntrinsicWidth(),
loc[1] + mDragOutline.getIntrinsicHeight());
mDragOutline.draw(canvas);
}
// 2. 绘制文件夹拖出后的"残影"效果
if (mFolderLeaveBehindCell[0] >= 0) {
drawFolderLeaveBehind(canvas);
}
// 3. (调试模式) 绘制网格线
if (DEBUG_VISUALIZE_OCCUPIED) {
drawOccupiedGrid(canvas);
}
}
/**
* 调试: 可视化占用矩阵
*/
private void drawOccupiedGrid(Canvas canvas) {
Paint paint = new Paint();
for (int x = 0; x < mCountX; x++) {
for (int y = 0; y < mCountY; y++) {
int[] pixel = new int[2];
cellToPoint(x, y, pixel);
if (mOccupied.cells[x][y]) {
paint.setColor(0x44FF0000); // 红色 = 已占用
} else {
paint.setColor(0x4400FF00); // 绿色 = 空闲
}
canvas.drawRect(
pixel[0], pixel[1],
pixel[0] + mCellWidth,
pixel[1] + mCellHeight,
paint);
// 绘制格子边框
paint.setColor(0x22FFFFFF);
paint.setStyle(Paint.Style.STROKE);
canvas.drawRect(
pixel[0], pixel[1],
pixel[0] + mCellWidth,
pixel[1] + mCellHeight,
paint);
paint.setStyle(Paint.Style.FILL);
}
}
}
九、与其他组件的交互
9.1 组件交互图
┌──────────────────────────────────────────────────────────┐
│ DragLayer │
│ (Touch 事件分发根节点, 拖拽 DragView 的绘制容器) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Workspace │ │
│ │ (PagedView 子类, 管理多个 CellLayout 页面) │ │
│ │ │ │
│ │ ★ 关键交互: │ │
│ │ • 确定 touch 在哪个 CellLayout 上 │ │
│ │ • 跨页面拖拽时切换 CellLayout │ │
│ │ • 调用 CellLayout 的 onDragOver/onDrop │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ CellLayout │ │ CellLayout │ ... │ │
│ │ │ (Page 0) │ │ (Page 1) │ │ │
│ │ └──────┬──────┘ └─────────────┘ │ │
│ │ │ │ │
│ └─────────┼─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ ShortcutAndWidgetContainer │ │
│ │ (CellLayout 的唯一直接子 View) │ │
│ │ │ │
│ │ ├── BubbleTextView (App Icon) │ │
│ │ ├── BubbleTextView (Shortcut) │ │
│ │ ├── AppWidgetHostView (Widget) │ │
│ │ └── FolderIcon (文件夹) │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ DragController │ │ LauncherModel │
│ │ │ │
│ 管理拖拽生命周期 │ │ 数据持久化 │
│ • startDrag() │◄──►│ • updateItem() │
│ • dispatchMove() │ │ • addItem() │
│ • onDrop() │ │ • deleteItem() │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ ItemInfo │ │ DeviceProfile │
│ │ │ │
│ 每个桌面项目的 │ │ 设备参数配置 │
│ 数据模型: │ │ • countX/Y │
│ • cellX, cellY │ │ • cellWidthPx │
│ • spanX, spanY │ │ • cellHeightPx │
│ • container │ │ • borderSpace │
│ • screenId │ │ • iconSizePx │
└─────────────────┘ └─────────────────┘
9.2 Workspace ↔ CellLayout 协作
/**
* Workspace 中调用 CellLayout 的典型流程
*/
// === 拖拽过程 ===
// Workspace.onDragOver() 中:
void onDragOver(DragObject d) {
// 1. 根据拖拽坐标确定当前 CellLayout
CellLayout layout = getCurrentDropLayout();
// 2. 如果切换到了新 CellLayout
if (layout != mDragTargetLayout) {
if (mDragTargetLayout != null) {
mDragTargetLayout.onDragExit(); // 离开旧页
}
mDragTargetLayout = layout;
mDragTargetLayout.onDragEnter(); // 进入新页
}
// 3. 让 CellLayout 计算放置位置
int[] targetCell = new int[2];
layout.performReorder(
d.x, d.y,
item.minSpanX, item.minSpanY,
item.spanX, item.spanY,
d.dragView.getView(),
targetCell, null,
CellLayout.MODE_DRAG_OVER);
}
// Workspace.onDrop() 中:
void onDrop(DragObject d) {
CellLayout layout = mDragTargetLayout;
// 1. 最终确定放置位置
int[] targetCell = new int[2];
layout.performReorder(
d.x, d.y,
item.minSpanX, item.minSpanY,
item.spanX, item.spanY,
d.dragView.getView(),
targetCell, null,
CellLayout.MODE_ON_DROP);
// 2. 添加到 CellLayout
if (targetCell != null) {
layout.addViewToCellLayout(view, -1, info.id,
new CellLayoutLayoutParams(
targetCell[0], targetCell[1],
item.spanX, item.spanY),
true);
// 3. 持久化到数据库
LauncherModel.modifyItemInDatabase(
getContext(), info,
LauncherSettings.Favorites.CONTAINER_DESKTOP,
layout.getPageIndex(),
targetCell[0], targetCell[1],
item.spanX, item.spanY);
}
}
// === 新应用安装时自动放置 ===
// 调用链: PackageInstallReceiver → LauncherModel → Workspace
boolean findEmptyCellAndOccupy(ItemInfo info) {
// 遍历所有 CellLayout 找空位
for (CellLayout layout : getWorkspaceScreens()) {
if (layout.findCellForSpan(info.spanX, info.spanY, result)) {
// 找到空位
info.cellX = result[0];
info.cellY = result[1];
info.screenId = layout.getScreenId();
return true;
}
}
return false; // 所有页面都满了
}
9.3 Hotseat 中的 CellLayout
/**
* Hotseat 底部快捷栏也使用 CellLayout
*
* 区别:
* - 通常是 1 行 N 列 (如 5×1)
* - 不支持 Widget (只支持图标/文件夹)
* - 不支持多页
* - 有特殊的 All Apps 按钮位置
*/
public class Hotseat extends CellLayout {
// 或在新版中:
public class Hotseat extends FrameLayout {
private CellLayout mContent; // 内部使用 CellLayout
void setup() {
mContent.setGridSize(
dp.numHotseatIcons, 1); // N 列 × 1 行
}
}
}
十、性能优化
/**
* CellLayout 中的性能优化策略
*/
// ============================================================
// 1. 避免频繁重建占用矩阵
// ============================================================
/**
* 使用 mTmpOccupied 进行拖拽时的模拟计算,
* 不修改真正的 mOccupied
* 只在 drop 确认时才更新 mOccupied
*/
void onDragOver(...) {
mOccupied.copyTo(mTmpOccupied); // 复制到临时矩阵
// 所有模拟操作在 mTmpOccupied 上进行
}
// ============================================================
// 2. 动画复用
// ============================================================
/**
* mReorderAnimators 缓存: 避免重复创建动画对象
* 拖拽过程中如果同一个 View 需要新动画, 取消旧的再创建
*/
// ============================================================
// 3. 减少 requestLayout 调用
// ============================================================
/**
* 批量更新时使用 mItemPlacementDirty 标记
* 而不是每次修改都 requestLayout
*/
void markDirty() {
mItemPlacementDirty = true;
// 在下一帧统一处理
}
// ============================================================
// 4. 硬件加速层
// ============================================================
/**
* 拖拽时对 CellLayout 设置硬件加速层
* 拖拽结束后恢复
*/
void onDragEnter() {
setLayerType(LAYER_TYPE_HARDWARE, null);
}
void onDragExit() {
setLayerType(LAYER_TYPE_NONE, null);
}
// ============================================================
// 5. 预计算像素坐标
// ============================================================
/**
* CellLayoutLayoutParams.setup() 在 measure 阶段预计算
* 而不是在 layout/draw 时计算
* 避免每帧重复计算
*/
十一、关键难点总结
┌─────────────────────────────────────────────────────────────┐
│ CellLayout 核心难点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 重排算法 (Reorder) │
│ • 多方向推移 + 递归冲突解决 │
│ • 最优方案搜索 (最小移动代价) │
│ • 动画与逻辑分离 (模拟 vs 提交) │
│ │
│ 2. Widget 处理 │
│ • 多格跨度 (spanX×spanY) │
│ • resize 时的空间创建 │
│ • 最小/最大跨度约束 (minSpan/maxSpan) │
│ │
│ 3. 坐标系转换 │
│ • 像素 ↔ 网格 双向转换 │
│ • RTL (Right-to-Left) 镜像 │
│ • 多分辨率/DPI 适配 │
│ • 旋转屏幕后重新布局 │
│ │
│ 4. 拖拽视觉反馈 │
│ • 拖拽轮廓预览 (drag outline) │
│ • 重排动画 (平移 + 缩放) │
│ • 文件夹打开/关闭动画 │
│ • 跨页面拖拽时的页面切换 │
│ │
│ 5. 状态一致性 │
│ • 占用矩阵 ↔ 实际子 View 同步 │
│ • 拖拽中的临时状态 vs 持久化状态 │
│ • 多线程: UI 线程 vs Model 线程 │
│ • 配置变更 (旋转) 后状态恢复 │
│ │
│ 6. 自适应网格 │
│ • 不同设备/屏幕尺寸的网格行列数 │
│ • DeviceProfile 驱动的布局参数 │
│ • 可变网格 (Android 12+ 支持 Widget 建议尺寸) │
│ │
└─────────────────────────────────────────────────────────────┘
CellLayout 核心方法调用频率:
高频 (每次拖拽移动):
├── pointToCellRounded() — 每次 touch move
├── findNearestArea() — 每次 touch move
├── performReorder() — 目标变化时
│ ├── findConflictingViews()
│ ├── pushViewsToTempLocation()
│ └── animateReorder()
└── onDraw() (drag outline)
中频 (拖拽开始/结束):
├── onDragEnter() / onDragExit()
├── addViewToCellLayout()
├── removeView()
├── markCellsAsOccupied/Unoccupied()
└── commitReorder()
低频 (初始化/配置变更):
├── setGridSize()
├── onMeasure() / onLayout()
└── DeviceProfile 参数更新
CellLayout 是 Launcher3 中代码量最大、逻辑最复杂的单个类,核心在于占用矩阵管理和重排算法。理解了这两个核心,就掌握了 CellLayout 90% 的设计意图。