[UIKit] UISheetPresentationController를 사용해서 바텀시트(Sheets)를 만들어보자

2024. 8. 30. 22:16UIKit, SwiftUI, H.I.G

예전 글에서 직접 <TOASTER 토스터> 프로젝트에서 사용할 바텀 시트(Sheets)를 커스텀해서 만든 방법을 소개했었다.

 

[UIKit] 재사용 Component 개발하기 (3) - 바텀 시트 (Sheets, Bottom Sheet)

[UIKit] 재사용 Component 개발하기 (1) - Toast Message💡 재사용 Component 개발하기 시리즈 글을 시작하며 이번 33기 앱잼이었던 프로젝트에서 우리 iOS 팀이 추구한 방향 중 하나는, 앱 내에서 반복되는 컴

mini-min-dev.tistory.com

하지만, WWDC21 <Customize and resize sheets in UIKit>에서 소개된 UISheetPresentationController를 사용해서 바텀시트(Sheets)를 만들면, 위의 글과 같은 복잡한 과정 없이 매우 간단하게 만들 수 있었다.

그래서 이번 글에서는 기존 커스텀 바텀시트를 UISheetPresentationController를 사용해서 구현한 바텀시트로 개선했던 작업에 대해 소개해보고자 한다.
특히 단순 바텀시트 사용법에 대한 내용 하나만 다루지 않고, 아래의 모든 내용들을 다뤄보는 말 그대로 시트(Sheets)의 모든 개념을 총정리한다고 생각하며 글을 썼으니 기대해봐도 좋겠다.

  • UISheetPresentationController의 기본 개념과 Detent를 바탕으로 Sheet의 높이를 설정하는 방법
  • 이중 바텀시트나 / 높이가 변하는 애니메이션이 적용된 바텀시트를 만드는 방법
  • Grabber(시트 상단에 있는 길쭉한 알약 모양의 뷰)를 포함해서 시트에 크기 조절이 가능하다는 것을 시각적으로 나타내는 방법
  • 바텀시트 모서리 둥글기, 시트를 제외한 나머지 배경 흐려지는 여부 설정, 각종 시트 레이아웃 등

 

1️⃣ UISheetPresentationController? Detent?

UISheetPresentationController는 iOS 15부터 사용가능한 UIKit의 기능으로, iOS 앱 화면 하단에서부터 나타나는 시트(Sheets, 아래에서 올라온다고 바텀시트라고 흔히 사용된다)를 만들 수 있도록 제공하는 클래스다.

기본적인 사용법은 매우 단순하다.
SheetPresentationController는 우리가 흔하게 사용하는 UIViewController를 모달로 present할 때 자동으로 생성된다.
굳이 UISheetPresentationController의 형태로 인스턴스를 만들 필요가 없다는 의미다! (그냥 ViewController를 present하는 것이 사용방법)

그러니까 아래 코드와 같이 UIViewController의 sheetPresentationController 속성을 통해 시트의 스타일을 접근하고, 이후에 해당 UIViewController를 present해주면 자연스럽게 시트(Sheet)를 구현하게 되는 것이다. (시트를 내리는 것은 당연하게 dismiss)

if let sheet = viewController.sheetPresentationController {
    // Customnize Sheets
}
present(viewController, animated: true)  // Present Sheets
dismiss(animated: true)                  // Dismiss Sheets

시트의 높이를 조절하는 방법으로 sheetPresentationController에서는 Detent라는 속성을 사용한다.

🤔 사전적 의미의 detent는 "멈춤쇠"로 "어떤 물리적 기계 장치의 움직임에 저항하거나 정지시키는 수단"이라고 해석되는데,
유동적 높이 조절이 가능한 시트(Sheets)에서 "멈출 수 있는 지점"이라는 의미로 아마 detent라는 단어가 사용된 듯하다.

기본적인 시스템 detent로 medium, large를 지원하고 있으며, 이 외에도 직접 높이를 설정할 수 있는 방법도 제공한다.

  • .medium() : 화면의 절반(half)을 차지하는 중간 크기의 시트
  • .large() : 화면의 거의 대부분(full)을 차지하는 큰 크기의 시트 -> sheetPresentationController의 default 값!
  • .custom(resolver:) : CGFloat 타입으로 원하는 높이를 직접 지정해서 만드는 시트

왼쪽부터 순서대로 medium, large, custom = 500.0으로 Detent를 설정한 모습

detent에서 하나 재밌는 점은 이 높이를 지정하는 detent라는 속성이 detents라는 배열로 담아 사용한다는 점이다.

당연히 시트가 한 높이만 갖는 경우에는 하나의 detent 타입만 배열에 추가해 주면 되겠지만,
만약 여러 개의 detent 타입을 배열에 추가해준 경우에는 각 지점이 모두 시트가 "멈출 수 있는 지점"으로 사용된다는 것이 특징이다.

