肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

接力(Handoff)在SwiftUI下的实现

SwiftUI

传统上,接力在UIKit和AppKit上的实现有两种。一种是基于UIDocument/NSDocument的,一种是基于NSResponder/UIResponder的。苹果有文档分别说明了两种情况下,要如何实现接力功能。

Handoff Programming Guide
Implementing Handoff in Your App

但是,针对SwiftUI,苹果并没有专门进行说明要如何实现接力。苹果网站上的确有一篇文档,同时用到SwiftUI和NSUserActivity,但是那篇文档偏重的是如何恢复应用的状态,并不是如何使用接力。

Increasing App Usage with Suggestions Based on User Activities

本文的结果,是我通过试验摸索出来的。适用于苹果的iOS 16和macOS 13。鉴于目前版本的苹果实现过于简陋。大概率苹果今后会做出较大的修改。这点希望阅读者注意。

接力的传统实现方法

类似接力这种预制的功能,最复杂的地方是,苹果本身设定了它预想的功能方式,这就像是填空题,而不是简答题,你需要在留给你的空位填入自己的内容,而不能天马行空,自己设计一套。

使得接力更为复杂的,是苹果还设置了几种完全不同的方式。

步骤1,设置Info.plist。

如果你的应用是文档应用,即基于UIDocumentNSDocument或者DocumentGroup,那么应该把接力的类型写在文档类型里。

其它方式,则应该把接力的类型写在顶层。

需要注意两点:

  1. 注册在Info.plist的目的是标记应用支持“接收”何种类型。即,发送本身是不需要注册的。
  2. 在Xcode 14中,默认是不存在Info.plist这个文件的,这个由Xcode生成项目时根据项目目标的Info选项卡自动生成。并且,在Info选项卡添加某些内容的时候会自动消失部分内容。
    不过,在添加文件类型的时候我们会发现,系统会自动添加一个“项目目标-Info.plist”的补充文件。这就是我们可以用来编辑的Info.plist文件,在Info选项卡中会自动消失的属性,我们都可以添加到这里。

步骤2,创建NSUserActivity。

这一步比较简单,只需要创建NSUserActivity的实例,设置属性,然后设置为becomeCurrent(),然后将实例赋值给相应的NSResponder

传递的数据只能是文档中指定的那些类型。

步骤3,修改NSUserActivity

复杂的来了,根据不同的场景,苹果共设置了三处,可以后续修改NSUserActivity的方法。

NSUserActivityDelegate

我们可以NSUserActivityDelegateuserActivityWillSave(NSUserActivity)方法来修改NSUserActivity的内容。

NSResponder

我们也可以通过NSResponderupdateUserActivityState(_:)方法来修改。

NSApplicationDelegate

最后,我们可以通过NSApplicationDelegateapplication(_:didUpdate:)方法完成对于NSUserActivity的最终修改。

感兴趣的读者可以同时使用三者,以了解者三者的调用顺序。

步骤4,接收NSUserActivity

接收是在NSApplicationDelegateapplication(_:continue:restorationHandler:)方法。此外,NSApplicationDelegate还有其它的方法用于快速响应接力已经处理出错的问题。

另外,NSUserActivityDelegate还包含一个userActivity(_:didReceive:outputStream:)方法,可以处理数据在设备之间的双向传送。

接力在SwiftUI下如何实现

根据苹果的文档,接力在SwiftUI创建应用使用userActivity(_:element:_:)userActivity(_:isActive:_:)来创建,然后使用onContinueUserActivity(_:perform:)来接收。

遇到的困境

不过在实际使用中,经常遇到SwiftUI创建NSUserActivity不及时,以及接收不到的问题。

在经过我的反复试验之后,我得出了如下结论。

创建NSUserActivity

要创建NSUserActivity,必须满足以下条件。

  1. NSUserActivity实例必须持续存在。因此,不能使用临时变量创建,必须将其赋值给视图的属性。
  2. 必须有对应的类型。
  3. 设置需要的内容。
  4. 必须使用becomeCurrent()

你也可以尝试SwiftUI原生提供的方式,我遇到的问题是应用启动后能成功,但是后续更新容易出现问题。
如果使用原生方式,那么就不需要becomeCurrent()。因为系统会自动根据预设条件自动判断是否becomeCurrent()

获取NSUserActivity

