관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] BottomSheet 구현 방법 (바텀 시트, FloatingPanel 프레임워크 사용) 본문

iOS 응용 (swift)

[iOS - swift] BottomSheet 구현 방법 (바텀 시트, FloatingPanel 프레임워크 사용)

jake-kim 2023. 2. 7. 22:12

FloatingPanel 프레임워크

* FloatingPanel github

  • bottom sheet UI 프레임워크 중 높은 star를 가지고 있는 프레임워크

  • FlaotingPanel을 이용하면 bottom sheet UI를 쉽게 구현할 수 있지만, 원하는 스타일의 bottom sheet를 구현하는 방법은 github에 나와있지 않으므로 따로 해당 글에서 알아보기

일반적인 BottomSheet 형태 2가지

1) bottom sheet의 뒷 배경을 터치할 수 있는 형태

TouchPass 바텀시트 (바텀시트 뒤 버튼 클릭 가능)

2) bottom sheet의 뒷 배경을 dimmed 처리하고, 터치하면 닫히는 형태

touch block 바텀 시트 (바텀시트 뒤 클릭 시 dismiss)

구현 아이디어

  • 1), 2) 타입 모두 handler를 가지고 내려서 dismiss 시킬 수 있어야하고, bottomSheet안에 있는 scrollView를 내려서도 dismiss가 가능하게 구현
  • FloatingPanel 프레임워크를 사용하면 ViewController를 받아서 그 view를 bottomSheet안에 띄울 수 있으므로, ScrollView 인스턴스를 가지고 있는 ViewController를 주입하여 사용 가능하도록 구현
  • bottomSheet의 높이는 동적으로 변해야하고, 최대 크기가 정해져 있어야하므로 SelfSizingTableView같은 것을 사용하여 구현
  • FloatingPanel을 상속받고 있는 BottomSheetViewController를 구현
    • BottomSheetViewController의 초기화에는 isTouchPassable와 contentViewController를 주입 받도록 구현

사용한 프레임워크

  • SnapKit과 Then은 코드 베이스로 UI 구현 시 편의를 위해 사용
target 'ExBottomSheet' do
  use_frameworks!

  pod 'SnapKit'
  pod 'Then'
  pod 'FloatingPanel'
end

구현 방법

  • FloatingPanel 프레임워크에 있는 FloatingPanelController를 상속받는 커스텀 VC 구현
final class BottomSheetViewController: FloatingPanelController {

}
  • 프레임워크에 내장된 set(contentViewController:)을 사용하여 contentViewController를 주입해주어야 하므로, scrollView를 가지고 있는 ViewController를 초기화할때 받아야하므로 protocol을 사용하여 인터페이스 선언
protocol ScrollableViewController where Self: UIViewController {
    var scrollView: UIScrollView { get }
}

final class BottomSheetViewController: FloatingPanelController {
    private let isTouchPassable: Bool
    
    init(isTouchPassable: Bool, contentViewController: ScrollableViewController) {
        self.isTouchPassable = isTouchPassable
        
        super.init(delegate: nil)
        
        setUpView(contentViewController: contentViewController) // 아래에서 계속
    }
    
    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }
}
  • init()에서 호출 되는 setupView 구현
    • isTouchPassable 분기문에 따라 적절한 값을 세팅
    • 아래에서 나오는 layout과 delegate 구현부는 아래에서 계속
private func setUpView(contentViewController: ScrollableViewController) {
    // Contents
    set(contentViewController: contentViewController)
    track(scrollView: contentViewController.scrollView)
    
    // Appearance
    let appearance = SurfaceAppearance().then {
        $0.cornerRadius = 16.0
        $0.backgroundColor = .white
        $0.borderColor = .clear
        $0.borderWidth = 0
    }
    
    // Surface
    surfaceView.grabberHandle.isHidden = false
    surfaceView.grabberHandle.backgroundColor = .gray
    surfaceView.grabberHandleSize = .init(width: 40, height: 4)
    surfaceView.appearance = appearance
    
    // Backdrop
    backdropView.dismissalTapGestureRecognizer.isEnabled = isTouchPassable ? false : true
    let backdropColor = isTouchPassable ? UIColor.clear : .black
    backdropView.backgroundColor = backdropColor // alpha 설정은 FloatingPanelBottomLayout 델리게이트에서 설정
    
    // Layout
    // 아래에서 계속
    let layout = isTouchPassable ? TouchPassIntrinsicPanelLayout() : TouchBlockIntrinsicPanelLayout()
    self.layout = layout
    
    // delegate
    delegate = self // 아래에서 계속
}
  • delegate 구현
extension BottomSheetViewController: FloatingPanelControllerDelegate {
    func floatingPanelDidMove(_ fpc: FloatingPanelController) {
        let loc = fpc.surfaceLocation
        let minY = fpc.surfaceLocation(for: .full).y
        let maxY = fpc.surfaceLocation(for: .tip).y
        let y = isTouchPassable ? max(min(loc.y, minY), maxY) : min(max(loc.y, minY), maxY)
        fpc.surfaceLocation = CGPoint(x: loc.x, y: y)
    }
    
