肇鑫的技术博客

肇鑫 / 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

Swift Packages Quick Learning

Swift Packages

So far, I have learnt four ways of adding frameworks, manually, CocoaPods, Carthage and Swift Package Manager. This article is a summary of how to creating your own Swift Packages.

What is Swift Package?

A Swift Package is a bundle of source files with a meaningful name.

Platform

If unspecified, Swift Package works in all versions of Apple Operation Systems, this make a lot of version warnings. So a better idea is to provide the platform your package could work with.

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyAppStore",
    platforms: [
        .macOS(.v11)
    ],

Dependencies

Adding dependencies is easy. However, you must added the dependencies to both targets and test targets.

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyAppStore",
    platforms: [
        .macOS(.v11)
    ],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "MyAppStore",
            targets: ["MyAppStore"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(url: "git@github.com:owenzhao/QRCodeKit.git", Package.Dependency.Requirement.branch("1.0.0")),
        .package(url: "git@github.com:weichsel/ZIPFoundation.git", "0.9.12"..<"1.0.0")
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "MyAppStore",
            dependencies: ["QRCodeKit", "ZIPFoundation"]),
        .testTarget(
            name: "MyAppStoreTests",
            dependencies: ["MyAppStore", "QRCodeKit", "ZIPFoundation"]),
    ]
)

Versioning

For versioning, there is a trick. There is no where in the swift part to set the version of your package. The version is set by git tag. So you need to do the versioning in your git tool.

swift_package_versioning

Resources

Swift Packages can get some kinds of sources as resources automatically, but you may also need to add sources manually.

You can add them as files one by one. Or you can add the folder directly. If you use process, Xcode maybe take further optimization to the resources. If you want to keep them unchanged, you can use copy.

    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "MyAppStore",
            dependencies: ["QRCodeKit", "ZIPFoundation"],
            resources: [
                .process("icons"),
                .process("AllApps.zip")
            ]),

Localization

Add default localizedation in Package.swift

let package = Package(
    name: "MyAppStore",
    defaultLocalization: "en",
    platforms: [
        .macOS(.v11),
        .iOS(.v14)
    ],

You must use genstrings to get localized strings.

  1. Go to the directory of swift files.
  2. Create a directory named en.lpproj.
  3. Run code find ./ -name "*.swift" -print0 | xargs -0 genstrings -o en.lproj.
  4. Copy en.lpproj as the name of you intent, say zh.lpproj.
  5. Translate the localized strings under zh.lpproj.
$ tree
.
├── AppInfoSwiftUIView.swift
├── MainSwiftUIView.swift
├── en.lproj
│   └── Localizable.strings
└── zh.lproj
      └── Localizable.strings

Using String resources in Swift Packages

Swift Package is built alone with the main bundle, it is called module Bundle, so you have to explicitly tell the bundle by name, or the system will try to find the string resources in the main bundle.

Button(action: download, label: {
    Text("Download", bundle: .module)
})

You should also notice that there are still some subtle issues. I found that the lang.lpproj directories must be under the target directory directly, or the preview won't take the environment effect.
However, running app goes fine whenever the directory goes.

References

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

SwiftUI Preview Crashed Issue With Exception `RLMException`. Reason: This method may only be called on RLMArray instances retrieved from an RLMRealm.

SwiftUI

After investigation, I found that this issue was caused by sorted method with keyPath.

List { // this list crashes
    ForEach(dayList.items.sorted(by: \.startDate, ascending: true)) { currentItem in
        ItemRowSwiftUIView(currentItem: currentItem)
    }.onDelete(perform: { indexSet in
        removeItems(atOffsets: indexSet)
    })
}
    
List { // this list doesn't crash
    ForEach(dayList.items.sorted(by: {
        $0.startDate < $1.startDate
    })) { currentItem in
        ItemRowSwiftUIView(currentItem: currentItem)
    }.onDelete(perform: { indexSet in
        removeItems(atOffsets: indexSet)
    })
}

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

When using the keyPath to sort, the preview crashed. That because sorted by keyPath used realm and for a unmanaged object dayList, there is no realm.

So we need to create a realm first.

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

    static func createRealmAndGetDayList() -> DayList {
        let realm = try! Realm(configuration: Realm.Configuration(inMemoryIdentifier: "for debug"))
        let dayList = DayList()
        
        try! realm.write({
            realm.add(dayList, update: .all)
        })

        return dayList
    }
}

Then everything will work.

Trump Realm With SwiftUI

SwiftUI

You may have already known how to using Realm with Swift. In case you were not, we would like to have a quick review.

Realm With Swift

Model

import Foundation
import RealmSwift

class Item:Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var title = ""
    @Persisted(originProperty: "items") var group: LinkingObjects<Group>
}

class Group:Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var items = RealmSwift.List<Item>()
}

Get Results

let realm = try! Realm()
let groups = realm.objects(Group.self)

Mutating Data

try! realm.write {
    let group = Group()
    realm.add(group, update: .all)
    group.items.append(Item())
}

Realm With SwiftUI

Model in Realm with SwiftUI is the same. However, how to getting results and mutating data are much different.

Get Results

Results are got implicitly.

import SwiftUI
import RealmSwift

struct ContentView: View {
    @ObservedResults(Group.self) var groups
    
    var body: some View {
        if let group = groups.last {
            MainSwiftUIView(group: group)
        } else {
            ProgressView().onAppear(perform: {
                $groups.append(Group())
            })
        }
    }
}

You can think @ObservedResults as some kind of Binding struct, its wrappedValue is the original Realm results.

public var wrappedValue: RealmSwift.Results<ResultType> { get }

When you call @ObservedResults(Group.self) var groups, Realm automatically created implicitly.

public init(_ type: ResultType.Type, configuration: RealmSwift.Realm.Configuration? = nil, filter: NSPredicate? = nil, keyPaths: [String]? = nil, sortDescriptor: RealmSwift.SortDescriptor? = nil)

Mutating Data

@ObservedResults(Group.self) var groups

The variable groups and $groups are different. groups is a frozen object of Group. While $groups is a ObservedResults object, it is unfrozen and is used to do mutation.

Frozen object mean the state of the object is unchanged, the normal Realm object is live and unfrozen.

private func add() {
    $group.items.append(Item())
}

@ObservedRealmObject

@ObservedRealmObject is similar like @ObservedResults, but it is not results, but for single realm object.

You should take care of the spelling. As there are a lot similar spellings. Like @ObservableObject, @ObservedObject, they all look like @ObservedRealmObject, if you misspell them, the compiler won't notify and you app won't work.
Also, there are two common naming space issues. RealmSwift.List<T> and SwiftUI.App.

References

Pop View and Void Stacked NavigationBars in SwiftUI

SwiftUI

We could use SwiftUI to pop a View to another, like this

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            NavigationLink(
                destination: PopView(),
                label: {
                    Text("Pop View")
                })
        }
    }
}

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

struct PopView:View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    var body: some View {
        Button(
            "Back",
            action: { self.presentationMode.wrappedValue.dismiss() }
        )
    }
}

This will create a button, when you press the button, a new pop up, like below.

Simulator Screen Shot - iPhone 12 Pro - 2021-08-12 at 15.49.44,

Stacked NavigationBars

import SwiftUI

struct PopSwiftUIView: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    var body: some View {
        NavigationView {
            Button(
                "Back",
                action: { self.presentationMode.wrappedValue.dismiss() }
            ).toolbar(content: {
                Button(action: { }, label: {
                    Text("Done")
                })
            })
        }
    }
}

struct PopSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        PopSwiftUIView()
    }
}

Simulator Screen Shot - iPhone 12 Pro - 2021-08-12 at 16.06.17

Above code will run like this, you can see the Done button is stacked to a lower level.

That is because you should only use NavigationView one time. You don't need to use it again if you previously used it.

However, in this case, you need to add NavigationView to preview yourself.

import SwiftUI

struct PopSwiftUIView: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    var body: some View {
        Button(
            "Back",
            action: { self.presentationMode.wrappedValue.dismiss() }
        ).toolbar(content: {
            Button(action: { }, label: {
                Text("Done")
            })
        })
    }
}

struct PopSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            PopSwiftUIView()
        }
    }
}

Hide Back Button

Use .navigationBarBackButtonHidden(true) to hide the back button.

Simulator Screen Shot - iPhone 12 Pro - 2021-08-12 at 16.12.52

References

Data State And Binding in SwiftUI

SwiftUI

In SwiftUI, the flows between data model and views are bidirectional automatically. That means, if a variable changes, the corresponding view changes too. The same is true on the reverse side.

@State

This mechanism is called binding. For variables within the same view, you can use @State property wrapper.

import SwiftUI

struct ContentView: View {
    @State var isEditing = true
    