private let exampleBottom = BottomSheetViewController()

if let sheet = exampleBottom.sheetPresentationController {
    sheet.detents = [.medium(), .large()]
}
present(exampleBottom, animated: true)

이게 무슨 말이냐면,
위 코드와 같이 detents 속성으로 medium()과 large()가 모두 저장되어 있을 때는 - 시트가 전체 화면의 중간만 가릴 수도 / 혹은 전체 화면을 모두 가릴 수도 있게 된다는 의미다.

이때 각 지점은 단순히 앱 사용자가 시트를 드래그를 하는 것만으로도 - 시트의 높이를 쉽게 바꿀 수 있는 식으로 동작하게 된다!

detent가 여러 개인 경우 "멈출 수 있는 지점" 또한 여러 개

 

2️⃣ UISheetPresentationController로 기존 재사용 바텀시트 코드 개선하기

추가적인 속성들은 여러 개가 더 있지만, 우선 이 정도의 기본 개념만을 알고 있는 상황에서 기존 바텀시트 로직을 리팩토링해보겠다.

<재사용 Component 만들기>라는 예전 글의 이름처럼 바텀시트 하나만 만드는 것이 목표가 아니라, 재사용이 가능하도록 클래스를 만드는 것이 목표였다는 점 리마인드부터 하고!

뷰 계층구조는 훨씬 단순해졌다.
흐려지는 뒷 배경(dimmedBackView)도 이제 신경 쓸 필요없이 나는 그저 공통으로 사용되는 부분과 변하는 부분 단 두 개만 구분하면 된다.
아래 화면에서 공통으로 사용되는 보라색 뷰 부분과 / 시트 역할에 따라 변하는 초록색 뷰 부분으로 나누어지는 것을 설명과 함께 확인해 보자.

  • 보라색 뷰 : 왼쪽 상단 titleLabel에 들어갈 String text값만 변할 뿐, 텍스트 스타일이나 closeButton의 역할 등은 공통으로 사용되는 부분이다.
  • 초록색 뷰 : 시트의 역할에 따라 뷰 자체가 달라진다. - 특히, 텍스트 필드가 사용되는 시트는 키보드가 항상 올라와있어야 하고 / 그 외 뷰에서는 키보드가 사용되지 않는다는 점을 주의해야 했다. (키보드 높이에 따른 시트 높이 계산 내용은 예전 글을 참고하자.)

초록색 부분에 해당하는 뷰는 별도의 UIView 인스턴스로 각 사용 화면에 따라 만들어서 사용할 거다.
반면, 보라색 부분에 해당하는 뷰는 공통으로 사용할 ViewController의 view 부분에 미리 구현해서 재사용할 수 있도록 만들었다.

실제 사용 시에는 (1) 초록색 부분에 해당하는 커스텀 뷰, 그리고 (2) 타이틀 텍스트, (3) 배경 색상으로 구분한 시트 타입까지 파라미터로 값을 받아 ViewController 인스턴스를 찍어내기만 하면 되는 방식이다.

private let addClipBottomSheetView = AddClipBottomSheetView()
private lazy var addClipBottom = ToasterBottomSheetViewController(bottomType: .white, bottomTitle: "클립 추가", insertView: addClipBottomSheetView)

여기서 다시 리마인드 해야 할 부분!

위에서 말했듯이 UISheetPresentationController 타입으로 객체를 만드는 것이 아니라, 
UIViewController의 sheetPresentationController 속성을 통해 시트의 스타일을 접근하는 방법을 사용할 것이므로, UISheetPresentationController 클래스가 아니라 UIViewController 클래스로 재사용 인스턴스를 만든다는 점이다!

final class ToasterBottomSheetViewController: UIViewController {
    // MARK: - UI Properties
    private var insertView: UIView
    
    private let titleLabel = UILabel()
    private let closeButton = UIButton()
    
    // MARK: - Life Cycle
    init(bottomType: BottomType,
         bottomTitle: String,
         insertView: UIView) {
        self.insertView = insertView
        super.init(nibName: nil, bundle: nil)
    }
    ...

sheetPresentationController 속성을 지정하는 부분은 ViewController 내의 별도의 메서드(setupSheetPresentation)로 분리했다.

  1. Sheet의 높이(detents) : 메서드를 통해 입력받은 값만큼을 시트 높이로 지정한다. medium도 large도 사용할 수 없으니 custom으로 지정해줘야 했다.
  2. preferredCornerRadius 속성으로 시트 상단 모서리에 적용할 둥글기(반지름, radius) 값을 지정할 수 있다.
  3. 입력받은 height 값에서 40을 빼며 시트 내부의 콘텐츠와 시트 자체의 경계 / Safe Area 계산에서 오는 차이를 보정해 줄 수 있었다. (bottomHeight - 40)
// ToasterBottomSheetViewController
func setupSheetPresentation(bottomHeight: CGFloat) {
    if let sheet = self.sheetPresentationController {
        if #available(iOS 16.0, *) {
            sheet.detents = [.custom(resolver: { _ in bottomHeight - 40 })]
        }
        sheet.preferredCornerRadius = 20
    }
}

자, 너무도 쉽게 Sheet 사용 준비가 끝났다.

배경이 흐려지는 것도, 시트가 올라오고 내려올 때 애니메이션도, 키보드에 따른 높이 구분도 신경 쓸 것 없이,
이제 실제 뷰컨에서 바텀 시트를 사용할 때는 아래와 같이 단순하게 코드 두 줄로 사용할 수 있게 되었다. (너무 쉽잖아?!?)

addClipBottom.setupSheetPresentation(bottomHeight: 198)
present(addClipBottom, animated: true)

완성된 Sheet 동작 화면

 

3️⃣ 이중 바텀시트도 너무 쉽게 만들 수 있다!

시트가 올라오고 내려오는 애니메이션과 레이아웃을 잡을 때 생기는 시간 차이를 고려할 필요 없이, present-dismiss로 시트를 올리고 내릴 수 있게 되면서 이중 바텀시트를 사용하는 것도 너무 간단해지게 된다.

💡 이중 바텀시트가 뭔데?
시트 내에서 또 다른 시트로 바로 연결되는 경우를 의미한다.
이때 두 번째 시트가 나타나기 전에 첫 번째 시트를 닫아야 하며, 필요한 경우에는 두 번째 시트를 닫은 후 다시 첫 번째 시트로 돌아가는 것을 고려할 수도 있다.

-> 이는 HIG에서 설명하고 있는 사항으로 이 부분에서 오해하고 있는 사람들이 많은데, 이중 바텀시트를 사용하는 것에 대한 Apple의 제약은 존재하지 않는다는 것이 팩트(fact)다.

이중 바텀시트 사용 상황에 대한 화면 설계

이중 바텀시트는 2차 스프린트 기간에서 추가된 기능으로,
"수정하기"라는 타이틀을 가진 시트에서 [제목 편집] 버튼 부분을 누를 경우 -> 기존 바텀시트는 사라지고 -> "링크 제목 편집"이라는 타이틀을 가진 또 다른 시트가 올라오는 로직을 구현하고 싶었다.

  • editBottom : "수정하기" 타이틀을 가진 바텀시트 (deleteLinkBottomSheetView를 담고 있다)
  • editLinkBottom : "링크 제목 편집" 타이틀을 가진 바텀시트 (editLinkBottomSheetView를 담고 있다)
private let deleteLinkBottomSheetView = DeleteLinkBottomSheetView()
private lazy var editBottom = ToasterBottomSheetViewController(bottomType: .gray,
                                                           bottomTitle: "수정하기",
                                                           insertView: deleteLinkBottomSheetView)

private let editLinkBottomSheetView = EditLinkBottomSheetView()
private lazy var editLinkBottom = ToasterBottomSheetViewController(bottomType: .white,
                                                                   bottomTitle: "링크 제목 편집",
                                                                   insertView: editLinkBottomSheetView)

다시 말하지만 sheetPresentationController를 사용하는 경우, 시트를 올리고 내리는 것은 모두 present-dismiss로 이루어진다.

첫 번째 시트에 대한 dismiss 로직이 이루어지고,
작업이 끝난 것을 의미하는 completion 블록에 두 번째 시트를 올리는 present 로직과 sheetPresentationController 속성을 설정하던 메서드를 호출하기만 하면 이중 바텀시트를 구현할 수 있다.

*즉, 시트에 연속된 작업이 이루어지는 경우에 대해서는 present와 dismiss 메서드에 있는 completion 블록을 사용하면 모두 구현할 수 있게 된다는 의미!

// 첫 번째 Sheet Present
editBottom.setupSheetPresentation(bottomHeight: 226)
present(editBottom, animated: true)

// 첫 번째 Sheet Dismiss -> 두 번째 Sheet Present
self.dismiss(animated: true, completion: {
    self.editLinkBottom.setupSheetPresentation(bottomHeight: 198)
    self.present(self.editLinkBottom, animated: true)
})

이중 바텀시트도 쉽게 구현 완료!

 

4️⃣ animateChanges : 애니메이션 적용해서 바텀시트 높이 조정하는 방법

sheetPresentationController에 구현되어 있는 또 다른 재밌는 기능을 소개해보겠다.

이번에는 *특정 경우에, 시트의 높이를 **자동으로 조정하고 싶은 상황이었다.
*<텍스트 필드에 들어온 텍스트가 n글자 이상인 경우> 혹은 <텍스트 필드에 들어온 텍스트가 중복 혹은 유효하지 않은 값으로 사용할 수 없는 경우 - 네트워킹으로 검증>를 의미
**사용자가 별도의 제스처를 주지 않아도 텍스트 필드 입력만으로, 시트의 높이가 자동으로 올라가고 내려가고 하는 상황을 의미

기본적으로 떠오른 방법은 detent의 custom 값을 조정하는 방법이었다.
하지만, 그냥 detent 값을 조정하는 경우에는 아래 화면과 같이 너무도 딱딱하고 빠르게 뷰의 높이가 조정되듯이 구현된다는 문제점이 있었다.

애니메이션 적용 전, 딱딱하게 시트가 움직이는 모습

animatedChanges는 시트의 크기나 위치 변경과 같은 UI 수정사항을 애니메이션 적용을 통해 부드럽게 적용할 수 있도록 만드는 속성이다.

이 경우에 아래 코드와 같이 detent의 custom 값을 조정하는 로직을 sheet.animateChanges { } 코드 블록 안에 구현함으로써,
UI의 수정사항 = 즉, 바텀 시트의 높이 변경 사항을 부드럽게 적용할 수 있었다.

func setupSheetHeightChanges(bottomHeight: CGFloat) {
    if let sheet = self.sheetPresentationController {
        sheet.animateChanges {
            if #available(iOS 16.0, *) {
                sheet.detents = [.custom(resolver: { _ in bottomHeight - 40 })]
            }
        }
    }
}

// 특정 상황에서 바텀시트의 높이를 수정
editLinkBottom.setupSheetHeightChanges(bottomHeight: 219)

애니메이션 적용 후, 부드럽게 높이가 조정되는 모습

 

5️⃣ 이외에도 UISheetPresentationController에서 지원하는 기능들은 뭐가 있을까?

지금까지 설명한 사항 말고도 많은 부가적인 기능을 sheetPresentationController에서 제공하고 있다. 간략하고 빠르게 훑어만 보겠다.


largestUndimmedDetentIdentifier (default = nil)

시트가 특정 detent에 도달할 때 시트 외부의 화면에서 dimmed 속성을 적용하고 싶지 않을 때 사용하는 속성이다.

이게 무슨 말이냐면, detents에 [.medium(), .large()] 두 값이 담겨 있을 때 해당 속성을 .medium()으로 설정하는 경우, 시트가 medium 위치에 있을 때는 배경이 어두워지지 않지만, large 위치에 있을 때는 어두워지는 효과가 사용된다는 의미!


prefersScrollingExpandsWhenScrolledToEdge (default = true)

막대를 드래그하는 경우에만 시트가 확장되도록 하고,
시트의 스크롤 뷰를 스크롤하는 경우에는 시트의 확장이 되지 않도록 만들고 싶으면 해당 속성을 false로 바꿔 지정해야 한다.

prefersGrabberVisible (default = false)

시트 상단에 grabber라는 작은 길쭉한 알약 모양의 뷰를 표시할지에 대한 여부를 정하는 속성이다.
grabber는 "손잡이"와 같은 역할로, grabber가 있다는 것은 사용자가 이 손잡이를 잡고 시트의 크기를 자유롭게 늘렸다/줄였다 할 수 있다는 것을 의미한다.
-> detents 배열에 두 가지 이상 Detent 속성이 들어간 경우 해당 속성을 true로 지정해 손잡이를 보여주는 것이 좋다.


prefersPageSizing (default = true)

시트가 자체적으로 크기를 조정하여, 읽기 가능한 콘텐츠의 크기에 맞출 수 있도록 만드는 속성이다.
default 값이 true로 모든 Sheet는 자동으로 내부 콘텐츠 크기가 맞춰서 조정되기 때문에 평소에 크게 사용할 상황은 없는 편!


prefersEdgeAttachedInCompactHeight (default = false)

시트가 화면에 밀착되는지 여부를 정하는 속성이다.
화면이 작거나 높이가 제한이 되는 기기 혹은 아래와 같이 가로 모드인 경우 시트가 화면 하단을 기준으로 붙도록 만들 수 있다.


widthFollowsPreferredContentSizeWhenEdgeAttached (default = false)

시트가 화면 하단에 밀착되어 있는 경우,
기기의 Safe Area가 아닌 preferredContentSize에 따라 시트의 너비를 조정할 수 있도록 만드는 속성이다.


대부분을 설명했지만,
아직 설명하지 못한 추가적인 속성이나 해당 글로는 부족해 공식문서를 참고하고 싶은 경우에는 아래 링크로 직접 들어가 확인하길 바란다.

바텀시트와 관련된 내용은 여기까지 ^__^

 

UISheetPresentationController | Apple Developer Documentation

A presentation controller that manages the appearance and behavior of a sheet.

developer.apple.com