✅ 목표
- 탭바 가리지 않음
- sheetPresentationController 사용 안 함
- UIView를 하단에서 슬라이드 업하는 느낌의 Bottom Sheet 형태 구현
✅ 구현 구조
LocationViewController
└ mapView (전체)
└ bottomSheetView (하단에서 올라옴)
└ 검색창, 버튼 등 원하는 UI
✅ BottomMapView 클래스로 분리된 경우 적용 예시
// BottomMapView.swift
class BottomMapView: UIView {
// MARK: - 상태 관리
enum SheetPosition: CaseIterable {
case min, mid, max
var height: CGFloat {
switch self {
case .min: return 150
case .mid: return UIScreen.main.bounds.height * 0.4
case .max: return UIScreen.main.bounds.height * 0.85
}
}
}
private var heightConstraint: NSLayoutConstraint!
private var currentPosition: SheetPosition = .min
init() {
super.init(frame: .zero)
setupView()
setupGesture()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupView() {
translatesAutoresizingMaskIntoConstraints = false
backgroundColor = .systemBackground
layer.cornerRadius = 20
layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.1
layer.shadowOffset = CGSize(width: 0, height: -2)
layer.shadowRadius = 5
}
func attach(to parentView: UIView) {
parentView.addSubview(self)
heightConstraint = heightAnchor.constraint(equalToConstant: currentPosition.height)
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
bottomAnchor.constraint(equalTo: parentView.safeAreaLayoutGuide.bottomAnchor),
heightConstraint
])
}
private func setupGesture() {
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
addGestureRecognizer(panGesture)
}
@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: superview)
let velocity = gesture.velocity(in: superview)
switch gesture.state {
case .changed:
let newHeight = heightConstraint.constant - translation.y
heightConstraint.constant = max(SheetPosition.min.height,
min(SheetPosition.max.height, newHeight))
gesture.setTranslation(.zero, in: superview)
case .ended:
let nearest = nearestSheetPosition(to: heightConstraint.constant, velocity: velocity)
animate(to: nearest)
default:
break
}
}
private func nearestSheetPosition(to height: CGFloat, velocity: CGPoint) -> SheetPosition {
let sorted = SheetPosition.allCases.sorted {
abs($0.height - height) < abs($1.height - height)
}
if abs(velocity.y) > 500 {
return velocity.y < 0 ? .max : .min
} else {
return sorted.first ?? .min
}
}
private func animate(to position: SheetPosition) {
currentPosition = position
heightConstraint.constant = position.height
UIView.animate(withDuration: 0.25) {
self.superview?.layoutIfNeeded()
}
}
}
📌 1. handlePanGesture(_:) — 팬 제스처 핸들러
팬 제스처(UIPanGestureRecognizer)를 통해 사용자가 BottomMapView를 위아래로 드래그할 수 있도록 해줌.
@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
/*
✅ 팬 제스처가 시작된 이후로 손가락이 슈퍼뷰 기준으로 얼마나 이동했는지를 나타내는 CGPoint 값 반환
여기서 superview는 BottomMapView가 올라가 있는 부모 뷰(LocationViewController)를 의미
❓왜 superview를 기준으로 측정할까?
제스처 이동 거리를 뷰 전체 기준에서 정확하게 측정하기 위해
*/
let translation = gesture.translation(in: superview)
/*
✅ gesture.velocity(in: superview)는 팬 제스처가 움직이고 있는 속도를
superview의 좌표계 기준으로 반환하는 함수
velocity.y가 1000이라면 → 사용자가 아래 방향으로 매우 빠르게 드래그하고 있다는 뜻.
velocity.y가 -800이라면 → 사용자가 위로 빠르게 드래그하고 있다는 뜻.
*/
let velocity = gesture.velocity(in: superview)
switch gesture.state {
case .changed:
let newHeight = heightConstraint.constant - translation.y
heightConstraint.constant = max(SheetPosition.min.height,
min(SheetPosition.max.height, newHeight))
gesture.setTranslation(.zero, in: superview)
case .ended:
let nearest = nearestSheetPosition(to: heightConstraint.constant, velocity: velocity)
animate(to: nearest)
default:
break
}
}
✅ velocity는 어떤 방향으로, 얼마나 빠르게 움직였는지를 실시간으로 알 수 있게 해주는 매우 유용한 값
✅ 필요하다면 UIScrollView와 연동하거나, 시트가 탁- 하고 "스냅"되는 애니메이션 로직을 만들 때 핵심적으로 사용
📌 2. nearestSheetPosition(to:velocity:) — 가장 가까운 시트 위치 결정
private func nearestSheetPosition(to height: CGFloat, velocity: CGPoint) -> SheetPosition {
let sorted = SheetPosition.allCases.sorted {
abs($0.height - height) < abs($1.height - height)
}
if abs(velocity.y) > 500 {
return velocity.y < 0 ? .max : .min
} else {
return sorted.first ?? .min
}
}
📍 역할:
시트가 어느 정도 높이에 있는지 판단해서, 손을 떼면 어디에 "딱" 붙여야 할지를 계산함. 예를 들어, 중간 높이에서 아래로 약간만 내렸다가 손을 떼면 → 다시 중간으로 스냅.
🔍 동작 방식:
- 현재 높이(height)와 미리 정의된 각 단계의 높이(SheetPosition)들을 비교해서 가장 가까운 위치를 정렬함.
- 속도가 빠를 경우 (예: 위로 빠르게 밀면 velocity.y < 0) → 방향에 따라 강제로 .max 또는 .min으로 이동시킴.
즉, 스와이프처럼 쓰는 걸 고려함. - 그렇지 않으면 가장 가까운 위치(sorted.first)로 스냅.
🧩 전제: 이 코드의 역할
이 함수는 팬 제스처의 높이(height)와 속도(velocity)를 받아서, SheetPosition 중 어디로 스냅할지를 결정
enum SheetPosition: CGFloat, CaseIterable {
case min = 100
case mid = 300
case max = 600
var height: CGFloat { rawValue }
}
예를 들어 height = 280, velocity.y = 300이라면, mid에 가깝고 천천히 움직이고 있는 상황
let sorted = SheetPosition.allCases.sorted {
abs($0.height - height) < abs($1.height - height)
}
- height와 가장 가까운 위치를 찾는 과정
- 예를 들어, 현재 bottom sheet 위치가 280이면…
- min: |100 - 280| = 180
- mid: |300 - 280| = 20
- max: |600 - 280| = 320
- → 가장 가까운 건 mid니까, sorted.first는 .mid가 됨
if abs(velocity.y) > 500 {
return velocity.y < 0 ? .max : .min
}
- 팬 제스처의 속도가 충분히 빠르면(500 이상), 가까운 위치를 무시하고 방향만 본다는 뜻
- velocity.y < 0: 위로 빠르게 스와이프 → .max
- velocity.y > 0: 아래로 빠르게 스와이프 → .min
즉, 빠르게 위로 밀면 최대 위치로, 빠르게 아래로 밀면 최소 위치로 이동
else {
return sorted.first ?? .min
}
- 빠르게 밀지 않았으면 → 가장 가까운 위치(sorted.first)로 snap
- 만약 예외적으로 없으면 기본값 .min 사용
🔁 예시
📌 예 1: 천천히 이동, 높이 = 280, 속도 = 100
- velocity.y가 500보다 작음 → 속도 무시
- height = 280 → .mid에 가까움 → .mid 반환
📌 예 2: 빠르게 위로 스와이프, 높이 = 250, 속도 = -900
- velocity.y = -900 → 빠름, 위 방향 → .max 반환
'Project > HiddenGem' 카테고리의 다른 글
🤔 여러 ViewController에서 위치 권한을 사용할 경우에는? (0) | 2025.06.10 |
---|---|
🔨 Raw value for enum case must be a literal 문제 해결! (0) | 2025.06.03 |
🤔 뷰 컨트롤러가 모달로 띄워졌는지 어떻게 알 수 있나? (0) | 2025.06.02 |
🗺️ 지도 표시하기 (0) | 2025.06.02 |
🔨 데이터 타입 변환 및 통합하기 (0) | 2025.05.29 |