일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
- 타임라인 포맷팅
- domain data
- swift navigationcontroller
- swift 백준
- reactorkit
- SWIFT
- button configuration
- paragraph style
- tusit font 추가 방법
- task cancellation
- RxSwift
- UIKit
- task cancel
- custom navigation bar
- custombottomsheet
- Tuist
- swift dashed line
- DP
- swift custom ui
- uikit toast
- identifiable
- BFS
- traits
- swift 점선
- swift concurrency
- claen architecture
- coordinator
- custom ui
- rxdatasources
- swift bottomsheet
- Today
- Total
김경록의 앱 개발 여정
[Swift UIKit] Custom Bottom Sheet 본문
이번 프로젝트에서는 두 가지 유형의 바텀시트를 구현해야 했습니다.
내부 디자인은 차이가 있지만, 전체적인 구조는 거의 동일했죠.
그래서 저는 하나의 바텀시트 베이스 클래스를 만들고, 그걸 상속해서 각각의 사용처에서 커스터마이징하는 구조로 설계했습니다.
공통 UI 구조 파악하기
두 가지 바텀시트 모두 아래와 같은 특징이 있었습니다.
- 모서리가 둥근 흰색 시트
- 반투명한 배경
- 아래에서 위로 올라오는 애니메이션
- 시트 상단에 그림자가 들어감
공통 요소가 명확했기 때문에, 기본 구조만 잘 만들어두면 각각의 케이스에 맞게 확장해서 사용할 수 있겠다고 생각했습니다. 그래서 재사용 가능한 Base BottomSheetViewController를 만들기로 했습니다.
🏗️ 재사용 가능한 구조로 설계하기
먼저 아래의 세 가지 주요 뷰를 정의했습니다.
- translucentView: 배경을 어둡게 표현하기 위한 반투명 뷰
- bottomSheetView: 바텀시트 본체에 해당하는 뷰
- contentView: 실제 내용을 담을 서브 뷰들용 컨테이너
여기서 contentView를 internal로 두어, 상속받는 클래스에서 직접 필요한 뷰들을 올릴 수 있도록 했습니다.
이렇게 하면 뷰 계층은 그대로 유지하면서, 바텀시트의 콘텐츠만 커스터마이징할 수 있게끔 설계했습니다.
☁️ 상단 그림자 처리하기(역방향)
아는 형님이 말씀해주신 부분중에 항상 디자인 시안에서 그림자를 놓치지말라 하셨습니다.
이 바텀시트에도 그림자가 있습니다.
특이하게도 시트 상단에 그림자가 들어가야 하겠네요
즉, 그림자가 아래 방향이 아니라 위로 향해야 했죠.
이를 위해 CALayer의 shadowOffset 값을 아래와 같이 음수로 설정해 주었습니다.
view.layer.shadowOffset = CGSize(width: 0, height: -10)
여기서 한 가지 주의할 점이 있었습니다. 그림자를 주려면 해당 뷰의 backgroundColor가 .clear가 아니어야 한다는 점입니다.
애니메이션 처리
지금 같이 뒷배경을 전체화면으로 설정하고 기본 제공되는 modal 애니메이션을 사용하면 굉장히 어색한 동작을 하게 됩니다.
그러므로 애니메이션 또한 커스텀해서 사용해야했습니다.
또한 SnapKit을 사용해 top 제약조건을 아래쪽으로 숨겨두고, 애니메이션 타이밍에 해당 constraint의 offset을 업데이트해 주었습니다.
특히 배경색이 자연스럽게 나타나게 해야했습니다.
bottomSheetConstraint?.update(offset: -view.frame.height * 0.42)
UIView.animate(withDuration: 0.25) {
self.translucentView.backgroundColor = UIColor.darkGray.withAlphaComponent(0.6)
self.view.layoutIfNeeded()
}
사라질 때도 동일하게 offset을 0으로 되돌리고, 배경의 알파값을 낮추는 방식으로 구현했습니다.
애니메이션이 끝난 후에는 dismiss(animated: false)로 바텀시트를 내리도록 했습니다.
서브 클래스에서 확장하기
이제 베이스 구조가 완성되었기 때문에, 이후부터는 필요한 화면에서 상속만 받아서 다음과 같이 사용할 수 있었습니다.
class CustomBottomSheet: BottomSheetViewController {
override func viewDidLoad() {
super.viewDidLoad()
let label = UILabel()
label.text = "커스텀 시트입니다"
contentView.addSubview(label)
label.snp.makeConstraints {
$0.center.equalToSuperview()
}
}
}
전체 코드
import UIKit
import SnapKit
// 바텀 시트 스타일에 상속하여 사용 사용처에서 modalPresentationStyle = .overFullScreen 준수
// 추가 뷰 객체는 contentView를 통해 레이아웃 설정
class BottomSheetViewController: UIViewController {
private var bottomSheetConstraint: Constraint?
private let translucentView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.darkGray.withAlphaComponent(0.0)
return view
}()
private let bottomSheetView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.setRoundedCorners(radius: 10, corners: .layerMinXMinYCorner, .layerMaxXMinYCorner)
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.25
view.layer.shadowOffset = CGSize(width: 0, height: -10)
view.layer.shadowRadius = 6
return view
}()
// 해당 뷰에 서브 클래스들 객체 담아 사용
let contentView: UIView = {
let view = UIView()
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
setupConstraints()
setupTranslucentView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
animatePresent()
}
private func setupLayout() {
view.addSubViews(
translucentView,
bottomSheetView
)
bottomSheetView.addSubview(contentView)
}
private func setupConstraints() {
translucentView.snp.makeConstraints {
$0.edges.equalToSuperview()
}
bottomSheetView.snp.makeConstraints {
$0.leading.trailing.equalTo(view.safeAreaLayoutGuide)
$0.height.equalTo(view).multipliedBy(0.42)
bottomSheetConstraint = $0.top.equalTo(view.snp.bottom).constraint // 시작은 화면 밖 아래
}
contentView.snp.makeConstraints {
$0.top.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
private func setupTranslucentView() {
let translucentViewTapped = UITapGestureRecognizer(
target: self,
action: #selector(handleDismiss)
)
translucentView.addGestureRecognizer(translucentViewTapped)
}
private func animatePresent() {
// 배경 어둡게 + BottomSheet 위로 애니메이션
self.bottomSheetConstraint?.update(offset: -view.frame.height * 0.42)
UIView.animate(
withDuration: 0.25,
delay: 0,
options: .curveEaseOut,
animations: {
self.translucentView.backgroundColor = UIColor.darkGray.withAlphaComponent(0.6)
self.view.layoutIfNeeded()
}
)
}
@objc
private func handleDismiss() {
// BottomSheet 다시 아래로 + 배경 투명하게
self.bottomSheetConstraint?.update(offset: 0)
UIView.animate(
withDuration: 0.25,
delay: 0,
options: .curveEaseIn,
animations: {
self.translucentView.backgroundColor = UIColor.darkGray.withAlphaComponent(0.0)
self.view.layoutIfNeeded()
}
) { _ in
self.dismiss(animated: false)
}
}
}
마무리하며
글을 정리하며 보니 높이에 관한 처리가 한번 더 필요하겠네요
내부 컨텐츠 높이에따라 조금 더 자연스러운 높이를 제공할 수 있을거같습니다.
🙇♂️ 참고가 된 사이트
'Trouble Shooting' 카테고리의 다른 글
[Swift UIKit] 앱 전반에서 사용 될 Toast Message 구현기 (0) | 2025.04.16 |
---|---|
[Swift UIKit] 점선 스타일 보더 적용하기(Dashed Border Style) (0) | 2025.04.16 |
[Swift UIKit] 앱 전반에서 사용되는 Custom NavigationContoller (0) | 2025.04.11 |
[Swift] 여러 상황에 대응하는 레이아웃 만들기(with SnapKit, update Layout) (0) | 2025.03.08 |
[Swift] Launch Screen에 Asset.xcassets 이미지가 적용되지 않는다면 (0) | 2025.02.01 |