肇鑫的技术博客

业精于勤,荒于嬉

SwiftUI读取、保存,界面的选择

在macOS系统中,我们可以使用NSOpenPanelNSSavePanel来实现文件的读取和写入。但是iOS并没有对应的类,这是因为虽然都是支持沙盒系统。macOS的沙盒和iOS的沙盒还是存在差异的。

macOS和iOS在沙盒部分的差异

macOS的沙盒是可选项,应用可以选择使用沙盒,也可以选择不使用。不过如果你想在苹果商店上架,就必须使用。不使用沙盒的应用,只能使用其它的渠道进行分发。但是仍旧可以使用Xcode进行打包,然后通过苹果的验证即可。

iOS的沙盒是必选项。应用必须使用沙盒。

macOS的应用使用NSOpenpanel打开用户选中的文件时,会自动获得选中文件的读取/写入权限。这被苹果称为所在位置读取。LSSupportsOpeningDocumentsInPlace

iOS的应用读取外部的文件的时候,不能自动获取相应的权限。因此,需要使用URLstartAccessingSecurityScopedResource()stopAccessingSecurityScopedResource()函数来获取临时的权限。

由于iOS的沙盒隔离的更加彻底。要选择读取文件,有两种方式,一种是类似打开窗口的UIDocumentPickerViewController,一种是用于文档类型应用的UIDocumentBrowserViewController。二者最大的区别,是后者支持创建空白文件。

SwiftUI

到了SwiftUI的时代,苹果同样提供了我们两种方式,分别对应iOS的UIDocumentPickerViewControllerUIDocumentBrowserViewController

函数fileImporter(isPresented:allowedContentTypes:onCompletion:)

这个函数对应的是UIDocumentPickerViewController。它的缺点是相比它对应的类,它缺少该类的一些特性。比如,不能在打开时预设文件夹。

DocumentGroup

这个结构体,对应类UIDocumentBrowserViewController。一旦你使用了它,你的应用就自动获得了UIDocumentBrowserViewController的功能。

如何选择

上面的分类谈完之后,下面谈谈使用时要如何选择。

首先,如果你的应用符合文件类型应用的特征,那么可以优先选择DocumentGroup。因为它可以是你的应用获得更多的预制特性,这样应用编写起来会更加简单。

不过,DocumentGroup的使用过程,我也遇到了一些无法克服的困难。如果你的应用有如下的需求,那么就别使用DocumentGroup了。

  1. 打开的文件类型是package类型的子类,或者是package类型子类内部的一部分。
  2. 不需要创建空文件的功能。

有些文件本身是文件夹,但是在Finder中显示为单独的文件。这类文件都属于package类型的子类。
我遇到的问题是,DocumentGroup无法打开这个类型文件,虽然类型注册是成功的。
有时我们的应用虽然符合文件类型应用的范围,但是我们本身并不创建空文件,而是使用其它应用创建的文件。
比如翻译xliff文件的应用。本身不需要创建看的xliff文件。而是使用其它应用,如Xcode,导出的xliff文件。

其它的一些使用技巧

需要注意的是,在使用URLstartAccessingSecurityScopedResource()stopAccessingSecurityScopedResource()函数的时候,这个URL应该是沙盒传递过来的那个最原始的URL。如果你经过计算,使用了它内部的其它URL,那么在写入操作的时候,也必须调用原始的URL,而不能用计算的URL。如果你使用了后者,则操作不会成功。

UISplitViewController in Modern UiKit

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

The Simplest Sample for Dragging Operation on UITableView

One of my friend requires an app feature for Poster 2 which requires drag operation on UITableView.

I did a research and found two resolutions. They all worked. However, they were all too complex. I wanted to find the simplest way to implemented the operation. Here it is.

Theory

There are two ways to do the tricks. One is to use UITableViewDiffableDataSource, the other is to use UITableViewDataSource. This article is focus on the latter. If you want to know the prior, just find the link at the end of this article.

override func viewDidLoad() {
    super.viewDidLoad()
    
    tableView.dragInteractionEnabled = true
    tableView.dataSource = self
    tableView.dragDelegate = self
}

// MARK: - UITableViewDataSource
extension ViewController: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)
        cell.textLabel?.text = items[indexPath.row]
        
        return cell
    }

// MARK: - UITableViewDragDelegate
extension ViewController:UITableViewDragDelegate {
    func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        // return [] // return [] won't stop the table view from draging. However, Apple wants us to do it on our own.
        
        return [UIDragItem(itemProvider: NSItemProvider(object: items[indexPath.row] as NSItemProviderWriting))]
    }
}

tableView.dragInteractionEnabled = true this line must not be the last line of the three-line code, or the drag operation won't work.

In docs, Apple says "Return an empty array to indicate that you do not want the specified row to be dragged." This requirement is for you to implement. Even if you return [] here, the drag operation still works.

The above code is enough for UITableView dragging operation. When you drag, the cell that you drag will move to the destination you drop. However, like all other operations on UITableView, you need to keep the model's consistence with the UI.

func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        guard sourceIndexPath != destinationIndexPath else {
            return
        }
        
        let item = items[sourceIndexPath.row]
        
        if sourceIndexPath.row < destinationIndexPath.row {
            items.insert(item, at: destinationIndexPath.row + 1)
            items.remove(at: sourceIndexPath.row)
        } else {
            items.remove(at: sourceIndexPath.row)
            items.insert(item, at: destinationIndexPath.row)
        }
        
        items.forEach {
            print($0)
        }
        print()
    }
}

References

Sample For UITableViewDiffableDataSource catalyst_reorder_example

Apple's Official Sample Adopting Drag and Drop in a Table View

My Own Sample