肇鑫的技术博客

业精于勤,荒于嬉

Sidebar, Hybrid Programming SwiftUI with AppKit

You could get a NavigationView easily in SwiftUI. However, in macOS, there is an issue. If you collapse the sidebar and release your mouse, you cannot expand it again. What even worse, the state is also be remembered by the system and you could not get the sidebar even if you restart the app.

Here is a sample.

import SwiftUI

struct SidebarSwiftUIView: View {
    @State private var shouldShowPurple = false
    
    var body: some View {
        NavigationView {
            List {
                NavigationLink(
                    destination: DestinationPageView(color: .purple),
                    isActive: $shouldShowPurple
                ) {
                    Text("Purple Page")
                }
                NavigationLink(
                    destination: DestinationPageView(color: .pink)
                ) {
                    Text("Pink Page")
                }
                NavigationLink(
                    destination: DestinationPageView(color: .orange)
                ) {
                    Text("Orange Page")
                }
            }
            .frame(minWidth: 180, idealWidth: 200, maxWidth: 250, alignment: .leading)
            .navigationTitle("Colors")
            Text("Select a color page from the links.")
        }
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) {
                shouldShowPurple = true
            }
        }
    }
}

struct SidebarSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        SidebarSwiftUIView()
    }
}

struct DestinationPageView: View {
    var color: Color
    var body: some View {
        Text("Destination Page")
            .font(.title)
            .foregroundColor(color)
            .frame(minWidth: 300, idealWidth: 450, maxWidth:.infinity)
    }
}

It runs like this.

macOS_sidebar
The issue:

macOS_collapse_sidebar

Hybrid Programming With AppKit

Hybrid SwiftUI with AppKit is easy. It takes 2 steps.

NSHostingViewController

  1. Create a NSHostingViewController in Storyboard.
  2. Create HostingViewController.swift and init it with the SwiftUIView.
import Cocoa
import SwiftUI

class HostingController: NSHostingController<SidebarSwiftUIView> {
    @objc required dynamic init?(coder: NSCoder) {
        super.init(coder: coder, rootView: SidebarSwiftUIView())
    }
}

Sidebar

Unless in iOS/iPadOS, there is no sidebar for navigation bar in macOS. So you have to do it yourself. Just simply add a toolbar in WindowController.

add_toolbar_in_window_controller

Make sure to set the tool item as Navigational in Position.

leading_the_sidebar

Toggle the Sidebar

SwiftUI will create a NSSplitViewController automatically. So we use respond chain to collapse/expand the sidebar.

import Cocoa

@main
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Insert code here to initialize your application
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }

    @IBAction private func showSideBar(sender:Any?) {
        NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
    }
}

Bind the action to first responder.

action_binding

Final

Now the app will look like this.

final_sidebar_window

References

Collapse sidebar in SwiftUI (Xcode 12)

NSOutlineView Briefing

The new version of Xliff Tool needs to use NSOutlineView. However, the official document by Apple is of flaw and the answers on SO are too old. I have to pay nearly a whole afternoon to find out what the right approach is. Here is the briefing.

Briefing

In fact, Apple does provide a sample project of NSOutlineView. However, that sample is based on cocoa bindings. So if you want to know cocoa bindings. Here is the way.

Navigating Hierarchical Data Using Outline and Split Views

Otherwise, welcome to go on.

On Apple's Docs, it says:

If you are using conventional data sources for content you must implement the basic methods that provide the outline view with data: outlineView(_:child:ofItem:), outlineView(_:isItemExpandable:), outlineView(_:numberOfChildrenOfItem:), and outlineView(_:objectValueFor:byItem:). Applications that acquire their data using Cocoa bindings do not need to implement these methods.

However, if you create a new project with the Xcode 12.4 with Storyboard, the sample you find from SO won't work.

That is because the document is outdated.

Steps

  1. Create a new project, using Swift and Storyboard.
  2. Add a Source List to the view controller.

The result will be look like this.

outline_view_in_view_controller

Right click the storyboard and choose "Outline View". You will find the Outline View is View based.

outline_view_is_view_based_by_default

That is the reason why Apple's document doesn't work. You need to change View based to Cell based to make it work.

codes

//
//  ViewController.swift
//  OutlineView Sample
//
//  Created by zhaoxin on 2021/3/20.
//

import Cocoa

class ViewController: NSViewController {
    @IBOutlet weak var outlineView: NSOutlineView!
    
