본문 바로가기

학습 노트/iOS (2021)

116 ~ 117. Split View Controller and Split View Controller Customizing.

Split View Controller

Split View Controller는 화면을 분할해서 두 개의 View Controller를 함께 표시하는 Container View Controller이다.

왼쪽을 Master View Controller(iOS 13), Primary View Controller
오른쪽을 Detail View Controller(iOS 13), Secondary View Controller라고 부른다.
이전에 배운 다른 Container View Controller와 다르게 Child View Controller의 수가 둘이다.
최초에는 iPad 전용이었지만 Adaptive Layout이 도입되며 종류에 상관없이 항상 사용할 수 있게 됐다.
iPad와 iPhone의 Landscape 모드에서는 View Controller를 나란히 표시할 수 있지만
iPhone의 Portrait 모드에서는 공간이 부족하다.

Split View Contoller는 이러한 문제를 해결하기 위해
Horizontal Size에 따라 다르게 동작한다.
iOS 13까지는 Regular에서는 위처럼 나란히 표시하고, Compact에서는 하나씩 표시한다.
iOS의 설정앱이 Split View Controller로 만들어진 대표적인 예이다.

즉, iPhone의 Portrait 모드에서 실행하면 Horizontal Size가 Compact이기 때문에
Navigation Controller를 활용해 Child View Controller를 관리한다.

iPhone Plus 모델을 Landscape 모드로 변경하면
Horizontal Size가 Regular로 바뀌고, Split View Contoller를 통해 Child View Controller를 관리한다.

Detail View Controller에서 항목을 선택하면 해당 View Controller 내에서 표시된다.
설정 앱은 Master View Contoller와 Detail View Controller를 개별 Navigation Controller에 Embed 하고 있다.
지금처럼 Regular일 때는 Navigation Stack이 별도로 관리되지만,

다시 Portrait 모드로 변경하면 하나로 합쳐진다.

iOS 13부터는 맥과의 호환성을 위해 Column 방식을 사용하게 되면서 많은 것이 바뀌었다.
강의 내용을 따라가긴 하지만, 바뀐 방식으로 따라가 보도록 한다.

Split View Controller는 Adaptive Layout을 지원한다.
Child View 계층을 자동으로 관리하고, 현재 Context에 가장 적합한 전환 방식으로 처리한다.

라이브러리에서 Split View Controller를 찾아 추가하면 네 개의 씬이 추가된다.
추가된 씬 중 가장 왼쪽이 Split View Controller이고, 가장 오른쪽이 Master(Primary) View Controller이다.
Master View Controller는 Navigation Controller에 Embed 되어있다.
그리고 나머지 하나가 Detail(Secondary) View Controller이다.
경우에 따라 Detail(Secondary) View Controller를 별도의 Navigation Controller로 Embed 하기도 한다.
구성에 제약은 없지만 보통 두가지 방식으로 구현한다.

Interface Builder로 구현하기

Segue 버튼과 Split View Controller를 연결한다.

실습에 사용할 새로운 씬을 Navigation Controller에 Embed 하고, Relationship으로 연결한다.
이때 Color Detail View Controller는 Secondary View로 연결한다.

이후 Split View Controller의 Attribute Inspector에서
Style을 Double Column으로,
Present Master With Gesture를 해제한다.
Display Mode를 One Columns Beside로,
Behavior를 Automatic으로,
Primary Edge를 Leading으로 설정한다.

해당 부분은 강의와는 조금 달라진 부분인데 기본 상태는 Overlay 방식으로 위의 사진처럼 표시된다.

//
//  SplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/01.
//

import UIKit

class SplitViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

    }
}
//
//  ColorListTableViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/01.
//

import UIKit

class ColorListTableViewController: UITableViewController {
    
    let list = MaterialColorDataSource.generateData()

