SwiftUI 如何恣意定制和管理系统中的窗口(Window)

138 阅读4分钟

在这里插入图片描述

概览

在苹果大屏设备上,我们往往需要借助多窗口(Multiwindow)来充分利用海量的显示空间,比如 Mac,iPad 以及 AppleTV 系统 等等。

在这里插入图片描述

所幸的是,SwiftUI 对多窗口管理提供了很好的支持。利用 SwiftUI 我们可以非常轻松的设置窗口在屏幕上的位置,大小以及拖动反馈。

在本篇博文中,您将学到如下内容:

  1. 限制窗口大小
  2. 任性选择窗口放置位置
  3. 检测窗口拖动状态

SwiftUI 窗口经过几多进化已经愈发趋于成熟,小伙伴们值得信赖。那还等什么呢?让我们立即开始窗口大冒险吧!

Let‘s go!!!;)


1. 限制窗口大小

从 SwiftUI 5.0(iOS 17,macOS 13)开始,苹果新增了 defaultSize 视图修改器专门用来设置窗口的尺寸:

在这里插入图片描述

利用 defaultSize 修改器,我们可以任性设置 Window 的长与宽。如下代码所示,我们将 search 窗口的大小设置成了 500 points x 500 points:

struct SugarBotApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
        
        WindowGroup(id: "search") {
            SearchFeatureView()
        }
        .defaultSize(width: 500, height: 500)
    }
}

除了设置窗口大小的初始值以外,我们还可以使用 SwiftUI 中的 windowResizability 修改器来决定后续用户能够如何更改窗口尺寸:

在这里插入图片描述

在下面的代码中,我们调用 windowResizability 修改器并传入 .contentSize 实参将 SearchFeatureView 的最大尺寸设置为其内容本身的大小,这意味着用户无法将 SearchFeatureView 窗口的尺寸需改为超越其视图内容“默认”的大小:

import SwiftUI

@main
struct MacTextApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .defaultSize(.init(width: 300, height: 300))
        
        WindowGroup(id: "search") {
            SearchFeatureView()
        }
        .defaultSize(width: 200, height: 300)
        .windowResizability(.contentSize)
    }
}

struct SearchFeatureView: View {
    var body: some View {
        Text("Searching Something...")
            .font(.largeTitle.weight(.black))
            .foregroundStyle(.green.gradient)
            .frame(minWidth: 200, minHeight: 300)
    }
}

struct ContentView: View {
    
    @Environment(\.openWindow) var openWindow
    
    var body: some View {
        VStack {
            Image("panda")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, 大熊猫侯佩!!")
                .font(.largeTitle)
            
            Button("打开搜索") {
                openWindow(id: "search")
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

运行效果如下所示:

在这里插入图片描述

通过为 windowResizability 修改器传入 .contentMinSize 实参,我们还可以限制窗口的最小尺寸为其默认大小,但“放任”窗口的最大尺寸:

WindowGroup(id: "search") {
    SearchFeatureView()
}
.defaultSize(width: 200, height: 300)
.windowResizability(.contentMinSize)

运行看一下,正是我们所需要的效果:

在这里插入图片描述

2. 任性选择窗口放置位置

默认情况下,每当使用 openWindow 环境值(environment value)打开窗口时,新窗口都会出现在上次打开的窗口前面。不过如果我们愿意的话,可以使用 defaultWindowPlacement 视图修改器来控制新窗口的放置。

struct SugarBotApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
        
        WindowGroup(id: "search") {
            SearchFeatureView()
        }
        .defaultWindowPlacement { content, context in
            #if os(visionOS)
            if context.windows.last?.id == "search" {
                return WindowPlacement(.trailing(context.windows.last!))
            } else {
                return WindowPlacement(.utilityPanel)
            }
            #else
            // ...
            #endif
        }
    }
}

正如小伙伴们在上面示例中所看到的那样:在 visionOS 里,我们使用 defaultWindowPlacement 视图修改器调整了窗口的放置位置。defaultWindowPlacement 修改器闭包返回 WindowPlacement 类型的实例。该类型用来定义窗口位置,允许我们控制要在其上放置窗口的“边”。

我们在 SwiftUI 中还定义了 visionOS 上 utilityPanel 的位置,该位置将窗口显示在当前窗口的略下方。我们还获得了上一个显示窗口的标识符:以便当它是搜索窗口(search)时将其显示在尾部边缘上(trailing edge)。

而在 macOS 中,我们并不访问 utilityPanel 的位置,而是可以通过闭包上下文中的显示属性访问当前显示器的信息。我们同样能够使用 content 实参去测量窗口内容的尺寸并计算窗口在屏幕上的精确位置:

struct SugarBotApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
        
        WindowGroup(id: "search") {
            SearchFeatureView()
        }
        .defaultSize(width: 500, height: 800)
        .windowResizability(.contentSize)
        .defaultWindowPlacement { content, context in
            #if os(visionOS)
            // ...
            #else
            let size = content.sizeThatFits(.unspecified)
            let positionX = context.defaultDisplay.bounds.midX - (size.width / 2)
            let positionY = context.defaultDisplay.bounds.maxY - size.height
            let position = CGPoint(x: positionX, y: positionY)
            return WindowPlacement(position, size: size)
            #endif
        }
    }
}

3. 检测窗口拖动状态

从 macOS 15 开始,SwiftUI 专门为窗口提供了一个 WindowDragGesture 拖动手势:

在这里插入图片描述

利用它我们可以轻而易举的监听指定窗口的拖动状态,并据此来调整我们应用的显示逻辑:

struct SecretView: View {
    @GestureState private var isWindowDragging = false
    
    var body: some View {
        Text("密码:1234567890")
            .font(.title.weight(.heavy))
            .foregroundStyle(.red.gradient)
            .redacted(reason: isWindowDragging ? .placeholder : [])
            .gesture(
                WindowDragGesture()
                    .updating($isWindowDragging) { _, state, _ in
                        state = true
                    }
            )
    }
}

从上面的代码可以看到,当我们通过密码 Text 视图拖动窗口时,密码会被遮挡显示:

在这里插入图片描述

注意,上面的代码需要用 Xcode 16beta 在 macOS 15 上编译运行。

现在,利用 SwiftUI 提供的架海擎天般的 Window 管理 API,我们可以随心所欲的管理和定制我们的窗口尺寸和拖动事件啦,棒棒哒!💯

总结

在本篇博文中,我们讨论了在 SwiftUI 中如何稳妥的管理和定制窗口(Window)外观;并介绍了如何利用 SwiftUI 6.0 最新增加的窗口拖拽手势实时的监听窗口的拖动状态。

感谢观赏,再会啦!8-)