要获取NSUserActivity,则需要满足以下条件:

  1. 在Info.plist中,注册要接收的NSUserActivity的类型。
  2. 这里,最麻烦的点来了。根据我的测试,如果想要接收NSUserActivity,在不同的系统中,机制居然是不一样的。
    1. 如果是macOS,那么请使用NSApplicationDelegate来接收NSUserActivity,SwiftUI自带的onContinueUserActivity(_:perform:)在macOS中没有正常调用。
    2. 如果是iOS,刚好反过来。必须使用SwiftUI自带的onContinueUserActivity(_:perform:)来接收NSUserActivityUIApplicationDelegate对应的方法反而会毫无响应。

结合以上知识点,一个能够同时支持macOS和iOS的接力的SwiftUI应用是这样的:

import SwiftUI

#if os(macOS)
class AppDelegate:NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
        if userActivity.activityType == "com.parussoft.Handoff-Test.editing" {
            NotificationCenter.default.post(name: .userActivityReceived, object: nil, userInfo: userActivity.userInfo)
            return true
        }
        
        return false
    }
}
#endif

@main
struct Handoff_TestApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
    #endif
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

extension Notification.Name {
    static let userActivityReceived = Notification.Name("userActivityReceived")
}

struct ContentView: View {
    @State private var counter = 0
    @State private var userActivity:NSUserActivity?
    
    private let userActivityReceivedPublisher = NotificationCenter.default.publisher(for: .userActivityReceived)
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world! \(counter)")
            
            Button("Add") {
                counter += 1
            }
        }
        .padding()
        .onContinueUserActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            if let userInfo = userActivity.userInfo as? [String:Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
        .onChange(of: counter) { newValue in
            let userActivity = NSUserActivity(activityType: "com.parussoft.Handoff-Test.editing")
            userActivity.title = NSLocalizedString("Editing", comment: "")
            userActivity.addUserInfoEntries(from: ["counter" : counter])
            userActivity.becomeCurrent()

            self.userActivity = userActivity
        }
        .onReceive(userActivityReceivedPublisher) { notification in
            if let userInfo = notification.userInfo as? [String : Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
    }
}

或者

import SwiftUI

#if os(macOS)
class AppDelegate:NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
        if userActivity.activityType == "com.parussoft.Handoff-Test.editing" {
            NotificationCenter.default.post(name: .userActivityReceived, object: nil, userInfo: userActivity.userInfo)
            return true
        }
        
        return false
    }
}
#endif

@main
struct Handoff_TestApp: App {
    #if os(macOS)
    @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
    #endif
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

extension Notification.Name {
    static let userActivityReceived = Notification.Name("userActivityReceived")
}

struct ContentView: View {
    @State private var counter = 0
    
    private let userActivityReceivedPublisher = NotificationCenter.default.publisher(for: .userActivityReceived)
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world! \(counter)")
            
            Button("Add") {
                counter += 1
            }
        }
        .padding()
        .userActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            userActivity.title = NSLocalizedString("Editing", comment: "")
            userActivity.addUserInfoEntries(from: ["counter" : counter])
        }
        .onContinueUserActivity("com.parussoft.Handoff-Test.editing") { userActivity in
            if let userInfo = userActivity.userInfo as? [String:Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
        .onReceive(userActivityReceivedPublisher) { notification in
            if let userInfo = notification.userInfo as? [String : Int], let counter = userInfo["counter"] {
                self.counter = counter
            }
        }
    }
}

我更推荐第一种方式。

最后,附上Info.plist。Xcode 14中,它的文件名为“Handoff-Test-Info.plist”。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDocumentTypes</key>
	<array/>
	<key>NSUserActivityTypes</key>
	<array>
		<string>com.parussoft.Handoff-Test.editing</string>
	</array>
</dict>
</plist>

补充说明

如果还是没有接力的提示,在项目中,打开iCloud的key-value权限。

完美解决Xcode在预览iOS应用SwiftUI View时,占用内存过多的问题

SwiftUI

我的Mac配置是M1,16GB内存,512GB固态硬盘。原以为足够使用了,顶多是偶尔内存不足,用磁盘虚拟一些就足够了。可没成想,这两天调试iOS应用,动不动内存压力就黄了。

虽然我没有感知到系统变慢,但是看着内存压力变黄就是不爽。于是想追究一下具体的原因。结果让我大吃一惊!

