개념 정리
UISheetPresentationController는 iOS 15 이상에서 제공되는 UIKit의 API로, 모달 시트 스타일의 뷰 컨트롤러를 제공하기 위해 사용된다.
UISheetPresentationController가 가지는 기본적인 특징은 아래와 같다.
UISheetPresentationController는 뷰 컨트롤러의 presentationController** 속성에 자동으로 생성되기 때문에 별도의 뷰를 만들거나 할 필요가 없다.
뷰 컨트롤러를 모달뷰로 사용하고 싶다면 단순히 modalPresentationStyle 혹은 modalTransitionStyle을 지정해주면 된다.
// 사용 예시
viewController.modalPresentationStyle = .formSheet
detents[]).medium(): 중간 크기..large(): 화면의 거의 전체를 차지하는 크기..custom(...): 커스텀 높이를 정의.sheet.detents = [.medium(), .large()]
largestUndimmedDetentIdentifiernil (배경 흐림 효과 적용).sheet.largestUndimmedDetentIdentifier = .medium
prefersGrabberVisiblefalse.sheet.prefersGrabberVisible = true
prefersScrollingExpandsWhenScrolledToEdgetrue.sheet.prefersScrollingExpandsWhenScrolledToEdge = false
preferredCornerRadiusnil (시스템 기본값 사용).sheet.preferredCornerRadius = 20.0
UISheetPresentationController.Detent.custom을 사용하여 원하는 높이를 설정할 수 있다.
if let sheet = modalVC.sheetPresentationController {
let customDetent = UISheetPresentationController.Detent.custom { context in
return 300 // 원하는 높이 (pt)
}
sheet.detents = [customDetent, .large()]
}
if let sheet = modalVC.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.selectedDetentIdentifier = .medium // 초기 높이 설정
}
시트 내부에 UIScrollView나 UITableView를 배치하면, 시트 높이와 스크롤 동작이 연동된다.
.pageSheet 또는 .formSheet로 설정해야 UISheetPresentationController가 적용된다.내용 정리
개인 과제를 진행 중 '내 프로필' 이라는 UI를 구현하여 이 뷰를 클릭했을 때 모달뷰로 내 프로필을 수정할 수 있는 화면을 보여주는 기능 구현하기
개인과제를 진행하다가 '내 프로필'을 구현하고 싶어서 시도를 해보았다. UI를 만드는건 어렵지 않았지만, 없던 것을 추가하는 탓에 기존 UI 세팅도 많이 건들어야 했어서 귀찮았다...

위 사진과 같이 UI를 구현했다.(그라데이션 색은 다음부터는 사용하고 싶지 않다...)
이렇게만 있으면 '내 프로필'이 고정되기 때문에 다른 연락처들처럼 수정할 수 있도록 구현하고 싶었다.
다만, 다른 연락처들 처럼 네비게이션 기능을 이용하는 것이 아니라 '모달' 기능을 이용해서 프로필 수정 화면을 구현하고 싶었다.
그래서 UISheetPresentationController라는 것을 찾아보았고 실제 과제에 적용해 보았다.
우선 내 프로필을 보여주는 뷰는 헤더와 스택뷰로 이루어진 커스텀 UIView이다.
때문에 이 상태로 모달뷰를 구현하면 내 프로필의 헤더를 클릭해도 모달이 표시되는 오류(?)가 발생하게 된다.
이것을 수정하기 위해 커스텀한 UIView에서 hitTest 메소드를 재정의 해주도록 한다.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard !self.stackView.frame.contains(point) else {
return super.hitTest(point, with: event)
}
return nil
}
이 코드는 사용자가 터치한 영역이 헤더뷰가 아닌 스택뷰일 경우에만 터치 이벤트를 발생시키도록 하는 코드이다.
다음으로 모달 뷰를 구현해 보도록 하자.
모달뷰를 구현하려면 '내 프로필' 뷰를 터치했을 때 모달뷰가 나타나도록 해야하는데, '내 프로필' 뷰는 UIView이다.
때문에 터치 이벤트를 작동시키기 위해 별도의 코드가 필요한데, 이번에는 UITapGestureRecognizer를 사용해 보았다.
/// 프로필 뷰의 UI를 세팅하는 메소드
func setupMyProfileView() {
self.myProfileView.backgroundColor = .lightGray.withAlphaComponent(0.1)
let touchEvent = UITapGestureRecognizer(target: self, action: #selector(showMyProfile)) // 터치 이벤트
self.myProfileView.addGestureRecognizer(touchEvent)
}
이렇게 하면 마치 UIButton처럼 해당 뷰에서 터치 이벤트가 일어났을 때 설정한 메소드를 실행할 수 있다.
기본적인 세팅을 맞쳤으니 이제 진짜 모달뷰를 구현할 차례이다.
모달뷰를 구현하는 것은 크게 어렵지 않다.
// 내 프로필 뷰를 눌렀을 때 작동될 메소드
@objc func showMyProfile() {
let modalProfileView = PhoneBookViewController()
modalProfileView.sheetPresentationController?.preferredCornerRadius = 50 // 모서리를 둥글게
modalProfileView.modalPresentationStyle = .formSheet // 모달 스타일 지정
self.present(modalProfileView, animated: true)
}
이렇게 하면 PhoneBookViewController가 모달의 형태로 나오게 될 것이다.

계획대로...
다만, 지금의 모달뷰는 문제가 있다.
PhoneBookViewController는 수정된 데이터를 저장하는 버튼을 네비게이션바에 추가했기 때문에 모달뷰로 PhoneBookViewController를 부른 현 상황에서는 데이터를 저장할 방법이 없다.
그래서 새로운 버튼을 만들어 주기로 했다.
우선 PhoneBookViewController 파일로 이동해서 새로운 버튼을 선언해준다.
private let myProfileSaveButton = UIButton()
// 내 프로필 정보를 저장하는 버튼의 UI를 세팅하는 메소드
func setupSaveButton() {
var config = UIButton.Configuration.plain()
let gradientColor = CAGradientLayer()
let colors: [UIColor] = [.systemMint.withAlphaComponent(0.2), .cyan.withAlphaComponent(0.3), .blue.withAlphaComponent(0.1)]
gradientColor.frame = CGRect(x: 0, y: 0, width: 120, height: 50)
gradientColor.colors = colors.map { $0.cgColor }
gradientColor.startPoint = CGPoint(x: 0.0, y: 0.0)
gradientColor.endPoint = CGPoint(x: 1.0, y: 1.0)
var titleAttr = AttributedString("저장하기")
titleAttr.font = .systemFont(ofSize: 25, weight: .bold)
titleAttr.foregroundColor = .white
config.attributedTitle = titleAttr
config.baseForegroundColor = .white
config.baseBackgroundColor = .clear
self.myProfileSaveButton.configuration = config
self.myProfileSaveButton.clipsToBounds = true
self.myProfileSaveButton.layer.addSublayer(gradientColor)
self.myProfileSaveButton.layer.cornerRadius = 20
self.myProfileSaveButton.addTarget(self, action: #selector(savedMyProfile), for: .touchDown)
}
버튼을 세팅하는 메소드가 상당히 긴데... 버튼의 색을 화려하게 꾸미고 싶어서 CAGradientLayer를 설정하느라 그렇다. 이렇게 하면 예쁜 버튼을 만들 수 있지만 코드를 어떻게 작성했냐에 따라 귀찮을 수 있기 때문에... 차라리 에셋에 이미지를 추가해서 사용하는게 편할 것 같다.
어쨌든 새로운 버튼을 만들어 줬으니 이제 버튼을 눌렀을 때 작동할 메소드도 만들어 준다.
// 내 프로필 정보(이름, 번호, 이미지)를 저장하는 메소드
@objc func savedMyProfile() {
storePhoneNumber() // 데이터를 저장하는 메소드
// 저장을 마치면 Alert을 통해 업데이트가 완료됨을 알려줌
ValidationAlert.showValidationAlert(on: self, title: "알림", message: "프로필 업데이트가 완료 되었습니다!")
self.dismiss(animated: true) // 모달뷰를 닫음
}
이렇게 메소드를 설정해주면 저장하기 버튼을 눌렀을 때 내가 입력한 데이터가 저장되고, 이것을 Alert으로 알려 사용자에게 프로필이 업데이트 되었음을 알려줄 수 있다. 그리고 자동으로 모달뷰가 닫히며 다시 메인뷰로 돌아가게 구현하였다!
이제 구현 결과를 보자.

오... 버튼 예쁘다...

어...?
버그가 발생했다(?)
원래 목적은 '저장하기' 버튼을 누르면 Alert이 뜨고, 확인을 누르면 자동으로 모달이 닫히게끔 하는 것이었는데, 지금은 내가 확인을 누르지 않아도 Alert이 혼자 사라지고 모달뷰는 닫히지도 않는다.
(이 때, 오늘 TIL은 이거다 싶었다.)
후... 버그를 수정해보자
기존에 내가 Alert을 구현한 메소드는 아래와 같다.
/// '확인' 버튼이 있는 커스텀 Alert
/// - Parameters:
/// - viewController: Alert을 띄울 뷰 컨트롤러
/// - title: Alert의 title
/// - message: Alert의 message
static func showValidationAlert(on viewController: UIViewController, title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "확인", style: .cancel)
viewController.present(alert, animated: true)
}
내 추측으로 위에서 발생한 버그는 Alert이 뜨고 바로 뒤의 코드인 self.dismiss(animation:)가 실행되면서 Alert을 꺼버리는 것 같았다. 분명 self로 지정했는데 왜 그런건지 모르겠다...
어쨌든 그래서 Alert의 핸들러를 이용해서 이 문제를 해결하려고 한다.
먼저 확인 버튼의 스타일을 .default로 바꿔주고, Alert을 호출하는 메소드의 파라미터에 새로운 매개변수를 추가한다.
static func showValidationAlert(on viewController: UIViewController, title: String, message: String, completion: (() -> Void)?) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "확인", style: .default) { _ in
completion?()
})
viewController.present(alert, animated: true)
}
UIAlertController는 handler라는 클로저를 갖고 있기 때문에 버튼을 누른 뒤 실행할 동작에 대해 설정해줄 수 있다. 여기서는 버튼을 누른 뒤 메소드의 매개변수인 클로저가 실행되도록 했는데, 다음 동작이 없을 수도 있기 때문에 클로저를 옵셔널 타입으로 설정해 주었다.
그럼 이제 다시 PhoneBookViewController로 돌아와서 아까 작성한 코드를 수정해준다.
ValidationAlert.showValidationAlert(on: self, title: "알림", message: "프로필 업데이트가 완료 되었습니다!") { [weak self] in
guard let self else { return }
self.dismiss(animated: true)
}
위 코드에서는 트레일링 클로저로 사용해서 매개변수가 보이지 않지만, completion: (() -> Void)? 를 사용해서 모달뷰를 닫는 동작을 실행하도록 코드를 작성하였다.
이 때, self를 참조하기 때문에 순환참조를 방지하기 위해 [weak self]로 약한 참조를 해주었다.
그럼 이제 다시 빌드를 해보자...!

됐다!!
계획대로 성공했다...
그럼 이제 마지막으로 실제로 프로필뷰의 데이터를 바꾼 뒤 저장하면 저장이 잘 되는지 확인해 보려고 한다.

어...?
왜 안 될까...
사실 시뮬레이터도 킹크랩이 싫었던게 아닐까...
원인을 파악해보기 위해 코드를 싹 확인해 본 결과... 생각보다 간단하지만 복잡한 문제였다.

대충 흐름도를 그려봤는데, 요약하자면 이렇다.
프로필뷰의 UI는 ViewController가 메모리에 올라갈 때 함께 그려진다. 이 때, 프로필뷰의 내부에서는 UserDefaults에서 프로필 정보(이름, 번호, 이미지)를 가져와서 프로필뷰에 업데이트 시켜준다.
그리고 모달뷰를 열고 저장하면 새로 작성한 데이터가 UserDefaults에 저장되지만, 이를 프로필뷰에 업데이트 시키는 작업이 없었던 것이다.
view.layoutIfNeeded같은 코드를 사용하면 될 것 같지만, 전혀 아니다.
view.layoutIfNeeded는 레이아웃을 재구성할 뿐, 프로필뷰의 데이터가 업데이트 되지 않는 것은 똑같기 때문에 의미가 없다.
때문에 프로필뷰에 새로운 메소드를 만들어 프로필뷰를 업데이트 시켜줄 수 있도록 해야했다.
업데이트하는 메소드를 만드는 것은 간단하다. internal한 메소드를 만들고, 그 안에 프로필뷰의 데이터를 새로 업데이트 하는 코드를 작성하면 된다. 나는 기존에 작성한 메소드들이 있었기 때문에 이 메소드를 활용하기로 했다.
// MyProfileView의 정보를 업데이트 하는 메소드
func reloadData() {
setupImageView() // 이미지를 불러오는 메소드
setupProfileText() // 텍스트를 불러오는 메소드
}
문제는 이 코드를 불러낼 타이밍이었다.
모달뷰는 닫히더라도 ViewController의 생명주기인 viewWillAppear같은 메소드가 실행되지 않는다. 왜냐하면 모달뷰가 열렸을 때 뒤의 뷰가 닫히는게 아니라 그 위에 쌓이는 형태이고, 여전히 화면에 보여지고 있기 때문이다.
만약 modalPresentationStyle을 fullScreen 등으로 했다면 가능했을 수도 있지만... 그래서는 모달뷰가 아니게 된다.
그래서 고민을 하다가 deinit을 활용하기로 했다.
모달뷰를 닫으면 해당 뷰 컨트롤러는 메모리에서 해제되기 때문에 deinit이 실행되는데, 이것을 활용하는 것이다.
먼저 PhoneBookViewController에 새로운 프로퍼티를 만들어준다.
var deinitCompletion: (() -> Void)?
이 프로퍼티는 현재 뷰 컨트롤러가 deinit되면 사용할 클로저이고, 항상 사용하는 것이 아니기 때문에 옵셔널 타입으로 지정해준다.
그리고 이제 뷰 컨트롤러의 deinit에 코드를 작성한다.
class PhoneBookViewController {
// ...
deinit {
self.deinitCompletion?()
}
}
이렇게 하면 현재 뷰 컨트롤러가 메모리에서 해제되고 클로저에 값이 있을 때, 해당 클로저의 코드를 실행하게 된다.
그럼 이제 다시 ViewController로 돌아가서 해당 코드를 사용해주면 된다.
// ViewController
@objc func showMyProfile() {
let modalProfileView = PhoneBookViewController()
modalProfileView.sheetPresentationController?.preferredCornerRadius = 50 // 모서리를 둥글게
modalProfileView.modalPresentationStyle = .formSheet // 모달 스타일 지정
// 모달뷰가 deinit 되는 경우 프로필 뷰 새로고침
modalProfileView.deinitCompletion = { [weak self] in
guard let self else { return }
self.myProfileView.reloadData()
}
self.present(modalProfileView, animated: true)
}
이 메소드는 아까 모달뷰를 띄울 때 사용했던 메소드이다. 여기에서 위에서 만든 클로저를 사용해 프로필뷰가 업데이트 되는 메소드를 사용하도록 한다.
이제 클로저는 모달뷰의 뷰 컨트롤러가 deinit되는 순간을 기다리다가 deinit이 되는 순간 프로필뷰를 업데이트 하는 메소드를 실행하게 되고 프로필뷰가 자연스럽게 업데이트 될 것이다.
빌드를 통해 테스트!!

완벽하다.

오늘은 개인과제의 추가 구현으로 내 프로필을 만들고 수정하는 기능을 추가해봤다.
처음으로 모달뷰를 사용해 보았는데, 사용법은 크게 어렵지 않았다.
다만 이걸 활용해서 데이터도 저장하고 이것저것 하려고 하다보니 조금 헤맨 것 같다...
다음부터는 조금 더 잘 활용할 수 있겠지 생각한다.