6、Folder(文件夹)的实现:FolderIcon与Folder的交互逻辑、文件夹的打开/关闭动画、文件夹内图标排序
文件夹这个功能,说白了就是桌面的「收纳盒」。用户把一堆应用图标拖到一起,系统自动创建一个文件夹。这个交互看起来简单,但实现起来坑不少。我最早做Launcher3的文件夹功能时,就被打开动画的坐标计算折腾了好几天。
今天咱们就把文件夹拆开揉碎了讲。核心就三块:FolderIcon与Folder的交互、打开/关闭动画、内部图标排序。这三块搞定了,文件夹功能就稳了。
6.1 整体架构:谁负责什么
先看一张架构图,理清各个类的职责。
嗯,这张图基本把核心类的关系说清楚了。FolderIcon 是显示在桌面上的那个小图标,里面会叠放几个小图标做预览。Folder 是打开后的弹窗容器,负责管理内部图标的排列。FolderPagedView 是分页视图,当图标超过一页时自动分页。
6.2 FolderIcon:桌面上的「门面」
FolderIcon 看起来是个图标,其实它是个「缩略图容器」。我习惯的做法是:在 FolderIcon 的绘制方法里,把前4个图标的bitmap缩小后叠在一起画出来。
// FolderIcon 核心绘制逻辑(简化版)
@Override
protected void onDraw(Canvas canvas) {
// 1. 先画背景圆角矩形
drawBackground(canvas);
// 2. 获取前4个子图标的bitmap
List<Bitmap> previewBitmaps = getPreviewBitmaps(4);
// 3. 按2x2网格排列,每个图标缩小到原来的40%
int previewSize = getWidth() / 2;
for (int i = 0; i < previewBitmaps.size(); i++) {
int row = i / 2;
int col = i % 2;
int left = col * previewSize;
int top = row * previewSize;
Rect srcRect = new Rect(0, 0,
previewBitmaps.get(i).getWidth(),
previewBitmaps.get(i).getHeight());
Rect destRect = new Rect(left, top,
left + previewSize, top + previewSize);
canvas.drawBitmap(previewBitmaps.get(i), srcRect, destRect, null);
}
// 4. 如果超过4个,在右下角显示 "+N" 标记
if (getItemCount() > 4) {
drawOverflowBadge(canvas, getItemCount() - 4);
}
}
6.3 交互逻辑:点击、拖拽、打开
用户点击FolderIcon,我们要打开Folder。这个交互看起来简单,但有几个细节要注意。
6.3.1 点击打开
FolderIcon 的 onClick 事件里,需要做三件事:
- 计算Folder的弹出位置(锚定在FolderIcon上方)
- 创建Folder实例,传入图标列表
- 启动打开动画
// FolderIcon.onClick() 核心逻辑
public void onClick(View v) {
// 1. 计算弹出位置
int[] iconPos = new int[2];
getLocationOnScreen(iconPos);
int folderLeft = iconPos[0] - folderWidth / 2 + getWidth() / 2;
int folderTop = iconPos[1] - folderHeight - 20; // 20px间距
// 2. 创建Folder
Folder folder = new Folder(getContext());
folder.setItems(mItems); // 传入图标数据
// 3. 添加到WindowManager并显示
WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.x = folderLeft;
params.y = folderTop;
params.width = folderWidth;
params.height = folderHeight;
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL;
mWindowManager.addView(folder, params);
// 4. 启动打开动画
folder.playOpenAnimation();
}
6.3.2 拖拽创建文件夹
用户把一个图标拖到另一个图标上,自动创建文件夹。这个逻辑在Launcher的拖拽监听器里处理。
我曾经踩过一个坑:拖拽过程中,目标图标的位置会变化,导致判断「是否重叠」的坐标不准确。后来我改用「拖拽中心点与目标图标中心点的距离」来判断,阈值设为图标宽度的1.2倍,效果就好多了。
// 拖拽创建文件夹的判断逻辑
public boolean onDragOver(View target, DragEvent event) {
if (!(target instanceof BubbleTextView)) return false;
// 计算拖拽中心与目标图标中心的距离
float dragCenterX = event.getX();
float dragCenterY = event.getY();
float targetCenterX = target.getX() + target.getWidth() / 2;
float targetCenterY = target.getY() + target.getHeight() / 2;
float distance = Math.sqrt(
Math.pow(dragCenterX - targetCenterX, 2) +
Math.pow(dragCenterY - targetCenterY, 2));
// 阈值:图标宽度的1.2倍
float threshold = target.getWidth() * 1.2f;
if (distance < threshold) {
// 触发创建文件夹
createFolder(target, dragSource);
return true;
}
return false;
}
6.4 打开/关闭动画:让交互更自然
动画是文件夹体验的关键。没有动画,文件夹的打开会显得很突兀。我建议用缩放+透明度的组合动画。
6.4.1 打开动画
打开时,Folder从FolderIcon的位置「展开」到完整大小。核心思路是:
- 初始状态:Folder的scale为0,alpha为0,位置在FolderIcon中心
- 结束状态:scale为1,alpha为1,位置在目标位置
- 动画时长:250ms,使用OvershootInterpolator(带一点弹性效果)
// Folder 打开动画
public void playOpenAnimation() {
// 设置初始状态
setScaleX(0f);
setScaleY(0f);
setAlpha(0f);
setPivotX(getWidth() / 2f);
setPivotY(getHeight() / 2f);
// 创建动画
AnimatorSet animatorSet = new AnimatorSet();
ObjectAnimator scaleX = ObjectAnimator.ofFloat(this, "scaleX", 0f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(this, "scaleY", 0f, 1f);
ObjectAnimator alpha = ObjectAnimator.ofFloat(this, "alpha", 0f, 1f);
animatorSet.playTogether(scaleX, scaleY, alpha);
animatorSet.setDuration(250);
animatorSet.setInterpolator(new OvershootInterpolator(1.5f));
animatorSet.start();
}
6.4.2 关闭动画
关闭动画是打开动画的逆过程。但有个细节:关闭时要记录当前滚动位置,下次打开时恢复到同一页。
// Folder 关闭动画
public void playCloseAnimation(Runnable onEnd) {
// 记录当前页码
mLastPageIndex = mPagedView.getCurrentPage();
AnimatorSet animatorSet = new AnimatorSet();
ObjectAnimator scaleX = ObjectAnimator.ofFloat(this, "scaleX", 1f, 0f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(this, "scaleY", 1f, 0f);
ObjectAnimator alpha = ObjectAnimator.ofFloat(this, "alpha", 1f, 0f);
animatorSet.playTogether(scaleX, scaleY, alpha);
animatorSet.setDuration(200);
animatorSet.setInterpolator(new AccelerateInterpolator());
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// 从WindowManager移除
mWindowManager.removeView(Folder.this);
if (onEnd != null) onEnd.run();
}
});
animatorSet.start();
}
6.5 文件夹内图标排序:规则与实现
文件夹里的图标怎么排?这个问题看似简单,但不同厂商有不同的做法。我见过按安装时间排的,按使用频率排的,还有按字母排的。我个人习惯用按应用名称拼音首字母排序,这是最符合用户直觉的。
6.5.1 排序算法
Android里排序用Collections.sort(),关键是要实现一个Comparator。中文排序要用Collator,否则拼音顺序会乱。
// 文件夹内图标排序
public void sortFolderItems(List<ItemInfo> items) {
// 使用Collator进行中文排序
Collator collator = Collator.getInstance(Locale.CHINESE);
Collections.sort(items, new Comparator<ItemInfo>() {
@Override
public int compare(ItemInfo a, ItemInfo b) {
// 先按类型排:应用在前,快捷方式在后
if (a.itemType != b.itemType) {
return a.itemType - b.itemType;
}
// 同类型按名称排序
return collator.compare(a.title, b.title);
}
});
// 排序后刷新布局
refreshFolderLayout();
}
6.5.2 分页逻辑
当图标超过16个(4x4网格),就需要分页了。FolderPagedView负责处理分页滑动。
| 网格规格 | 每页图标数 | 适用场景 |
|---|---|---|
| 3x3 | 9 | 小屏手机或平板 |
| 4x4 | 16 | 主流手机默认 |
| 5x5 | 25 | 大屏或用户自定义 |
// 分页逻辑
public void layoutFolderPages(List<ItemInfo> items) {
int itemsPerPage = GRID_COLS * GRID_ROWS; // 默认16
int pageCount = (int) Math.ceil((double) items.size() / itemsPerPage);
mPagedView.removeAllPages();
for (int page = 0; page < pageCount; page++) {
// 创建新页面
ViewGroup pageView = createPageView();
int startIndex = page * itemsPerPage;
int endIndex = Math.min(startIndex + itemsPerPage, items.size());
for (int i = startIndex; i < endIndex; i++) {
// 创建图标View并添加到页面
View iconView = createIconView(items.get(i));
pageView.addView(iconView);
}
mPagedView.addPage(pageView);
}
// 恢复上次的页码
mPagedView.setCurrentPage(mLastPageIndex);
}
6.6 避坑指南
最后分享几个我踩过的坑,希望能帮你少走弯路。
- 文件夹位置越界:当FolderIcon靠近屏幕边缘时,Folder弹出后可能超出屏幕。解决办法是计算位置时做边界检测,超出时自动偏移。
- 动画卡顿:如果文件夹内有大量图标(比如50个),打开动画会卡。我建议在动画期间只显示前16个图标,其余延迟加载。
- 触摸事件冲突:Folder浮在WindowManager上,点击外部关闭时,可能会误触到桌面图标。解决办法是在Folder的根布局设置setClickable(true),拦截外部点击事件。
我曾经在一个项目里,用户反馈文件夹打开后点不到里面的图标。查了半天,发现是Folder的触摸事件被WindowManager的某个系统层拦截了。最后在Folder的onTouchEvent里加了个return true,问题就解决了。嗯,有时候问题就是这么简单,但排查起来真要命。
文件夹的实现,说白了就是处理好「预览-打开-排序-关闭」这个闭环。每个环节都不难,但连起来就需要细心了。希望今天的讲解能帮你理清思路,少踩几个坑。
公众号:蓝海资料掘金营,微信deep3321