    override func viewDidLoad() {
        super.viewDidLoad()
        
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let vc = segue.destination.children[0] as? ColorDetailViewController {
            if let cell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: cell) {
                vc.color = list[indexPath.row]
            }
        }
    }

    // MARK: - Table view data source

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // #warning Incomplete implementation, return the number of rows
        return list.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

        cell.textLabel?.text = list[indexPath.row].title

        return cell
    }
    
    override func collapseSecondaryViewController(_ secondaryViewController: UIViewController, for splitViewController: UISplitViewController) {
        print(self, #function)
    }
    
    override func separateSecondaryViewController(for splitViewController: UISplitViewController) -> UIViewController? {
        print(self, #function)
        return nil
    }

}

강의와는 다르게 Secondary View 전에 Navigation Conroller가 연결되어있다.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
	if let vc = segue.destination.children[0] as? ColorDetailViewController {
		if let cell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: cell) {
			vc.color = list[indexPath.row]
		}
	}
}

따라서 항목을 선택했을 때 데이터를 전달해 주는 부분을 destination.children[0]으로 수정한다.

//
//  ColorDetailViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/01.
//

import UIKit

class ColorDetailViewController: UIViewController {
    
    var color: MaterialColorDataSource.Color? {
        didSet {
            updateColor()
        }
    }
    
    @IBOutlet weak var colorView: UIView!
    
    @IBOutlet weak var label: UILabel!
    

    override func viewDidLoad() {
        super.viewDidLoad()
        
        updateColor()
        // Do any additional setup after loading the view.
    }
    func updateColor() {
        title = color?.title
        colorView?.backgroundColor = color?.color
        label?.text = color?.hex
    }
    
    deinit {
        print(#function, self)
    }
}

각 씬들의 코드는 위와 같다.

임의로 생성하는 MaterialColorDataSource는 강의의 파일을 그대로 사용했다.

현재 상태에서는
Segue 버튼을 선택하면 곧바로 Color Detail 씬으로 전환되고,
Color List 씬에서 항목을 선택해도 Color Detail 씬으로 전환되지 않는다.

기기를 Landscape 모드로 전환하면 왼쪽에 Primary View Controller의 씬이,
오른쪽에 Secondary View Controller의 씬이 표시된다.
Color List 씬에서 항목을 선택하면 Color Detail 씬에서 데이터를 표시하도록 구현한다.

우선 Color List 씬의 Cell과 Color Detail씬을 Show Detail로 연결한다.

시뮬레이터를 통해 확인해 보면 Portrait 모드에서 항목을 선택하면 Color Detail 씬이 표시된다.
하지만 Landscape 모드에서 항목을 선택하면 Navigation Stack에 push 되지 않고,
오른쪽의 Detail View Controller가 교체된다.
이는 View Controller 자체가 새 인스턴스로 교체됨을 의미한다.

따라서 View Controller가 소멸될 때 출력되는 로그가 반복적으로 남게 된다.
기기를 다시 Portrait 모드로 전환하면 다시 Navigation stack에 push 된다.

현재 상태에서 Split View Controller를 호출하면
처음에는 빈 Color Detail View가 표시된다.
자동으로 첫번째 데이터를 표시할 수 있도록 구현한다.

//
//  CustomSplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/02.
//

import UIKit

class CustomSplitViewController: UISplitViewController {
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
		delegate = self
	}
	
	func setupDefaultValue() {
		
	}
}

extension CustomSplitViewController: UISplitViewControllerDelegate {

}

UISplitViewController를 상속하는 새 클래스 파일을 만들고,
해당 클래스를 Split View Controller의 Custom Class로 지정한다.
해당 클래스의 setupDefaultValue 메소드를 통해 이를 구현한다.

func setupDefaultValue() {
	guard let nav = viewControllers.first as? UINavigationController, let primaryVC = nav.viewControllers.first as? ColorListTableViewController else {
		return
	}
}

우선 Primary View Controller에 접근해야한다.
Split View Controller에 Navigation Controller를 통해 연결되어있기 때문에,
Navigation Controller를 먼저 바인딩한 후 Primary View Controller에 연결한다.

func setupDefaultValue() {
	guard let nav = viewControllers.first as? UINavigationController, let primaryVC = nav.viewControllers.first as? ColorListTableViewController else {
		return
	}
	guard let secondaryVC = viewControllers.last.children[0] as? ColorDetailViewController else {
		return
	}
}

이어서 데이터를 표시할 Secondary View Controller도 바인딩한다.

