본문 바로가기

학습 노트/iOS (2021)

128 ~ 129. View Transition and View Controller Presentation

View Transition

//
//  ViewTransitionViewController.swift
//  Transition Practice
//
//  Created by Martin.Q on 2021/11/16.
//

import UIKit

class ViewTransitionViewController: UIViewController {
    
    @IBOutlet weak var containerView: UIView!
    @IBOutlet weak var pinkView: UIView!
    @IBOutlet weak var greenView: UIView!
    
    @IBAction func startTransition(_ sender: Any) {
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

}

사용할 Scene과 코드는 위와 같다.

Pink View는 Attribute에서 Hidden을 활성화한 상태이다.

Transition 대상은 반드시 첫번째 파라미터로 전달된 View 계층에 속해야 한다.
Transition을 구현하는 방법은 두 가지이다.

  • 사라지는 View를 계층에서 제거하기
    View 계층에서 제거되는 View가 약한 참조로 연결되어있고, 소유자가 없다면 메모리에서 즉시 제거된다.
    그렇기 때문에 다시 추가해야 한다면 outlet을 연결할 때 강한 참조로 연결해야 한다.
  • isHidden 속성을 사용하기
    View 계층에 영향을 주지 않고 Transition을 구현할 수 있다.

실습에선 두 번째 방법을 사용한다.

@IBAction func startTransition(_ sender: Any) {
    UIView.transition(with: containerView, duration: 1, options: [.transitionCurlUp]) {
        self.pinkView.isHidden = !self.pinkView.isHidden
        self.greenView.isHidden = !self.greenView.isHidden
    } completion: { _ in
        return
    }

}

UIView의 transition(with:) 메서드를 사용한다.
첫 번째 파라미터로 Transition이 실행될 View를 전달해야 한다.
두 번째 파라미터로 Transition 실행 시간을 지정한다.
세 번째 파라미터로 Transition의 종류를 지정한다.
이후의 파라미터는 Animation의 것과 동일하다.

완성된 Transition은 위와 같은 모습이다.
화면에 표시되는 두 View의 isHidden 속성을 동시에 Toggle 하기 때문에
서로 전환되는 효과를 보여준다.

@IBAction func startTransition(_ sender: Any) {
//        UIView.transition(with: containerView, duration: 1, options: [.transitionCurlUp]) {
//            self.pinkView.isHidden = !self.pinkView.isHidden
//            self.greenView.isHidden = !self.greenView.isHidden
//        } completion: { _ in
//            return
//        }
    UIView.transition(from: greenView, to: pinkView, duration: 1, options: [.transitionFlipFromLeft]) { finished in
        UIView.transition(from: self.pinkView, to: self.greenView, duration: 1, options: [.transitionFlipFromRight], completion: nil)
    }
}

UIView의 transition(from:) 메서드를 사용한다.
해당 메서드는 Transition 대상을 직접 파라미터로 전달한다.
from 파라미터는 View 계층에서 제거할 View를 전달한다.
to 파라미터는 View 계층에 새롭게 추가할 View를 전달한다.
두 대상은 반드시 동일한 계층에 포함되어 있어야 한다.

나머지 파라미터는 동일하다.

completion에서 다시 한 번 실행해 다시 greenView를 pinkView로 전환하도록 한다.

지금과 같은 방식으로 구현한다면 view의 hidden 속성은 해제해야 한다.

실행하면 greenView에서 pinkView로 한 번 전환이 되고 충돌이 발생하며 종료된다.

말 그대로 from을 계층에서 제거하고 to를 계층에 추가하기 때문에
completion에서 Transition을 실행할 때는 to에 전달된 view가 존재하지 않는다.

@IBOutlet weak var containerView: UIView!
@IBOutlet var pinkView: UIView!
@IBOutlet var greenView: UIView!

이는 약한 참조로 연결되어있기 때문에 발생하는 문제로 해당 연결을 강한 참조로 바꿔준다.

이번엔 충돌은 사라졌지만 마지막 pinkView가 제대로 표시되지 않는다.

지금 사용한 메서드는 새로운 view를 계층에 추가하지만 제약조건을 추가하진 않는다.
따라서 제약 오류가 발생하고, 사진처럼 Bounds가 0으로 초기화된 상태가 된다.
이 문제는 Animation options으로 쉽게 해결할 수 있다.

UIView.transition(from: greenView, to: pinkView, duration: 1, options: [.transitionFlipFromLeft, .showHideTransitionViews]) { finished in
	UIView.transition(from: self.pinkView, to: self.greenView, duration: 1, options: [.transitionFlipFromRight, .showHideTransitionViews], completion: nil)
}

options에 showHideTransitionViews를 추가한다.
이렇게 되면 View 계층을 조작하지 않고 isHidden 속성을 대신 Toggle 한다.
만약 계층에서 제거해야 한다면 Completion Handler에서 제거할 수 있고,
새로 추가할 View는 메서드를 호출하기 전에 View 계층에 미리 추가해 두면 문제가 없다.
Transition이 실행되는 동안 View가 계층에서 추가되거나 삭제되지 않기 때문에 약한 참조로 연결되어 있어도 문제가 없다.

@IBOutlet weak var containerView: UIView!
@IBOutlet weak var pinkView: UIView!
@IBOutlet weak var greenView: UIView!

약한 참조로 연결을 변경하고 다시 결과를 확인해 본다.

정상적으로 동작한다.

 

View Controller Presentation

View Controller로 구현한 UI를 화면에 표시하는 방법은 두 가지이다.

  • Container View Controller에 Embed 하기
    Container View Controller가 화면을 관리한다.
  • 화면을 직접 표시하기
    이 방법을 Presentation이라고 한다.

UIVIewController 클래스는 Presentation에 필요한 모든 기능을 제공한다.
새로운 View Controller 표시하고 제거하는 API와 Presentation과 Transtion의 Style을 설정하는 API를 제공한다.
별도의 설정 없이 기본 방식으로 표시하게 되면 화면이 Modal 방식으로 표시된다.
아래쪽에서 위로 이동하는 Animation을 가지고, 화면 전체 혹은 일부를 채운다.

View Controller가 새로운 View Controller를 표시하면 새로운 관계가 형성된다.
새롭게 표시된 화면은 Presented View Controller가 되고,
이것을 표시한 화면은 Presenting View Controller가 된다.

Presented VIew Controller가 차지하는 Frame은 Presentation Context가 결정한다.
Presentation이 시작되면 계층을 따라 올라가면서 Presentation Context로 지정된 View Controller를 검색한다.
보통 Navigation Controller 등의 Container가 지정된다.
만약 존재하지 않는다면 Root View Controller가 사용된다.

//
//  ViewControllerPresentationViewController.swift
//  Transition Practice
//
//  Created by Martin.Q on 2021/11/18.
//

import UIKit

class ViewControllerPresentationViewController: UIViewController {
    
    @IBAction func unwindToPresenting(_ unwindSegue: UIStoryboardSegue) {
    }
    
    @IBAction func present(_ sender: Any) {
    }
    

