发现这个问题的起因是我想实现一个 macOS App 的设置窗口,窗口大小自动随内部视图变化尺寸,并且是能平滑地有动画效果的变换尺寸。期望效果如下图所示:
这张 GIF 中的窗口来自 Preferences 。它的底层实现是利用点击 NSToolbar 上的 NSToolbarItem,来改变窗口内的视图,并根据新视图的大小设置窗口尺寸( NSWindow.animator().setFrame() )。但是这些都是基于 AppKit 中的类与函数,我希望能够简单的利用 SwiftUI 来实现类似的效果。
1、简单模型
我用 SwiftUI 设置了一个简单的 View:
struct ContentView: View { @State var toggle = false var body: some View { VStack { Button("change") { self.toggle.toggle() } if toggle { VStack { Rectangle().fill(Color.red) }.frame(width: 400, height: 300) } else { VStack { Text("green") Rectangle().fill(Color.green) }.frame(width: 400, height: 400) } } } }
如上图所示,NSWindow 模式是会根据其 contentView 的尺寸变化来实时的改变窗口尺寸。只是它的尺寸变化是瞬间完成的,并没有一个过度效果。
为了让 NSWindow 能够“动画”地改变窗口尺寸,我参考了 stackoverflow 上的一个答案。对 NSWindow 进行了派生并重写了 setContentSize 方法。
class MyWindow: NSWindow { var lastContentSize: CGSize = .zero override func setContentSize(_ size: NSSize) { if lastContentSize == size { return } NSLog("🌈 setContentSize \(lastContentSize) ==> \(size)") lastContentSize = size var newOrigin = self.frame.origin newOrigin.y -= size.height - self.frame.height let newFrame = NSRect(origin: newOrigin, size: size) self.animator().setFrame(newFrame, display: false) } }
修改了窗口类型之后的效果如上图所示,窗口尺寸改变时候的动画效果是有了。但是视图中的 change 按钮也会随着变化,这并不是我希望的效果,我希望这个按钮是静止的。但这似乎是无法避免的,因为窗口的动画变化效果必然连带 contentView 的动画效果。单纯只用 SwiftUI 来实现的话,这个按钮也是在视图中的。而不像 NSToolbar 上的按钮那样,独立于 contentView 存在。
欸,真是过了这个坑,又遇一个坎。所以我决定要么就是 接受纯 SwiftUI.View 尺寸变化导致 NSWindow 尺寸变化时候的无动画效果,要么就是 接受使用 NSToolbar 的复杂度(可以利用 Preferences 这个库,真良心 👍 )
2、SetContentSzie 获取到错误尺寸
在上面我设计的例子中,从运行结果来看似乎并没有什么 Bug。但实际上,如果去看那个 NSLog 打印出来的日志,会发现对于绿色视图,它获取到的 size 是不准确的。实际上绿色视图的尺寸应该是(400, 450),而不是日志中输出的(400, 449.7421875)。
这会导致计算出来的窗口“原点”可能是错误的,那么 setFrame 时候,窗口就会出现跳动。但不知道为什么,我今天怎么也无法复现这种错误效果了。我他妈 =。=#
我调试了好久,才发现是因为在绿色视图中使用了 Text 的关系,除了 Text,还有 HStack 也会导致这种结果。
我推测这是因为 Text、HStack 这些视图在尺寸上是 non-greedy 的,它们只会占用自己实际用到的尺寸,而不是“贪婪”地占用父视图的所有剩余空间。所以这就使得窗口在 setContentSize 的时候,无法获取精确的视图尺寸??即使我对 Text、HStack 设置了固定大小的 frame 也是没有作用的。而红色视图中的 Rectangle 是 greedy layout 的,所以在计算视图尺寸的时候,能明确的确定红色视图的大小。欸,我也不知道这个理由说不说的过去,apple 文档提都没提视图尺寸是怎么计算的,在网上查了好几天也是一头雾水。
然后就根据我自己猜测的原因,我尝试了用 GeometryReader 将 non-greedy View 包裹起来的话,就能获取到精确尺寸了。因为 GeometryReader 也是一个 greedy layout 的视图。(那么这样看来 VStack 也是 non-greedy 的囖)
var body: some View { VStack { Button("change") { self.toggle.toggle() } if toggle { GeometryReader{ gr in HStack { Text("red") } Rectangle().fill(Color.red) }.frame(width: 400, height: 300) } else { GeometryReader { gr in Text("green") Rectangle().fill(Color.green) }.frame(width: 400, height: 400) } } }