SwiftUI Bug - NSWindow.setContentSize 获取到错误 size

SwiftUI Bug - NSWindow.setContentSize 获取到错误 size

发现这个问题的起因是我想实现一个 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)
        }
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *

TOC