SwiftUI学习笔记03 – 如何在SwiftUI中访问Window

2023年4月11日 · 1 year ago

SwiftUI学习笔记 01我提过现阶段的SwiftUI,无法直接在View里直接访问所属的Window。如果开发的是一个iOS App还好,需要hack到Window的地方不多,但在Mac上跟Window交互就实在太普遍了。别的不说,仅仅是调用AppKit的很多接口都少不了Window参数,比如在Mac上打开/保存文件用到的NSOpenPannel/NSSavePannel,我们常常会把它挂在到当前Window上:

func beginSheetModal(for window: NSWindow)

这个接口接受一个window参数,可以展示一个好看的系统保存文件窗口,并挂载在当前App window上(图左)。如果我们拿不到window,那就只能调用runModal()方法,这样唤出的窗口跟我们App主窗口是分离的,不太优雅(图右)。尤其对于面向文档的App来说,这种体验很不苹果。

除了调用Cocoa的方法,有时候我们也需要根据Window Size进行部分UI Elements的调整,就像获取super view的frame一样自然。所以在SwiftUI中获取window势在必行。

本文涉及Sample Code请看这个👉🏻gist

一、放弃SwiftUI App入口,改用NSHostingController

利用SwiftUI提供的NSHostingController,我们可以走老一套AppKit的路,不用SwiftUI来打开View,而是先创建一个NSWindow,然后再把SwiftUI View通过NSHostingController放上去。

通过在MainWindowController这个级别持有MainViewModelwindow,我们就能很方便地实现两者的交互。非常“简单粗暴”,但有效。

但是这种方法只适用于非SwiftUI App入口创建的Window,比如展示一个Settings Window或者一个About Window。但是如果我需要拿到SwiftUI一开始创建的Root Window,采用这种方法就必须推倒重来,改用AppKit启动App。

这样一来,SwiftUI方便的commandGroup, shortcut, WindowGroup之类的新特性我们就享受不到了,有没有保留SwiftUI入口的方案呢?

二、参考GeometryReader实现一个WindowReader

当我们需要根据super view的frame进行sub view布局时,SwiftUI提供了GeometryReader这样的工具。

上述代码使得左边的Text占super view的33% width, 右边占67%。(Example来自这里)

如果我能实现一个WindowReader { window in … }是不是就无缝衔接,果味十足了🤔

我们来看看GeometryReader的声明:

关键在@ViewBuilder这个修饰符。

SwiftUI的View是一个protocol,我们熟悉的body是一个带有@ViewBuilder修饰的属性:

@ViewBuilder @MainActor var body: Self.Body { get }

所以要实现GeometryReader的效果,我们就需要新建一个类似的结构:

那么怎么获取当前View的Window呢?我们可以通过NSView实例的window属性来拿到。如果是nil说明这个NSView已经被移除,如果不为空则是它所在Window的实例。

上述WindowReader这个结构体是SwiftUI的View,为了能在SwiftUI View里访问NSView,我们需要使用NSViewRepresentable这个protocol。UIKit里也有类似的UIViewRepresentable协议,可以实现SwiftUI与AppKit/UIKit的混用。

首先我们创建一个NSView的Subclass,为的是通过这个NSView拿到当前的Window:

这样当该NSViewviewDidMoveToWindow()被调用时,我们就可以往windowViewModel里记录当前的Window。

然后我们创建一个WindowViewRepresentable,以便SwiftUI的View可以访问到这个WindowView:

最后,我们在WindowReaderbody里面,创建一下这个WindowViewRepresentable:

最终我们就可以像使用GeometryReader一样,在SwiftUI里使用WindowReader

这种解法学自aheze/Popovers这个项目,感兴趣的读者可以阅读源码以及这个issue,以及本文相关的gist: SwiftUI Notes 03

三、通过Introspect曲线救国

直接在SwiftUI的布局代码中获取window我们通过WindowReader实现了,但我还有些方法是通过ViewModel或者Button的Action Block实现的,虽然通过WindowReader我也可以给每个需要用到Window的View全部无脑嵌套一层,但是有没有其他方法呢?

比如我能否通过View Modifier来实现呢?

第一篇笔记里我们介绍过这个SwiftUI-Introspect项目,它通过给SwiftUI的View里注入(inject)一个NSView/UIView然后再通过AppKit/UIKit的方法向上寻找对应平台的实现,从而获取List背后的NSTableView/UITableView这样的功能。

所以只要我们的View里用到了Introspect framework支持的View我们就能直接拿到它,然后再获取它的Window属性,比如:

如果View用到了ScrollView我们就能这样把window拿到并赋值给viewModel。Instrospect的原理是在updateNSView()被调用时回调这个block,所以如果这个View经常刷新它就会频繁回调,viewModel要记得去重后再update。

四、有没有更通用一点的解法?

Instrospect的做法当然不保险,只要苹果升级系统修改实现直接就报废。但我们可以学习它的通过扩展View来实现类似的效果。

跟 #2 类似,我们同样需要一个NSView作为基础,通过它来获取window:

我们在viewDidMoveToWindow回调的时候,调用getWindow()block,把它当前的window回调给SwiftUI。

同样的,我们也需要把它用NSViewRepresentable包装一下给SwiftUI:

SwiftUI这边,我们这次不使用@ViewBuilder,而是扩展SwiftUI的View,给它添加实例方法:

这里我们的inject方法采用Introspect framework的,用overlay()覆盖一个frame为0的空白View,跟上面的background()做法异曲同工。最终效果如下:

直接通过View的getWindow() block即可获取当前View所在的Window,然后ViewModel就可以为所欲为啦!哈哈哈

五、What's Next?

SwiftUI目前还做不到API 100%覆盖UIKit/AppKit,我想它的目标应该也不会如此。但是可以想见,SwiftUI的API未来会越来越丰富,而且也在每年迭代进化中。去年WWDC的NavigationSplitViewNavigationStack就是对此前NavigationView的改进。

一开始我接触SwiftUI,还是免不了要推倒方案,重回UIKit/AppKit的实现,但是如果咬咬牙,想一下是否能通过NSViewRepresentable来bridge两套UI框架,打通了之后真的成就感满满。既不需要放弃SwiftUI便利的新能力,又能用上原生平台框架更强大更丰富的自定义能力。

有了这个东西,其实已经可以绕过大部份SwiftUI目前还解决不了的问题了。

六、相关链接