func setupDefaultValue() {
	guard let nav = viewControllers.first as? UINavigationController, let primaryVC = nav.viewControllers.first as? ColorListTableViewController else {
		return
	}
	guard let secondaryVC = viewControllers.last.children[0] as? ColorDetailViewController else {
		return
	}
	secondaryVC.color = primaryVC.list.first
}

이후 primaryVC의 list에 존재하는 데이터를 secondaryVC의 Color에 저장한다.

//
//  SplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/01.
//

import UIKit

class SplitViewController: UIViewController {
	
	@IBAction func unwindToSplitHost(_ sender: UIStoryboardSegue) {
	
	}
	
	override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
	
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
	
	}
}

해당 동작은 Split View Controller가 화면에 표시되기 전에 호출되어야 한다.
따라서 메인화면에서 Split View Controller를 표시하기 위한 Segue를 호출하기 직전에 호출되는
prepare 메소드를 사용해 이를 실행한다.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
	if let vc = segue.destination as? CustomSplitViewController {
		vc.setupDefaultValue()
	}
}

메소드가 존재하는 클래스를 바인딩하고, setupDefaultValue를 호출한다.

정상적으로 첫 번째 데이터를 불러오는 것을 확인할 수 있다.
지금처럼 Split View Controller를 호출하면 항상 첫 번째로 Secondary View Controller를 표시한다.
Primary View Controller를 표시하고 싶다면 Delegate를 구현해야 한다.

//
//  CustomSplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/02.
//

extension CustomSplitViewController: UISplitViewControllerDelegate {
	func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
	
	}
}

구현해야 하는 메소드는 UISplitViewControllerDelegate 프로토콜에 선언되어있다.
splitViewContoller(_:collapseSecondary:onto:)메소드는 Horizontal Size가 Compact로 바뀔 때마다 호출된다.
해당 메소드에서 Boolean을 반환해야 하고, false를 반환하면 알아서 처리한다.
기본 상태에선 secondaryViewController를 Navigation Stack에 추가한다.
반대로 true를 반환하면 Split View Controller는 아무런 처리를 하지 않는다.
보통은 Secondary View Controller가 표시하는 내용을 Primary View Controller에 추가하는 코드를 구현할 때 사용한다.

extension CustomSplitViewController: UISplitViewControllerDelegate {
	func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
		return true
	}
}

이렇게 하면 원하는 결과를 얻을 수 없다.
iOS 14부터 Split View Controller가 재정비되면서 방식이 조금 바뀐 셈이다.

간단하게 Interface Builder에서 Primary View Controller 쪽으로 Compact view controller segue를 하나 더 연결해 주면 된다.

기본값이 Primary View Controller로 변경되었다.

Code로 Split View 구현하기

//
//  SplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/01.
//

import UIKit

class SplitViewController: UIViewController {
	
	@IBAction func CodeAction(_ sender: Any) {
	
	}
	
	
	@IBAction func unwindToSplitHost(_ sender: UIStoryboardSegue) {
	
	}
	
	override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
		if let vc = segue.destination as? CustomSplitViewController {
			vc.setupDefaultValue()
		}
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
	}
}

Code 버튼을 클래스와 Action으로 연결하고,
해당 메소드 내에서 코드를 구현한다.

@IBAction func CodeAction(_ sender: Any) {
	guard let PrimaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorList") else { return }
	let nav = UINavigationController(rootViewController: PrimaryVC)
}

우선 Primary View Controller를 생성하고, Navigation Controller에 Embed 한다.

@IBAction func CodeAction(_ sender: Any) {
	guard let PrimaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorList") else { return }
	let nav = UINavigationController(rootViewController: PrimaryVC)
	
	guard let secondaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorDetail") else { return }
	let nav2 = UINavigationController(rootViewController: secondaryVC)
}

이번엔 Secondary View Controller를 생성하고, Navigation Controller에 Embed 한다.

@IBAction func CodeAction(_ sender: Any) {
	guard let PrimaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorList") else { return }
	let nav = UINavigationController(rootViewController: PrimaryVC)
	
	guard let secondaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorDetail") else { return }
	let nav2 = UINavigationController(rootViewController: secondaryVC)
	
	let splitVC = CustomSplitViewController()
	splitVC.viewControllers = [ nav, nav2 ]
}

