네이버 지도 앱을 보면 사진과 같이 아래에서 위로 잡아올리면 뷰가 나타나는 형식의 레이아웃을 확인할 수 있다.
이러한 방식을 보통 Scrollable Bottom Sheet
라고 부르는 듯 하다.
팀프로젝트 프들에 해당 방식의 뷰가 필요했는데, searchBar에 검색 시 지도와 함께 하단에 Scrollable Bottom Sheet가 생성되고, 이를 터치 시 일정 부분의 뷰가 올라오고 리스트를 확인할 수 있게 하는 방식이어야 했다.
따라서 초기에 시도한 방식은 다음과 같았다.
다만, 3번 이후에서 막힌게, 뷰 자체에도 테이블 뷰를 추가해야 하였으며 사용자의 터치 인식을 받아 위로 뷰가 끌어올려져야 했다.
이런 방식은 vc가 아닌, result VC에 UIView로 추가했을 시 맥락적으로 너무 많은 부분을 포괄하게 되어 코드가 복잡해지는 문제가 발생했다.
내가 vc를 사용하지 않은, 못한 이유는 애초에 vc를 childVC로 추가하여 segue이동을 하도록 하는 방식이 존재한다는 것을 몰랐기 때문이다.
따라서 검색을 해보았고, stackOverflow에서 해당 방식에 대한 풀 코드를 담은 깃헙 링크를 찾을 수 있었다.
따라서, 이번 글에서는 어떻게 Scrollable Bottom Sheet를 구성할 수 있는지 파악하고 정리해두고자 한다.
mport UIKit
class ScrollableBottomSheetViewController: UIViewController {
@IBOutlet weak var headerView: UIView!
@IBOutlet weak var tableView: UITableView!
let fullView: CGFloat = 100
var partialView: CGFloat {
return UIScreen.main.bounds.height - 150
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
tableView.register(UINib(nibName: "DefaultTableViewCell", bundle: nil), forCellReuseIdentifier: "default")
let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(ScrollableBottomSheetViewController.panGesture))
gesture.delegate = self
view.addGestureRecognizer(gesture)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 0.6, animations: { [weak self] in
let frame = self?.view.frame
let yComponent = self?.partialView
self?.view.frame = CGRect(x: 0, y: yComponent!, width: frame!.width, height: frame!.height - 100)
})
self.view.roundCorners(corners: [.topLeft,.topRight], radius: 10)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self.view)
let velocity = recognizer.velocity(in: self.view)
let y = self.view.frame.minY
if (y + translation.y >= fullView) && (y + translation.y <= partialView) {
self.view.frame = CGRect(x: 0, y: y + translation.y, width: view.frame.width, height: view.frame.height)
recognizer.setTranslation(CGPoint.zero, in: self.view)
}
if recognizer.state == .ended {
var duration = velocity.y < 0 ? Double((y - fullView) / -velocity.y) : Double((partialView - y) / velocity.y )
duration = duration > 1.3 ? 1 : duration
UIView.animate(withDuration: duration, delay: 0.0, options: [.allowUserInteraction], animations: {
if velocity.y >= 0 {
self.view.frame = CGRect(x: 0, y: self.partialView, width: self.view.frame.width, height: self.view.frame.height)
} else {
self.view.frame = CGRect(x: 0, y: self.fullView, width: self.view.frame.width, height: self.view.frame.height)
}
}, completion: { [weak self] _ in
if ( velocity.y < 0 ) {
self?.tableView.isScrollEnabled = true
}
})
}
}
}
extension ScrollableBottomSheetViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 30
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 50
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return tableView.dequeueReusableCell(withIdentifier: "default")!
}
}
extension ScrollableBottomSheetViewController: UIGestureRecognizerDelegate {
// Solution
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let gesture = (gestureRecognizer as! UIPanGestureRecognizer)
let direction = gesture.velocity(in: view).y
let y = view.frame.minY
if (y == fullView && tableView.contentOffset.y == 0 && direction > 0) || (y == partialView) {
tableView.isScrollEnabled = false
} else {
tableView.isScrollEnabled = true
}
return false
}
}
이외 해당 vc를 사용하는 SearchViewController에 구현한 부분이다.
private func setResultView(){
let bottomSheetVC = ScrollableBottomSheetViewController()
self.addChild(bottomSheetVC)
self.view.addSubview(bottomSheetVC.view)
bottomSheetVC.didMove(toParent: self)
let height = view.frame.height
let width = view.frame.width
bottomSheetVC.view.frame = CGRect(x: 0, y: self.view.frame.maxY, width: width, height: height)
}
우선, ScrollableBottomSheet vc의 레이아웃은 xib 파일로 존재한다.
마찬가지로, 해당 vc에 사용되는 tableView의 레이아웃 또한 xib파일로 존재하며, cell 또한 그러하다.
class ScrollableBottomSheetViewController: UIViewController {
@IBOutlet weak var headerView: UIView!
@IBOutlet weak var tableView: UITableView!
let fullView: CGFloat = 100
var partialView: CGFloat {
return UIScreen.main.bounds.height - 150
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
tableView.register(UINib(nibName: "DefaultTableViewCell", bundle: nil), forCellReuseIdentifier: "default")
let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(ScrollableBottomSheetViewController.panGesture))
gesture.delegate = self
view.addGestureRecognizer(gesture)
}
...
}
UIScreen.main.bounds.height
면 화면 전체를 채우는 높이, 즉 화면에서 맨 아래쪽 부분의 y값을 말한다. 여기서 150을 뺀 값이 partialView인데, 즉 partialView의 높이는 아래에서부터 150만큼이 된다는 뜻이다.register
메소드를 사용하여 연결해주는 것을 확인할 수 있다.UIPanGestureRecognizer
을 사용하였다. 생성자에서 action으로 들어가는 objc 함수가 곧 panGesture 인식 시 실행될 메소드이다. override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 0.6, animations: { [weak self] in
let frame = self?.view.frame
let yComponent = self?.partialView
self?.view.frame = CGRect(x: 0, y: yComponent!, width: frame!.width, height: frame!.height - 100)
})
self.view.roundCorners(corners: [.topLeft,.topRight], radius: 10)
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self.view) //하단 공식 문서 참고
let velocity = recognizer.velocity(in: self.view) //하단 공식 문서 참고
let y = self.view.frame.minY //프레임의 상단 y 좌표
//사용자의 panning으로 뷰의 위치가 최대로 펼쳤을 때~최소 사이즈일때 사이일 경우
if (y + translation.y >= fullView) && (y + translation.y <= partialView) {
//화면에 표시되는 뷰의 프레임의 위치를 사용자가 panning한 부분에 맞춰 변경해준다.
self.view.frame = CGRect(x: 0, y: y + translation.y, width: view.frame.width, height: view.frame.height)
//changing the translation value resets the velocity of the pan
recognizer.setTranslation(CGPoint.zero, in: self.view)
}
//.ended == The gesture recognizer has received touches recognized as the end of a continuous gesture.
if recognizer.state == .ended {
//y방향 속도값을 통해 duration 값 설정. 애니메이션에 사용하기 위한 값.
var duration = velocity.y < 0 ? Double((y - fullView) / -velocity.y) : Double((partialView - y) / velocity.y )
duration = duration > 1.3 ? 1 : duration
//.allowUserInteraction은 애니메이션 중 유저인터렉션을 받겠다는 의미
UIView.animate(withDuration: duration, delay: 0.0, options: [.allowUserInteraction], animations: {
//아래로 내리고 있었을 경우 자동으로 partialView로 바꿔줌.
if velocity.y >= 0 {
self.view.frame = CGRect(x: 0, y: self.partialView, width: self.view.frame.width, height: self.view.frame.height)
} else {
//위로 올리고 있었을 경우 자동으로 fullView로 바꿔줌.
self.view.frame = CGRect(x: 0, y: self.fullView, width: self.view.frame.width, height: self.view.frame.height)
}
}, completion: { [weak self] _ in
//fullView가 되었을 경우 테이블뷰의 스크롤을 가능하도록 바꿔준다.
//tableView의 스크롤과, view 자체의 스크롤이 간섭되지 않도록 하기 위함
if ( velocity.y < 0 ) {
self?.tableView.isScrollEnabled = true
}
})
}
}
extension ScrollableBottomSheetViewController: UIGestureRecognizerDelegate {
//테이블뷰가 동시에 스크롤되는것을 방지하기 위함
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let gesture = (gestureRecognizer as! UIPanGestureRecognizer)
let direction = gesture.velocity(in: view).y
let y = view.frame.minY
//사용자가 panning을 통해 scrollView를 아래로 닫고 있거나 이미 partialView일 경우 tableView의 스크롤을 막는다.
//tableView.contentOffset.y == 0은 테이블뷰의 내용이 최상단, 즉 상단으로 스크롤 완료된 상태
if (y == fullView && tableView.contentOffset.y == 0 && direction > 0) || (y == partialView) {
tableView.isScrollEnabled = false
} else {
//그게 아닌 경우, 즉 fullView 인 경우, 그리고 테이블뷰의 내용이 아직 최상단이 아닌 경우 테이블뷰 스크롤 가능
tableView.isScrollEnabled = true
}
//기본적으로 동시에 여러 제스쳐를 받지 않음.
return false
}
}
테이블뷰 구현 내용은 여기서 다룰 필요가 없으므로 생략한다.
이번 기능 추가를 통해 frame에 관련된 공부가 좀 더 필요함을 느꼈다.(애니메이션 또한 프레임 좌표축을 이용한 것이 대부분이기때문에..)