[iOS] Auto Layout을 이용하여 페이스북의 Bottom Sheet을 만들어보자! - Part 2

제이티·2021년 5월 26일
3

저희가 앞에서 Bottom Sheet이 열리고 닫히는 애니메이션을 만들고, 사용자가 button을 누르면 Bottom Sheet이 열리고, dimmedView를 탭하면 Bottom Sheet이 닫히도록 구현해주었습니다! 이번에는 Bottom Sheet을 드래그하면 발생하는 애니메이션과 기능을 구현해주도록 할게요~

구현을 시작하기 전에, Bottom Sheet의 두 가지 상태에 대해 설명해 드릴 거에요! 두 가지 상태는 각각 .expanded와 .normal로 구현해줄 겁니다 ㅎㅎ

Bottom Sheet의 상태를 지정하기 위한 enum 만들기

Bottom Sheet의 상태를 지정하기 위해 enum을 만들어 줍니다~

class BottomSheetViewController: UIViewController {
    // ...
    enum BottomSheetViewState {
        case expanded
        case normal
    }
    
    // Bottom Sheet과 safe Area Top 사이의 최소값을 지정하기 위한 프로퍼티
    // 기본값은 30으로 지정
    var bottomSheetPanMinTopConstant: CGFloat = 30.0
    // 드래그 하기 전에 Bottom Sheet의 top Constraint value를 저장하기 위한 프로퍼티
    private lazy var bottomSheetPanStartingTopConstant: CGFloat = bottomSheetPanMinTopConstant
    // ...
}

Pan Gesture Recognizer를 이용해 드래그 액션 지정

그 다음으로 View Controller의 root View에 Pan Gesture Recognizer를 설정하여 사용자의 드래그를 인지하고, Bottom Sheet이 이에 따라 움직이도록 할게요! 여기서 여러분이 이렇게 생각하실 수 있을 거 같아요~ "왜 View Controller의 root view에 Pan Gesture Recognizer를 설정하지? Bottom Sheet에 설정해도 되는 거 아닌가?" 여러분이 페이스북이나 슬랙에서 Bottom Sheet의 바깥쪽을 드래그해보시면 Bottom Sheet이 움직이는 걸 볼 수 있어요! 그래서 이렇게 설정해준 거랍니다.

override func viewDidLoad() {
    super.viewDidLoad()
    // ...
    
    // Pan Gesture Recognizer를 view controller의 view에 추가하기 위한 코드
    let viewPan = UIPanGestureRecognizer(target: self, action: #selector(viewPanned(_:)))
    
    // 기본적으로 iOS는 터치가 드래그하였을 때 딜레이가 발생함
    // 우리는 드래그 제스쳐가 바로 발생하길 원하기 때문에 딜레이가 없도록 아래와 같이 설정
    viewPan.delaysTouchesBegan = false
    viewPan.delaysTouchesEnded = false
    view.addGestureRecognizer(viewPan)
}

// 해당 메소드는 사용자가 view를 드래그하면 실행됨
@objc private func viewPanned(_ panGestureRecognizer: UIPanGestureRecognizer) {
    let translation = panGestureRecognizer.translation(in: self.view)
    
    print("유저가 위아래로 \(translation.y)만큼 드래그하였습니다.")
}

앱을 실행해보고, Bottom Sheet을 드래그하면 콘솔에 다음과 같이 나타날 거에요 ㅎㅎ

사용자가 위로 드래그할 경우 translation.y의 값은 음수가 되고, 사용자가 아래로 드래그할 경우 translation.y의 값은 양수가 되는 걸 확인할 수 있죠! translation.y의 값을 top constraint value와 합하여 Bottom Sheet을 움직여줄 수 있답니다.

panGestureRecognizer는 세 가지 상태를 가지는데, 각각 .began, .changed, .end에요.

.began은 우리가 막 터치를 시작했을 때의 상태이고, .changed는 우리가 드래그할 때의 상태이고, .end는 우리가 드래그를 그만 두고 터치를 떼었을 때의 상태랍니다.

.began 상태에서는 아까 만들어준 프로퍼티 bottomSheetPanMinTopConstant에 현재 Bottom Sheet의 top constraint의 constant 값을 담아줄 거에요!

bottomSheetPanStartingTopConstant = bottomSheetViewTopConstraint.constant

이 값을 이용해서 .changed 상태에서, Bottom Sheet의 top constraint를 바꾸어주어 움직이는 애니메이션을 만들어줄 수 있답니다. 현재 top constraint의 constant 값에 드래그로 움직인 거리(translation.y)를 더해주는 식으로 말이에요!

bottomSheetViewTopConstraint.constant = bottomSheetPanStartingTopConstant + translation.y

그리고 만약 사용자가 Bottom Sheet을 safe Area Top으로부터 bottomSheetPanMinTopConstant(기본값 30pt) 떨어진 위치보다 더 위로! 드래그하려 하면 드래그가 되지 않도록 설정해 줄게요~ 방금 위에 적은 코드에 if문을 추가해주면 됩니다

if bottomSheetPanStartingTopConstant + translation.y > bottomSheetPanMinTopConstant {
    bottomSheetViewTopConstraint.constant = bottomSheetPanStartingTopConstant + translation.y
}

이 코드들을 viewPanned 메소드에 적어주면 다음과 같습니다~

@objc private func viewPanned(_ panGestureRecognizer: UIPanGestureRecognizer) {
    let translation = panGestureRecognizer.translation(in: self.view)
    
    switch panGestureRecognizer.state {
    case .began:
        bottomSheetPanStartingTopConstant = bottomSheetViewTopConstraint.constant
    case .changed:
        if bottomSheetPanStartingTopConstant + translation.y > bottomSheetPanMinTopConstant {
            bottomSheetViewTopConstraint.constant = bottomSheetPanStartingTopConstant + translation.y
        }
    case .ended:
        print("드래그가 끝남")
    default:
        break
    }
}

이제 프로젝트를 빌드하고 실행시켜보면, Bottom Sheet을 드래그하였을 때 움직이는 걸 볼 수 있어요!

잘 작동하네요 ㅎㅎ 하지만 아직 빠진 게 있죠? 페이스북이나 슬랙같은 앱을 보면 우리가 드래그하다 손을 떼면 특정 위치로 Bottom Sheet이 이동하잖아요! 아직 이게 구현되지 않았으니 지금부터 구현해보도록 하겠습니다~

Snap 효과 추가

Bottom Sheet이 .normal과 .expanded, 두 가지 상태를 갖는다고 했었죠! 사용자가 드래그하다 손을 떼었을 때Bottom Sheet의 높이에 따라 Bottom Sheet이 Snap되는 효과를 줄게요 ㅎㅎ 이걸 구현하기 위해서 함수 하나를 정의해 주도록 하겠습니다~

//주어진 CGFloat 배열의 값 중 number로 주어진 값과 가까운 값을 찾아내는 메소드
func nearest(to number: CGFloat, inValues values: [CGFloat]) -> CGFloat {
    guard let nearestVal = values.min(by: { abs(number - $0) < abs(number - $1) })
    else { return number }
    return nearestVal
}

위 함수의 결과를 예를 들어 설명해드릴게요! nearest(to: 3, values: [1, 10])을 실행하면 1과 10 중 3과 가까운 값을 찾습니다! 3과 가까운 값은 1이 되겠죠? 그래서 nearest(to: 3, values: [1, 10])의 값은 1이 됩니다.

이 함수를 이용해서 Bottom Sheet의 높이가 가까운 곳에 따라 Snap 효과가 발생하도록 코드를 작성해줄게요!

@objc private func viewPanned(_ panGestureRecognizer: UIPanGestureRecognizer) {
    let translation = panGestureRecognizer.translation(in: view)
    
    switch panGestureRecognizer.state {
    case .began:
        bottomSheetPanStartingTopConstant = bottomSheetViewTopConstraint.constant
    case .changed:
        if bottomSheetPanStartingTopConstant + translation.y > bottomSheetPanMinTopConstant {
            bottomSheetViewTopConstraint.constant = bottomSheetPanStartingTopConstant + translation.y
        }
    case .ended:
        let safeAreaHeight = view.safeAreaLayoutGuide.layoutFrame.height
        let bottomPadding = view.safeAreaInsets.bottom
        // 1
        let defaultPadding = safeAreaHeight+bottomPadding - defaultHeight
        
        // 2
        let nearestValue = nearest(to: bottomSheetViewTopConstraint.constant, inValues: [bottomSheetPanMinTopConstant, defaultPadding, safeAreaHeight + bottomPadding])
        
        // 3
        if nearestValue == bottomSheetPanMinTopConstant {
            print("Bottom Sheet을 Expanded 상태로 변경하기!")
        } else if nearestValue == defaultPadding {
            // Bottom Sheet을 .normal 상태로 보여주기
            showBottomSheet()
        } else {
            // Bottom Sheet을 숨기고 현재 View Controller를 dismiss시키기
            hideBottomSheetAndGoBack()
        }
    default:
        break
    }
}

바뀐 코드는 case .ended: 부분입니다! 각 코드에 대해서 설명해드릴게요

  1. defaultPadding 변수는 Bottom Sheet의 높이가 defaultHeight일 때 safeAreaTop과 bottomSheet 사이의 거리를 계산한 변수에요
  2. nearest 메소드를 이용해서 현재 bottomSheet과 safeAreaTop 사이의 거리가 bottomSheetPanMinTopConstant(bottomSheet과 safeAreaTop 사이의 최소 거리), defaultPadding, 그리고 safeAreaHeight + bottomPadding과 비교하여 가장 가까운 위치를 nearestValue 변수에 저장해줍니다!
    1. bottomSheetPanMinTopConstant가 nearestValue의 값이 되는 경우는 Bottom Sheet이 SafeAreaTop과 가장 가까운 경우에요
    2. defaultPadding이 nearestValue의 값이 되는 경우는 Bottom Sheet이 defaultHeight에 가까운 높이값을 갖는 경우에요
    3. safeAreaHeight과 bottomPadding을 합한 값이 nearestValue의 값이 되는 경우는 Bottom Sheet이 view.bottom과 가장 가까운 경우에요
  3. nearestValue의 값에 따라 위에 적어준 3가지 경우로 나누어서 코드를 작성해주었습니다!

빌드하고 앱을 실행시켜 주면, 드래그를 멈추었을 때 Bottom Sheet이 normal 상태의 위치로 snap되거나 닫히는 걸 볼 수 있어요 ㅎㅎ

expanded 상태가 되었을 때 위로 올라가는 snap 효과는 아직 구현해주지 않았죠? 기존에 작성한 메소드 showBottomSheet을 수정해서 구현해주도록 할게요!

expanded 상태의 snap 효과 구현

showBottomSheet 메소드를 실행하면 Bottom Sheet을 .normal 상태로 애니메이션 효과를 주면서 이동시켜주죠! 하지만 .expanded 상태로는 이동시켜 주지 않아요 😂 이걸 해결하기 위해서 showBottomSheetExpanded라는 메소드를 새로 만들 수도 있지만, 이것보다는 showBottomSheet을 수정해서 코드의 양을 줄이고 더 효율적으로 작성할 수 있답니다 ㅎㅎ

자 그러면 이제 showBottomSheet 메소드를 수정해볼까요? 메소드가 받아오는 새로운 파라미터 atState: BottomSheetViewState를 추가해줄게요! atState 파라미터의 값에 따라서 .expanded 상태로 애니메이션 효과를 주거나, .normal 상태로 애니메이션 효과를 주도록 구현할 예정이에요

private func showBottomSheet(atState: BottomSheetViewState = .normal) {
    if atState == .normal {
        let safeAreaHeight: CGFloat = view.safeAreaLayoutGuide.layoutFrame.height
        let bottomPadding: CGFloat = view.safeAreaInsets.bottom
        bottomSheetViewTopConstraint.constant = (safeAreaHeight + bottomPadding) - defaultHeight
    } else {
        bottomSheetViewTopConstraint.constant = bottomSheetPanMinTopConstant
    }
    
    UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: {
        self.dimmedView.alpha = 0.7
        self.view.layoutIfNeeded()
    }, completion: nil)
}