Split View Controller는 앞서 생성 해둔 CustomSplitViewController로 생성한다.
이후, 속성을 설정한다.
이때, 반드시 Primary View Controller를 먼저 배열에 저장해야 한다.

@IBAction func CodeAction(_ sender: Any) {
	guard let PrimaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorList") else { return }
	let nav = UINavigationController(rootViewController: PrimaryVC)
	
	guard let secondaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorDetail") else { return }
	let nav2 = UINavigationController(rootViewController: secondaryVC)
	
	let splitVC = CustomSplitViewController()
	splitVC.viewControllers = [ nav, nav2 ]
	
	present(splitVC, animated: true, completion: nil)
}

마지막을 modal 방식으로 view를 표시한다.

의도한 대로 동작한다.

 

Split View Controller Customizing

Split View Controller는 Horizontal Size에 따라 배치 방식을 변경한다.
Compact 클래스에서는 하나의 Child만 표시하고, Regular Class에서는 둘이나 셋을 나란히 표시할 수 있다.

배치방식을 결정하는 속성은 Display Mode이다.
Split View Contoller는 크게 4가지 Display Mode를 제공한다.

  • Automatic
    실행환경에 따라 자동으로 배치한다.
  • Secondary Only
    Primary view controller를 숨기고 Secondary view만 표시한다.
  • One Column Beside
    Primary view controller를 옆에, Secondary view를 함께 표시한다.
  • One Column Overlay
    Primary view controller를 옆에 표시하되, Secondary view를 가리면서 표시한다.

외에 Two Column이 붙은 메뉴는 Triple Column을 사용하는 경우에 동작한다.

Display Mode를 모든 사이즈에 적용하는 것은 불가능하다.
예를 들면 One Column Beside는 Compact Size를 지원하지 않는다.
따라서 Display Mode를 설정할 때에는 직접 설정하는 것이 아니라 Split View Controller에게 알려주는 방식으로 설정한다.
Split View Controller는 현재 Context에 따라 최종 Display Mode를 결정한다.

//
//  CustomSplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/02.
//

import UIKit

class CustomSplitViewController: UISplitViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        delegate = self
    }

    func setupDefaultValue() {
        guard let nav = viewControllers.first as? UINavigationController, let primaryVC = nav.viewControllers.first as? ColorListTableViewController else {
            return
        }
        guard let secondaryVC = viewControllers.last?.children[0] as? ColorDetailViewController else {
            return
        }
        secondaryVC.color = primaryVC.list.first
    }
}

extension CustomSplitViewController: UISplitViewControllerDelegate {
    func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
        return true
    }
}

이전에 사용했던 Split View Controller는 CustomSplitViewContoller에 연결되어있다.
해당 클래스에서 Display Mode를 설정한다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	delegate = self
	
	displayMode = .automatic
}

이렇게 설정을 바꾸면 오류가 발생한다.
displayMode 속성은 읽기 전용 속성으로 값을 변경할 수 없다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	delegate = self
	
	preferredDisplayMode = .automatic
}

따라서 DisplayMode를 변경할 때는 preferredDisplayMode 속성을 통해 변경한다.

iPad에서 전체 화면으로 앱을 실행하면 Horizontal Size는 항상 Regular이다.
Display Mode를 Automatic으로 설정하면 Orientation에 따라 Display Mode가 달라진다.
iPad의 Portrait 모드에서는 One Column Overlay로 실행되고,
Gesture Control을 끈 상태에서는 위와 같이 Secondary Only로 표시된다.

iPad의 Landscape 모드에서는 One Column Beside로 표시된다.
보통은 지금처럼 Automatic을 사용하지만, iPad의 경우 너비가 충분하기 때문에.
항상 One Column Beside를 사용해도 문제가 없다.

iOS 14 미만의 버전에서
Secondary Only로 표시되는 경우 Primary View로 접근할 방법이 없기 때문에 버튼을 추가해 줘야 한다.
단, Landscape 모드에서는 굳이 버튼이 필요하지 않으니 Display Mode가 바뀌는 시점을 찾아,
Bar Button Item을 추가하거나 삭제해야 한다.

