肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

A Little Research on How PasscodeLock Works

PasscodeLock is the sample project of AppLocker. I took some hours and found how it worked.

Let The App Run

The original project used Carthage to manage the framework. It depended Valet. However, the latest Valet is 4.x but PasscodeLock depends 3.x. So we need to specify the version.

pod 'Valet' , '< 4.0'

I switched Carthage to CocoaPods. So I also had to remove the Carthage part in building phase.

Core

sc

// AppALConstants.swift
// MARK: - Keyboard
@IBAction func keyboardPressed(_ sender: UIButton) {
    switch sender.tag {
    case ALConstants.button.delete.rawValue:
        drawing(isNeedClear: true)
    case ALConstants.button.cancel.rawValue:
        clearView()
        dismiss(animated: true) {
            self.onSuccessfulDismiss?(nil)
        }
    default:
        drawing(isNeedClear: false, tag: sender.tag)
    }
}

private func drawing(isNeedClear: Bool, tag: Int? = nil) { // Fill or cancel fill for indicators
    let results = pinIndicators.filter { $0.isNeedClear == isNeedClear }
    let pinView = isNeedClear ? results.last : results.first
    pinView?.isNeedClear = !isNeedClear
    
    UIView.animate(withDuration: ALConstants.duration, animations: {
        pinView?.backgroundColor = isNeedClear ? .clear : .white
    }) { _ in
        isNeedClear ? self.pin = String(self.pin.dropLast()) : self.pincodeChecker(tag ?? 0)
    }
}

private func pincodeChecker(_ pinNumber: Int) {
    if pin.count < ALConstants.maxPinLength {
        pin.append("\(pinNumber)")
        if pin.count == ALConstants.maxPinLength {
            switch mode {
            case .create:
                createModeAction()
            case .change:
                changeModeAction()
            case .deactive:
                deactiveModeAction()
            case .validate:
                validateModeAction()
            }
        }
    }
}

When a user presses a key, private func drawing(isNeedClear: Bool, tag: Int? = nil) is called.

  1. pinView is the current indicator. It is set to turn white.
  2. then func pincodeChecker(_ pinNumber: Int) is called, the result is pin.
  3. when pin.count is 4, the indicators are cleared.

UISplitViewController in Modern UiKit

iOS

I have a review and find this article is the fourth time that I am talking about UISplitViewController. That is because UISplitViewController is hard and UISplitViewController changes.

Finally, this time we will only talk the modern part of UISplitViewController, which means those talks are base on iOS 14 and later.

UISplitViewController In Generalui-split-view-overview@2x-w589.5

Above picture is from Apple's document site.

UISplitViewController

The typical app of UISplitViewController is the Mail app in app.

You should be aware that the order of the ViewControllers. There are primary, supplementary and secondary.
While, in Xcode, the orders are different.

xcode_segues

Whenever you find an unknown bug, first make sure you have one and only one UINavigationViewController that is in the primary ViewController.

Goal

Our goal is to have an app all in blue. So first I added some data to it.

xcode_storyboard

First, I choose all background to system blue in storyboard. Then added data.

PrimaryTableViewController.swift

//
//  PrimaryTableViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class PrimaryTableViewController: UITableViewController {
    private var items:[Int] = (1...3).map { $0 }

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    // MARK: - Table view data source
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "mainCell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item)
        
        return cell
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let splitViewController = self.splitViewController {
            splitViewController.show(.supplementary)
        }
    }
}

SupplementaryViewController.swift

//
//  SupplementaryViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SupplementaryViewController: UIViewController {
    private var items:[Int] = (1...5).map { $0 }

    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

extension SupplementaryViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "supportCell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item)
        
        return cell
    }
}

extension SupplementaryViewController:UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let splitViewController = self.splitViewController {
            splitViewController.show(.secondary)
        }
    }
}

SecondViewController.swift

//
//  SecondViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SecondViewController: UIViewController {
    private var items:[Int] = (1...10).map { $0 }

    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

extension SecondViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item)
        
        return cell
    }
}

The app run like this.

sc_01

As you can see, the color of the title bar is not blue.

We need to add an image to background of the navigationBar.

UIColor+Image.swift

//
//  UIColor+Image.swift
//  Any Counter 2
//
//  Created by zhaoxin on 2021/10/20.
//  Copyright © 2021 ParusSoft.com. All rights reserved.
//

import Foundation
import UIKit

extension UIImage {
    static func from(color: UIColor) -> UIImage {
        let rect = CGRect(x: 0, y: 0, width: 1, height: 1)
        UIGraphicsBeginImageContext(rect.size)
        let context = UIGraphicsGetCurrentContext()
        context!.setFillColor(color.cgColor)
        context!.fill(rect)
        let img = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return img!
    }
}

SecondViewController.swift

//
//  SecondViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SecondViewController: UIViewController {
    private var items:[Int] = (1...10).map { $0 }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationController?.navigationBar.setBackgroundImage(UIImage.from(color: .systemBlue), for: .default)
    }
}

extension SecondViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item)
        
        return cell
    }
}

The title bar turned to blue when the device was landscape. But in portrait, the color didn't change. Also, there was extra space in portrait.

sc_02-w1,104

sc_03

View Hierarchy

xcode_views-w1,565
We captured the portrait view controller in Xcode and looked into the view's hierarchy. We found that there were two instead of one UINavigationController used.

UISplitViewController is complex and hard to predict. Because it is combined states of two UINavigationViewControllers.
So each time we encounter a problem, we must consider the current state of the two UINavigationViewControllers.

