肇鑫的技术博客

业精于勤,荒于嬉

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

Strongly Recommend You to Add Your Own UINavigationControllers even in iOS 14 when Using UISplitViewController

In iOS 14, Apple adapts UISplitViewController from two columns to three columns. Especially, it says that

If you set a child view controller that’s not a navigation controller, the split view controller creates a navigation controller for it.

However, Apple didn't say that it will try to reuse the UINavigationController when you reset the columns.

That means, when you set .secondary view controller to another view controller, the split view controller will push the new view controller to the back stack, which is not what you want.

Issue Sample

UISplitViewController Issue

  1. run the sample
  2. you will see detail 01, press back
  3. you will see master 01, press back
  4. you will see primary, press Master 02
  5. you will see Master 02, press Master 02
  6. you will see Detail 02, press back

You expect to see Master 02.
You see Detail 01.

Work Around

Add a UINavigationViewController to each of detail view controllers.

UISplitViewController Issue work around

I have file this as a bug to Apple. The id is FB8640357.

Apple's Reply

Just got the reply from Apple. 8 days since I have filed this issue. Apple says:

After reviewing your feedback, we have some additional information for you:

This is expected behavior.

The secondary column gets this special treatment if the view controller gets set to a non-navigation controller when it already has a nav controller—i.e., it’s pushed onto the nav stack, as they discovered.

Expected use patterns would be:

• Change the contents of the detail view controller (not the vc itself) and send -showColumn:UISplitViewControllerColumnSecondary (if desired) to confirm that the secondary column is visible, or ...
• For the secondary column only, clear it first by setting its vc to nil, then setting new desired vc, or ...
• Manage the navigation controller in the secondary column directly with -setViewControllers: or push/pop. If you’re using an automatically created UINavigationController instead of one you provided, you can find it with -viewControllerForColumn: and then ask that vc for its navigation controller.

Apple gave 3 recommends. The first was easy to think of, but it needed the two view controllers to be very similar.

I like the second recommend most. func setViewController(_ vc: UIViewController?, for column: UISplitViewController.UISplitViewController.Column) does allow vc to be nil. And it is direct. There is some performance lose though. As it will create another UINavigationViewController each time.

The third recommend has the most performance. But it is indirect. And it smells bad. It should be seen as a workaround.

Final

I recommend to fix it in storyboard as I previously did. Or, using the second recommend by Apple.