在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
这个级别持有MainViewModel
与window
,我们就能很方便地实现两者的交互。非常“简单粗暴”,但有效。
但是这种方法只适用于非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:
这样当该NSView
的viewDidMoveToWindow()
被调用时,我们就可以往windowViewModel
里记录当前的Window。
然后我们创建一个WindowViewRepresentable
,以便SwiftUI的View可以访问到这个WindowView
:
最后,我们在WindowReader
的body
里面,创建一下这个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的NavigationSplitView
和NavigationStack
就是对此前NavigationView
的改进。
一开始我接触SwiftUI,还是免不了要推倒方案,重回UIKit/AppKit的实现,但是如果咬咬牙,想一下是否能通过NSViewRepresentable
来bridge两套UI框架,打通了之后真的成就感满满。既不需要放弃SwiftUI便利的新能力,又能用上原生平台框架更强大更丰富的自定义能力。
有了这个东西,其实已经可以绕过大部份SwiftUI目前还解决不了的问题了。
六、相关链接
- 本文涉及代码gist: SwiftUI Notes 03
- WindowReader相关的Popovers项目: aheze/Popovers
- SwiftUI-Introspect
- SwiftUI学习笔记01
- SwiftUI学习笔记02 – 苹果官方资源
- SwiftUI学习笔记03 – 如何在SwiftUI中访问Window
- SwiftUI学习笔记04 – 如何调试SwiftUI? | 枫言枫语