드래그 가능한 BottomSheet 만들기(DraggableBottomSheet)

  • by

이 기사는 21년 6월 23일에 게시된 기사입니다.

작성된 시점과 현재 사이에 차이가 있을 수 있습니다.

들어가기 전에

Ounce를 개발할 때 버튼을 누르면 아래에서 선물 모달 방식으로 올라가는 뷰를 만들었습니다.

그냥 시간이 다가오고 애플이 기본적으로 제공하는 방식으로 구현했습니다.

잠시 코드를 가져옵니다.

let targetVC = ViewController()
targetVC.modalPresentationStyle = .popover
self.present(targetVC, animated: true, completion: nil)

자, 좀 더 앞으로 드래그가 가능하고, 이전의 뷰가 백그라운드 처리가 되어 있는 뷰를 만들어 봅시다.

구현

뷰 전환은 ViewController → BottomCardViewController입니다.

먼저 구현에 필요한 확장 기능을 추가합니다.

화면을 전환할 때는 현재 화면(ViewController)의 스냅샷을 찍고 화면을 전환한 후(BottomSheetViewController) 바탕화면으로 사용합니다.

그리고 ViewController에서 사용할 화면 변환 함수를 만들지 만 tapGestureRecognizer를 사용하도록합니다.

// UIView+Snapshot.swift

extension UIView  {
    // render the view within the view's bounds, then capture it as image
  func asImage() -> UIImage {
    let renderer = UIGraphicsImageRenderer(bounds: bounds)
    return renderer.image(actions: { rendererContext in
        layer.render(in: rendererContext.cgContext)
    })
  }
}
//UIViewController.swift

let tapGesture = UITapGestureRecognizer(target: self,
					action: #selector(self.tapImageView(_:)))

@objc func tapImageView(_ sender:UITapGestureRecognizer) {
        let bottomCardViewController = BottomCardViewController()
        bottomCardViewController.modalPresentationStyle = .fullScreen
        bottomCardViewController.backgroundImage = view.asImage()
        
        self.present(bottomCardViewController, animated: false, completion: nil)
    }

여기까지 쓰면 ViewController에서해야 할 일이 끝났습니다.

그런 다음 BottomCardViewController를 만듭니다.

BottomCardViewController에는 다음 UIComponet 및 Property가 필요합니다.

  • 배경을 투명하게 만들기 dimmerView: UIView
  • cardView: UIView
  • backgroundImage: UIImage – 스냅샷 이미지
  • 스냅샷에 전달된 이미지가 포함된 UIImageView
  • cardViewTopConstraints: NSLayoutConstraint – 드래그하면 이 값의 변화에 ​​따라 실제로 뷰가 이동합니다.

처음 화면 전환을 할 때 아래에서 cardView가 올라가야하기 때문에 viewDidLoad에 다음과 같이 레이아웃을 취했습니다.

cardView의 Top Constraint를 잡은 것을 보면 카드 뷰의 Top이 우리가 화면에서 보는 뷰 바로 아래에 있음을 알 수 있습니다.

func setupViews() {
	if let safeAreaHeight = UIApplication.shared.windows.first?
            .safeAreaLayoutGuide.layoutFrame.size.height,
           let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom {
            cardViewTopConstraint = cardView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,
                                                                  constant: safeAreaHeight + bottomPadding)
        }
        
        cardView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        cardView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        cardView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        NSLayoutConstraint.activate((cardViewTopConstraint))
}

그런 다음 viewDidAppear에서 카드 뷰를 들어 올리는 함수를 사용하면 좋네요.

카드보기를 높이는 showCard() 함수를 어떻게 구현해야 하나요?

  • dimmerView의 알파 값 조정
  • cardView의 topConstraint 값 조정
private func showCard() {
    self.view.layoutIfNeeded()
    if
      let safeAreaHeight = UIApplication
        .shared
        .windows.first?
        .safeAreaLayoutGuide
        .layoutFrame
        .size
        .height,
      let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom {
      cardViewTopConstraint.constant = (safeAreaHeight + bottomPadding) / 2
    }

    let showCard = UIViewPropertyAnimator(duration: 1, curve: .easeIn, animations: {
      self.view.layoutIfNeeded()
    })

    showCard.addAnimations({
      self.dimmerView.alpha = 0.7
    })
    showCard.startAnimation()
  }

cardViewTopConstraint.constant=(safeAreaHeight+bottomPadding)/2를 통해 카드 뷰의 위치를 ​​중앙에 배치했습니다.

이렇게 단지 끝나면 좋다고 생각합니다만, 끝이 아닙니다.

topConstraint의 값은 변경되었지만 UI의 변화는 실시간으로 반영되지 않습니다.

나는 애니메이션을주고 싶어서 카드보기의 위치를 ​​실시간으로 업데이트하고 싶습니다.

뷰의 위치를 ​​갱신하는 방법은 layoutIfNeeded() 를 이용하는 것입니다.

나는 애니메이션을 실행하는 몇 가지 방법 중 하나 인 UIViewPropertyAnimator를 사용하여 카드보기의 위치를 ​​업데이트합니다.

그리고 화면 Dim 처리도 UIViewPropertyAnimator에 전달합니다.

이제 하단 시트를 닫는 함수를 만듭니다.

showCard() 를 이해하면 쉽게 알 수 있습니다.

거의 같은 함수입니다.

차이점은 카드보기가 내려갈 때 BottomSheetViewControll을 dismisss하는 것입니다.

private func hideAndDismiss() {
  self.view.layoutIfNeeded()
  if
    let window = UIApplication.shared.windows.first {
    let safeAreaHeight = window.safeAreaLayoutGuide.layoutFrame.size.height
    let bottomPadding = window.safeAreaInsets.bottom
    cardViewTopConstraint.constant = (safeAreaHeight + bottomPadding)
  }
  let hideAndDismiss = UIViewPropertyAnimator(duration: 1, curve: .easeIn, animations: {
    self.view.layoutIfNeeded()
  })

  hideAndDismiss.addAnimations({
    self.dimmerView.alpha = 0.0
  })
  hideAndDismiss.addCompletion({ position in
    if position == .end {
      if(self.presentingViewController !
= nil) { self.dismiss(animated: false, completion: nil) } } }) hideAndDismiss.startAnimation() }

지금까지 완료되면 다음과 같이 작동합니다.

gif 변환 과정에서 하단 시트가 오르는 모습이 조금 어색해졌어요.


여기까지 실장했다면, 큰 스켈레톤은 모두 실장한 것이므로, 실은 모두 만든 것도 변하지 않는다고 생각합니다.

이제 드래그가 가능합니다.

단순히 핸들 뷰를 만들고 드래그 할 때 cardViewTopConstraint가 변경되는 것을 구현하기 만하면됩니다.

어떤 것이라도 상태가 있는 경우에는 대부분 Enum을 통해 관리하는 것이 편하다고 생각합니다.

그래서 cardView의 위치도 Enum으로 관리하도록 합니다.

enum CardViewState {
        case expanded // Safe Area Top에서 30pt 떨어진 상태
        case normal // (Safe Area Height + Safe Area Bottom Inset) / 2
 }

expanded를 어떻게 처리합니까? 이미 구현한 showCard를 재사용할 수 있습니다.

이를 위해 showCard를 리팩토링합니다.

showCard의 매개 변수는 void이지만 재사용을 위해 atState:CardViewState를 매개 변수로 추가합니다.

그런 다음 if 또는 switch 문을 통해 atState 값에 따라 분기합니다.

private func showCard(atState: CardViewState = .normal) {
        self.view.layoutIfNeeded()
        
        if let safeAreaHeight = UIApplication.shared.windows.first?
            .safeAreaLayoutGuide.layoutFrame.size.height,
           let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom {
            
            if atState == .expanded {
                cardViewTopConstraint.constant = 30.0
            } else {
                cardViewTopConstraint.constant = (safeAreaHeight + bottomPadding) / 2
            }
            cardPanStartingTopConstant = cardViewTopConstraint.constant
        }
        

        let showCard = UIViewPropertyAnimator(duration: 0.3, curve: .easeIn, animations: {
            self.view.layoutIfNeeded()
          })

        showCard.addAnimations({
            self.dimmerView.alpha = 0.7
          })
        showCard.startAnimation()

    }

Card View를 드래그하려면 UIPanGestureRecognizer를 등록해야 합니다.

GestureRecognizer는 사용자가 보기에 행동하는 것을 추적하는 데 도움이 됩니다.

예를 들어, 사용자가 보기를 길게 터치하고, 보기를 드래그하는 것을 알 수 있으며, 그에 맞게 우리가 원하는 행동(스크롤하거나 다른 보기를 보여줄 것)을 취할 수 있게 됩니다.

합니다.

func panViewGesture() {
        let viewPan = UIPanGestureRecognizer(target: self, action: #selector(viewPanned(_: )))
        
        // iOS는 기본적으로 touch를 감지 (recode) 하기 전에 약간의 딜레이를 준다.

즉시 반응해야 하기 때문에 false viewPan.delaysTouchesBegan = false viewPan.delaysTouchesEnded = false cardView.addGestureRecognizer(viewPan) handleView.addGestureRecognizer(viewPan) } @objc func viewPanned(_ panRecognizer: UIPanGestureRecognizer) { // 구현하면 될 것 }

이제 viewPanned 함수를 구현해 보겠습니다.

UIPanGestureRecognize의 state라는 속성을 이용하면 됩니다.

이 상태로 cardViewTopConstaraint를 변경할 수 있습니까?

코드를 구현하기 전에 각 상태를 어떻게 구현하는지 생각해 봅시다.

.began

  • 처음 드래그를 시작할 때 topConstraint 값 저장

.changed

  • 드래그 중인 상태(cardViewTopConstarint 변경 중)
  • topConstraint가 expanded mode (30)보다 작아서는 안됩니다.

  • 드래그를 따라 dimmer View의 alpha 값 조정

.ended

  • 드래그 종료 후의 액션 처리

그리고 사용자가 아래로 드래그를 빠르게 할 때 (Snap) 하단 시트를 닫으면 사용자의 관점에서 매우 편해집니다.

얼마나 빨리
뷰를 드래그했거나 velocity라는 변수를 통해 알 수 있습니다.

그래서 이것을 감지하면 하단 시트를 닫아 주면 됩니다.

@objc func viewPanned(_ panRecognizer: UIPanGestureRecognizer) {
  let translation = panRecognizer.translation(in: view)
  let velocity = panRecognizer.velocity(in: view)

  switch panRecognizer.state {
  case .began:
    cardPanStartingTopConstant = cardViewTopConstraint.constant

  case .changed:
    if cardPanStartingTopConstant + translation.y > 30.0 {
      cardViewTopConstraint.constant = cardPanStartingTopConstant + translation.y
    }

  case .ended:
    if velocity.y > 1500.0 {
      hideAndDismiss()
      return
    }


    if
      let safeAreaHeight = UIApplication
        .shared.windows
        .first?
        .safeAreaLayoutGuide
        .layoutFrame
        .size.height,
       let bottomPadding = UIApplication.
        shared.windows.
        first?.
        safeAreaInsets.
        bottom {

      if cardViewTopConstraint.constant < (safeAreaHeight + bottomPadding) * 0.25 {
        showCard(atState: .expanded)
      } else if cardViewTopConstraint.constant < safeAreaHeight - 70 {
        showCard(atState: .normal)
      } else {
        hideAndDismiss()
      }
    }
  default:
    break
  }
}

여기까지 오면 다음과 같은 결과를 얻을 수 있습니다.


전체 코드 및 참조

//
//  BottomCardViewController.swift
//  DraggableBottomCard
//
//  Created by psychehose on 2021/06/19.
//

import UIKit

class BottomCardViewController: UIViewController {
    
    enum CardViewState {
        case expanded
        case normal
    }
    var cardViewState: CardViewState = .normal
    
    private var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()

    var dimmerView: UIView = {
        let dimmerView = UIView()
        dimmerView.alpha = 0.0
        dimmerView.backgroundColor = .gray
        dimmerView.translatesAutoresizingMaskIntoConstraints = false
        return dimmerView
    }()
    
    var cardView: UIView = {
        let cardView = UIView()
        cardView.translatesAutoresizingMaskIntoConstraints = false
        cardView.backgroundColor = .white
        cardView.clipsToBounds = true
        cardView.layer.cornerRadius = 10.0
        cardView.layer.maskedCorners = (.layerMinXMinYCorner, .layerMaxXMinYCorner)
        return cardView
    }()
    
    var handleView: UIView = {
        let handleView = UIView()
        handleView.layer.cornerRadius = 3.0
        handleView.clipsToBounds = true
        handleView.translatesAutoresizingMaskIntoConstraints = false
        handleView.backgroundColor = .gray
        return handleView
    }()
    var cardViewTopConstraint: NSLayoutConstraint!
var cardPanStartingTopConstant: CGFloat = 30.0 var backgroundImage: UIImage? override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white imageView.image = backgroundImage configureLayout() tapBackgroundImageGesture() panViewGesture() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) showCard() } } // MARK: - Gesture extension BottomCardViewController { func tapBackgroundImageGesture() { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapImageView(_: ))) imageView.addGestureRecognizer(tapGesture) imageView.isUserInteractionEnabled = true } @objc func tapImageView(_ sender: UITapGestureRecognizer) { hideAndDismiss() } func panViewGesture() { let viewPan = UIPanGestureRecognizer(target: self, action: #selector(viewPanned(_: ))) // iOS는 기본적으로 touch를 감지 (recode) 하기 전에 약간의 딜레이를 준다.