//
//  CustomSplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/02.
//

extension CustomSplitViewController: UISplitViewControllerDelegate {
	func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
		return true
	}
	
	func splitViewController(_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode) {
	
	}
}

UISplitViewControllerDelegate 프로토콜에 선언되어있는 splitViewController(wollChangeTo:)메소드는
Display Mode가 바뀌기 전에 호출되는 메소드이다.
파라미터로 전달되는 Display Mode에 따라 분기해,
모드에 따라 Button을 표시하고 숨기도록 구현한다.

func splitViewController(_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode) {
	switch displayMode {
	case .secondaryOnly, .primaryOverlay:
		viewControllers.last?.children.first?.navigationItem.leftBarButtonItem = displayModeButtonItem
	default:
		viewControllers.last?.children.first?.navigationItem.leftBarButtonItem = nil
	}
}

Split View Controller가 제공하는 Bar Button Item은 displayModeButtonItem이 반환한다.
해당 버튼을 사용하도록 한다.

func setupDefaultValue() {
	guard let nav = viewControllers.first as? UINavigationController, let primaryVC = nav.viewControllers.first as? ColorListTableViewController else {
		return
	}
	guard let secondaryVC = viewControllers.last?.children[0] as? ColorDetailViewController else {
		return
	}
	secondaryVC.color = primaryVC.list.first
	
	switch displayMode {
	case .secondaryOnly, .primaryOverlay:
		secondaryVC.navigationItem.leftBarButtonItem = displayModeButtonItem
	default:
		secondaryVC.navigationItem.leftBarButtonItem = nil
	}
}

모드가 변경되지 않는 첫 번째 씬을 초기화하는 부분에도 동일하게 작성한다.

해당 과정으로 해결되지만 이후 버전에서는 Secondary Only 모드이더라도 Gesture를 끄지 않는 이상 기본적으로 버튼이 표시된다.

Primary view와 Secondary view의 사이즈는 Split View Controller의 Size inspector에서 설정할 수 있다.

  • Faraction
    0.0 ~ 1.0 사이의 값을 입력한다.
    전체 너비에 대한 상대적인 비율로, 0.5로 설정하면 50%를 의미한다.
  • Min Width
    최소 너비를 설정한다.
    기본 상태로는 최솟값은 0, 최댓값은 320이다.
  • Max Width
    최대 너비를 설정한다.
    최대값 320을 넘기기 위해서 는 최대 너비를 설정해야 한다.
//
//  SplitViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/11/01.
//

@IBAction func CodeAction(_ sender: Any) {
	guard let PrimaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorList") else { return }
	let nav = UINavigationController(rootViewController: PrimaryVC)
	
	guard let secondaryVC = storyboard?.instantiateViewController(withIdentifier: "ColorDetail") else { return }
	let nav2 = UINavigationController(rootViewController: secondaryVC)
	
	let splitVC = CustomSplitViewController()
	splitVC.viewControllers = [ nav, nav2 ]
	
	splitVC.presentsWithGesture = false
	splitVC.preferredPrimaryColumnWidthFraction = 0.5
	
	splitVC.minimumPrimaryColumnWidth = 100
	splitVC.maximumPrimaryColumnWidth = view.bounds.width / 2
	
	splitVC.primaryEdge = .trailing
	
	present(splitVC, animated: true, completion: nil)
}

각각의 속성들은 코드로도 변경할 수 있다.
presentsWithGesture는 제스처 속성을,
preferredPrimaryColumnWidthFraction은 Primary view의 Fraction width 속성을,
minimumPrimaryColumnWidth는 Primary view의 Minimum Width 속성을,
MaximumPrimaryColumnWidth는 Primary view의 Maximum Width 속성을 설정한다.
Maximum Width 속성의 경우 화면의 전체 너비를 반영할 수 있도록 view의 전체 너비를 통해 계산한다.

primaryEdge는 iOS 11에서 추가된 속성으로 Primary View를 표시할 위치를 설정한다.
Leading은 화면의 왼쪽에, Trailing은 화면의 오른쪽에 표시한다.

Primat View가 오른쪽에서 표시되고,
화면의 반을 정확히 나눠 갖고 있다.