    override func viewDidLoad() {
        super.viewDidLoad()
    }
}
//
//  ViewControllerPresentingViewController.swift
//  Transition Practice
//
//  Created by Martin.Q on 2021/11/18.
//

import UIKit

class ViewControllerPresentingViewController: UIViewController {
    @IBAction func dismiss(_ sender: Any) {
        
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

    }

}

사용할 Scene과 코드는 위와 같다.

Segue로 연결하기

가장 단순한 방법은 Modal Segue를 연결하는 방법으로,
Trigger와 View Controller를 연결하면 된다.
이렇게 연결하면 Present Scene이 Presenting View Controller가 되고,
Dismiss Scene의 Presented View Controller가 된다.

Presented VIew Controller를 화면에서 제거하는 것으로 Dismiss라고 한다.
Presented View Controller의 Segue 버튼은 Unwind Segue와 연결되어있다.
Unwind Segue는 Presentation Style과 Transition Style을 확인하고, 가장 적합한 방식으로 화면을 전환한다.

아래에서 위로 올라오면서 표시되고,
사라질 때는 위에서 아래로 내려가면서 사라진다.

Code로 연결하기

@IBAction func present(_ sender: Any) {
    guard let modalVC = storyboard?.instantiateViewController(withIdentifier: "presented") else {
        return
    }
    present(modalVC, animated: true, completion: nil)
}

표시할 View Controller를 바인딩하고,
바인딩된 View Controller를 present 메서드로 표시한다.
첫 번째 파라미터로 View Controller를 전달하고,
두 번째 파라미터로 Animation 여부를,
세 번째 파라미터로 완료 후 실행할 코드를 전달한다.

@IBAction func dismiss(_ sender: Any) {
    dismiss(animated: true, completion: nil)
}

dismiss Scene에서 이전 화면으로 돌아갈 때는 present 메서드가 아닌 dismiss 메서드를 사용한다.
첫 번째 파라미터로 Animation 여부를,
두 번째 파라미터로 완료 후 실행할 코드를 전달한다.

결과는 동일하다.
present 메서드로 화면을 표시한 다음에는 dismiss 메서드를 사용해 돌아간다는 것을 기억하자.

present 메서드는 항상 Modal 방식으로 화면을 표시한다.
따라서 push나 replace로 표시학 싶다면 다른 메서드를 사용해야 한다.

show(modalVC, sender: sender)

show 메서드는 push 방식으로 화면을 표시한다,
Navigation Controller에 Embed 되어있다면 Navigation Stack에 push 한다.
Split View Controller에 Embed 되어있다면 Master View Controller에 push 한다.
어느 Container에서 Embed 되어있지 않다면 Modal 방식으로 표시한다.

showDetailViewController(modalVC, sender: sender)

showDetailViewContoller 메서드는
Split View Controller에 Embed 되어 있을 때 Detail View Controller에 표시하는 것을 제외하면 show와 동일하다.

위의 두 방식은 새로운 화면을 Modal로 표시하는 것이 아니기 때문에 Presented View Controller를 제거하는 방법도 달라진다.

이전에 사용한 dismiss 메서드는 present 메서드를 사용해 화면을 표시한 경우에만 사용할 수 있기 때문에,
show나 showDetailViewContoller 메소드를 사용한 경우 다른 메소드를 사용해야 한다.

popViewController(animated: true)

만약 Navigation Controller에 Embed 되어 Stack에 Push 된 상황이라면 popViewController 메서드를 사용해야 한다.

//
//  TransitionStyleViewController.swift
//  Transition Practice
//
//  Created by Martin.Q on 2021/11/18.
//

import UIKit

class TransitionStyleViewController: UIViewController {
    
    var transitionStyle = UIModalTransitionStyle.coverVertical
    
    @IBAction func styleSeg(_ sender: UISegmentedControl) {
        transitionStyle = UIModalTransitionStyle(rawValue: sender.selectedSegmentIndex) ?? .coverVertical
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        
    }
    
    @IBAction func present(_ sender: Any) {
        let SB = UIStoryboard(name: "ViewControllerPresentation", bundle: nil)
        let VC = SB.instantiateViewController(withIdentifier: "presented")
        
        present(VC, animated: true, completion: nil)
    }
    
    

    override func viewDidLoad() {
        super.viewDidLoad()

    }
}

사용할 Scene과 Code는 위와 같다.

public enum UIModalTransitionStyle : Int {

    
    case coverVertical = 0

    case flipHorizontal = 1

    case crossDissolve = 2

    @available(iOS 3.2, *)
    case partialCurl = 3
}

segment에서 선택된 index는 UIModalTrnsitionStyle의 원시 값과 동기화돼 transitionStyle에 저장된다.

만약 지금처럼 Segue로 이미 연결되어 있다면 Attribute에서 Transition을 바꾸는 것이 가능하다.
이러한 방식은 Transition 방식이 고정되어 있을 때 주로 사용하고, 동적으로 변경해야 한다면
Segue가 실행되기 전에 presentedViewController에 접근해 스타일을 변경한다.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    let presentedVC = segue.destination
    