为什么Xcode在iOS应用的预览时,会占用额外的内存?

所谓占用额外的内存,是相比较同样的代码,macOS版本应用预览时占用的内存。

macOS应用在预览时,因为Xcode本身就是在macOS运行的,所以无需模拟系统,只需要运行程序就可以了。所以相比于模拟iOS应用,少了模拟了iOS系统,节省了大量的内存。

根据上面的原因,我首先想到的是可以使用模拟较低版本的iOS系统。因为众所周知,版本越新的系统,可能消耗的资源就越大。目前Xcode自带的模拟器是iOS 16.4,由于我的新应用最低支持到iOS 16,所以我选择额外下载iOS 16.0的模拟器。

经过测试我发现,使用iOS 16.0模拟器,相对于iOS 16.4的模拟器,可以节省约5%的内存。16GB*5%=0.8GB。

此外,我之前默认都是使用iPhone SE 3进行模拟,因为这个也是我目前在使用的手机。不过iPhone SE 3本身是4GB内存,所以模拟也需要4GB内存,而iOS 16支持的手机中,内存最小的是iPhone 8,于是我将iPhone SE 3换成iPhone 8,结果发现内存的占用又降低了一些。

将模拟器从iPhone SE 3换成iPhone 8之后,可以再多节省约2%的内存。两次节省合计16GB*(2%+5%)=1.12GB,节省内存超过1GB。

大功告成了吗?

原来只需要换掉模拟器,就可以节省超过1GB的内存。事情真的只有这么简单吗?并没有!

平常我们在调试应用时,都是用Xcode写好界面和功能,在预览里看着差不多了,然后上真机上跑的。

xcode_preview

如图,我是在图中2所示的那样,通过制定模拟器型号来进行模拟的。但是我发现,最终占用的内存大小,要比我预期的大。

进一步分析,我发现,原来图中1部分的内容,会对于内存占用产生巨大的影响。

Xcode在预览时存在bug,导致模拟器内存占用翻倍

我们先做一番测试,然后你就知道问题出在哪里了。

  1. 首先我们维持2不变,然后将1的内容也改成iPhone 8。然后退出Xcode,重新开。(退出Xcode并重新开的目的是恢复真机模拟所占用的缓存)
  2. 点击预览,当预览呈现时,我们查看内存占用。此时内存的占用符合我们的预期。
  3. 将1的内容改成真机iPhone SE 3。因为我们模拟是指定的模拟器,此时3的内容应该不变。
    1. 但实际上,你会发现3位置的标签在转圈,这意味着模拟器在刷新。而这本来是不应该发生的。
    2. 转圈结束后,我们再次查看内存,发现内存占用大大增加。

这说明,Xcode出了bug,在我们指定模拟器的情况下,1的内容是否改变,应该不影响2、3的模拟器的,但实际上,虽然3表面上还是2的模拟器的最终结果,但是Xcode还额外开了1的模拟器,这导致模拟器打开的数量翻倍,内存占用大大增加。

验证上面的提到的bug

我们可以通过如下的步骤验证上面提到的bug:

  1. 将2中指定模拟器的代码注释掉。这样3中的模拟器,就会伴随1的变化而变化。
  2. 关闭Xcode,并重开,然后选择预览。
  3. 将1中的iPhone SE 3,换成iPhone 8,然后重新回到步骤2。

我们发现,无论是从1到2,还是从3到2,最终占用的内存都大大低于上面的情况。换句话说,也就是当2不设置时,因为1与3是相同的,每次都会只打开1个模拟器,因此内存占用的区别仅仅是iOS版本和iPhone版本区别,而不涉及到重复打开模拟器的问题。

不完美的解决方案

那么,我们为了保证同时还要调节真机的需要,是不是就只能妥协。不指定模拟器的型号,而选择与真机一致这种次优的解决方案了呢?

完美解决方案

并不是!我最终找到了完美的解决方案。

我们知道,Xcode之所以能调用多个模拟器,是因为系统中存在多个模拟器。那么如果我们通过手段,删除掉多余的模拟器,那是不是Xcode就不会调用了呢?抱着试试看的想法,我删除了所有模拟器,仅保留iPhone 8 iOS 16.0这一个。然后再次进行测试。