    private let cats:[String] = {
        (1...3).map {
            return String(format: "cat%02d", $0)
        }
    }()
    
    private let dogs:[String] = {
        (1...4).map {
            return String(format: "dog%02d", $0)
        }
    }()
    
    lazy private var animals = [cats, dogs]
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }


}

extension ViewController:NSOutlineViewDataSource {
    // 2
    func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
        if item == nil {
            return animals[index]
        }
        
        if let list = item as? [String] {
            return list[index]
        }
        
        return item as! String
    }
    
    // 3
    func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
        if item is [String] {
            return true
        }
        
        return false
    }
    
    // 1
    func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
        if item == nil {
            return animals.count
        }
        
        if let list = item as? [String] {
            return list.count
        }
        
        return 0
    }
    
    // 4
    // This function only works with Cell based Outline Views.
//    func outlineView(_ outlineView: NSOutlineView, objectValueFor tableColumn: NSTableColumn?, byItem item: Any?) -> Any? {
//
//        if item is [String] {
//            let row = outlineView.row(forItem: item)
//
//            if row == 0 {
//                return "cats"
//            }
//
//            return "dogs"
//        }
//
//        return item as! String
//    }
}

extension ViewController:NSOutlineViewDelegate {
    // 4
    // This function only works with View based Outline Views.
    func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
        let view:NSTableCellView!

        if item is [String] {
            view = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier("HeaderCell"), owner: self) as? NSTableCellView

            let row = outlineView.row(forItem: item)

            if row == 0 {
                view.textField?.stringValue = "cats"
            } else {
                view.textField?.stringValue = "dogs"
            }
        } else {
            view = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier("DataCell"), owner: self) as? NSTableCellView
            view.textField?.stringValue = item as! String
        }

        return view
    }
}

Look at the order number of the function, that is the order the system calls them.

  1. At first, the system asks how many children of root.
  2. Then the system asks you to provide the object that represent the child.
  3. Then the system asks you if the child is expandable.
  4. Then the system asks you to provide the view/cell of the child.
  5. When you click a child, the system goes to step 1, but this time, the child is the new root.

You should aware that there are two kinds of items. One is Any?, the other is Any.
They are different.
The Any? type is the parent node of the child. For first level cells, item is nil. nil means it is the root.
The Any type is the child.

References

My Sample

Apple's Document Outline View

NSOutlineView example on SO

Mac OSX 开发基础控件学习之 NSOutlineView

NSTextView Best Practice

Initiate Font

Unlike UITextView, you cannot assign font in NSTextView with empty string in Interface Builder. There are many ways to set the font, like to set the font of NSTextView.textStorage.font or set the attributedString of NSTextView.textStorage or just set the attributes. However, those methods are all imperfect.

Working With Input Method

The answers above are all not working with input method. The cursor indicator will still be the unset font's style and the font only applied when you have finished inputing.

The only correct way is to set the typingAttributes property of NSTextView.

inputTextView.typingAttributes = [
    .font : NSFont.userFont(ofSize: 17.0) ?? NSFont.systemFont(ofSize: 17.0),
    .foregroundColor : NSColor.labelColor
]

Don't forget to set the foregroundColor as well, or you textview won't work with light/dark mode switching.

Undo

It is easy to turn on undo in Interface Builder. However, you should know that every time you set string or attributedString property, the undo operations are clear.

You may notice that if the textview is in a modal window, its view controller is presented by presentAsModalWindow(_:), the undo operations will not work as expected.

NSTextDelegate And NSTextStorageDelegate

Both NSTextDelegate and NSTextStorageDelegate work with NSTextView, however there are slightly differences between those functions.

// NSTextDelegate
func textDidBeginEditing(_ notification: Notification)
// only runs once when a user first starts editing.

func textDidChange(_ notification: Notification) 
// only runs when a user edits in the textview.

// NSTextStorageDelegate
textStorage(_ textStorage: NSTextStorage, willProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int)
// and
func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int)
// runs every time a user edits, also runs when the developer sets string property in code.

Mixing Fonts Causing the Whole Line Moves

Jun-20-2020 19-50-37

This issue happens when using texts with emoji or texts in different languages. Both TextEdit.app and Pages.app have this issue. Changing text size over 22 may solve this issue. There is no other method to solve this issue yet.

Others

Weight and Line Height of Font between macOS and iOS