그리고 이렇게 작성해준 showBottomSheet 메소드를 viewPanned에 추가해줍니다!

@objc private func viewPanned(_ panGestureRecognizer: UIPanGestureRecognizer) {
    let translation = panGestureRecognizer.translation(in: view)
    
    switch panGestureRecognizer.state {
    case .began:
        bottomSheetPanStartingTopConstant = bottomSheetViewTopConstraint.constant
    case .changed:
        if bottomSheetPanStartingTopConstant + translation.y > bottomSheetPanMinTopConstant {
            bottomSheetViewTopConstraint.constant = bottomSheetPanStartingTopConstant + translation.y
        }
    case .ended:
        let safeAreaHeight = view.safeAreaLayoutGuide.layoutFrame.height
        let bottomPadding = view.safeAreaInsets.bottom
        let defaultPadding = safeAreaHeight+bottomPadding - defaultHeight
        
        let nearestValue = nearest(to: bottomSheetViewTopConstraint.constant, inValues: [bottomSheetPanMinTopConstant, defaultPadding, safeAreaHeight + bottomPadding])
        
				// 
        if nearestValue == bottomSheetPanMinTopConstant {
            showBottomSheet(atState: .expanded)
        } else if nearestValue == defaultPadding {
            // Bottom Sheet을 .normal 상태로 보여주기
            showBottomSheet(atState: .normal)
        } else {
            // Bottom Sheet을 숨기고 현재 View Controller를 dismiss시키기
            hideBottomSheetAndGoBack()
        }
    default:
        break
    }
}

