肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

Solved Issue: macOS USB Still Power On When System Is Set To Sleep

macOS

I encountered a nasty problem on my Mac. The keyboard backlight didn't turn off when the Mac was set to sleep. This issue happened before long times ago. At that time, I had no idea and had to reinstall the whole macOS. It would take hours. It was my last move. This time, I hope to find another way to solve this.

pmset -g

In terminal, using

pmset -g

This would list the power settings. And I showed that "sharingd" that prevent the system from sleep. That was a printed job that waiting for the printer. I had a wireless printer that was offline.

Opened the printer and print out the job and the issue fixed.

Final

The offline printing job stop the system from sleep. That was a service of system and not easy to find out.

Reference

Dealing with Filenames that contain "/" in macOS

macOS

My app crashed when I saved a file to disk. The reason was that the filename was "Poster 2/咕唧2.json". Although you can use "/" in a filename that in Finder. macOS uses "/" as the separator between directories and filenames. When the app ran, macOS tried to find a filename "咕唧2.json" under "Poster 2" directory, instead of finding a file named "Poster 2/咕唧2.json".

Finder uses ":" and Terminal uses "/"

You need to replace the "/" in filenames to ":". And Finder will read it show them as "/". So when writing, use

appInfos.forEach {
    let jsonData = getJsonData(from: $0)
    var filename = $0.name
    replaceSlashWithColon(&filename)
    let outputURL = URL(fileURLWithPath: filename + ".json", isDirectory: false, relativeTo: subFolder)
    try! jsonData.write(to: outputURL)
}
            
private func replaceSlashWithColon(_ str:inout String) {
    str = str.replacingOccurrences(of: "/", with: ":")
}

When reading:

appInfos = fileList.map({ name, version -> AppInfo in
    var filename = name
    replaceSlashWithColon(&filename)
    let url = URL(fileURLWithPath: filename + ".json", isDirectory: false, relativeTo: subfolder)
    let jsonData = try! Data(contentsOf: url)
    return try! decoder.decode(AppInfo.self, from: jsonData)
})

You only need to replace "/" to ":". You don't need to do the reverse. The system will take care of the rest.

References

Sidebar, Hybrid Programming SwiftUI with AppKit

macOSSwiftUISwift

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

macOS

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

macOS

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

The Differences between @ojbc and @IBAction through an Interesting Bug/feature of Interface Builder

macOS

Last week I started an open source app Xliff Tool. It was an app to help developers translate Xliff files exported by Xcode.

We know that for the main menu of an app, its menu items look up the responder chain to find actions to perform. So those functions need to be dynamic. For example, a "Open..." menu item under "File" menu, it looks up for a function called openDocument(_:), so I could create a function in AppDelegate.swift like this:

@objc func openDocument(_ sender: Any?) {
    ...
}

However, when I created another customized menu item of my own, I could not find the function name in first responder with Interface Builder.

@objc func openDatabaseDirectory(_ sender: Any?) {
    ...
}

The fix was easy, just changed @objc to @IBAction, and the function name would be shown.

@IBAction func openDatabaseDirectory(_ sender: Any?) {
    ...
}

Q1: Why the prior @objc function was shown but the latter wasn't?

The answer is the prior @objc function wasn't shown. What was shown in Interface Builder is another @IBAction from NSDocumentController.

/* The action of the File menu's Open... item in a document-based application. The default implementation of this method invokes -beginOpenPanelWithCompletionHandler:, unless -fileNamesFromRunningOpenPanel is overridden, in which case that method is invoked instead for backward binary compatibility with Mac OS 10.3 and earlier. If an array other than nil is obtained from that call, it invokes -openDocumentWithContentsOfURL:display:completionHandler: for each URL and, if an error is signaled for any of them, presents the error in an application-modal panel.
*/
@IBAction open func openDocument(_ sender: Any?)

So in Interface Builder the @IBAction function was shown and in app runtime, the dynamic function on the first responder is the @objc one.

Both @IBAction and @objc are dynamic/objective-c functions, one difference is that the prior can be shown in Interface Builder.

An interesting bug/feature of Interface Builder

There was a "Save as..." menu item under "File" menu, connecting to saveAs(_:), I firstly implemented that function

@objc func saveAs(_ sender: Any?) {
    ...
}

Then I changed the "Save As..." menu item to "Export Xliff File...", and refactored the name of the function as well.

@objc func exportXliffFile(_ sender: Any?) {
    ...
}

Then I ran the app, the @objc func exportXliffFile(_ sender: Any?) still worked.

interface_builder_issue

