⚠️ 주의 : 해당 글은 Storyboard가 아닌 Code를 이용하여 Auto Layout을 작성합니다! 아직 코드로 Auto Layout을 짜는 게 익숙하지 않은 분들에겐 어려운 내용일 수 있습니다.
안녕하세요! 제이티입니다. 오늘은 Bottom Sheet을 처음부터 프레임워크를 사용하지 않고 만들어보도록 하겠습니다!
만들기 전에 먼저 Bottom Sheet이 어떤 건지 보고 가도록 할까요?
iOS 앱을 사용하다보면 버튼을 탭했을 때 Card 모양의 Modal이 하단에서 나올 때가 있죠! 이 Modal을 사용자가 드래그하여 움직이거나 닫는 게 가능하구요. 어떤 건지 이해가 안되실 것 같아 이미지를 첨부할게요! 페이스북이나 슬랙에서 사용되는 Bottom Sheet의 이미지랍니다~
이런 거 많이 보셨죠? Bottom Sheet은 iOS 앱 여기저기서 많이 사용되고 있어요. 하지만 놀랍게도! UIKit에서는 Bottom Sheet을 제공하지 않습니다 ㄷㄷ 우리가 직접 커스텀해서 만들어줘야 합니다... 실화인가...?
다행히 Bottom Sheet을 직접 구현하지 않고도 사용할 수 있는 panModal이라는 프레임워크가 존재하지만, 한 번 욕심 내서! 직접 Bottom Sheet을 구현해보도록 해요! 자 그러면~ 가보도록 하겠습니닿ㅎㅎㅎ
UIPresentationController를 이용하여 만들어보려고 열심히 시도하였으나 다양한 버그를 만나며 결국 실패하여(...) UIViewController와 Pan Gesture, Auto Layout을 이용하여 Bottom Sheet을 직접 만들었습니다...!
프로젝트를 생성하고 ViewController.swift 파일을 먼저 열어줄게요! 그리고 아래와 같이 코드를 작성하여 ViewController의 가운데에 Button을 추가하고, button이 눌리면 buttonTapped 메소드가 호출되도록 구현합니당
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .orange
let button = UIButton(type: .system)
button.setTitle("Open Filter", for: .normal)
view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func buttonTapped() {
print("버튼이 눌렸습니다")
}
}
ViewController.swift 파일을 저장하고 실행해보면 아래와 같은 UI가 나타날 거에요...! Open Filter라고 적힌 버튼을 터치하면 "버튼이 눌렸습니다"라는 문자열이 출력된답니다 😊
이번에는 새로운 ViewController 파일을 생성합니다~ 이 때 파일 이름은 BottomSheetViewController.swift로 지정하도록 할게요. 그리고 아래와 같은 코드를 작성해주세요!
import UIKit
class BottomSheetViewController: UIViewController {
// 1
private let dimmedView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.darkGray.withAlphaComponent(0.7)
return view
}()
// 2
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
// 3
private func setupUI() {
view.addSubview(dimmedView)
setupLayout()
}
// 4
private func setupLayout() {
dimmedView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
dimmedView.topAnchor.constraint(equalTo: view.topAnchor),
dimmedView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmedView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
dimmedView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
buttonTapped 메소드를 구현하여 Open Filter라고 적힌 버튼을 터치하면 위에서 구현한 BottomSheetViewController가 보여지도록 하겠습니다...!
...
// ViewController.swift 파일의 메소드 buttonTapped
@objc func buttonTapped() {
let bottomSheetVC = BottomSheetViewController()
// 1
bottomSheetVC.modalPresentationStyle = .overFullScreen
// 2
self.present(bottomSheetVC, animated: false, completion: nil)
}
...
이제 프로젝트를 실행하여 결과를 확인해볼까요? "Open Filter"라고 적힌 버튼을 터치하면 아래 이미지와 같이 기존 ViewController의 view가 어두워지며 보일 거에요!
만약 ViewController의 view가 보이지 않는다면 bottomSheetViewController의 modalPresentationStyle이 .overFullScreen인지 한 번 확인해보세요 😊
BottomSheetViewController.swift 파일로 돌아가서 이번에는 보여질 Bottom Sheet을 추가해볼게요...!
class BottomSheetViewController: UIViewController
{
...
// 1
private let bottomSheetView: UIView = {
let view = UIView()
view.backgroundColor = .white
// 좌측 상단과 좌측 하단의 cornerRadius를 10으로 설정한다.
view.layer.cornerRadius = 10
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
view.clipsToBounds = true
return view
}()
// 2
private var bottomSheetViewTopConstraint: NSLayoutConstraint!
...
// 3
private func setupUI() {
view.addSubview(dimmedView)
view.addSubview(bottomSheetView)
setupLayout()
}
// 4
private func setupLayout() {
...
bottomSheetView.translatesAutoresizingMaskIntoConstraints = false
let topConstant = view.safeAreaInsets.bottom + view.safeAreaLayoutGuide.layoutFrame.height
bottomSheetViewTopConstraint = bottomSheetView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: topConstant)
NSLayoutConstraint.activate([
bottomSheetView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
bottomSheetView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
bottomSheetView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
bottomSheetViewTopConstraint,
])
}
}
bottomSheetView를 생성해줍니다! bottomSheetView의 backgroundColor는 white로 설정하고~ maskedCorners를 설정하여 좌측 상단과 좌측 하단의 cornerRadius를 10으로 설정해줍니다! 만약 maskedCorners를 설정하지 않으면...! 우측 상단과 우측 하단에도 cornerRadius가 적용됩니다!
bottomSheet이 view의 상단에서 떨어진 거리를 설정하기 위한 Constraint를 선언합니다. 해당 프로퍼티를 이용하여 bottomSheet의 높이를 조절해줄 수 있어요! 뒤에서 나올 코드에서 사용할 프로퍼티입니다~
view에 bottomSheetView를 추가해주는 코드랍니다
bottomSheetView의 Auto Layout을 설정하는 코드를 작성하였습니다! bottomSheetView의 leading과 trailing을 view의 safeArea에, bottomSheetView의 bottom을 view의 bottom과 같도록 먼저 설정해주고~ bottomSheetView의 top Constraint는 위에서 선언한 bottomSheetViewTopConstraint의 값으로 지정해 주었어요.
top Constraint의 constant 값은 미리 계산해준 topConstant 값으로 지정해줍니다! 계산해준 topConstant 값은 bottomSheet이 처음에 보이지 않도록 하는 것을 목적으로 계산한 값이에요!
코드를 다 작성하였으니 실행해서 결과를 확인해볼게요! 확인해보면... 어라? 아까와 변화가 없네요! 어떻게 된 걸까요? 우리가 아까 설정해준 top Constraint의 constant 값이 bottomSheetView가 처음에 보이지 않도록 설정된 값이기 때문에 bottomSheetView가 추가되었지만 보이지 않는 거에요! 한 번 코드를 수정해서 bottomSheetView가 잘 추가되었는지 확인해볼게요!
private func setupLayout() {
...
bottomSheetView.translatesAutoresizingMaskIntoConstraints = false
// topConstant 값을 300으로 바꾸어준다. 이 때 타입을 CGFloat로 명시해주어야 한다.
let topConstant: CGFloat = 300
bottomSheetViewTopConstraint = bottomSheetView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: topConstant)
NSLayoutConstraint.activate([
bottomSheetView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
bottomSheetView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
bottomSheetView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
bottomSheetViewTopConstraint,
])
}
그리고 코드를 실행하면~ 짠! 이번에는 bottomSheetView가 나타나있는 걸 볼 수가 있죠! 결과를 확인해주었으니 다시 코드를 원래대로 돌려줄게요
private func setupLayout() {
...
bottomSheetView.translatesAutoresizingMaskIntoConstraints = false
// 해당 부분을 다시 원래대로 되돌려준다.
let topConstant = view.safeAreaInsets.bottom + view.safeAreaLayoutGuide.layoutFrame.height
bottomSheetViewTopConstraint = bottomSheetView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: topConstant)
NSLayoutConstraint.activate([
bottomSheetView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
bottomSheetView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
bottomSheetView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
bottomSheetViewTopConstraint,
])
}
그리고 이어서 코드를 작성할게요!
이번에는 Bottom Sheet이 열리는 애니메이션을 추가해 줄 거에요! 이를 위해 UIView 클래스의 animate 메소드를 사용하도록 할게요
// BottomSheetViewController.swift
// 1 - 열린 BottomSheet의 기본 높이를 지정하기 위한 프로퍼티
var defaultHeight: CGFloat = 300
// 2
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
showBottomSheet()
}
private func setupUI() {
view.addSubview(dimmedView)
view.addSubview(bottomSheetView)
// 3
dimmedView.alpha = 0.0
setupLayout()
}
// 4
private func showBottomSheet() {
let safeAreaHeight: CGFloat = view.safeAreaLayoutGuide.layoutFrame.height
let bottomPadding: CGFloat = view.safeAreaInsets.bottom
bottomSheetViewTopConstraint.constant = (safeAreaHeight + bottomPadding) - defaultHeight
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: {
// 4 - 1
self.dimmedView.alpha = 0.7
// 4 - 2
self.view.layoutIfNeeded()
}, completion: nil)
}
Bottom Sheet이 열리고 난 뒤의 높이를 defaultHeight 프로퍼티에 저장해줍니다! defaultHeight의 값은 클래스 외부에서 바꿀 수 있도록 private로 설정하지 않았어요.
viewDidAppear 메소드를 override하여 BottomSheetViewController의 view가 나타난 뒤 showBottomSheet 메소드를 실행하도록 구현해주었어요
setupUI 메소드에서 dimmedView의 초기 alpha값을 0으로(완전히 투명해서 안보이도록) 지정해주었어요. 이 값은 아래에 구현한 showBottomSheet 메소드에서 0.7로 바뀌게 되어 있답니다.
bottomSheetViewTopConstraint의 constant 값을 바꾸어주어 bottomSheet의 높이가 defaultHeight이 되도록 해주었어요. 이 때, bottomSheetViewTopConstraint의 constant 값은 UIView.animate 메소드 밖에서 바꾸어 주었답니다.
UIView.animate 메소드의 animations에 closure를 전달해 주었어요~
1) 클로저 내부에는 dimmedView의 alpha값이 0.7로 바뀌도록 설정이 되어 있어요 ㅎㅎ UIView.animate 내부에 해당 클로저를 넣어주어 dimmedView가 서서히 나타나도록 구현한 것이죠!
2) 클로저 내부에 하나 더 코드가 들어있는데, 바로 self.view.layoutIfNeeded라는 메소드를 실행하는 코드에요. 이 코드는 주로 Constraint의 값이 바뀌었을 때 애니메이션 효과를 주기 위해 넣어주는데요, 여기서는 bottomSheetViewTonConstraint의 constant 값이 바뀌잖아요? 이 constant 값이 바뀌면서 bottomSheetView가 올라오는 효과를 주기 위해 self.view.layoutIfNeeded 메소드를 실행해주었답니다.
이제 프로젝트를 실행해주면 아래와 같이 열리는 애니메이션 효과가 추가됩니다!
Bottom Sheet이 열리는 애니메이션을 추가해주었으니 이번에는 닫히는 애니메이션을 추가해줘야겠죠! 이번에도 코드부터 보도록 할게요
// BottomSheetViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
// 1
let dimmedTap = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapped(_:)))
dimmedView.addGestureRecognizer(dimmedTap)
dimmedView.isUserInteractionEnabled = true
}
// 2
private func hideBottomSheetAndGoBack() {
let safeAreaHeight = view.safeAreaLayoutGuide.layoutFrame.height
let bottomPadding = view.safeAreaInsets.bottom
bottomSheetViewTopConstraint.constant = safeAreaHeight + bottomPadding
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: {
self.dimmedView.alpha = 0.0
self.view.layoutIfNeeded()
}) { _ in
if self.presentingViewController != nil {
self.dismiss(animated: false, completion: nil)
}
}
}
// 3
@objc private func dimmedViewTapped(_ tapRecognizer: UITapGestureRecognizer) {
hideBottomSheetAndGoBack()
}
다시 프로젝트를 실행해주면 dimmedView를 탭하였을 때 아래 이미지처럼 닫히는 걸 보실 수 있을 거에요 희희
여기까지 적는 게 생각보다 오래 걸렸네요 ㅎㅎ... 아래에 제가 참고한 글에서 여기쯤에서 끊어가니까! 저도 여기에서 일단 글을 끊고 다음에 이어서 글을 작성하도록 할게요. 다음 파트에서는 화면을 드래그해서 bottom sheet을 올리고, 내리고, 닫는 기능을 추가하도록 하겠습니다. 그러면 다음에 또 봐요 안녕~
참고한 글 : Replicating Facebook's Draggable Bottom Card using Auto Layout - Part 1/2