본문 바로가기

학습 노트/iOS (2021)

108 ~ 109. Orientation, Rotation and Container View Controller

Orientation and Rotation

iOS는 홈버튼의 위치에 따라서 Device Orientation을 7가지로 구별한다.

  • Portrait
    홈버튼이 아래쪽에 위치하는 가장 기본적인 상태이다.
  • Portrait Upside Down
    홈버튼이 위쪽에 위치하는 상태이다.
  • Landscape Left
    홈버튼이 왼쪽에 위치하는 상태이다.
  • Landscape Right
    홈버튼이 오른쪽에 위치하는 상태이다.
  • Face Up
    홈버튼이 하늘을 향하는 상태이다.
  • Face Down
    홈버튼이 바닥을 향하는 상태이다.
  • Unknown
    iOS가 Device Orientation을 인식할 수 없는 상태이다.

Interface Orientation은 앱이 지원하는 논리적 Orientation이다.
기본적으로 아이패드는 모든 Orientation을 지원하지만 아이폰에서는 Portrait Upside Down을 지원하지 않는다.
기기를 돌리면 지원하는 Orientation으로 앱 화면이 회전한다.

일반적인 경우 Interface Orientation과 Device Orientation은 일치한다.
Interface Orientation은 View Controller 마다 개별적으로 설정하기 때문에
특정 View Controller가 Portrait View만 지원하도록 설정돼있다면 이 View Controller는 항상 Portrait View로 표시된다.

기기가 회전하면 Rotation 이벤트가 발생한다.
Device Rotation 이벤트는 UIDevice와 Notification 패턴을 활용해 처리하지만
이를 직접 처리하게 되는 경우는 흔하지 않다.

Interface Rotation 이벤트는 UIViewController를 Overriding 하는 방식으로 처리한다.
대부분의 Rotation 이벤트는 이렇게 처리한다.

앱이 지원하는 Device Orientation은 앱의 Deployment Info에서 설정한다.
설정한 Orientation은 info에 저장된다.

설정에서 선택한 값들은 Supported interface Orientations와 Supported interface Orientations(iPhone)에 저장된다.
아이패드에서도 같은 값을 적용하려면 Supported interface Orientations (iPad)를 수정하거나 삭제하면 된다.

사용할 씬은 위와 같다.
Orientation 씬이 Modal 방식으로 표시되게 된다.

//
//  InterfaceOrientationViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/19.
//

import UIKit

class InterfaceOrientationViewController: UIViewController {

	override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
	
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
	
	}

}

UIViewController 클래스에는 supportedInterfaceOrientations 속성이 선언되어있다.
아이패드에서는 모든 Orientation을 반환한다.
아이폰에서는 Portrait Upside Down을 제외한 모든 Orientation이 반환되도록 되어있다.
supportedInterfaceOrientations 속성을 통해 지원할 Orientation을 지정한다.

//
//  InterfaceOrientationViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/19.
//

import UIKit

class InterfaceOrientationViewController: UIViewController {
	
	override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
		return [.landscapeLeft, .landscapeRight]
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
	}

}

landscapeleft와 landscaperight를 배열에 담아 반환해 landscape 모드만 지원하도록 했다.

//
//  InterfaceOrientationViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/19.
//

import UIKit

class InterfaceOrientationViewController: UIViewController {
	
	override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
		return [.landscapeLeft, .landscapeRight]
	}
	
	override var shouldAutorotate: Bool {
		return true
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
	}

}

shuldAutorotate 속성이 true를 반환하면 기기가 회전할 때마다 지원하는 Interface Orientation으로 자동으로 회전된다.
반대로 false를 반환하면 회전하지 않는다.

적용하고 결과를 확인해 보면 Landscape 모드로만 화면이 표시된다.

두 번째 버튼은 Navigation Controller를 거쳐 같은 씬으로 전환된다.

하지만 이 경우에는 Portrait Mode로 씬을 표시한다.
Navigation Controller처럼 Container View에 포함되어있는 경우
해당 Container View가 Orientation을 결정한다.
따라서 지금과 같은 경우엔 구현한 코드가 무용지물이 된다.

//
//  OrientationVavViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/19.
//

import UIKit

class OrientationVavViewController: UINavigationController {
	override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
		return [.landscapeRight, .landscapeLeft]
	}
	override var shouldAutorotate: Bool {
		return true
	}
}

Navigation Controller에 연결된 Custom Class에도 동일한 코드를 작성한 뒤 확인해 보면

두 번째 Button을 눌러도 Landscape Mode로 화면을 표시한다.

마지막 Button을 누르면 Media Player가 동작한다.

사용할 씬은 위와 같고,

//
//  LandscapeModalViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/19.
//

import UIKit
import AVFoundation

class LandscapeModalViewController: UIViewController {
    
    @IBOutlet weak var closeButton: UIButton!
    @IBOutlet weak var playerView: PlayerView!
    

    override func viewDidLoad() {
        super.viewDidLoad()

        guard let url = Bundle.main.url(forResource: "video2", withExtension: "mov") else { return }
        let player = AVPlayer(url: url)
        playerView.player = player
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        playerView.player?.play()
    }
    
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        playerView.player?.pause()
    }
}

extension UIUserInterfaceSizeClass {
    var description: String {
        switch self {
        case .compact:
            return "Compact"
        case .regular:
            return "Regular"
        default:
            return "Unspecified"
        }
    }
}

씬에 연결된 코드는 이렇게
씬이 로드된 다음 미디어를 불러오고,
뷰가 나타난 다음 이를 재생한다.
뷰가 사라질 때는 재생을 멈추도록 구현되어있다.

//
//  PlayerView.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/19.
//

import UIKit
import AVFoundation

class PlayerView: UIView {
    var player: AVPlayer? {
        get {
            return playerLayer.player
        }
        set {
            playerLayer.player = newValue
        }
    }
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

PlayerView에 연결된 클래스 파일은 위와 같다.
프로젝트를 생성할 때 조금 애를 먹었는데, 이번 실습에는 실제 미디어 파일을 재생하기 때문에 이를 올바르게 import 해야 한다.
AVPlayer를 사용하기 때문에 AVFoundation을 import 해 줘야 한다.

video와 video2 파일과 같이 원하는 경로에 드래그해서 import 하면 되지만,

다음에 뜨는 창에서 Add to targets을 제대로 선택해 줘야
미디어의 url을 받아오는 과정에서 오류를 막을 수 있다.

View가 Modal 방식으로 나타나면서 지정한 미디어를 즉시 재생하는 것을 볼 수 있다.
지금은 Portrait 방식으로 재생되지만, 앞에서 했던 것처럼 Landscape를 반환해
해당 모드로 표시되게 할 수도 있다.

이번에는 기기의 방향에 따라 해당 모드를 지원하되,
처음 표시될 때에는 Landscape 모드로 재생되도록 구현해 본다.

class LandscapeModalViewController: UIViewController {
	
	@IBOutlet weak var closeButton: UIButton!
	@IBOutlet weak var playerView: PlayerView!
	
	override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
		return .landscapeLeft
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
	
	guard let url = Bundle.main.url(forResource: "video2", withExtension: "mov") else { return }
		let player = AVPlayer(url: url)
		playerView.player = player
	}
	
	override func viewDidAppear(_ animated: Bool) {
		super.viewDidAppear(animated)
		
		playerView.player?.play()
	}
	
	
	override func viewWillDisappear(_ animated: Bool) {
		super.viewWillDisappear(animated)
		
		playerView.player?.pause()
	}
}

preferredInterfaceOrientationForPresentation 메소드를 override 해 landscapeLeft를 반환한다.
이것으로 처음 Modal에 진입할 때 홈버튼이 왼쪽으로 향한 방향으로 재생이 된다.

의도한 대로 처음 진입 시 Landscape 모드로 재생되고, 화면 회전 시에도 잘 반응한다.
지금은 미디어가 4:3 비율이라 괜찮지만

영상의 비율이 달라지면 X 표시가 영상의 색 때문에 시인성이 떨어지는 경우가 생긴다.
이를 해결해 본다.

override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
	super.willTransition(to: newCollection, with: coordinator)
}

메소드를 추가한다.
기기를 회전하게 되면 Interface Orientation이 메소드에 따라 회전된다.
이렇게 되면 연관된 traitCollection이 업데이트된다.
willTransition 메소드는 실제로 trait Collection이 업데이트되지 직전에 호출된다.
Trait Collection의 verticalSize를 통해 가로인지 세로인지를 대략적으로 파악한다.

interfaceOrientation을 통해 정확히 판단할 수도 있지만 adaptive Layout이 도입된 iOS8 이상부터는 사용되지 않는다.

override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
	super.willTransition(to: newCollection, with: coordinator)
	
	print(newCollection.verticalSizeClass.description)
	
	switch newCollection.verticalSizeClass {
	case .regular:
		closeButton.backgroundColor = UIColor.red.withAlphaComponent(0.3)
	default:
		closeButton.backgroundColor = UIColor.blue.withAlphaComponent(0.3)
	}

}

verticalSizeClass를 통해 레이아웃을 파악한다.
regular일 경우 배경을 붉은색으로, compact일 경우 파란색으로 변경한다.

위와 같이 배경이 변하고, 콘솔에 로그도 출력되는 것을 볼 수 있다.
하지만 이는 아이폰에만 해당되는 결과이다.

아이패드에서는 정상적으로 동작하지 않는 것을 볼 수 있고, 콘솔에도 로그가 표시되지 않는다.
앞에서 구현한 willTransition 메소드는 trait Collection이 업데이트돼야 호출된다.
하지만 아이패드에서 전체 화면으로 단일 앱을 실행하는 경우는 기기의 방향에 관계없이 reguler로 고정된다.
따라서 Orientation을 정확하게 판단하고 싶다면 Root View의 크기를 기준으로 판단해야 한다.

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
	super.viewWillTransition(to: size, with: coordinator)
	
	print(size.height, size.width)
	
	if size.height > size.width {
		closeButton.backgroundColor = UIColor.red.withAlphaComponent(0.3)
	} else {
		closeButton.backgroundColor = UIColor.blue.withAlphaComponent(0.3)
	}
}

viewWillTransition 메소드는 Root View의 size가 업데이트되기 전에 호출된다.
첫 번째 파라미터로 새로운 사이즈가 반환된다.
해당 값을 이용해서 Orientation을 판단할 수 있다.

진입하는 시점에는 크기가 변경되지 않기 때문에 메소드가 호출되지 않는다.
따라서 배경색이 변경되지 않는다.

두 메소드 모드 마지막 파라미터로 transition coordinator가 반환된다.
이는 Animation을 적용할 때 사용된다.
이를 사용해 배경색을 변경할 때 animation을 적용해 본다.

override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
	super.willTransition(to: newCollection, with: coordinator)
	
	print(newCollection.verticalSizeClass.description)
	
	coordinator.animate(alongsideTransition: { (context) in
		switch newCollection.verticalSizeClass {
		case .regular:
			self.closeButton.backgroundColor = UIColor.red.withAlphaComponent(0.3)
		default:
			self.closeButton.backgroundColor = UIColor.blue.withAlphaComponent(0.3)
		}
	}, completion: nil)
}

coordinator의 animate 속성으로 애니메이션을 적용할 수 있다.

 

Container View Controller

보통의 View Controller는 화면을 구성하는 UI를 직접 구현하고 이벤트를 처리한다.
이것을 Content View Controller라고 부른다.
반면 Container View Controller는 Child View Controller를 관리하고, Layout과 Transition을 담당한다.
Content View Controller가 담당하던 UI 구성과 이벤트 처리는 Child View Contoller가 담당한다.

모든 앱에서 요구하는 Container View Controller는 CocoaTouch에서 기본으로 제공한다.
Navigation Controller가 대표적이고, 자신이 관리하는 Child View Controller를 Push & POP 방식으로 전환한다.
Tab Bar Controller는 화면 하단의 tab을 선택할 때마다 화면의 Child View Controller를 교체한다.

class CustomContainer: UIViewController {
}

Custom Content View Controller는 UIViewController가 제공하는 API를 활용해 자유롭게 구현한다.