Let me explain this. Though in the above picture it was shown that the menu item "Export Xliff File..." is connected to "exportXliffFile:" function of first responder. As we explained in Q1, there was no corresponding @IBAction function named "exportXliffFile:", so no one could choose "exportXliffFile:" in Interface Builder at all.

Q2: Then why was "exportXliffFile:" connected in Interface Builder and it still worked?

The answer is the operations that I did.

  1. There is @IBAction in NSDocument.saveAs(_:). So @objc func saveAs(_ sender: Any?) worked.
  2. When refactored in Xcode, both the name of @objc func saveAs(_ sender: Any?) and the connection "saveAs:" in Interface Builder were changed.
  3. The name of @IBAction in NSDocument.saveAs(_:) was unchanged as it was in a readonly header.
  4. When the app ran, the menu item looked up for a function called "exportXliffFile:" and found.

You can not pick a @objc function in Interface Builder, but if you refactor its name from a @IBAction, it will still work.

Others

The Real Reason of Not Working NSMenuDelegate with Interface Builder

NSSavePanel Best Practice

macOS

Sometimes I will use NSSavePanel. However, every time I use it, I have to how to using it. Here is the best practice so next time I will not have to look the docs up.

@objc func exportXliffFile(_ sender: Any?) {
    let exportPanel = NSSavePanel()
    exportPanel.prompt = NSLocalizedString("Export", comment: "")
    exportPanel.allowedFileTypes = ["xliff"]
    let xliffURL = (NSApp.delegate as? AppDelegate)?.xliffURL
    exportPanel.directoryURL = xliffURL
    exportPanel.nameFieldStringValue = xliffURL!.lastPathComponent
    exportPanel.beginSheetModal(for: view.window!) { [unowned self] (response) in
        if response == .OK {
            self.save(to: exportPanel.url!)
        }
    }
}

About code is from my open source app Xliff Tool.

Others

https://developer.apple.com/documentation/appkit/nssavepanel/1534419-allowedfiletypes#

Shortcuts with modify mask in macOS

macOS

For Poster 2 Mac, which has been released lately, one of the feedbacks I got, is if I could provide a method to quickly compose and share texts. For current users, the steps are:

  1. Move mouse/trackpad to click the menu bar item of Poster 2.
  2. Move mouse to click the Write button.
  3. Compose.

Also, the user may have to close the window when texts are sent.

I want to improve those experiences. The easiest idea is to bind composing with mouse click directly. But I don't want that. In my own experience, you must make your app as simple as possible, as you may not even know, that some of the users have never known of right click. If you app need to do something with right click, those functions would be seen as never worked.

A big difference from a macOS user to an iOS user is the former uses a physical keyboard. So I want to add shortcuts for composing and hiding app.

As the beginning, I was thinking adding a menu item to do the same with Write button. However, I found I could bind Write menu item with key enter, but I could not call it in my app. I got a beep sound and nothing happened. I could use modifier mask like command, but that was not what I wanted.

Responder Chain

You should know what is a responder chain. For a brief, a responder chain is something how your app responds to a user event. For example, if a user presses keyboard a. The first responder gets the a and looks for itself if it can react the event, if not, it will pass the event to the next responder, usually is the view which contains itself. Things will go on until the event is react or there is no further responder.

NSStandardKeyBindingResponding Protocol

However, there are always some other things you have to consider. Many subclasses of NSView adopt NSStandardKeyBindingResponding protocol, and some keys have been occupied already. The good thing is, if you want to use those keys, you should override the corresponding method.

Beep Sound

Some subclasses of NSView also implemented the default beep sound if the key is not register. You must exclude the key you want in NSResponder's keyDown(with:) method.

My Resolution

Press enter key for quick editing, and press esc key to hide window.

Enter Key

Subclassing NSWindow and override keyUp(with:) method to compose.

Esc Key

Override cancelOperation(_:) for esc key.

class CancelWindow: NSWindow {
    override func keyDown(with event: NSEvent) {
        if event.keyCode == 36 { // enter key

        } else {
            super.keyDown(with: event   )
        }
    }
    
    override func keyUp(with event: NSEvent) {
        if event.keyCode == 36 { // enter key
            (self.contentViewController as? NextMainViewControllerMac)?.writePost(nil)
        } else {
            super.keyUp(with: event)
        }
    }
    
    override func cancelOperation(_ sender: Any?) {
        (NSApp.delegate as? AppDelegate)?.hide()
    }
}

Enter Key for NSButton

There is also something need to mention. If you add enter to keyEquivalent of NSButton, the border become blue automatically. This is a feature which can't be changed.