[iOS] Scrollable Bottom Sheet

RudinP·2024년 5월 4일
0

Study

목록 보기
226/258


네이버 지도 앱을 보면 사진과 같이 아래에서 위로 잡아올리면 뷰가 나타나는 형식의 레이아웃을 확인할 수 있다.
이러한 방식을 보통 Scrollable Bottom Sheet라고 부르는 듯 하다.

팀프로젝트 프들에 해당 방식의 뷰가 필요했는데, searchBar에 검색 시 지도와 함께 하단에 Scrollable Bottom Sheet가 생성되고, 이를 터치 시 일정 부분의 뷰가 올라오고 리스트를 확인할 수 있게 하는 방식이어야 했다.

따라서 초기에 시도한 방식은 다음과 같았다.

1. SearchBar의 result VC를 생성했다.

2. 해당 result VC에 mapkit을 사용하여 지도가 전체 화면에 걸쳐 표시되도록 하였다.

3. 코드로 UIView를 만들어 추가하였다.

다만, 3번 이후에서 막힌게, 뷰 자체에도 테이블 뷰를 추가해야 하였으며 사용자의 터치 인식을 받아 위로 뷰가 끌어올려져야 했다.
이런 방식은 vc가 아닌, result VC에 UIView로 추가했을 시 맥락적으로 너무 많은 부분을 포괄하게 되어 코드가 복잡해지는 문제가 발생했다.
내가 vc를 사용하지 않은, 못한 이유는 애초에 vc를 childVC로 추가하여 segue이동을 하도록 하는 방식이 존재한다는 것을 몰랐기 때문이다.
따라서 검색을 해보았고, stackOverflow에서 해당 방식에 대한 풀 코드를 담은 깃헙 링크를 찾을 수 있었다.

따라서, 이번 글에서는 어떻게 Scrollable Bottom Sheet를 구성할 수 있는지 파악하고 정리해두고자 한다.

전체 코드 (Scrollable Bottom Sheet View Controller)

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 또한 그러하다.

클래스 변수 및 viewDidLoad

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)
 }
 ...
 }
  • headerView, 즉 맨 처음에 보이는 부분의 UIView를 IBOutlet으로 연결하였다.
  • tableView, 즉 결과를 나열할 테이블뷰를 IBOutlet으로 연결하였다.
  • fullView: 뷰를 전부 펼쳤을 시, 어느 y 위치까지 펼쳐지는지의 기준이다. Swift에서는 하단으로 내려갈수록 y값이 증가하는 것을 명심하자.
  • partialView: 처음, 즉 터치하지 않은 기본적인 표시되는 뷰의 높이를 저장한 값이다. UIScreen.main.bounds.height면 화면 전체를 채우는 높이, 즉 화면에서 맨 아래쪽 부분의 y값을 말한다. 여기서 150을 뺀 값이 partialView인데, 즉 partialView의 높이는 아래에서부터 150만큼이 된다는 뜻이다.
  • viewDidLoad에서는 기본적인 델리게이트, 데이터소스 연결을 한다. 여기서는 xib파일을 사용했기 때문에 tableView의 cell을 register메소드를 사용하여 연결해주는 것을 확인할 수 있다.
  • 스크롤 인식, 즉 터치 제스쳐를 인식하기 위해 UIPanGestureRecognizer을 사용하였다. 생성자에서 action으로 들어가는 objc 함수가 곧 panGesture 인식 시 실행될 메소드이다.
  • view.addGestureRecognizer을 해야 해당 뷰가 제스쳐를 인식할 수 있게 된다.

viewDidAppear

  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)
    }
  • 뷰가 화면에 나타날 시, partialView가 뿅 하고 나타나는 애니메이션을 추가하는 부분이다.

panGesture

@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
                    }
            })
        }
    }

UIGestureRecognizerDelegate

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에 관련된 공부가 좀 더 필요함을 느꼈다.(애니메이션 또한 프레임 좌표축을 이용한 것이 대부분이기때문에..)

profile
iOS 개발자가 되기 위한 스터디룸...

0개의 댓글