Solution

First, we wanted all UINavigationController turn blue. So we do it in the UISplitViewController.

SplitViewController.swift

//
//  SplitViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SplitViewController: UISplitViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        children.forEach {
            if let nav = $0 as? UINavigationController {
                nav.navigationBar.setBackgroundImage(UIImage.from(color: .systemBlue), for: .default)
            }
        }
    }
}

And remove the previous changes in SecondViewController.swift.

sc_04-621

Now the portrait view controller was blue, however, the gap was still there.

When you set both UINavigationViewControllers, the space doubles in portrait mode.
You must to hide a second UINavigationViewController to remove the space.
However, isHidden of navigationBar didn't work here.
You have to set background image to nil to hide the UINavigationViewController.

SecondViewController.swift

//
//  SecondViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SecondViewController: UIViewController {
    private var items:[Int] = (1...10).map { $0 }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let frame = view.frame
        if frame.height > frame.width {
            self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default)
        }
    }
}

extension SecondViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item)
        
        return cell
    }
}

Now the gap was gone. However, when rotating, the title went blank again.
sc_05-621

sc_06

set background image when screen rotate

SecondViewController.swift

//
//  SecondViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SecondViewController: UIViewController {
    private var items:[Int] = (1...10).map { $0 }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let frame = view.frame
        if frame.height > frame.width {
            self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default)
        }
    }

    // MARK: - rotate
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        if size.height > size.width {
            self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default)
        } else {
            self.navigationController?.navigationBar.setBackgroundImage(UIImage.from(color: .systemBlue), for: .default)
        }
    }
}

extension SecondViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item)
        
        return cell
    }
}

SupplementaryViewController.swift

//
//  SupplementaryViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SupplementaryViewController: UIViewController {
    private var items:[Int] = (1...5).map { $0 }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let frame = view.frame
        if frame.height > frame.width {
            self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default)
        }
    }
    

    // MARK: - rotate
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        if size.height > size.width {
            self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default)
        } else {
            self.navigationController?.navigationBar.setBackgroundImage(UIImage.from(color: .systemBlue), for: .default)
        }
    }

}

extension SupplementaryViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "supportCell", for: indexPath)
        let item = items[indexPath.row]
        cell.textLabel?.text = String(item)
        
        return cell
    }
}

extension SupplementaryViewController:UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let splitViewController = self.splitViewController {
            splitViewController.show(.secondary)
        }
    }
}

TintColor Lost When Landscape

There was a bug on landscape. TintColor set in storyboard was lost.

SplitViewController.swift

//
//  SplitViewController.swift
//  UISplitView Sample
//
//  Created by zhaoxin on 2021/10/21.
//

import UIKit

class SplitViewController: UISplitViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        children.forEach {
            if let nav = $0 as? UINavigationController {
                nav.navigationBar.setBackgroundImage(UIImage.from(color: .systemBlue), for: .default)
                nav.navigationBar.tintColor = .systemYellow
            }
        }
    }
}

Sample Project

*UISplitView Sample

Applying async/await in Xcode 13

Combine

Applying async/await was not hard. It just has some difficulties that I didn't expect.

Comparing Code Styles

I had already use AwaitKit to program async before Xcode 13. The async/await codes looked similar. Comparing:

    // AwaitKit version
    private func usersShow() throws {
        let weiboAPI = WeiboAPIType.users_show.weiboAPI
        let (data, reponse) = try `await`(URLSession.shared.dataTask(.promise, with: weiboAPI.urlRequest))
        let httpURLResponse = reponse as! HTTPURLResponse

        if (200..<300).contains(httpURLResponse.statusCode) {
            weiboAPI.saveStatus(from: data)
        } else {
            throw Result.weiboError(httpURLResponse.statusCode, data)
        }
    }
    
    // Xcode 13 version
    @available(iOS 15.0, *)
    private func usersShow() async throws {
        let weiboAPI = WeiboAPIType.users_show.weiboAPI
        let (data, reponse) = try await URLSession.shared.data(for: weiboAPI.urlRequest)
        let httpURLResponse = reponse as! HTTPURLResponse
        
        if (200..<300).contains(httpURLResponse.statusCode) {
            weiboAPI.saveStatus(from: data)
        } else {
            throw Result.weiboError(httpURLResponse.statusCode, data)
        }
    }

Only the definition line and `let (data, response) line are different. So refactoring old codes are easy.

Issues

Unlike AwaitKit, async/await in Xcode 13 sorts code to be async and sync. But what if you want to use an async function to be run in a closure that not allowed async to run?

The answer is to provide the async version functions. Like func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) for func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask. However, not all API currently are provided the counterpart. More will be come.

Apple provides sync and async versions of functions, but another problem shows. As async only works in IOS 15, so we may want the compiler to run the async version in iOS 15 and run the sync version in other prior iOS.

The compiler doesn't allow this feature. As somehow the compiler considers the async and sync versions are conflict to use together. So we should wait until the Concurrency is backwards to iOS 13.

References

Async Programming

Combine

There are many ways to program asynchronously. "GCD", "Operation queue" are commonly used. Also there is Combine and async/await that are newly introduced.

Combine

Combine is useful. You can think it as a notification, when a notice comes the receiver get notified and run some code.

However, combine has it limitation. It is a well-designed series operations followed by its rules. You have to carefully design the path. Also, it can't be finished with a throw. So you have to convert throw to Just.

async/await

async/await is more flexible. You can do it as what you want. Especially if you want to chain functions with throws.

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.