结果符合预期!因为系统中不再包含iPhone SE 3的模拟器,所以即便1切换为iPhone SE 3,3中的标签也不会有任何变化。因为iPhone 8是唯一的模拟器,所以任何时候,都只会用iPhone 8进行模拟。2中的指定模拟器也是没有必要的了。

至此,Xcode在模拟iOS应用的SwiftUI View预览时,占用内存过高的问题完美解决了。

实际使用中,我的内存剩余,从不足40%,恢复到接近70%。压力也不再变黄了。
测试时使用内存压力的手段是在终端中输入memory_pressure命令,查看最后一行的剩余内存。

其它节省内存的技巧

我发现Xcode有一个问题,正常退出Xcode之后,有时SourceKitService并没有伴随退出,而是继续留驻在内存中。它会占据1GB左右的空间。解决办法是在任务管理中,手动终止这个进程。

为了解决这个问题,我手动写了一个工具。会在在Xcode关闭之后,查看是否有SourceKitService遗留,有则自动结束,减少内存占用。如果你也需要,可以尝试使用。

SwiftUI view recreated may cause bugs what are hard to debug

SwiftUI

Today, I encountered an issue that VideoPlayer turned to blank when iPhone screen was rotated.

The VideoPlayer was in a modal view that created with fullScreenCover. The sample code was like this:

import SwiftUI
import AVKit

struct TopView: View {
    private let player = AVQueuePlayer(playerItem: nil)
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Using Publisher for UIDevice.orientationDidChangeNotification

At first, I thought I should recreate the player when the screen was rotated.

import SwiftUI
import AVKit

struct TopView: View {
    @State private var playerItem:AVPlayerItem?
    
    private let player = AVQueuePlayer(playerItem: nil)
    private let publisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
    
    var body: some View {
        if playerItem != nil {
            VideoPlayer(player: player)
                .onReceive(publisher, perform: { _ in
                    self.player.pause()
                    self.playerItem = nil
                })
        } else {
            ProgressView()
                .onAppear(perform: setPlayer)
        }
    }
    
    private func setPlayer() {
        if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
            self.playerItem = AVPlayerItem(url: videoURL)
            player.replaceCurrentItem(with: playerItem)
            
            Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                if player.status == .readyToPlay {
                    player.play()
                    
                    if player.timeControlStatus == .playing {
                        timer.invalidate()
                    }
                }
            }
        }
    }
}

However, the new code didn't work. I added more debug point and finally found that TopView was recreated when the screen was rotated. Since the view was recreated, the player in onReceive was newly created, it couldn't stop the playing of the previously played item.

The issue was because, unlike other structs and objects, which could automatically released when container view was released. AVPlayer hold its owned reference when playing. This behavior caused the SwiftUI view was not released properly.

Use Binding from parent view

The solution was easy. Since I wanted the player to be constant, I should set it up in the parent view.

import SwiftUI
import AVKit

struct TopView: View {
    @Binding var player:AVQueuePlayer
    
    var body: some View {
        VideoPlayer(player: player)
            .onAppear {
                if let videoURL = Bundle.main.url(forResource: "sample", withExtension: "mov") {
                    let playerItem = AVPlayerItem(url: videoURL)
                    player.replaceCurrentItem(with: playerItem)
                    
                    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
                        if player.status == .readyToPlay {
                            player.play()
                            
                            if player.timeControlStatus == .playing {
                                timer.invalidate()
                            }
                        }
                    }
                }
            }
    }
}

Now everything worked fine.

Final Thoughts

Some objects hold their own references as strong. Those objects may keep view from release and cause bugs. We should using Binding to create those objects in a higher view which is not recreated. Then the bugs are fixed.

VideoPlayer Turns Black Screen When Replace a Player Item

SwiftUI

I was working on a project that shows videos from the Photo Library. However, each time I replaced the current video, the VideoPlayer turned black screen for a while.

I had no idea why this happened so I looked for what Photos app did for videos. It turned out that Photos showed a preview of the video first and then automatically played the video when the video data was loaded.

I did the same. First I fetched the preview image. When the preview was downloaded, I fetched the video.

The issue was the same. After some investigation, I found that the method of fetching video was called twice, which was not expected. Then I found that was because fetching preview may be called multiple times. Photo Library may provide a low quality image first then provide the full quality image. This issue was solved by read the info dictionary of the fetching preview result. But the black screen issue was still there.

Maybe the AVPlayer or PlayerItem was not ready?

