5

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

 1 year ago
source link: https://justinyan.me/post/5656
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

2023年4月11日 · 9 mins ago
webp

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

func beginSheetModal(for window: NSWindow)
webp

这个接口接受一个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目前还解决不了的问题了。

六、相关链接


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK