본문 바로가기

Project/HiddenGem

🤔 탭바는 가리지 않고, 커스텀 Bottom Sheet처럼 동작하는 UI를 원한다면?

 

✅ 목표

  • 탭바 가리지 않음
  • 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 반환