6、Folder(文件夹)的实现:FolderIcon与Folder的交互逻辑、文件夹的打开/关闭动画、文件夹内图标排序

文件夹这个功能,说白了就是桌面的「收纳盒」。用户把一堆应用图标拖到一起,系统自动创建一个文件夹。这个交互看起来简单,但实现起来坑不少。我最早做Launcher3的文件夹功能时,就被打开动画的坐标计算折腾了好几天。

今天咱们就把文件夹拆开揉碎了讲。核心就三块:FolderIcon与Folder的交互打开/关闭动画内部图标排序。这三块搞定了,文件夹功能就稳了。

6.1 整体架构:谁负责什么

先看一张架构图,理清各个类的职责。

文件夹模块核心类关系 FolderIcon 继承自 BubbleTextView 显示文件夹预览图标 Folder 继承自 LinearLayout 管理内部图标布局 FolderPagedView 支持多页滑动 图标分页显示 点击 → 打开Folder 管理子View 分页容器 交互流程 用户拖拽图标 → FolderIcon.onDrop() → 创建/更新Folder → 排序 → 动画打开 用户点击空白 → Folder.onClickOutside() → 动画关闭 → 更新FolderIcon预览

嗯,这张图基本把核心类的关系说清楚了。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);
    }
}
我的经验:预览图标的bitmap不要每次都重新加载,缓存起来。我在项目中遇到过快速滑动桌面时FolderIcon频繁重绘,导致卡顿。后来加了个LruCache,只存最近使用的32个预览bitmap,问题就解决了。

6.3 交互逻辑:点击、拖拽、打开

用户点击FolderIcon,我们要打开Folder。这个交互看起来简单,但有几个细节要注意。

6.3.1 点击打开

FolderIcon 的 onClick 事件里,需要做三件事:

  1. 计算Folder的弹出位置(锚定在FolderIcon上方)
  2. 创建Folder实例,传入图标列表
  3. 启动打开动画
// 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();
}
注意:Folder是添加到WindowManager上的,不是添加到桌面布局里。这样做的好处是Folder可以浮在桌面之上,不受桌面滚动影响。但坏处是触摸事件需要自己处理穿透逻辑——点击Folder外部要关闭它。

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();
}
小技巧:OvershootInterpolator的张力值我一般设为1.5~2.0。太大会显得弹得过分,太小又没弹性感。你可以自己调调看,找到最舒服的值。

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);
}
注意:分页后,图标拖拽到边缘时要能自动翻页。这个功能实现起来比较麻烦,需要监听拖拽事件,当拖拽位置靠近左右边缘时,自动切换到下一页。我建议用Handler.postDelayed做延迟触发,避免频繁翻页。

6.6 避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路。

  • 文件夹位置越界:当FolderIcon靠近屏幕边缘时,Folder弹出后可能超出屏幕。解决办法是计算位置时做边界检测,超出时自动偏移。
  • 动画卡顿:如果文件夹内有大量图标(比如50个),打开动画会卡。我建议在动画期间只显示前16个图标,其余延迟加载。
  • 触摸事件冲突:Folder浮在WindowManager上,点击外部关闭时,可能会误触到桌面图标。解决办法是在Folder的根布局设置setClickable(true),拦截外部点击事件。

我曾经在一个项目里,用户反馈文件夹打开后点不到里面的图标。查了半天,发现是Folder的触摸事件被WindowManager的某个系统层拦截了。最后在Folder的onTouchEvent里加了个return true,问题就解决了。嗯,有时候问题就是这么简单,但排查起来真要命。

文件夹的实现,说白了就是处理好「预览-打开-排序-关闭」这个闭环。每个环节都不难,但连起来就需要细心了。希望今天的讲解能帮你理清思路,少踩几个坑。


公众号:蓝海资料掘金营,微信deep3321