    // 특정 속도로 아래로 당겼을때 dismiss 되도록 처리
    public func floatingPanelWillEndDragging(_ fpc: FloatingPanelController, withVelocity velocity: CGPoint, targetState: UnsafeMutablePointer<FloatingPanelState>) {
        guard velocity.y > 50 else { return }
        dismiss(animated: true)
    }
}
  • 위에서 있었던 layout관련 코드 구현 방법
// 위에서 나왔던 코드
let layout = isTouchPassable ? TouchPassIntrinsicPanelLayout() : TouchBlockIntrinsicPanelLayout()

// TouchPassIntrinsicPanelLayout.swift
final class TouchPassIntrinsicPanelLayout: FloatingPanelBottomLayout {
    override var initialState: FloatingPanelState { .tip }
    override var anchors: [FloatingPanelState : FloatingPanelLayoutAnchoring] {
        return [
            .tip: FloatingPanelIntrinsicLayoutAnchor(fractionalOffset: 0, referenceGuide: .safeArea)
        ]
    }
}

// TouchBlockIntrinsicPanelLayout.swift
final class TouchBlockIntrinsicPanelLayout: FloatingPanelBottomLayout {
    override var initialState: FloatingPanelState { .full }
    override var anchors: [FloatingPanelState : FloatingPanelLayoutAnchoring] {
        return [
            .full: FloatingPanelIntrinsicLayoutAnchor(fractionalOffset: 0, referenceGuide: .safeArea)
        ]
    }

    override func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
        0.5
    }
}

사용하는 쪽

  • 콘텐츠의 크기가 동적으로 늘어나야하므로, SelfSizingTableView 구현
    • 단, 최대 크기를 정하여 일정 크기 이상 늘어나면 사이즈가 maxHeight로 고정되도록 구현
import UIKit

final class SelfSizingTableView: UITableView {
    private let maxHeight: CGFloat
    
    override var intrinsicContentSize: CGSize {
        CGSize(width: contentSize.width, height: min(contentSize.height, maxHeight))
    }
    
    init(maxHeight: CGFloat) {
        self.maxHeight = maxHeight
        super.init(frame: .zero, style: .grouped)
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        invalidateIntrinsicContentSize()
    }
}
  • 클라이언트 코드 (= 바텀 시트를 사용하는 부분의 코드)
import UIKit
import SnapKit
import Then

class ViewController: UIViewController {
    private let stackView = UIStackView().then {
        $0.axis = .vertical
        $0.spacing = 12
    }
    private let button1 = UIButton(type: .system).then {
        $0.setTitle("touch pass 바텀시트 오픈", for: .normal)
        $0.setTitleColor(.systemBlue, for: .normal)
        $0.setTitleColor(.blue, for: .normal)
    }
    private let button2 = UIButton(type: .system).then {
        $0.setTitle("touch block 바텀시트 오픈", for: .normal)
        $0.setTitleColor(.systemBlue, for: .normal)
        $0.setTitleColor(.blue, for: .normal)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(stackView)
        [button1, button2].forEach(stackView.addArrangedSubview(_:))
        
        stackView.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(24)
            $0.centerX.equalToSuperview()
        }
        
        button1.addTarget(self, action: #selector(tap1), for: .touchUpInside)
        button2.addTarget(self, action: #selector(tap2), for: .touchUpInside)
    }
    
    @objc private func tap1() {
        let bottomSheetViewController = BottomSheetViewController(isTouchPassable: true, contentViewController: MyViewController())
        present(bottomSheetViewController, animated: true)
    }
    
    @objc private func tap2() {
        let bottomSheetViewController = BottomSheetViewController(isTouchPassable: false, contentViewController: MyViewController())
        present(bottomSheetViewController, animated: true)
    }
}

// ScrollableViewController: 클라이언트 코드에서 해당 프로토콜에 명시된 인터페이스에 접근
final class MyViewController: UIViewController, ScrollableViewController {
    private let tableView = SelfSizingTableView(maxHeight: UIScreen.main.bounds.height * 0.7).then {
        $0.allowsSelection = false
        $0.backgroundColor = UIColor.clear
        $0.separatorStyle = .none
        $0.bounces = true
        $0.showsVerticalScrollIndicator = true
        $0.contentInset = .zero
        $0.indicatorStyle = .black
        $0.estimatedRowHeight = 34.0
        $0.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }
    
    var scrollView: UIScrollView {
        tableView
    }
        
    init() {
        super.init(nibName: nil, bundle: nil)
        setUpView()
    }
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    private func setUpView() {
        view.addSubview(tableView)
        
        tableView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        tableView.dataSource = self
    }
}

extension MyViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        20
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = "cell\(indexPath.row)"
        return cell
    }
}

* 전체 코드: https://github.com/JK0369/ExBottomSheet_example

* 참고

https://github.com/scenee/FloatingPanel

Comments