    presentedVC.modalTransitionStyle = transitionStyle
}

prepare 메서드는 Segue가 실행되기 직전 호출되기 때문에 이러한 구현에 적합하다.
modalTransitionStyle 속성에 transitionStyle에 저장된 스타일을 전달한다.

@IBAction func present(_ sender: Any) {
    let SB = UIStoryboard(name: "ViewControllerPresentation", bundle: nil)
    let VC = SB.instantiateViewController(withIdentifier: "presented")
    
    VC.modalPresentationStyle = .fullScreen
    VC.modalTransitionStyle = transitionStyle
    
    present(VC, animated: true, completion: nil)
}

코드에서도 동일하게 presentation을 실행하기 전에 스타일을 변경해 준다.

Segment의 선택에 따라 각기 다른 번환 방식을 보여준다.
그중 Curl up 방식은 두 가지 제약이 존재한다.

  • Presentation Style이 Full Screen으로 지정되어 있어야 한다.
  • Presented View Controller에서 새로운 View Controller를 Modal 방식으로 표시할 수 없다.

두 가지를 지키지 않으면 충돌이 발생할 수 있으므로 주의해야 한다.

사용할 Scene은 위와 같고, 각각의 Button들은 Tag값을 가지고 있다.

public enum UIModalPresentationStyle : Int {

    
    case fullScreen = 0

    @available(iOS 3.2, *)
    case pageSheet = 1

    @available(iOS 3.2, *)
    case formSheet = 2

    @available(iOS 3.2, *)
    case currentContext = 3

    @available(iOS 7.0, *)
    case custom = 4

    @available(iOS 8.0, *)
    case overFullScreen = 5

    @available(iOS 8.0, *)
    case overCurrentContext = 6

    @available(iOS 8.0, *)
    case popover = 7

    
    @available(iOS 7.0, *)
    case none = -1

    @available(iOS 13.0, *)
    case automatic = -2
}

해당 Tag 값들은 UIModalPresentationStyle의 원시 값과 일치하며,
해당 Tag값을 사용해 Presentation Style을 적용하도록 구현한다.

//
//  PresentationStyleViewController.swift
//  Transition Practice
//
//  Created by Martin.Q on 2021/11/18.
//

import UIKit

class PresentationStyleViewController: UIViewController {
    
    @IBAction func present(_ sender: UIButton) {
        let SB = UIStoryboard(name: "ViewControllerPresentation", bundle: nil)
        let VC = SB.instantiateViewController(withIdentifier: "presented")
        let style = UIModalPresentationStyle(rawValue: sender.tag) ?? .fullScreen
        VC.modalPresentationStyle = style
        
        present(VC, animated: true, completion: nil)
    }

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

}

사용할 코드는 위와 같다.
Button의 Tag를 사용해 UIModalPresentationStyle을 생성하고, present 메서드를 호출하기 전에 전달해 적용한다.

Page Sheet를 제외하면 모든 방식이 동일하게 Full Screen 방식으로 표시된다.

Presentation Style은 Horizontal Size에 영향을 받는다.
Regular인 경우 기본적으로 Full Screen 방식으로 표시되고, 홈버튼이 없는 기기의 경우 Page Sheet만 정상적으로 동작한다.

Full Screen으로 표시될 때 viewWillDisappear 메서드가 호출되고,
사라질 때 viewWillAppear 메소드가 호출되는 것을 확인할 수 있다.

Presentation 과정에서 Transition이 완료되면 Presenting View Controller가 계층에서 제거되고,
반대로 Presented View Controller가 화면에서 제거되면 계층에 다시 추가된다.

하지만 Over Full Screen으로 표시될 때는 두 메소드가 호출되지 않는다.
Over 접두어가 붙은 Presentation Style은 Presenting View Controller를 계층에서 제거하지 않는다.
그렇기 때문에 로그가 출력되지 않는다.