이제 빌드하고 앱을 실행해주면~ 드래그하다 손을 떼었을 때 Bottom Sheet이 .expanded 상태로 애니메이션 효과가 생깁니다!

아직 구현하지 않은 기능이 하나 더 있죠! 드래그할 때에 빠르게 드래그 하면 Bottom Sheet이 dismiss 되는 기능이 구현되지 않았어요! 이번엔 이 기능을 구현해보도록 할게요!

빠르게 아래로 스와이프하면 Bottom Sheet이 dismiss되도록 구현

사용자가 빠르게 아래로 스와이프하는 걸 인식하기 위해서 pan gesture recognizer의 velocity라는 프로퍼티를 사용할 수 있답니다~ velocity를 이용해서 드래그의 속도를 확인할 수 있습니다!

panGestureRecognizer.velocity(in: view)

Transition 프로퍼티처럼 사용자가 화면을 위로 드래그하면 velocity는 음수의 값을 갖고, 화면을 아래로 드래그하면 양수의 값을 가지게 되어 있답니다. 사용자의 드래그가 빠를 수록 velocity의 절대값도 커져요!

print를 이용해서 스와이프 스피드를 기록할 수 있답니다. 제가 참고한 글에서는 빠르게 스와이프하면 스피드가 1500정도라고 하니 스피드가 1500이 넘으면 닫히도록 코드를 구성해줄게요!

@objc private func viewPanned(_ panGestureRecognizer: UIPanGestureRecognizer) {
    let translation = panGestureRecognizer.translation(in: view)
    
    let velocity = panGestureRecognizer.velocity(in: view)
    
    switch panGestureRecognizer.state {
    case .began:
        bottomSheetPanStartingTopConstant = bottomSheetViewTopConstraint.constant
    case .changed:
        if bottomSheetPanStartingTopConstant + translation.y > bottomSheetPanMinTopConstant {
            bottomSheetViewTopConstraint.constant = bottomSheetPanStartingTopConstant + translation.y
        }
    case .ended:
				// ----------- 추가된 코드
        if velocity.y > 1500 {
            hideBottomSheetAndGoBack()
            return
        }
        // -----------
        let safeAreaHeight = view.safeAreaLayoutGuide.layoutFrame.height
        let bottomPadding = view.safeAreaInsets.bottom
        let defaultPadding = safeAreaHeight+bottomPadding - defaultHeight
        
        let nearestValue = nearest(to: bottomSheetViewTopConstraint.constant, inValues: [bottomSheetPanMinTopConstant, defaultPadding, safeAreaHeight + bottomPadding])
        
        if nearestValue == bottomSheetPanMinTopConstant {
            showBottomSheet(atState: .expanded)
        } else if nearestValue == defaultPadding {
            // Bottom Sheet을 .normal 상태로 보여주기
            showBottomSheet(atState: .normal)
        } else {
            // Bottom Sheet을 숨기고 현재 View Controller를 dismiss시키기
            hideBottomSheetAndGoBack()
        }
    default:
        break
    }
}