즉시 반응해야 하기 때문에 false viewPan.delaysTouchesBegan = false viewPan.delaysTouchesEnded = false cardView.addGestureRecognizer(viewPan) handleView.addGestureRecognizer(viewPan) } @objc func viewPanned(_ panRecognizer: UIPanGestureRecognizer) { let translation = panRecognizer.translation(in: view) let velocity = panRecognizer.velocity(in: view) switch panRecognizer.state { case .began: cardPanStartingTopConstant = cardViewTopConstraint.constant case .changed: if cardPanStartingTopConstant + translation.y > 30.0 { cardViewTopConstraint.constant = cardPanStartingTopConstant + translation.y } self.dimmerView.alpha = dimmerAlphaWithTopConstant(value: cardViewTopConstraint.constant) case .ended: if velocity.y > 1500.0 { hideAndDismiss() return } if let safeAreaHeight = UIApplication.shared.windows.first? .safeAreaLayoutGuide.layoutFrame.size.height, let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom { if cardViewTopConstraint.constant < (safeAreaHeight + bottomPadding) * 0.25 { showCard(atState: .expanded) } else if cardViewTopConstraint.constant < safeAreaHeight - 70 { showCard(atState: .normal) } else { hideAndDismiss() } } default: break } } } // MARK: - UILayout extension BottomCardViewController { func configureLayout() { view.addSubview(imageView) imageView.addSubview(dimmerView) view.addSubview(cardView) view.addSubview(handleView) imageView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true dimmerView.topAnchor.constraint(equalTo: imageView.topAnchor).isActive = true dimmerView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor).isActive = true dimmerView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor).isActive = true dimmerView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor).isActive = true if let safeAreaHeight = UIApplication.shared.windows.first? .safeAreaLayoutGuide.layoutFrame.size.height, let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom { cardViewTopConstraint = cardView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: safeAreaHeight + bottomPadding) } cardView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true cardView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true cardView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true NSLayoutConstraint.activate((cardViewTopConstraint)) handleView.centerXAnchor.constraint(equalTo: cardView.centerXAnchor).isActive = true handleView.widthAnchor.constraint(equalToConstant: 60).isActive = true handleView.heightAnchor.constraint(equalToConstant: 6).isActive = true handleView.bottomAnchor.constraint(equalTo: cardView.topAnchor, constant: -10).isActive = true } } extension BottomCardViewController { private func showCard(atState: CardViewState = .normal) { self.view.layoutIfNeeded() if let safeAreaHeight = UIApplication.shared.windows.first? .safeAreaLayoutGuide.layoutFrame.size.height, let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom { if atState == .expanded { cardViewTopConstraint.constant = 30.0 } else { cardViewTopConstraint.constant = (safeAreaHeight + bottomPadding) / 2 } cardPanStartingTopConstant = cardViewTopConstraint.constant } let showCard = UIViewPropertyAnimator(duration: 0.3, curve: .easeIn, animations: { self.view.layoutIfNeeded() }) showCard.addAnimations({ self.dimmerView.alpha = 0.7 }) showCard.startAnimation() } private func hideAndDismiss() { self.view.layoutIfNeeded() if let safeAreaHeight = UIApplication.shared.windows.first? .safeAreaLayoutGuide.layoutFrame.size.height, let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom { cardViewTopConstraint.constant = (safeAreaHeight + bottomPadding) } let hideAndDismiss = UIViewPropertyAnimator(duration: 0.3, curve: .easeIn, animations: { self.view.layoutIfNeeded() }) hideAndDismiss.addAnimations({ self.dimmerView.alpha = 0.0 }) hideAndDismiss.addCompletion({ position in if position == .end { if(self.presentingViewController !
= nil) { self.dismiss(animated: false, completion: nil) } } }) hideAndDismiss.startAnimation() } } extension BottomCardViewController { private func dimmerAlphaWithTopConstant(value: CGFloat) -> CGFloat { let fullDimAlpha: CGFloat = 0.7 guard let safeAreaHeight = UIApplication.shared.windows.first? .safeAreaLayoutGuide.layoutFrame.size.height, let bottomPadding = UIApplication.shared.windows.first?.safeAreaInsets.bottom else { return fullDimAlpha } let fullDimPosition = (safeAreaHeight + bottomPadding) / 2.0 let noDimPosition = safeAreaHeight + bottomPadding if value < fullDimPosition { return fullDimAlpha } if value > noDimPosition { return 0.0 } return fullDimAlpha * 1 - ((value - fullDimPosition) / fullDimPosition) } }

https://fluffy.es/facebook-draggable-bottom-card-modal-1/

Replicating Facebook’s Draggable Bottom Card using Auto Layout – Part 2/2

In Part 1, we have managed to implement the show and hide card animation when user tap on button or the dimmer view. In this part, we are going to implement the card dragging animation. This post assume that you already knew about Auto Layout and Delegate.

fluffy.es

https://fluffy.es/facebook-draggable-bottom-card-modal-2/

Replicating Facebook’s Draggable Bottom Card using Auto Layout – Part 2/2

In Part 1, we have managed to implement the show and hide card animation when user tap on button or the dimmer view. In this part, we are going to implement the card dragging animation. This post assume that you already knew about Auto Layout and Delegate.

fluffy.es