View Controller의 기능이 방대하고 복잡할 경우 이를 쪼개 구현한 뒤,
Container View Controller로 표시하면 구현이 단순해지고, 유지보수에도 유리하다.

사용할 씬은 위와 같다.
Top과 Bottom 씬을 상단의 View Controller에 표시하도록 구현한다.
즉 상단의 View Controller는 Container View Controller로서 동작하고, 나머지는 Child View Controller로 동작한다.
Child View Controller는 같은 크기로 표시되며, 수직으로 배치한다.

Child View를 추가하는 가장 쉬운 방법은 라이브러리에서 Container View를 추가하는 것이다.

추가하면 위와 같은 형태가 된다.
씬에 추가된 것은 Container View, 오른쪽에는 Container View에 해당하는 새로운 View Controller가 표시된다.
즉 새롭게 추가된 View Controller는 Container View의 Child View로써 동작한다.

아래쪽에 새로운 View Controller를 추가하고,

같은 크기로 표시될 수 있도록 제약을 추가한다.

Container View에 묶여있는 View Controller를 수정하면 씬의 상단에 표시되게 된다.
지금은 별도로 구성하지 않고, TOP 씬을 Child Controller로 지정하여 표시하도록 한다.

현재 존재하는 View Controller를 삭제하고,

Embed로 이 둘을 묶는다.

이렇게 되면 viewDidLoad가 호출되는 시점에 Child로써 추가되고,
Container View의 Frame에 Child View의 Root View가 표시된다.
Embed가 아닌 다른 방식으로 연결할 경우 제대로 동작하지 않으므로 주의해야 한다.

실행해 보면 의도한 대로 동작하는 것을 확인할 수 있다.

이번엔 코드를 통해 Child를 추가해 본다.

//
//  ContainerViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/23.
//

import UIKit

class ContainerViewController: UIViewController {
    
    @objc func removeChild() {
        
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(removeChild))
    }

}

extension ContainerViewController {
    override var description: String {
        return String(describing: type(of: self))
    }
}

사용할 코드는 위와 같다.

class ContainerViewController: UIViewController {
	@IBOutlet weak var BottomContainer: UIView!
	
	@objc func removeChild() {
	
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
		navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(removeChild))
	}

}

우선 아래쪽의 View를 outlet으로 연결한다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	if let childV = storyboard?.instantiateViewController(withIdentifier: "BottomContainer") {
		addChild(childV)
	}
	
	navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(removeChild))
}

Child View를 추가하는 코드는 viewDidLoad에서 작성한다.
우선 새로운 Controller 인스턴스를 생성하고, addChild 메소드를 사용해 Child View를 지정한다.
이 상태로라면 지정은 됐지만 실제로 화면에 표시하지는 않는다.

if let childV = storyboard?.instantiateViewController(withIdentifier: "BottomContainer") {
	addChild(childV)
	childV.view.frame = BottomContainer.bounds
	BottomContainer.addSubview(childV.view)
}

 

표시하려면 Frame을 설정하고 계층에 추가해야 한다.
Frame은 미리 생성해 둔 View의 크기에 맞춰 설정하고, Child View의 Root View를 subView로 추가한다.
이때 instanctiateViewContoller 메소드에 전달되는 identifier는 View Controller의 ID로

View Controller의 Identity Inspector에서 설정할 수 있다.

이렇게 두 개의 View Controller가 화면에 표시된다.

Child View를 추가할 때는 Root View를 계층에 추가하기 전에 Container에 추가해야 한다.
반대로 제거할 때는 계층에서 제거한 다음 Container에서 제거한다.

View Controller에 버튼을 하나 추가한다.

//
//  BottomViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/23.
//

import UIKit

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

}

View Controller에 연결된 클래스 파일에 action으로 연결하고,
기능을 구현한다.

@IBAction func removeFromParent(_ sender: Any) {
	view.removeFromSuperview()
	removeFromParent()
}

위에서 언급했듯 제거할 때는 계층에서 먼저 제거한 뒤 Container에서 제거한다.
이렇게 View를 제거하는 코드는 보통 Child View에서 구현하지만,
반대로 Container View에서 구현하는 것도 가능하다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	if let childV = storyboard?.instantiateViewController(withIdentifier: "Bottom") {
		addChild(childV)
		childV.view.frame = BottomContainer.bounds
		BottomContainer.addSubview(childV.view)
	}
	
	navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(removeChild))
}

Container View에는 Navigation Bar Item이 추가되어있고,

@objc func removeChild() {

}

해당 버튼은 위의 메소드와 연결되어있다.
해당 메소드를 통해 Child를 제거하도록 구현한다.

@objc func removeChild() {
	for vc in children {
		vc.view.removeFromSuperview()
		vc.removeFromParent()
	}
}

Child View의 목록은 children 속성에 배열로 저장되어있다.
따라서 해당 배열을 순회하며 Child View를 제거하고, Container에서도 삭제한다.

각각의 Button들이 제대로 동작한다.

View Controller는 Container에 View가 추가되거나 삭제되는 시점에 메소드를 호출한다.

override func willMove(toParent parent: UIViewController?) {
	super.willMove(toParent: parent)
}

willMove 메소드는 Container에 View가 추가되거나 삭제 될 때 마다 호출된다.
추가될 때는 Container View Controller가 전달되고, 삭제될 때는 nil이 전달된다.

override func willMove(toParent parent: UIViewController?) {
	super.willMove(toParent: parent)
	print(String(describing: type(of: self)), #function, parent?.description ?? "nil")
}

로그를 출력하도록 작성한다.

override func didMove(toParent parent: UIViewController?) {
	super.didMove(toParent: parent)
	print(String(describing: type(of: self)), #function, parent?.description ?? "nil")
}

didMove 메소드는 Container에 추가되거나 삭제된 후에 호출된다.
전달되는 파라미터는 이전과 동일하다.
마찬가지로 로그를 출력하도록 구현한다.

//
//  TopViewController.swift
//  ViewControllerPractice
//
//  Created by Martin.Q on 2021/10/23.
//

import UIKit

class TopViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
    }
    
    override func willMove(toParent parent: UIViewController?) {
        super.willMove(toParent: parent)
        print(String(describing: type(of: self)), #function, parent?.description ?? "nil")
    }
    
    override func didMove(toParent parent: UIViewController?) {
        super.didMove(toParent: parent)
        print(String(describing: type(of: self)), #function, parent?.description ?? "nil")
    }

}

동일한 코드를 Top View Controller의 Custom Class 파일에도 작성하고 결과를 확인한다.

View가 표시될 때는 위와 같은 로그가 발생하고,
TopViewController에서 willMove와 didMove 메소드가 호출됐고,
BottomViewController에서 willMove 메소드가 호출된 것을 볼 수 있다.

둘의 차이점은 TopViewController는 embed segue로 연결됐고,
나머지 하나는 코드로 연결됐다는 점이다.
즉 embed로 연결했을 때는 정상적으로 두 메소드가 모두 호출되지만 코드는 그렇지 않다.
따라서 이러한 경우 직접 호출해 줘야 한다.

Child View를 삭제하면 위와 같은 로그를 확인할 수 있다.
이 또한 마찬가지로 코드를 통해 삭제했기 때문에 didMove 메소드만 호출됐다.

두 경우 모두 해결해 본다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	if let childV = storyboard?.instantiateViewController(withIdentifier: "Bottom") {
		addChild(childV)
		childV.didMove(toParent: self)
		childV.view.frame = BottomContainer.bounds
		BottomContainer.addSubview(childV.view)
	}
	
	navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(removeChild))
}

Container View Controller에서는 addChild 다음에 didMove 메소드를 직접 호출해 준다.
파라미터로는 self를 전달한다.

@objc func removeChild() {
	for vc in children {
		vc.willMove(toParent: nil)
		vc.view.removeFromSuperview()
		vc.removeFromParent()
	}
}

제거할 때는 willMove 메소드를 추가해 준다.
willMove 메소드는 계층에서 제거되기 전에 호출해야 한다.

씬에 진입할 때와 View를 삭제할 때 willMove와 didMove가 모두 호출되는 것을 확인할 수 있다.
이때 didMove는 삭제되는 시점에 연속하여 호출될 수 있음을 감안하도록 하다.