새 코드는 case .ended의 바로 하단에 추가되었어요! 이제 Bottom Sheet을 빠르게 내리면 닫힐 겁니다! 실행해서 결과를 보도록 할게요

잘 닫히는 걸 볼 수 있네요! 이제 마지막으로~ 우리가 Bottom Sheet을 특정 높이까지 드래그할 때 배경의 어두운 정도(DimmedView의 alpha값)가 서서히 바뀌도록 구현해줄게요. 위로 드래그할 수록, 점점 어두워지고 아래로 드래그할 수록 점점 밝아지도록 말이에요!

Dimmer view의 alpha 값 변경 구현

페이스북의 Bottom Sheet을 보면 위로 올라올 수록 배경이 점점 어두워지다가, 어느 시점이 되면 배경 색이 유지되는 걸 볼 수 있습니다!

현재 Bottom View의 TopConstraint의 constant 값을 받아 dimmedView의 alpha값을 계산하는 메소드를 하나 만들어줄게요. 메소드의 이름은 dimAlphaWithBottomSheetTopConstraint로 지정해주고, 파라미터로 value: CGFloat을 지정해주겠습니다.

private func dimAlphaWithBottomSheetTopConstraint(value: CGFloat) -> CGFloat {
    let fullDimAlpha: CGFloat = 0.7
    
    let safeAreaHeight = view.safeAreaLayoutGuide.layoutFrame.height
    let bottomPadding = view.safeAreaInsets.bottom
    
		// bottom sheet의 top constraint 값이 fullDimPosition과 같으면
		// dimmer view의 alpha 값이 0.7이 되도록 합니다
    let fullDimPosition = (safeAreaHeight + bottomPadding - defaultHeight) / 2

		// bottom sheet의 top constraint 값이 noDimPosition과 같으면
		// dimmer view의 alpha 값이 0.0이 되도록 합니다
    let noDimPosition = safeAreaHeight + bottomPadding

		// Bottom Sheet의 top constraint 값이 fullDimPosition보다 작으면
		// 배경색이 가장 진해진 상태로 지정해줍니다.    
    if value < fullDimPosition {
        return fullDimAlpha
    }
    
		// Bottom Sheet의 top constraint 값이 noDimPosition보다 크면
		// 배경색이 투명한 상태로 지정해줍니다. 
    if value > noDimPosition {
        return 0.0
    }
    
		// 그 외의 경우 top constraint 값에 따라 0.0과 0.7 사이의 alpha 값이 Return되도록 합니다
    return fullDimAlpha * (1 - ((value - fullDimPosition) / (noDimPosition - fullDimPosition)))
}

위의 코드를 작성한 뒤 viewPanned 메소드의 case .changed 아래, 그리고 showBottomSheet의 UIView.animate 아래에 코드를 추가해주어 완성해줍니다!

@objc private func viewPanned(_ panGestureRecognizer: UIPanGestureRecognizer) {
        let translation = panGestureRecognizer.translation(in: view)
        
        let velocity = panGestureRecognizer.velocity(in: view)
        
        switch panGestureRecognizer.state {
        case .began:
            bottomSheetPanStartingTopConstant = bottomSheetViewTopConstraint.constant
        case .changed:
            if bottomSheetPanStartingTopConstant + translation.y > bottomSheetPanMinTopConstant {
                bottomSheetViewTopConstraint.constant = bottomSheetPanStartingTopConstant + translation.y
            }
						// --------------- 추가된 코드
            dimmedView.alpha = dimAlphaWithBottomSheetTopConstraint(value: bottomSheetViewTopConstraint.constant)
						// ---------------
        case .ended:
            // ...
        default:
            break
        }
    }

