Skip to Content

File

macOS Finder 风的 Miller Columns 文件浏览器,root 模式下能看整机。长按任何行冒扇形菜单。右边一抽屉放收藏 + 最近。想同时操作两个目录?划出一块 MoveDock。想把路径丢过去?直接拽。


一句话

多列横向滚动的文件浏览器 + 全手势操作 + 特权 syscall 后端。

源码:src/screens/FileScreen.tsx(87 KB,整个项目最复杂的一屏)。它本身包含了4 个相对独立的子系统


实拍

Miller Columns(横向滑、多列并排)

Miller Columns 多列并排

单列模式(窄屏 / 折叠状态)+ MoveDock 悬浮

单列 + MoveDock

右下角那个带 ⇄ SWAP 按钮的紫色小浮窗就是 MoveDock 最小档——要操作第二个目录时双击它展开,双击 SWAP 交换主副。


长这样

┌────────────────────────────────────────────────────────────┐ │ [ ≡ ] [ root | user ] [ ⚙ ]│ ← top bar ├────────────────────────────────────────────────────────────┤ │ ╱data ╱adb ╱modules [jump▸]│ ← 当前路径 ├─────────────┬─────────────┬──────────────────┬─────────────┤ │ /data │ /data/adb │ /data/adb/mods │ │ │ ─────── │ ─────── │ ──────── │ │ │ adb │ modules │ shadow_ce │ │ │ app │ sepolicy │ shadow_paging │ ← 下一列预览 │ apps │ service.d │ killsu │ │ local │ │ │ │ ... │ │ │ │ │ │ │ ├─────────────┴─────────────┴──────────────────┴─────────────┤ │ ┌───────────────────────┐ │ │ │ MoveDock (可选) │ │ │ │ /data/local/tmp │ │ ← 副面板 │ │ ───── │ │ 拖文件用 │ │ a.sh b.apk │ │ │ │ │ │ │ └───────────────────────┘ │ └────────────────────────────────────────────────────────────┘

手势铁三角

  • 纵向 flick 在单列里滚
  • 横向 Pan 整体左右滑列
  • 长按单行 弹扇形菜单

三种手势同时监听,用 gesture-handler 的 Race / Exclusive 组合决定谁赢。


4 个子系统一览

子系统干啥关键代码
Miller Columns多列横向滚动 + 单列纵向滚动自造的 Pan + Animated translateX
Fan Menu长按行冒扇形选项useFanGesture
JUMP Drawer右边划出收藏 / 最近Pan detected at edge → drawer animates
MoveDock浮动副面板三档尺寸可切、支持拖入

详细每个讲一遍。


1. Miller Columns

视觉

一列 = 一个目录。选了某列的某行 → 右边浮出下一列显示那个子目录的内容。超过屏幕宽的列被推到右边,左边列保留。

[列0] [列1] [列2] [列3] [列4] /data → /data/adb → /data/adb/modules → shadow_paging → kern.h kern.ko README

为什么不用 ScrollView

React Native 自带的 <ScrollView horizontal> 会和单列内的纵向 ScrollView 抢手势——用户 flick 一下往下,RN 经常判断成横向 pan,整屏飞出去。

Shadow Nav 自己写了一套:

const scrollX = useSharedValue(0); const pan = Gesture.Pan() .activeOffsetX([-10, 10]) // 横向超过 10px 才开始 .failOffsetY([-15, 15]) // 纵向超过 15px 就放弃(让给单列 list) .onUpdate(e => { scrollX.value += e.translationX; }) .onEnd(e => { // 惯性:afterVelocity 按物理减速 scrollX.value = withDecay({ velocity: e.velocityX, deceleration: 0.998 }); });

关键是 failOffsetY ——纵向 wiggle 超过 15px 就直接放弃 pan。让给里层的 FlatList。

自动滚动到新列

点一行 → 加一列到右边 → 自动 scrollX lerp 到新列可见

