肇鑫的技术博客

业精于勤,荒于嬉

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

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

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

SwiftUI, Core Data and CloudKit Part 2

According to Apple, adding CloudKit Core Data, was as easy as following three steps.

  1. Enable iCloud Capacity in Xcode and check CloudKit.
  2. Add a default iCloud container.
  3. Replacing NSPersistentContainer with NSPersistentCloudKitContainer.

Then everything will work.

Using Core Data With CloudKit

It is not true. I did that and that won't work. I even checked the Core Data model with "Used with CloudKit", and there was still an error and CloudKit schema couldn't be created as there was no store for CloudKit.

So I had to create a new project with Core Data and CloudKit enabled at the very beginning.

If you want to know how to convert a Core Data project with CloudKit. See this, Getting Started with Core Data and CloudKit

Create a new project

Create a new project named "Todo List 2" and enable Core Data and CloudKit.

Project Code

  1. Add iCloud capability and check CloudKit.
  2. Then add a default container "iCloud.(You app bundle id)"

iCloud Capability

This is enough for a macOS app. If yours is an iOS app, you must add Background Modes capability and check "Remote notifications".

Be careful when you testing your app. The development and release app used different databases in CloudKit. So if you test your app, you must use the development version.

References