    var body: some View {
        VStack {
            if isEditing {
                Text("Editing")
            } else {
                Text("Done")
            }
        }.onTapGesture {
            isEditing.toggle()
        }.frame(width: 200, height: 200, alignment: .center)
        .font(.title)
        .padding()
    }
}

When the code runs, the app shows a text with "Editing". If you click the text, the isEditing is toggled and app shows a text with "Done".

state_sample

@Binding

@State is used only its own view. If you need to pass @State to another view, the destination view should not use @State, but @Binding to get the @State variable.

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var isEditing = true
    
    var body: some View {
        ContentView(isEditing: $isEditing)
    }
}


import SwiftUI

struct ContentView: View {
    @Binding var isEditing:Bool
    
    var body: some View {
        VStack {
            if isEditing {
                Text("Editing")
            } else {
                Text("Done")
            }
        }.onTapGesture {
            isEditing.toggle()
        }.frame(width: 200, height: 200, alignment: .center)
        .font(.title)
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(isEditing: .constant(true))
    }
}

For @Binding variable, you must use .constant(true) as a parameter. Also there is a $ prefix for @State as a @Binding variable.

@Binding in Closure

Many times, we may need binding in closure. For example, doing things when List is selected. However, you will find that @Binding doesn't work in parameters in closure.

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var bools:[Bool] = Array(repeating: true, count: 10)
    
    var body: some View {
        List(bools, id: \.self) { isEditing in
            ContentView(isEditing: $isEditing) // Cannot find '$isEditing' in scope
        }
    }
}

That is because the parameter in closure cannot be used as @Binding.

New Trick

import SwiftUI

struct AnotherSwiftUIView: View {
    @State var bools:[Bool] = Array(repeating: true, count: 10)
    
    var body: some View {
        List(bools.indices, id: \.self) { idx in
            ContentView(isEditing: $bools[idx])
        }
    }
}

We use Array.indices and idx to get the element from the struct property, this works with @Binding.

App Crash Issue When Delete Data With Nested Bindings

@Binding is useful. But you also need to take good card of it. Some times, it leads crashes. For example,

import SwiftUI

struct ContentView: View {
    static let deleteNum = Notification.Name("deleteNum")
    @Binding var num:Int
    
    var body: some View {
        HStack {
            Text("\(num)")
            Spacer()
            Button("Delete", action:delete)
        }
    }
    
    private func delete() {
        NotificationCenter.default.post(name: ContentView.deleteNum, object: self, userInfo: ["number":num])
    }
}

import SwiftUI

struct AnotherSwiftUIView: View {
    let pub = NotificationCenter.default.publisher(for: ContentView.deleteNum)
    @State var array = [1,2,3,4,5]
    
    var body: some View {
        List(array.indices, id:\.self) { idx in
            ContentView(num: $array[idx])
        }.onReceive(pub, perform: { noti in
            if let info = noti.userInfo,
               let num = info["number"] as? Int,
               let index = array.firstIndex(of: num) {
                array.remove(at: index)
            }
        })
    }
}

struct AnotherSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        AnotherSwiftUIView()
    }
}

nested_bindings

The app will crash when you click the "Delete" button. The issue happens because the array is changed but the array.indices are not changed accordingly in the app.
We need to double check the index.

import SwiftUI

struct AnotherSwiftUIView: View {
    let pub = NotificationCenter.default.publisher(for: ContentView.deleteNum)
    @State var array = [1,2,3,4,5]
    @State var indexSet = IndexSet()
    
    var body: some View {
        List(array.indices, id:\.self) { idx in
            Safe($array, index: idx) { binding in
                ContentView(num: binding)
            }
        }.onReceive(pub, perform: { noti in
            if let info = noti.userInfo,
               let num = info["number"] as? Int,
               let index = array.firstIndex(of: num) {
                array.remove(at: index)
            }
        })
    }
}

struct AnotherSwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        AnotherSwiftUIView()
    }
}

struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
   
   typealias BoundElement = Binding<T.Element>
   private let binding: BoundElement
   private let content: (BoundElement) -> C

   init(_ binding: Binding<T>, index: T.Index, @ViewBuilder content: @escaping (BoundElement) -> C) {
      self.content = content
      self.binding = .init(get: { binding.wrappedValue[index] },
                           set: { binding.wrappedValue[index] = $0 })
   }
   
   var body: some View {
      content(binding)
   }
}

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)