if let video {
    player.replaceCurrentItem(with: video)
    
    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
        if player.status == .readyToPlay {
            playerItem = video
            timer.invalidate()
        }
    }
}

The result was the same.

Maybe there was still some time between the player was ready and the player was really playing.

if let video {
    player.replaceCurrentItem(with: video)
    
    Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
        if player.status == .readyToPlay {
            player.play()
            
            if player.timeControlStatus == .playing {
                playerItem = video
                timer.invalidate()
            }
        }
    }
}

The issue solved.

Conclusion

  1. Do not create another AVPlayer each time. Just create an empty player and replace the replaceCurrentItem.
  2. There was time gap between player ready and player real playing. So you need to play the player first when player was ready and then updated the UI after the player was really playing.
  3. I was using a timer instead of a notification as the former was more simple to use.

SwiftUI with Preview

SwiftUI

Some times, only the preview of SwiftUI view crashes. Both the simulator and the real device don't crash. So we need to do extra works or don't run some methods if the target is a preview.

We can't identify preview from debug, so we add a isPreview variable under debug, and that won't leave the variable in release version.

struct SomeView : View {
    #if DEBUG
    var isPreview = false
    #endif
    var body: some View {
        Text("Hello World!")
            .onAppear {
                #if DEBUG
                if isPreview {
                    return
                }
                #endif
                
                foo()
            }
    }
    
    private func foo() {
        ...
    }
}

struct SomeView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            SomeView(isPreview: true)
                .previewDevice(PreviewDevice(rawValue: "iPhone SE 2"))
                .previewDisplayName("iPhone SE 2")
            
            SomeView(isPreview: true)
                .previewDevice(PreviewDevice(rawValue: "iPhone 14 Pro"))
                .previewDisplayName("iPhone 14 Pro")
        }
    }
}

An Interesting Issue of @State Variable Not Set after Set

SwiftUI

In my recent project, a @State variable was not set after set.

        self.project?.currentTransUnit?.isVerified = true
        self.project?.update() // debug point. 
        // po "self.project?.currentTransUnit?.isVerified" prints false.

So I created another project to narrow the issue.

//
//  Model_SampleApp.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import SwiftUI

@main
struct Model_SampleApp: App {
    @State private var foo:Foo? = Foo(bar: Bar(item: Item(name: "Johnny")))
    
    var body: some Scene {
        WindowGroup {
            ContentView(foo: $foo)
        }
    }
}
//
//  ContentView.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import SwiftUI

struct ContentView: View {
    @Binding var foo:Foo?
    
    var body: some View {
        VStack {
            Text(foo?.bar.item.name ?? "nil")
            
            HStack {
                TextField("name", text: Binding(get: {
                    foo?.bar.item.name ?? "nil"
                }, set: { newValue in
                    foo?.bar.item.name = newValue
                }))
            }
        }
        .padding()
        .onChange(of: foo) { newValue in
            print(newValue)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(foo: .constant(nil))
    }
}
//
//  Model.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import Foundation

struct Foo:Equatable {
    var bar:Bar
}

struct Bar:Equatable {
    var item:Item
}

struct Item:Equatable {
    var name:String
    let id = UUID()
}

All things went fine. I noticed that onChange required Equatable. So I did a test.

func onChange<V>(of value: V, perform action: @escaping (V) -> Void) -> some View where V : Equatable
struct Item:Equatable {
    var name:String
    let id = UUID()
    
    static func == (lhs: Item, rhs: Item) -> Bool {
        return lhs.id == rhs.id
    }
}

Now the issue happened again. So I guess for optimize performance. When set to a @State variable, SwiftUI compared the previous and current value by Equatable. If they were the same, SwiftUI thought they were identical and didn't replace that current with the previous value. So if we implemented the Equatable's method with part implementation on purpose, the issue appeared on the values which were not mentioned in Equatable's method.

Understand TabView in SwiftUI and Adopt a TabView with indicators into Window’s Toolbar

SwiftUI

A Simplest TabView Sample

ContentView

struct ContentView: View {
    var body: some View {
        TabView {
            ForEach(1..<4) { id in
                TabItemView(id: id)
            }
        }
    }
}

TabItemView

struct TabItemView: View {
    @State var id:Int
    
