티스토리 뷰
iOS Expert | SideMenu 만들기 UIViewControllerTransitioningDelegate
글그리 2020. 12. 7. 14:28많은 애플리케이션이 SideMenu를 사용하고있다. XCode는 TabViewController, NavigationViewController 등은 제공하지만 SideMenu는 제공하지 않는다. 그래서 직접 만들어보기로 했다.
좌측 상단에 버튼을 추가하고 버튼을 눌렀을 때 새로운 ViewController를 추가할 수 있도록 만들면 될 것 같다.
let sideButton = UIButton()
sideButton.setImage(UIImage(systemName: "line.horizontal.3"), for: .normal)
sideButton.addTarget(self, #selector(presentSideMenu), for: .touchUpInside)
scrollView.addSubView(sideButton)
// constraints...
// ...
@objc func presentSidMenu() {
// present side menu
}
적절히 좌측 상단에 3line 이미지를 가진 버튼을 추가하고 눌렀을 때 실행되는 함수를 정의한다. SwiftUI의 경우 버튼처럼 콜백함수가 필요할 때 closure를 사용했는데, UIKit은 아직 object-c의 잔재가 남아있는 것 같다.
SideMenuViewController
다음은 side menu로 사용할 ViewController가 필요하다. 이 메뉴의 내용은 지금 중요한게 아니기 때문에 나중에 side menu로 보여질 때 식별할 수 있도록 배경색만 지정하고 넘어가도록 한다.
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.systemGreen
}
present
present는 화면에 새로운 ViewController를 띄운다. 아무런 설정을 하지않고 사용하면 새로운 ViewController를 pageSheet style로 화면에 추가해준다.
let sideMenu = SideMenuViewController()
present(sideMenu, animated: true, completion: nil)
예전에는 full screen으로 보였지만, IOS13부터 pageSheet가 기본 설정이 되었다고 한다. 새로운 ViewController가 화면 전체를 차지하지 않기 때문에 새로운 화면이 pop up 되었다는 느낌을 주고, 내가 지금 앱의 어느정도 깊이 있는지 시각적으로 알수 있어서 좋은것 같다.
하지만 여기에서 중요한 점은 ViewController가 동시에 2개가 보이고 있다는 점이다. 한 화면에 여러개의 ViewController를 그릴 수 있다면, 새롭게 보이는 ViewController의 위치만 화면의 왼쪽을 차지하도록 바꿔서 SideMenu를 만들 수 있지 않을까?
아래 목록에 적힌 것들을 사용해서 transition 효과를 만들 수 있다. 직접 만들어보면서 하나씩 알아보자.
- UIViewControllerTransitioningDelegate
- UIViewControllerTransitionCoordinator
- UIPresentationController
- UIViewControllerAnimatedTransitioning
UIViewControllerTransitioningDelegate
present로 화면에 보여지게 될 ViewController(=SideMenuViewController)로 화면이 전환될 때 사용할 presentationController와 animatedController 등을 생성하는 protocol로, present를 호출하면 UIKit에 의해 인스턴스가 생성된다. presentationController는 transition 위치를 을 제어하거나 도중에 발생하는 이벤트를 받아서 처리할 수 있고, animatedController은 전환효과에 사용할 animation을 제어한다.
새로운 파일을 만들고 NSObject를 상속받는 클래스를 만든다. UIViewControllerTransitioningDelegate는 NSObject 클래스만 확장할 수 있는 protocol이기 때문이다. 그리고 이 클래스를 확장해서 사용한다. 클래스 원형에서 아무것도 안해도 상관없지만 비어있으면 허전하니까 side menu의 넓이를 저장할 수 있는 변수를 하나 선언한다.
presentationController 함수를 재정의한다. 이곳에서 새롭게 만들 presentationController를 생성해 줄 것이다.
class SideTransitionDelegate : NSObject {
var sideWidth: CGFloat = 0
init(width: CGFloat) {
sideWidth = width
super.init()
}
}
extension SideTransitionDelegate : UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController) -> UIPresentationController? {
// return our UIPresentationController
}
}
아직 생성할 presentation controller가 없기 때문에 아무것도 반환하지 않고있다.
presented, presenting, source 앞으로 나올 코드에서 view, ViewController에 presented, presenting 등 접두어가 붙어있다. presented는 present의 대상 즉, 예제에서 SideMenu를 말하며, presenting은 기존에 있던 최상위. source는 present를 수행한 주체 즉, 예제에서 Profile을 말한다. 일반적인 경우 source = presenting이지만 항상 같지는 않다. |
UIPresentationController
새로운 파일을 만들고 이번에는 UIPresentationController를 상속받는 클래스를 생성한다. UIKit은 새로운 ViewController를 화면에 그릴 때 UITransitionView를 만들고, 그 위에 새로운 ViewController를 그리는데, 이 transitionView를 관리하는 controller라고 볼 수 있다.
이 controller에서 몇개의 함수와 변수를 재정의해서 원하는 동작을 하도록 만들어보자. 먼저 containerView에 배경으로 사용할 view를 추가할 것이다. 생성자에서 dimmingView라는 view를 생성하고 초기화한다.
class SidePresentationController : UIPresentationController {
private var dimmingView: UIView!
init(presentedViewController: UIViewController, presenting presentinViewController: UIViewController?) {
super(init(presentedViewController: presentedViewController, presenting: presentingViewController)
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
dimmingView.alpha = 0.0
}
}
presentationTransitionWillBegin()은 전환효과(transition)이 실행되기 직전에 호출되는 함수로 transition이 실행되기 전에 필요한 처리를 할 수 있다. 여기에서는 dimmingView를 추가하고, 반투명하게 변하도록 animation효과를 준다.
UIKit은 present가 실행될 때 UIPresentationController를 생성하면서 UIViewControllerTransitionCoordinator 인스턴스를 생성한다. 이 인스턴스는 transitionCoordinate라는 이름으로 ViewController에 생성되며, ViewController 외부에서 transition효과와 동시에 어떤 효과가 보여져야 할 때 활용할 수 있다.
dimmingView의 경우 SideMenuController에 속해있지 않는 외부 view이기 때문에 SideMenuViewController의 animationController로는 제어할 수 없고, coordinator를 통해 동시에 효과를 재생할 수 있도록 한다.
// SidePresentationController
override func presentationTransitionWillBegin() {
guard let dimmingView = dimmingView, let containerView = containerView else {
return
}
// add dimming view
containerView.insertSubView(dimmingView, at: 0)
// constraints...
// dimming view animation
if let coordinator = presentedViewController.transitionCoordinator {
coordinator.animate(alongsideTransition: {_ in
self.dimmingView.alpha = 1.0
}, completion: nil)
} else {
dimmingView.alpha = 1.0
}
}
dismissalTransitionWillBegin() 는 dismiss 즉, 화면에서 사라지는 transition이 실행되기 직전에 호출된다. present와 같은 방법으로 dimmingView를 안보이게 처리할 수 있다.
// SidePresentationController
override func dismissalTransitionWillBegin() {
if let coordinator = presentedViewController.transitionCoordinator {
coordinator.animate(alongsideTransition: {_ in
self.dimmingView.alpha = 0.0
}, completion: nil)
} else {
dimmingView.alpha = 0.0
}
}
coordinate가 실행하는 animate와 동시에 dimmingView.alpha가 1.0, 0.0 으로 바뀌는건 알겠는데, coordinate가 어떤 animation을 실행하는지, 길이는 얼마나 되는지 아직 아무것도 정의해 준게 없다. coordinate가 사용할 animatedController을 정의하지 않았기 때문이다. presentationController에서 아직 해야할 작업이 남았으니, 이 작업을 마무리하고 다시 delegate로 돌아가자.
frameOfPresentedViewInContainerView는 이름 그대로 containerView 안에서 presentedView의 크기를 뜻하는 변수다. present가 끝났을 때 SideMenuViewController가 화면에서 차지할 크기를 말한다. presentecView가 화면의 왼쪽에서만 보일 수 있도록 frame을 만들어서 돌려준다.
// SidePresentationController
override var frameOfPresentedViewInContainerView: CGRect {
var frame: CGRect = .zero
frame.size = CGSize(100, containerView!.bound.height)
// can be
// frame.size = CGSize(sideWidth, containerView!.bound.height)
return frame
}
100이라는 상수를 사용했는데, 아까 delegate를 만들 때 받은 sideWidth값을 가져다가 사용하면 될 것 같다. delegate에서 했던 것과 같은 방법으로 변수를 추가하고 생성자도 override해서 100 대신 sideWidth값을 사용할 수 있도록 하자.
delegate로 돌아가서 아무것도 반환하지 않던 extension에 SidePresentationController를 생성해서 돌려주는 코드를 추가한다.
// SideTransitionDelegate
extension SideTransitionDelegate : UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController) -> UIPresentationController? {
return SidePresentationController(presentedViewController: presented,
presenting: presenting,
width: sideWidth)
}
}
코드만 보면 머리아프니까 지금까지 진행사항을 시각적으로 보면 이렇다.
UIViewControllerAnimatedTransitioning
다시 delegate로 돌아가서 animationController를 정의하자. 프로토콜에 보면 interactionController도 있는데 이건 지금 사용할건 아니기 때문에 animationController(forPresented:)와 animationController(forDismissed:)를 재정의한다.
// SideTransitionDelegate
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// return our animation instance
}
func animationController(forDismissed dismissed) -> UIViewControllerAnimatedTransitioning? {
// return our animation instance
}
사실 여기에서 아무것도 return하지 않으면 아래에서 위로 올라오는 animation이 기본적으로 재생된다. 하지만 side menu는 옆에서 등장해야하기 때문에 필요한 동작을 할 수 있는 class를 새롭게 만들어보자.
새로운 파일을 만들고 delegate와 마찬가지로 NSObject를 상속받도록 한다. present, dismiss 두가지 경우에 따라 다르게 동작해야 하기 때문에 구분할 수 있는 isPresent 플래그를 추가한다.
class SidePresentationAnimator : NSObject {
var isPresent: Bool
init(isPresent: Bool) {
self.isPresent = isPresent
super.init()
}
}
UIViewControllerAnimatedTransitioning또한 NSObject protocol이다. 필요한 두 함수를 정의한다. 플래그에 따라 화면 왼쪽 밖에서 등장하거나, 화면 왼쪽 밖으로 퇴장하도록 frame animation을 만든다.
extension SidePresentationAnimator : UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let key = UITransitionContextViewControllerKey = isPresent ? .to : .from
guard let controller = transitionContext.viewController(forKey: key) else {
return
}
if isPresent {
transitionContext.containerView.addSubView(controller.view)
}
let presentFrame = transitionContext.finalFrame(for: controller)
var dismissFrame = presentFrame
dismissFrame.origin.x = -presentFrame.width
let initialFrame = isPresent ? dismissFrame : presentFrame
let finalFrame = isPresent ? presentFrame : dismissFrame
controller.view.frame = initialFrame
UIView.animate(withDuration: transitionDuration(using: transitionContext,
animation: {
controller.view.frame = finalFrame
}, completion: {bFinished in
if !self.isPresent {
controller.view.removeFromSuperView()
}
transitionContext.completeTransition(bFinished)
})
}
}
그리고 이 인스턴스를 를 생성할 수 있도록 다시 delegate로 돌아가서 코드를 추가한다. 이렇게 만들어진 animationController에 의해 실행된 UIView.animate가 바로 위에서 coordinate가 재생하고있는 animation이다. 따라서 이 길이를 조절하면 dimmingView가 반투명하게 되는 시간도 동시에 바뀐다.
// SideTransitionDelegate
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return SidePresentationAnimator(isPresent: true)
}
func animationController(forDismissed dismissed) -> UIViewControllerAnimatedTransitioning? {
return SidePresentationAnimator(isPresent: false)
}
Use
이렇게 다 만들었지만 실행해보면 아무것도 바뀐 것이 없다. 열심히 만든 transitionDelegate를 아직 사용하지 않고 있기 때문이다. ViewController는 present가 실행되면 transitionDelegate를 사용해서 controller를 생성한다고 했다. SideMenuViewController의 transitionDelegate를 위에서 만든 SideTransitionDelegate로 지정하자.
또 UIPresentationController를 customize 했기 때문에 modalPresentationStyle enum을 .custom으로 바꿔준다. 첫 부분에서 말한 pageSheet가 기본값이었던 enum 변수이다.
let sideMenu = SideMenuViewController()
sideMenu.transitionDelegate = SideTransitionDelegate()
sideMenu.modalPresentationStyle = .custom
present(sideMenu, animated: true, completion: nil)
이미 포스팅이 너무 길어지고 있어서 의미만 맞으면 최대한 짧게 표현하고 있지만, 사실 delegate인스턴스의 경우 이렇게 present를 할 때마다 생성하기 보다는 미리 생성해두고 여러곳에서 사용하는 것이 좋다.
Dismiss
이제 프로필 좌측 상단에 있는 버튼을 누르면 프로필이 조금 어두워지면서 side menu가 나타나는 것을 볼 수 있다. 하지만 다시 돌아갈 수가 없다. 오른쪽 어두운 부분을 터치하면 dismiss될 수 있도록 tapGestureRecognizer를 추가해보자.
// SidePresentationController
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
dimmingView.alpha = 0.0
let recognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSideMenu))
dimmingView.addGestureRecognizer(recognizer)
// ...
@objc func dismissSideMenu() {
presentingViewController.dismiss(animated: true, completeion: nil)
}
presentingViewController(=ProfileViewController)의 dismiss를 호출한다는 점이 좀 의아했는데, present를 실행한 주체가 presentingViewController라는 점을 생각하면 present를 한 객체가 dismiss까지 책임진다는 점에서 당연한 것일 수도 있겠다.
Result
일단 side menu가 보이도록 만들어 보았다. 메뉴 구성은 아직 생각해둔게 없지만 tableView가 되지 않을까 싶다.
뭘 넣지?
전체 소스코드는 아래 링크에서 다운받을 수 있다.
https://github.com/eastroot1590/TodoChallenge/releases/tag/SideMenu_Initial
'Programming > IOS' 카테고리의 다른 글
IOS Beginner | 서버로부터 데이터 수신하기 URLSession (0) | 2020.12.09 |
---|---|
iOS Expert | SideMenu에 gesture 추가하기 UIPercentDrivenInteractionTransitioning (0) | 2020.12.08 |
iOS Expert | UIScrollView StickyHeader 만들기 (0) | 2020.12.03 |
IOS Beginner | MVC 패턴으로 분리 (0) | 2020.12.01 |
IOS Beginner | CALayer를 사용해서 다양한 모양 연출하기 (0) | 2020.11.30 |
- Total
- Today
- Yesterday
- 운영체제
- scala
- SOCKET
- 데이터베이스
- Spring
- mongoDB
- Cocos2d-x
- 수학
- C/C++
- C
- ios
- C++
- 국내여행
- game
- swift
- 알고리즘
- Java
- JSP
- 자료구조
- rxswift
- machine learing
- Git
- OS
- 드라마
- database
- SwiftUI
- winsock
- ue4
- DesignPattern
- SHADER
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |