Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
Tags
- RxCocoa
- HIG
- 애니메이션
- ios
- Clean Code
- SWIFT
- 클린 코드
- 리펙터링
- Refactoring
- UICollectionView
- Observable
- uiscrollview
- 리펙토링
- 리팩토링
- UITextView
- uitableview
- Xcode
- Protocol
- collectionview
- MVVM
- rxswift
- map
- tableView
- 스위프트
- ribs
- clean architecture
- swiftUI
- swift documentation
- Human interface guide
- combine
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] BottomSheet 구현 방법 (바텀 시트, FloatingPanel 프레임워크 사용) 본문
iOS 응용 (swift)
[iOS - swift] BottomSheet 구현 방법 (바텀 시트, FloatingPanel 프레임워크 사용)
jake-kim 2023. 2. 7. 22:12FloatingPanel 프레임워크
- bottom sheet UI 프레임워크 중 높은 star를 가지고 있는 프레임워크
- FlaotingPanel을 이용하면 bottom sheet UI를 쉽게 구현할 수 있지만, 원하는 스타일의 bottom sheet를 구현하는 방법은 github에 나와있지 않으므로 따로 해당 글에서 알아보기
일반적인 BottomSheet 형태 2가지
1) bottom sheet의 뒷 배경을 터치할 수 있는 형태
2) bottom sheet의 뒷 배경을 dimmed 처리하고, 터치하면 닫히는 형태
구현 아이디어
- 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
* 참고
'iOS 응용 (swift)' 카테고리의 다른 글
Comments