    var body: some View {
        Text("Tab \(id)")
            .tabItem {
                Text(String(id))
            }
    }
}

first_tabview_sample

Life Cycle of Tab Item View

TabItemView

struct TabItemView: View {
    @State var id:Int
    
    var body: some View {
        Text("Tab \(id)")
            .tabItem {
                Text(String(id))
            }
            .onAppear(perform: {
                print("I am tab \(id)")
            })
            .onDisappear {
                print("Tab \(id) am quit!")
            }
    }
}

Runs.

I am tab 1
Tab 1 am quit!
I am tab 2
Tab 2 am quit!
I am tab 3
Tab 3 am quit!
I am tab 1

Is the tab item view just hidden or actually quit when you change a tab?

TabItemView

struct TabItemView: View {
    @State var id:Int
    @State private var title = ""
    
    var body: some View {
        HStack {
            Text("Tab \(id)")
                .onAppear(perform: {
                    print("I am tab \(id)")
                })
                .onDisappear {
                    print("Tab \(id) am quit!")
            }
            
            TextField("Title", text: $title)
        }
        .tabItem {
            Text(String(id))
        }
        .padding()
    }
}

Input "Test" in tab 1, and change to other tabs, then change back to tab 1. You will see the "Test" is still there. That means the tab item view was just hidden, not quit.

third_tabview_sample

Change Tab Name

TabItemView

struct TabItemView: View {
    @State var id:Int
    @State private var title = ""
    
    var body: some View {
        HStack {
            Text("Tab \(id)")
                .onAppear(perform: {
                    print("I am tab \(id)")
                })
                .onDisappear {
                    print("Tab \(id) am quit!")
            }
            
            TextField("Title", text: $title)
        }
        .tabItem {
            Text(getTabName())
        }
        .padding()
    }
    
    private func getTabName() -> String {
        if title.isEmpty {
            return String(id)
        }
        
        return title
    }
}

change_tab_name

Put Indicators in Window's Title Bar

ContentView

@main
struct TabView_SampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .windowToolbarStyle(.unifiedCompact(showsTitle: false))
    }
}

tab_in_window_title

The indicators worked. But the tab title no longer worked.

Fix Tab Title

TabItemView

struct TabItemView: View {
    @Binding var tabContent:TabContent
    
    var body: some View {
        HStack {
            Text("Tab \(tabContent.id)")
            
            TextField("Title", text: $tabContent.title)
                .frame(minWidth: 300)
        }
        .tabItem {
            Text(getTabName())
        }
        .padding()
    }
    
    private func getTabName() -> String {
        if tabContent.title.isEmpty {
            return String(tabContent.id)
        }
        
        return tabContent.title
    }
}

struct TabContent:Identifiable, Equatable, Hashable {
    var id = 0
    var title = ""
}

ContentView

struct ContentView: View {
    @State private var tabContents:[TabContent] = {
        (1..<4).map { TabContent(id: $0) }
    }()
    
    @State private var currentTabContent = TabContent(id: 1)
    
    var body: some View {
        ForEach($tabContents) { $tabContent in
            if currentTabContent.id == tabContent.id {
                TabItemView(tabContent: $tabContent)
            }
        }
        .toolbar {
            HStack {
                Picker(String(currentTabContent.title), selection: $currentTabContent) {
                    ForEach(tabContents) { tabContent in
                        Text(tabContent.title.isEmpty ? String(tabContent.id) : tabContent.title).tag(tabContent)
                    }
                }
                .pickerStyle(.segmented)
                .onChange(of: currentTabContent) { newValue in
                    if let index = tabContents.map({$0.id}).firstIndex(of: newValue.id) {
                        tabContents[index] = newValue
                    }
                }
                
                Spacer()
            }
        }
        .frame(width: 400, height: 50)
    }
}

worked_tab_in_window_title

A Little Guide of NSViewControllerRepresentable

SwiftUI

What is NSViewControllerRepresentable

In SwiftUI, there are no View Controllers, only Views. So what should we do if want to use something related to View Controllers? Here comes NSViewControllerRepresentable/UIViewControllerRepresentable. Basically, we can use instance of View Controller Representable as View, contains a view controller.

Since view controllers have their own view, ViewControllerRepresentable has not view body.

protocol NSViewControllerRepresentable : View where Self.Body == Never

