在这一节里,我们不和其他教程一样细讲每个实现原理,从我们大多数应用中经常碰到的窗口操作去实现,比如 如何在SwiftUI 中实现一个登陆窗口,并且当成功登陆后关闭登陆窗口并打开主窗口,以及了解如何设置窗口相关属性。
第一步 创建项目
我们先学习如何创建SwiftUI项目,和在项目中选择何种方式去创建SwiftUI 项目,即两种方式创建SwiftUI项目的区别和在实际项目中如何使用他们。
首先我们打开 Xcode 创建项目,在项目中我们选择 macOS 类型的项目,并选择 App 点击 next
进入到项目参数选择界面
先填写项目名称 我这里填写 MyApp 在选择 Life Cycle 为 AppKit App Delegate 项目类型,这里还有一个 SwiftUI App
我们先看看 AppKit App Delegate 的代码目录结构 ,这里是我们比较关心的文件
AppDelegate.swift 文件中 主要有启动项目的相关设置代码。项目的启动是从这里开始的。
ContentView.swift 这个是我们窗口的内容视图页面,也是我们主要的开发编辑视图的文件。
Assets.xcassets 这个是我们项目用到的资源文件目录,以及相应的资源设置目录
Main.storyboard 这个是我们顶部菜单,及其他菜单,等相关设置,在 info.plist 指定了这个文件,所以不要删除。
info.plist 是苹果的一些配置信息文件。
MyApp.entitlements 文件是一些权限配置,服务调用配置,证书配置等相关的配置文件。
第二步 创建登录窗口并打开主窗口
我们来了解一下项目文件,及制作我们的登录窗口,并从登录窗口中打开我们的主窗口。
我们先打开 AppDelegate.swift 文件。
import Cocoa
import SwiftUI
//这里注册为主执行函数类
@main
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
//这里是应用启动完成后要执行的代码
func applicationDidFinishLaunching(_ aNotification: Notification) {
//创建一个内容视图,为后添加到应用窗口中
let contentView = ContentView()
// 创建一个窗口,我们的应用需要一个窗口,并设置相应的配置
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
// styleMask 设置相应的样式,.titled 需要标题,.closable 可以关闭窗口,.miniaturizable 可以最小化,.resizable 可改变窗口大小,.fullSizeContentView 可全屏当前视图
//设置窗口如果关闭后是否销毁窗口,这里设置为false 即关闭窗口不销毁窗口,可以被下次makeKeyAndOrderFront 再次显示。
window.isReleasedWhenClosed = false
//让窗口在屏幕上居中
window.center()
//给窗口设置一个名字,并自动保存
window.setFrameAutosaveName("Main Window")
//讲我们之前创建的内容视图设置到当前窗口上
window.contentView = NSHostingView(rootView: contentView)
//让窗口显示出来,这里要显示窗口是必须要调用该方法的。
window.makeKeyAndOrderFront(nil)
}
//这里的应用将要结束后要执行的代码
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}
这是系统创建的默认启动页面代码。可以从我的注释中去了解代码相关的逻辑。
再看 ContentView.swift 页面。
import SwiftUI
struct ContentView: View {
//这里就没什么讲的了,主要的布局数据
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
//主要用于预览使用即点击预览区域 resume 的时候显示。
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
接下来,我们创建一个 LoginView 并且让主窗口先显示我们的Login页面.
选择 项目文件夹 MyApp 右键 新建文件【new File】 在弹出的对话框中 选择 SwiftUI View 格式文档,点击 Next 填写文件名 LoginView
文件创建好以后 我们加入一个按钮,用于后面打开我们的主窗口(至于如何排版,这里就不再细讲),以模拟我们登陆成功以后如何打开主窗口。
import SwiftUI
struct LoginView: View {
var body: some View {
//用于读取父类的相关尺寸参数
GeometryReader { proxy in
VStack{
Spacer()
Text("Hello, Login View!")
Spacer().frame(width: proxy.size.width, height: 50, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
Button("打开主窗口"){
}
Spacer()
}
}
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView()
}
}
接下去我们就是要在启动页面修改启动的视图为LoginView 并创建一个 主窗口用于显示主显示内容视图,我们回到 AppDelegate.swift 内容页面,代码修改如下。
import Cocoa
import SwiftUI
@main
class AppDelegate: NSObject, NSApplicationDelegate {
var mainWin: NSWindow!
var loginWin: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
//启动后先显示Login窗口
let loginView = LoginView()
loginWin = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
loginWin.isReleasedWhenClosed = true
loginWin.center()
loginWin.setFrameAutosaveName("Login Window")
loginWin.contentView = NSHostingView(rootView: loginView)
loginWin.makeKeyAndOrderFront(nil)
}
//创建一个方法 用于打开主窗口
@objc //注册函数可以让obj-c 调用
func openMainWindow() {
//如果之前已经创建了,就直接执行后面一句 显示,否则创建
if nil == mainWin { // create once !!
let mainView = ContentView().frame(width: 800, height: 500, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
mainWin = NSWindow(
contentRect: NSRect(x: 20, y: 20, width: 800, height: 500),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
mainWin.center()
mainWin.setFrameAutosaveName("Main Window")
mainWin.isReleasedWhenClosed = false
mainWin.contentView = NSHostingView(rootView: mainView)
}
mainWin.makeKeyAndOrderFront(nil)
}
}
这里我们添加了一个 openMainWindow 的方法 用于打开主窗口界面。
那么 我们需要打开窗口的时候,比如 在LoginView 的页面打开这个窗口 我们可以用 下面的代码打开:
struct LoginView: View {
var body: some View {
//用于读取父类的相关尺寸参数
GeometryReader { proxy in
VStack{
Spacer()
Text("Hello, Login View!")
Spacer().frame(width: proxy.size.width, height: 50, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
Button("打开主窗口"){
NSApp.sendAction(#selector(AppDelegate.openMainWindow), to: nil, from:nil)
}
Spacer()
}
}
}
}
我们这里 要调用 AppDelegate.openMainWindow 的方法时,需要用到 NSApp.sendAction 来发送相关的事件,并且 事件为 Selector 的形式,为了检查方便 SwiftUI 给我们提供了 #selector 的标记 用于关联事件。
到这里 我们已经可以从登陆页面中打开我们的主窗口了,但是同时我们发现一个问题,就是两个窗口关闭后 程序依然没有退出,这个时候我们需要在启动页面 AppDelegate.swift 中添加一个方法,
class AppDelegate: NSObject, NSApplicationDelegate {
var mainWin: NSWindow!
var loginWin: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
//启动后先显示Login窗口
let loginView = LoginView()
loginWin = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
loginWin.isReleasedWhenClosed = true
loginWin.center()
loginWin.setFrameAutosaveName("Login Window")
loginWin.contentView = NSHostingView(rootView: loginView)
loginWin.makeKeyAndOrderFront(nil)
}
//创建一个方法 用于打开主窗口
@objc //注册函数可以让obj-c 调用
func openMainWindow() {
//如果之前已经创建了,就直接执行后面一句 显示,否则创建
if nil == mainWin { // create once !!
let mainView = ContentView().frame(width: 800, height: 500, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
mainWin = NSWindow(
contentRect: NSRect(x: 20, y: 20, width: 800, height: 500),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
mainWin.center()
mainWin.setFrameAutosaveName("Main Window")
mainWin.isReleasedWhenClosed = false
mainWin.contentView = NSHostingView(rootView: mainView)
}
mainWin.makeKeyAndOrderFront(nil)
}
//当所有窗口关闭时,退出程序,如果返回false 即不退出程序,如果返回true 即退出。
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
这里以后,我们的窗口如果都关闭后,整个程序就可以退出了。
第三步 关闭登录窗口,并打开主窗口,及设置居中
我们这一步需要在打开主窗口后,如何关闭登录窗口,并设置主窗口在屏幕居中。
在此之前我们需要了解一个全局变量的成员属性 NSApp.keyWindow
这个成员属性 是指 当前应用下 可以接受到 键盘鼠标按键操作的窗口,也就是 我们最后调用了 makeKeyAndOrderFront 后指向的窗口。
所以这里 在我们没有点击打开窗口之前 这个 keyWindow 指向的是我们的登录(loginWin)窗口,打开后指向的是我们的mainWin.
了解了这一步以后我们就知道如何关闭我们的登录窗口 并 设置 主窗口居中了。
修改 LoginView.swift 代码如下。
struct LoginView: View {
var body: some View {
GeometryReader { proxy in
VStack{
Spacer()
Text("Hello, Login View!")
Spacer().frame(width: proxy.size.width, height: 50, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
Button("打开主窗口"){
//这里的 keyWindow 指向的是登录窗口
NSApp.keyWindow!.close()
NSApp.sendAction(#selector(AppDelegate.openMainWindow), to: nil, from:nil)
//这里的keyWindow 指向的是主窗口,因为调用了 AppDelegate.openMainWindow 后 主窗口 makeKeyAndOrderFront 中设置指向了。
NSApp.keyWindow!.center()
}
Spacer()
}
}
}
}
到这里,我们已经完成了 从登录窗口中打开主窗口,并结束退出登录窗口,我们可以返回去看看 AppDelegate.swift 文件中的代码。
loginWin.isReleasedWhenClosed = true
这一句代码我们可以设置,关闭后销毁对话框,释放loginWin 的内存。
第4步 在窗口之间传值
如果在窗口之间传值,我们需要了解两个东西。
ObservableObject 和 @EnvironmentObject
这里两个玩意一般配套使用,
ObservableObject 是一个观察者对象协议,大概意思是 ObservableObject 下的 @Published 属性包装器包装后的属性 只要修改,就会被swiftUI 观察到,并作出相应的反应。
这里也不细考这个协议,需要了解更多 可以自己网站找补。
而 @EnvironmentObject 是一个依赖注入修饰符,用于注册相应的关联,编译器没有办法根据当前View的具体内容来进行更精确的判断,只要你的View中进行了声明,依赖关系便建立了。
了解这两个玩意后,我们开始编写我们的代码。
我们先创建一个文件 MyFactory.swift 这个文件不要选择SwiftUI 直接选择 swift文件即可。
代码如下:
import SwiftUI
//用于传递值的工厂类
final class MyFactory: ObservableObject {
@Published var UserName: String = ""
func setUserName(name: String) {
UserName = name
}
}
然后我们在 启动页面 AppDelegate.swift 中构造实例,并传递给登录和主窗口,这样在登录窗口中修改内容就可以反应到主窗口上面。
class AppDelegate: NSObject, NSApplicationDelegate {
var mainWin: NSWindow!
var loginWin: NSWindow!
var factory = MyFactory() //构造一个用于传值的实例对象
func applicationDidFinishLaunching(_ aNotification: Notification) {
//启动后先显示Login窗口
let loginView = LoginView()
.environmentObject(factory) //传递factory到登录窗口
loginWin = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
loginWin.isReleasedWhenClosed = true
loginWin.center()
loginWin.setFrameAutosaveName("Login Window")
loginWin.contentView = NSHostingView(rootView: loginView)
loginWin.makeKeyAndOrderFront(nil)
}
//创建一个方法 用于打开主窗口
@objc //注册函数可以让obj-c 调用
func openMainWindow() {
//如果之前已经创建了,就直接执行后面一句 显示,否则创建
if nil == mainWin { // create once !!
let mainView = ContentView()
.frame(width: 800, height: 500, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
.environmentObject(factory) //传递factory到主窗口
mainWin = NSWindow(
contentRect: NSRect(x: 20, y: 20, width: 800, height: 500),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
mainWin.center()
mainWin.setFrameAutosaveName("Main Window")
mainWin.isReleasedWhenClosed = false
mainWin.contentView = NSHostingView(rootView: mainView)
}
mainWin.makeKeyAndOrderFront(nil)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
接着 我需要在 LoginView.swift 和 ContentView.swift 页面中接收该参数。
LoginView.swift
struct LoginView: View {
//注册依赖,以接收传递值
@EnvironmentObject var factory:MyFactory
var body: some View {
GeometryReader { proxy in
VStack{
Spacer()
Text("Hello, Login View!")
Spacer().frame(width: proxy.size.width, height: 50, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
Button("打开主窗口"){
//设置传值参数
factory.setUserName(name: "wj008")
NSApp.keyWindow!.close()
NSApp.sendAction(#selector(AppDelegate.openMainWindow), to: nil, from:nil)
NSApp.keyWindow!.center()
}
Spacer()
}
}
}
}
ContentView.swift
struct ContentView: View {
//注册依赖,以接收传递值
@EnvironmentObject var factory:MyFactory
//这里就没什么讲的了,主要的布局数据
var body: some View {
//读取传值参数
Text("Hello, My Name:"+factory.UserName)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
到这里 我们整个项目的目标就已经基本实现。
另记 SwifUI App 模式
在SwifUI App 项目类型中 ,还有两种打开窗口的方式,
我这里为了区别 项目名为 MyTest
首先 如果我们选择 SwifUI App 创建项目,那么 目录中 就没有 AppDelegate.swift 文件 ,并多了一个 MyTestApp.swift 文件,这个时候我们的程序入口就从该处启动了。
同样 我们需要一个委托类来处理我们推出程序的逻辑。
所以我们修改代码如下。
MyTestApp.swift
import SwiftUI
class AppDelegate: NSObject, NSWindowDelegate, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
@main
struct MyTestApp: App {
//注入使用委托的类
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
LoginView().frame(width: 400, height: /*@START_MENU_TOKEN@*/300/*@END_MENU_TOKEN@*/, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
}
WindowGroup("MainView"){
ContentView().frame(width: 800, height: 500, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
.handlesExternalEvents(preferring: Set(arrayLiteral: "mainview"), allowing: Set(arrayLiteral: "*")) // 这里的用意是防止每次都创建新的窗口,意思是查找窗口存在就直接激活而不重新创建新的窗口
}
.handlesExternalEvents(matching: Set(arrayLiteral: "mainview"))
//这里是添加一个窗口,确保有一个窗口存在就不会退出程序
}
}
LoginView.swift
struct LoginView: View {
@Environment(\.openURL) var openURL
var body: some View {
GeometryReader { proxy in
VStack{
Spacer()
Text("Hello, Login View!")
Spacer().frame(width: proxy.size.width, height: 50, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
Button("打开主窗口"){
if let url = URL(string: "mytest://mainview") {
openURL(url)
}
}
Spacer()
}
}
}
}
到这里 我们还需要设置一下我们的应用名称,否则我们没有相应的应用路径打开主窗口。
我们先选择我们的项目
设置我们的应用名称,这样我们就可以通过 url mytest://mainview 打开我们的页面。
不过在这里要关闭登录页面 就比较麻烦一些,使用 NSApp.keyWindow 的方式 不能配合 openURL 进行关闭页面,可能期间有一个时间差。
需要先获得 当前视图的窗口 ,修改 LoginView.swift 代码如下.
import SwiftUI
class WindowObserver: ObservableObject {
weak var window: NSWindow?
}
struct LoginView: View {
@Environment(\.openURL) var openURL
@StateObject var windowObserver: WindowObserver = WindowObserver()
var body: some View {
GeometryReader { proxy in
VStack{
Spacer()
Text("Hello, Login View!")
Spacer().frame(width: proxy.size.width, height: 50, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
Button("打开主窗口"){
if let url = URL(string: "mytest://mainview") {
windowObserver.window = NSApp.keyWindow
openURL(url)
//为啥需要延时关闭,是因为openURL 在打开窗口的时候需要执行一些逻辑,而如果提前把这个窗口关闭 就会执行 applicationShouldTerminateAfterLastWindowClosed 导致程序退出,无法打开主窗口,所以需要等到两个窗口都存在的时候才关闭登录窗口
waitToClose()
}
}
Spacer()
}
}
}
func waitToClose(){
//延时等待窗口打开成功后关闭登录窗口
DispatchQueue.main.asyncAfter(deadline: .now()+0.01) {
if(NSApp.windows.count>1){
windowObserver.window?.close();
NSApp.keyWindow?.center()
return
}else{
waitToClose()
}
}
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView()
}
}
以上两种模式,我个人建议使用第一种 AppDelegate.swift 模式 可操作性比较大。