useEffect(() => { const targetX = totalWidth - screenWidth; // 最右列刚好贴屏右 scrollX.value = withTiming(targetX, { duration: 300 }); }, [columns.length]);

2. Fan Menu —— 长按行冒扇形

每一行 <FileRow> 自己负责:

const { onLongPress, ...fanProps } = useFanGesture({ onCommit: (slice) => handleSliceAction(slice, row), }); <Pressable onLongPress={onLongPress} {...fanProps}> {row.name} </Pressable>

长按 400ms → 扇形冒出来:

╱ Open ╱ Copy Path [行] ─── Move ╲ Copy ╲ Chmod ╲ Chown Delete

7 个选项扇形排列。

扇形视觉细节

  • 旋转的光圈:两圈——内圈顺时针 3s 转完、外圈逆时针 4s 转完。SweepGradient + BlurMask 做出”扫光”效果
  • 线长=半径:基础圆(低 alpha)标记扇形”死区”——手指在这圈内都当作未选择
  • 每条光束尾端一个点:发光 + 径向 glow
  • hover 态:手指停在某光束上 → 该光束整体亮度 ×1.5,尾端点 scale ×1.3

onLongPress 行自识别

关键设计:行自己触发 onLongPress,Pan gesture 只处理 move / commit——零坐标命中测试

// useFanGesture.ts 骨架 const onLongPress = () => { // 行主动告诉 hook "我被长按了,在这个坐标" setAnchor({ x: pageX, y: pageY, row }); }; const pan = Gesture.Pan() .onUpdate(e => { if (!anchor) return; // 算 relative 向量 → 落在哪条光束上 const angle = Math.atan2(e.y - anchor.y, e.x - anchor.x); const slice = angleToSlice(angle); setHoverSlice(slice); }) .onEnd(() => { if (hoverSlice) onCommit(hoverSlice); setAnchor(null); });

如果不用行自识别,Pan 就得对每一行做包围盒命中测试——行数上千会炸。


3. JUMP Drawer —— 右边抽屉

右边缘向左 swipe → 一张抽屉从右滑出:

┌────────────────────────────┐ │ [主视图] ┌────────┐│ │ │ JUMP ││ │ Miller │ ││ │ Columns │ 收藏 ││ │ │ /data ││ │ │ /sdcard││ │ │ ││ │ │ 最近 ││ │ │ /tmp ││ │ │ ... ││ │ └────────┘│ └────────────────────────────┘
  • 宽度 240px
  • 底:半透明磨砂 rgba(15,15,20,0.92) + 背景模糊
  • 点抽屉外的任何地方 → 抽屉收回(backdrop dismiss)

路径 drag 从 JUMP 拖出来

JUMP 里的路径可以长按拖出——松手丢到主视图 / MoveDock 上。

const drag = Gesture.Pan() .onStart(() => { // measureInWindow → 记录起始点 setDragging({ path: row.path, startX, startY }); }) .onUpdate(e => { // 拖动时显示一张半透明 ghost 跟着手指 ghostX.value = e.absoluteX; ghostY.value = e.absoluteY; }) .onEnd(e => { // 命中哪个 drop zone? const zone = findZone(e.absoluteX, e.absoluteY); if (zone === 'main') navigateTo(row.path); else if (zone === 'dock') openDockAt(row.path); });

4. MoveDock —— 浮动副面板

同时操作两个目录?点主视图右上角 [→] 按钮,底部升起一个副面板:

┌────────────────────────────────┐ │ │ │ [主 Miller Columns] │ │ │ ├────────────────────────────────┤ │ /data/local/tmp [⇅] │ ← MoveDock 顶栏:双击交换主副 │ │ │ a.sh b.apk │ ← 副面板列表 │ c.zip d.md │ └────────────────────────────────┘

3 档高度

点 MoveDock 顶栏右边小把手:

高度用途
min40px(只显一行)只占当前路径,快速参考
mid屏幕 35%(默认)常用
max屏幕 70%大量拖拽时

双击顶栏 = 交换主副

const onDoubleTap = () => { const tmp = mainPath; setMainPath(dockPath); setDockPath(tmp); haptic('light'); };

“我在主视图深入了个目录,想回头看刚才的” → 双击交换。

从 Dock 拖到主视图(反向 drag)

和 JUMP 一样——Dock 里任何一行可以长按拖出,丢到主列某列。


路径 drag 坐标系的那个坑

拖拽过程中最折磨人的 bug:

期望:ghost 贴着手指动 现实:ghost 永远偏下 64px

原因:gesture-handler 给的 event.absoluteX/Y窗口坐标系(相对 window 左上角),但我们的 <Animated.View> 父容器顶部有 64px 的 top bar——它的 local 坐标系 y=0 对应窗口 y=64。

所以 ghost 画在 absoluteY=100实际上在窗口 y=164 的位置。

解法:offset 补偿

const [containerOffset, setContainerOffset] = useState({ x: 0, y: 0 }); useEffect(() => { containerRef.current?.measureInWindow((x, y) => { setContainerOffset({ x, y }); }); }, []); // 画 ghost 时减掉 offset: const ghostStyle = useAnimatedStyle(() => ({ left: ghostX.value - containerOffset.x, top: ghostY.value - containerOffset.y, }));

measureInWindow 告诉我们容器自己在窗口中的绝对位置,减掉就能把 absolute 坐标转成 local


后端:FsModule Kotlin 桥

所有文件操作都走 src/native/fs.tsFsModule (Kotlin) → 内部决定是走 Os.*(非 root)还是走 su 会话池(root)。

3 个常驻 su 会话

┌─ FsModule singleton ─────────────┐ │ │ │ sessions = [ su_0, su_1, su_2 ] │ ← 启动时 spawn 3 个 su 进程 │ │ 每个进程保持打开 │ executor = 4-thread pool │ │ │ └───────────────────────────────────┘

JS 侧发 readDir / stat / chmod:

readDir("/data") → JS thread 发 promise → bridge 扔到 Kotlin executor → executor worker 抢到一个 idle session → 写 stdin: "ls -la /data\n" → 阻塞 readLine,watchdog 5s timeout → parse 输出 → resolve promise → session 回 idle

为什么不每次 Runtime.exec("su -c ...")

每次 exec 都要fork + exec + 解析 —— 50+ms 开销。persistent session <5ms

nsenter -t 1 -m

root 模式下每个会话开启时做一次:

exec nsenter -t 1 -m -- sh

切到 PID 1 的 mount namespace——一进去就能看到整台机器的完整 mount 视图(包括 zygote 看不到的 system mount、vendor 分区等)。

对文件管理器极其重要——不这样的话 /data/vendor_de/ 这种路径根本看不见


性能:目录 LRU 缓存

JS 侧 src/native/fs.ts 带 LRU 32 个目录:

const cache = new LRU<string, FileEntry[]>(32); export async function readDir(path: string): Promise<FileEntry[]> { const cached = cache.get(path); if (cached) return cached; const entries = await NativeModules.FsModule.readDir(path); cache.set(path, entries); return entries; }

用户反复切回同一目录 → 秒出。

Invalidate 规则:mkdir / delete / move / rename 都会 invalidate 相关目录的 cache


手势仲裁(那个最乱的部分)

同一 FileRow 同时监听四种手势

手势用途触发条件
Tap打开行(进下一列)<200ms 松手
LongPress弹 fan menu>400ms 按住
Pan (horizontal)让给父层 Miller 滑列translationX 先超 10px
Pan (vertical)让给单列 FlatList 滚translationY 先超 15px

仲裁用 Gesture.Race 组合:

const composed = Gesture.Race( Gesture.Exclusive(tap, longPress), // tap 和 longPress 互斥 Gesture.Simultaneous(panX, panY) // 两个 pan 谁先通过 activation 谁赢 );

实战教训:failOffset 配得比 activeOffset 小一点——让 pan 不会”激活后又失败”。


相关文件

  • src/screens/FileScreen.tsx — 主屏(87 KB,Miller Columns + Fan + Jump + Dock + Drag)
  • src/hooks/useFanGesture.ts — 长按扇形共享 hook
  • src/native/fs.ts — JS 侧 FsModule 包装 + LRU 缓存
  • src/native/fileIcons.ts — 文件类型图标映射
  • android/.../FsModule.kt — Kotlin 实现(su 池 / nsenter / Os.* fallback)
Last updated on