A Simple Sample of NSViewControllerRepresentable

  1. Create a new SwiftUI app.
  2. Add a new file, choose Cocoa Class, name it as TextViewController.swift.
    Choose create xib file as well.
  3. Add a close button and label.

xib

  1. TextViewController.swift
//
//  TextViewController.swift
//  ViewControllerRepresentable Test
//
//  Created by zhaoxin on 2022/6/12.
//

import Cocoa

class TextViewController: NSViewController {
    @IBOutlet weak var label: NSTextField!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func closeButtonClicked(_ sender: Any) {
        self.dismiss(sender)
    }
}
  1. TextView
//
//  TextView.swift
//  ViewControllerRepresentable Test
//
//  Created by zhaoxin on 2022/6/12.
//

import AppKit
import SwiftUI

struct TextView:NSViewControllerRepresentable {
    func makeNSViewController(context: Context) -> TextViewController {
        let controller  = TextViewController()        
        return controller
    }    
    
    func updateNSViewController(_ nsViewController: TextViewController, context: Context) {

    }
}
  1. ContentView.swift
//
//  ContentView.swift
//  ViewControllerRepresentable Test
//
//  Created by zhaoxin on 2022/6/12.
//

import SwiftUI

struct ContentView: View {
    @State var title = ""
    @State var showTextView = false
    
    var body: some View {
        HStack {
            TextField("Title", text: $title)
        
            Button("Show Text View") {
                showTextView = true
            }
            .sheet(isPresented: $showTextView) {
                TextView()
            }
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

The app ran as below, but the close button didn't work.

first_sample

Coordinator

View Controllers are class, and View Representables are structs. We use Coordinator to pass changes to SwiftUI.

  1. TextView
//  ViewControllerRepresentable Test
//
//  Created by zhaoxin on 2022/6/12.
//

import AppKit
import SwiftUI

struct TextView:NSViewControllerRepresentable {
    @Environment(\.presentationMode) var presentationMode
    @Binding var title:String
    
    class Coordinator: NSObject, TextViewControllerDelegate {
        var parent: TextView

        init(_ parent: TextView) {
            self.parent = parent
        }
        
        func textView(_ tvc: TextViewController) {
            self.parent.presentationMode.wrappedValue.dismiss()
        }
    }
    
    func makeNSViewController(context: Context) -> TextViewController {
        let controller  = TextViewController()
        controller.delegate = context.coordinator
        
        return controller
    }
    
    func updateNSViewController(_ nsViewController: TextViewController, context: Context) {
        nsViewController.label.stringValue = title
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
}
  1. TextViewController
//
//  TextViewController.swift
//  ViewControllerRepresentable Test
//
//  Created by zhaoxin on 2022/6/12.
//

import Cocoa

class TextViewController: NSViewController {
    @IBOutlet weak var label: NSTextField!
    weak var delegate:TextViewControllerDelegate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func closeButtonClicked(_ sender: Any) {
        delegate?.textView(self)
    }
}

protocol TextViewControllerDelegate:AnyObject {
    func textView(_ textView:TextViewController)
}
  1. ContentView
//
//  ContentView.swift
//  ViewControllerRepresentable Test
//
//  Created by zhaoxin on 2022/6/12.
//

import SwiftUI

struct ContentView: View {
    @State var title = ""
    @State var showTextView = false
    
    var body: some View {
        HStack {
            TextField("Title", text: $title)
        
            Button("Show Text View") {
                showTextView = true
            }
            .sheet(isPresented: $showTextView) {
                TextView(title: $title)
            }
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}//
//  ContentView.swift
//  ViewControllerRepresentable Test
//
//  Created by zhaoxin on 2022/6/12.
//

import SwiftUI

struct ContentView: View {
    @State var title = ""
    @State var showTextView = false
    
    var body: some View {
        HStack {
            TextField("Title", text: $title)
        
            Button("Show Text View") {
                showTextView = true
            }
            .sheet(isPresented: $showTextView) {
                TextView(title: $title)
            }
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

App ran like this and the close button worked.

second_sample

Update Variables of NSViewController

You may aware that there was a func

func updateNSViewController(_ nsViewController: TextViewController, context: Context) {
    nsViewController.label.stringValue = title
}

We couldn't call nsViewController.label.stringValue = title in makeNSViewController, as in the make function the View Controller was created, but the xib was not loaded. We can think this update as viewDidLoad function in View Controller.

Resources