iPad에서는 Page Sheet는 약간의 여백을,
Form Sheet는 조금 더 큰 여백을 표시한다.
표시할 내용이 많은 경우 Page Sheet를 사용하고, 적은 경우 Form Sheet를 사용하는 편이다.
또한 이 둘은 Presenting View Controller를 계층에서 제거하지 않는다.

Popover 방식은 충돌이 발생하거나 over Full Screen으로 표시하는데,
이는 Popover에서 충족 시겨 줘야 할 몇 가지 조건이 있기 때문이다.

@IBAction func present(_ sender: UIButton) {
    let SB = UIStoryboard(name: "ViewControllerPresentation", bundle: nil)
    let VC = SB.instantiateViewController(withIdentifier: "presented")
    let style = UIModalPresentationStyle(rawValue: sender.tag) ?? .fullScreen
    VC.modalPresentationStyle = style
    
    if let pc = VC.popoverPresentationController {
        pc.sourceView = sender
    }
    
    present(VC, animated: true, completion: nil)
}

Popover 방식으로 표시하기 위해선 sourceView나 barButtonItem을 설정해야 한다.
popoverPresentationController를 바인딩하고, trigger를 SourceView로 설정한다.

if let pc = VC.popoverPresentationController {
    pc.sourceView = sender
    VC.preferredContentSize = CGSize(width: 300, height: 500)
}

prederredContentSize를 통해 popover의 크기를 설정할 수도 있다.

표시되는 크기가 변경됐다.

사용할 Scene은 위와 같다.
current Context 버튼은 ViewControllerPresentation storyboard의 presented Scene과 연결되어있다.

이 둘을 연결하는 Segue는 Current Context로 연결되어있다.
Presentation Context는 새로운 화면이 표시될 Frame을 제공한다.
보통 Container View Controller나 Root View Controller가 이 역할을 수행한다.
지금은 Navigation Controller에 연결되어있기 때문에 Navigation Controller가 Presentation Context이다.
따라서 Current Context로 지정하면 Navigation Controller의 Root View와 동일한 Frame에 새 화면이 표시된다.
이 Presentation Context를 변경해 본다.

//
//  PresentContextViewController.swift
//  Transition Practice
//
//  Created by Martin.Q on 2021/11/18.
//

import UIKit

class PresentContextViewController: UIViewController {
    
    @IBAction func switchChange(_ sender: Any) {
        
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

}

연결된 코드는 위와 같고 Scene의 Switch는 switchChange 메서드와 연결되어있다.
switch의 상태가 on일 때 현재 Scene을 Presentation Context로 지정하도록 구현한다.

@IBAction func switchChange(_ sender: UISwitch) {
    definesPresentationContext = sender.isOn
}

definesPresentationContext 속성을 true로 변경하면 해당 Scene이 Presentation Context로 지정된다.
UIKit이 Presentation을 실행하기 전에 계층에서 해당 속성이 true인 View Controller를 검색하고,
가장 먼저 탐색되는 View Controller가 Presentation Context로 사용된다.

보통의 Content View Controller는 기본값이 false이고,
Contatiner View Controller는 true이다.

해당 속성에 switch의 상태를 동기화하도록 작성했다.

스위치가 꺼진 상태에선 Navigaiton Controller가 Presentation Context가 된다.
따라서 Current Context로 표시되는 새로운 화면에는 Navigation Bar가 표시되지 않는다.
하지만 스위치가 켜진 상태에선 현재의 Scene이 Presentation Context가 된다.
따라서 Current Context로 표시되는 새로운 화면에는 Navigation Bar가 표시된다.

이처럼 유용하게 사용할 수 있지만 사용에는 주의가 필요하다.
Current Context Style은 Presenting View Controller를 계층에서 제거한다.

이 상태에서 이전 화면으로 돌아가면 정상적으로 이전 화면을 불러올 수 없다.
이전으로 돌아가기 전에 Presented View Controller를 제거하거나 Over Current Context Style로 지정해야 한다.

Over Current Context Style에서는 정상적으로 이전 화면을 표시할 수 있다.