private func showBottomSheet(atState: BottomSheetViewState = .normal) {
    if atState == .normal {
			// ...
    } else {
        bottomSheetViewTopConstraint.constant = bottomSheetPanMinTopConstant
    }
    
    UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn, animations: {
				// ------------ 수정된 코드
				self.dimmedView.alpha = self.dimAlphaWithBottomSheetTopConstraint(value: self.bottomSheetViewTopConstraint.constant)
				// ------------ 
        self.view.layoutIfNeeded()
    }, completion: nil)
}

자 완성되었습니다! panModal이나 다른 라이브러리 없이 Bottom Sheet을 구현하였습니다!

여기서부터는 필수 구현이 아니라 여러분이 필요하면 추가하는 선택 구현입니다! 조금 더 뷰를 예쁘게 보이도록 하기 위해 drag Indicator를 추가해주도록 할게요 ㅎㅎ

Drag Indicator 추가하기

// BottomSheetViewController.swift
private let dragIndicatorView: UIView = {
    let view = UIView()
    view.backgroundColor = .white
    view.layer.cornerRadius = 3
    return view
}()

private func setupUI() {
    // ...
    view.addSubview(dragIndicatorView)
    dimmedView.alpha = 0.0
    
    setupLayout()
}

private func setupLayout() {
    // ...
    
    dragIndicatorView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        dragIndicatorView.widthAnchor.constraint(equalToConstant: 60),
        dragIndicatorView.heightAnchor.constraint(equalToConstant: dragIndicatorView.layer.cornerRadius * 2),
        dragIndicatorView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
        dragIndicatorView.bottomAnchor.constraint(equalTo: bottomSheetView.topAnchor, constant: -10)
    ])
}

실행해주면 다음과 같이 drag Indicator가 나타나는 걸 확인할 수 있습니다~

Initializer를 정의해주어 contentViewController 추가해주기

Bottom Sheet의 내용물로 UIViewController를 전달해주면 알아서 Bottom Sheet 내부에 UIViewController의 view가 채워지도록 구현해주겠습니다!

// BottomSheetViewController.swift

private let contentViewController: UIViewController

// 이니셜라이저 구현
init(contentViewController: UIViewController) {
    self.contentViewController = contentViewController
    super.init(nibName: nil, bundle: nil)
}

required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

private func setupUI() {
    // ...
    addChild(contentViewController)
    bottomSheetView.addSubview(contentViewController.view)
    contentViewController.didMove(toParent: self)
    bottomSheetView.clipsToBounds = true
    dimmedView.alpha = 0.0
    
    setupLayout()
}

private func setupLayout() {
    // ...
    contentViewController.view.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        contentViewController.view.topAnchor.constraint(equalTo: bottomSheetView.topAnchor),
        contentViewController.view.leadingAnchor.constraint(equalTo: bottomSheetView.leadingAnchor),
        contentViewController.view.trailingAnchor.constraint(equalTo: bottomSheetView.trailingAnchor),
        contentViewController.view.bottomAnchor.constraint(equalTo: bottomSheetView.bottomAnchor)
    ])
}

위와 같이 코드를 구성하고 앱을 실행하면 오류가 발생합니다! ViewController.swift 파일로 돌아가서 buttonTapped 메소드를 아래의 코드와 같이 수정해줍니다

@objc func buttonTapped() {
    let bottomSheetVC = BottomSheetViewController(contentViewController: UIViewController())
    bottomSheetVC.modalPresentationStyle = .overFullScreen
    self.present(bottomSheetVC, animated: false, completion: nil)
}

BottomSheetViewController의 이니셜라이저의 contentViewController로 임시로 빈 UIViewController를 전달해주었습니다. 앞으로 여러분이 Bottom Sheet의 내용물로 담기고자 하는 view controller를 전달해주시면 해당 view controller의 view가 Bottom Sheet을 채우게 됩니다!

아래는 contentViewController로 UITableViewController를 전달해준 예시 이미지에요 ㅎㅎ

이상으로 글을 마치겠습니다! 읽어주셔서 감사합니다~

참고한 글 : Replicating Facebook's Draggable Bottom Card using Auto Layout - Part 2/2

profile
현재 iOS를 공부하고 있는 프린이입니다!

1개의 댓글

comment-user-thumbnail
2022년 4월 19일

저도 UIPresentationController 으로 구현하려다 실패했는데 좋은 글 보고갑니다 감사합니다 😊

답글 달기