ByteNoteByteNote
SwiftUI 文件面板:拖出即删,窗口置顶磨砂
字

字节笔记本

2026年10月7日 · 约 9 分钟读完

SwiftUI 文件面板:拖出即删,窗口置顶磨砂

API中转
¥120

做一个 macOS 文件暂存面板,表面需求只有四句:文件能拖进来,也能拖回访达;窗口不要标题栏;始终浮在最前面;背景是一层能透出桌面的磨砂。真正耗时间的不是把文件放进数组,而是三件容易缠在一起的事:拖出之后列表什么时候删、窗口里面换顺序时条目为什么会消失、磨砂为什么只糊在窗框上。

拖出之后才从列表删除

拖入只收文件 URL

拖入用 SwiftUI 的 onDrop,声明的类型是 UTType.fileURL。系统交过来的是一组 NSItemProvider,从里面取出文件 URL,再转成路径放进数组。点击按钮添加走的是 NSOpenPanel,allowsMultipleSelection、canChooseFiles、canChooseDirectories 都打开,这样文件和文件夹可以一次选多个。

判断用户有没有确认,只看模态返回值:

swift
if panel.runModal() == .OK {
    filePaths.append(contentsOf: panel.urls.map { $0.path })
}

runModal() 是同步的。用户点取消时根本不会进这个分支,不必再写一套取消回调。路径直接来自 panel.urls,不要自己拼字符串。

拖出是相反方向。onDrag 里用路径构造 NSURL,交给 NSItemProvider,并把 suggestedName 设成 lastPathComponent。落到访达时,文件名还是原来的名字,而不是系统临时起的一串。

不要在拖动开始时删条目

第一版把删除写在 onDrag 的开头:手一按下去,路径就从数组里拿掉。结果是拖到一半松手,或者只是在窗口里挪了一下,面板上的文件已经没了,访达里却什么都没多出来。删除必须发生在拖出成功之后,而不是拖动开始时。

这条路上还撞过两个编译错误。有一版给 NSItemProvider 设置 delegate,编译器报 Value of type 'NSItemProvider' has no member 'delegate',下一行又报 Cannot find type 'NSItemProviderDelegate'。拖放完成并不是靠这个并不存在的委托类型回调的,硬套只会停在编译期。另一处是闭包多写了一截,报 Extra trailing closure passed in call,把多余的尾随闭包删掉就能过。

需求最后被收成一句很短的话:只要确认是拖出了窗口,就从列表去掉。拖动一开始就删,是把意图做反了。

窗口内排序和窗口外删除必须分开

面板上的多个文件还要能排序。每个图标如果同时挂了 onDrag 和 onDrop,窗口内部的换位也会被当成一次拖放。现象很具体:把一个文件拖到另一个文件上面,源文件直接消失。删除逻辑没有区分这次松手落在窗口里,还是落到了窗口外。

拆开的办法是单独做一个 DragState。onDrag 时记下被拖的路径和起点,起点用窗口的 mouseLocationOutsideOfEventStream 取。然后开一个 0.1 秒的 Timer 采样当前鼠标位置,用 hypot 算位移。移动超过 20 点,才把它当成一次有效拖动,避免手抖误触发。

拖放有没有结束,这版看的是通用粘贴板里还有没有 fileURL。拖放进行中,粘贴板类型还在;拖放结束并且鼠标已经不在窗口里,再发通知,让列表删掉那一条。窗口内的 onDrop 只负责重排数组,不要调用这条删除。

这套信号并不漂亮。用定时器轮询鼠标,用粘贴板类型推测拖放是否结束,都是间接判断,窗口动画或快速连拖时可能误判。它解决的是优先级:先分清落点,再决定是换序还是删除。窗口内一对图标就丢条目,就是这两条路写进了同一个闭包。

图标也不该永远用 SF Symbol 的 doc。NSWorkspace.shared.icon(forFile:) 会按路径返回系统给这个文件或文件夹准备的图标,再用 Image(nsImage:) 放进 SwiftUI。文件夹、图片和压缩包在面板里可以一眼分开,不必自己维护一张扩展名对照表。

无标题栏、置顶,和真正透下去的磨砂

去掉标题栏要改窗口样式,不是把界面顶部那一行字藏起来。有一次改动只删了内容区的标题,系统标题栏还在。正确的位置在 Scene 上:windowStyle(HiddenTitleBarWindowStyle())。

置顶放在 AppDelegate 的 applicationDidFinishLaunching 里,通过 NSApplicationDelegateAdaptor 接进 SwiftUI 生命周期。拿到 NSApplication.shared.windows.first 之后,把 level 设为 .floating,collectionBehavior 带上 .canJoinAllSpaces 和 .fullScreenAuxiliary。这样切换桌面,或者别的应用进入全屏时,面板仍然能浮在上面。

磨砂用 NSVisualEffectView。material 用 .hudWindow,想要更重的模糊可以换成 .fullScreenUI,更轻可以试 .popover 或 .sidebar。state 设为 .active,窗口失焦时效果还在。blendingMode 用 .behindWindow,模糊采样的是窗口后面的桌面,而不是窗口自己画出来的内容。

只加这层视图还不够。有一版窗框已经透了,列表区域仍是一块实底。原因是窗口默认不透明,SwiftUI 根视图又铺了不透明背景。要同时做三件事:window.backgroundColor = .clear,window.isOpaque = false,再把 NSVisualEffectView 插到 contentView 最底下,autoresizingMask 跟随宽高。SwiftUI 根视图的背景也要让给这层效果,不能再铺不透明色,否则磨砂永远在内容后面看不见。

代码拆成两个文件更稳。入口文件只管 App、AppDelegate 和磨砂视图,ContentView 只管列表、拖放和 DragState。窗口层级和列表状态放在一起时,改透明度很容易把拖放回调弄丢。

先分清落点

这个面板的四条交互各有自己的事件:拖入是 onDrop 和打开面板,拖出是 onDrag 加上拖放结束后的删除,窗口内换序是单独的 drop 处理,外观是窗口层级和磨砂视图。混在一个闭包里,就会轮流出现一拖就删、一对图标就丢、磨砂只糊窗框。先把落点分清,外观才有地方放。

窗口置顶与磨砂要改的三处属性

窗口尺寸和重复路径

面板最初的窗口大约是宽 600、高 200,列表是横向滚动,滚动区域高度约 100。这个尺寸只够一行图标。文件一多,用户靠左右滑,而不是纵向堆叠。横向列表里如果同一路径被加了两次,ForEach 又用路径本身当 id,SwiftUI 会把两条当成同一个身份,删除和重排都会错位。追加之前先判断数组里有没有这条路径,比事后在界面上找重复图标省事。

打开面板和拖入都会拿到用户选过的路径。如果应用开了沙箱,还要在能力里允许读取用户选择的文件,否则 NSOpenPanel 能弹出,拖出去或读取图标时仍会失败。NSWorkspace 取图标同样依赖这条路径当时可读。

还有一个被提出但没有和置顶磨砂写在一起的交互:窗口默认藏起来,只有拖着文件经过时才出现。那和“始终浮动的一块磨砂”是两条产品路线。前者要监听系统级的拖放进入,后者只是一个普通窗口加高层级。代码如果已经把 level 设成 .floating 并且一直显示,就不要同时承诺“平时看不见”。两条路线选一条做完,再叠加另一条。

图标、删除时机、窗口透明度,这三处都依赖路径和窗口对象还活着。windows.first 在多窗口时可能不是你以为的那一块面板。入口如果以后会再开设置窗,拖放状态里应该记下面板自己的窗口,而不是每次都取数组里的第一个。

相关文章

分享: