[UIKit] 재사용 Component 개발하기 (3) - 바텀 시트 (Sheets, Bottom Sheet)

2024. 7. 16. 23:24UIKit, SwiftUI, H.I.G

 

[UIKit] 재사용 Component 개발하기 (1) - Toast Message

💡 재사용 Component 개발하기 시리즈 글을 시작하며 이번 33기 앱잼이었던 프로젝트에서 우리 iOS 팀이 추구한 방향 중 하나는, 앱 내에서 반복되는 컴포넌트(내비게이션바, 바텀시트, 알림 창, 토

mini-min-dev.tistory.com

 

[UIKit] 재사용 Component 개발하기 (2) - 팝업창 (Alerts)

[UIKit] 재사용 Component 개발하기 (1) - Toast Message 💡 재사용 Component 개발하기 시리즈 글을 시작하며 이번 33기 앱잼이었던 프로젝트에서 우리 iOS 팀이 추구한 방향 중 하나는, 앱 내에서 반복되는

mini-min-dev.tistory.com

 

1️⃣ iOS 관점에서 HIG 살펴보기 : 바텀시트(Sheets)란?

Alerts 때와 마찬가지로 iOS 공식 문서 (Human Interface Guideline)에서는 바텀시트라는 용어 대신 Sheets라고 부른다.

Sheets는 사용자가 "현재 작업 중인 흐름에서 벗어나지 않는 긴밀하게 관련된 특정 작업"을, 부모 화면을 살짝 가리는 화면으로 수행할 수 있도록 만드는 Component이다.
주로 표출되는 화면이 하단에서 나타나기 때문에 Bottom + Sheet가 합쳐져 바텀시트라고 흔히 부르기도 한다.

Sheets는 기본적으로 모달(Modal)로 사용되지만, 플랫폼에 따라 때로는 논모달(non-modal)로 사용될 수도 있다.

Modal로써 Sheets가 사용될 때(macOS, visionOS, watchOS)는 부모 뷰와 사용자 간의 상호작용하지 못하지만, non-modal로써 Sheets가 활용될 때(iOS, iPadOS)는 Sheets 화면을 닫지 않고도 부모 작업에 직접 영향을 끼칠 수 있다.
*아래 화면과 같이 메모 앱에서 Sheets 화면을 닫지 않고도 텍스트를 선택하거나 적용하는 것을 동시에 진행할 수 있는 것은 non-modal로써 동작하는 Sheets의 대표적인 사례이다.

Sheets 1. Non-modal sheets의 대표적인 예시

H.I.G에서 설명하고 있는 올바른 Sheets 가이드라인에 대해 더 자세하게 알아보자.
여기서는 플랫폼에 상관없이 공통적으로 설명하고 있는 올바른 사례(Best Practice)와 iOS/iPad 플랫폼에서 특별히 고려해야 할 사항(iOS/iPadOS Platform Consideration)을 나누어서 살펴보겠다.

1. 공통적으로 고려해아 할 사항
- 간결하고 명확한 내용을 Sheets에 담을 것
- 메인 인터페이스에서 한 번에 하나의 Sheet만 표시할 것 : 시트를 닫으면 다시 원래의 부모 창으로 돌아간다는 것을 사용자에게 전달해줘야 한다는 의미. 시트 내에서 또 다른 시트로 연결되는 경우 - 두 번째 시트가 나타나기 전에 첫 번째 시트를 닫아야 한다.
(단, 필요한 경우 두 번째 시트를 닫은 후에 다시 첫 번째 시트로 돌아가는 것을 고려할 수도 있다. -> 이중 바텀 시트에 대한 HIG 차원에서 Apple의 특별한 제약은 없다!)
- 부모 뷰의 주요 작업에 영향을 끼치는 작업을 Sheets에서 할 경우 non-modal Sheets를 고려할 것

2. iOS/iPadOS에서 특별히 고려해야 할 사항
- Sheets의 높이(detent)는 내용에 따라 개발자가 유동적으로 적용(large, medium 등)할 것
- Sheets가 나타나고 사라질 때는 부드러운 애니메이션을 사용해 부드러운 피드백을 제공할 것
- 크기 조절이 가능한 Sheets의 경우 Grabber(시트 상단에 있는 길쭉한 알약 모양의 뷰)를 포함해 사용자에게 조절 가능성을 전달할 것
- Sheets를 dismiss할 때는 스와이프(swipe) 액션을 가능하도록 할 것 : 사용자는 탭보다 스와이프를 통한 dismiss 액션을 기대하고 있음. 만약, swipe를 시작하기 전에 sheet에 저장되지 않은 변경 사항이 있는 경우 Action Sheet를 사용하여 확인할 수 있도록 해야 한다.

 

2️⃣ 공통으로 반복되는 부분을 타입으로 정리하고, Sheets의 뷰 구성을 나눠보자

이번에도 앱 내에서 사용되는 모든 바텀시트 화면을 모아서 공통점을 뽑아 추상화를 시킬 수 있는 지점을 고민했다.
분류할 수 있는 부분을 생각해보니 아래 세 가지 정도로 크게 나눠볼 수 있을 것 같았다.

  1. 왼쪽 상단에 있는 TitleLabel과 오른쪽 상단에 있는 x 버튼은 모든 Sheet에서 공통으로 사용된다. -> 공통으로 만들자!
  2. Sheets view의 backgroundColor에 따라 white 타입과 gray 타입으로 나눌 수 있다.
  3. UITextField를 사용하고 키보드가 하단에 고정되어 사용자로부터 값을 입력받는 타입, 수정하기와 같이 여러 선택지 중 하나를 선택받는 타입, 사용자에게 정보를 알려주고 하단에 고정되어 있는 검정 버튼을 눌러야 넘어가는 타입으로 구분할 수 있다.


여기서 문제는 2번 분류와 3번 분류가 충돌한다는 점이 있었다.

아래 3번째 4번째 시트를 보게 되면,
하단에 검정 버튼이 공통으로 고정되어서 같은 타입을 분류하려고 하더라도 뷰의 배경색 (왼쪽은 white vs 오른쪽은 gray)이나
시트의 역할 (왼쪽은 정보성 vs 오른쪽은 다중 선택-중복 선택이 가능하기 때문에 하단 버튼이 필요)이 다르기 때문에 색상을 통한 분류와 역할을 통한 분류가 같이 이루어질 수 없었다.
또한, 3번 바텀시트나 4번 바텀시트는 해당 타이머 뷰 외에서는 사용되지 않는 타입이었기에 개인적으로 한 단계 더 추상화를 나눠야 하는 필요성에 대해 다소 의문을 가지게 되었고,

그래서 시트의 목적을 제외하고 상단 타이틀과 x 버튼을 공통 고정 + 시트 배경색에 따른 두 가지의 타입으로 분류하는 방식으로 공통 Component를 생성하고자 설계했다.

Sheets 2. TOASTER 프로젝트에서 사용되는 바텀시트 Component의 대표 유형을 뽑아 정리한 피그마

코드로는 이런 식으로 타입을 만들어서 사용할 수 있다.

import UIKit

enum BottomType {
    case white, gray
    
    var color: UIColor {
        switch self {
        case .white:
            return .toasterWhite
        case .gray:
            return .gray50
        }
    }


Sheets는 처음부터 커스텀 ViewController로 만들었다.
즉, View 구성부터 바텀시트가 올라가고 내려가는 효과, 그리고 제스처에 따라 움직이는 기능 등을 모두 직접 구현했다는 의미!

뷰의 구성은 단순하다.

바텀시트가 올라가는 효과를 주기 위해서 흐려지는 배경 색의 dimmedBackView와 바텀시트의 내용이 될 bottomSheetView로 먼저 만들어서 VC의 기본 view 위에 올려줬다. (아래 1번 -> 2번 화면에 해당)
이후, bottomSheetView 안에서는 titleLabel과 closeButton을 공통으로 사용하기로 했으니 공통 부분에 미리 만들어두고, 실제 각 화면마다 달라지는 내용(insertVIew)을 개별적으로 구현해서 추가하는 방식을 생각했다. (아래 2번 -> 3번 화면에 해당)

Sheets 3. 공통 Component에서 구현할 바텀시트의 뷰 계층

코드로 보면 이런 식으로 뷰를 만들고 계층을 형성해줬다고 볼 수 있다.

final class BottomSheetViewController: UIViewController {
            
    private var insertView = UIView()
    private let dimmedBackView = UIView()
    private let bottomSheetView = UIView()
    
    private let titleLabel = UILabel()
    private let closeButton = UIButton()
    
    ...
    
    func setupHierarchy() {
        [dimmedBackView, bottomSheetView].forEach { view.addSubview($0) }
        [titleLabel, closeButton, insertView].forEach { bottomSheetView.addSubview($0) }
    }
}

 

3️⃣ Sheets 기본 기능 구현하기 (init, show, hide)

결국 Sheets의 핵심 기능은 내가 원하는 높이만큼 바텀시트를 올리고, 작업이 완료되거나 스와이프를 하거나 dimmedView 영역을 Tap했을 때 애니메이션과 함께 바텀시트를 내리는 것이라고 볼 수 있다.

기본 생성자부터 살펴보자.
위에서 정의했던 BottomType, titleLabel에 들어갈 String, 바텀 시트의 높이 height, 그리고 내부 UIView까지 총 4개의 파라미터를 받도록 했다.

final class BottomSheetViewController: UIViewController {
        
    private var bottomHeight: CGFloat = 100
	
    ...
    
    init(bottomType: BottomType,
         bottomTitle: String,
         height: CGFloat,
         insertView: UIView) {
        super.init(nibName: nil, bundle: nil)
        
        self.bottomSheetView.backgroundColor = bottomType.color
        self.titleLabel.text = bottomTitle
        self.bottomHeight = height
        self.insertView = insertView
    }
    ...

이번에는 Sheets를 올리는 로직을 살펴보자.

(바텀 시트) 뷰가 화면에 나타날 때 커스텀 애니메이션을 적용해주기 위해 showBottomSheet 메서드를 viewDidAppear 생명주기에 추가해 줬다. -> 실제 해당 VC를 present할 때 적용되는 애니메이션은 false 처리를 해줘야겠다.

바텀시트가 올라가는 듯한 효과를 주기 위해서 UIView에 animation을 주겠다.
애니메이션이 적용되는 동안에는 (1) 배경 뷰는 어두워지는 화면으로 전환, (2) bottomSheetView의 top값이 "뷰 전체 높이 - 바텀시트의 높이 = superView의 top에서 얼만큼 떨어져 있는가"로 변경되며 간격을 재설정하도록 만들었고, (3) self.view.layoutIfNeeded 코드로 변경된 레이아웃이 즉시 적용될 수 있도록 만들었다.

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)        
    showBottomSheet()
}

/// 바텀 시트 표출
func showBottomSheet() {
    UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: {
        self.dimmedBackView.backgroundColor = .black.withAlphaComponent(0.5)
        self.bottomSheetView.snp.remakeConstraints {
            $0.bottom.leading.trailing.equalToSuperview()
            $0.top.equalToSuperview().inset(self.view.frame.height - self.bottomHeight)
        }
        self.view.layoutIfNeeded()
    })
}

extension BottomSheetViewController {
    /// 기존 레이아웃 (SnapKit 사용)
    func setupLayout() {
        dimmedBackView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        bottomSheetView.snp.makeConstraints {
            $0.leading.trailing.bottom.equalToSuperview()
        }
        
        insertView.snp.makeConstraints {
            $0.leading.trailing.bottom.equalToSuperview()
            $0.top.equalToSuperview().inset(64)
        }
        
        titleLabel.snp.makeConstraints {
            $0.top.leading.trailing.equalToSuperview().inset(20)
        }
        
        closeButton.snp.makeConstraints {
            $0.top.trailing.equalToSuperview().inset(20)
        }
    }
}

Sheets를 내리는 로직도 올리는 로직과 크게 다르지 않다.

애니메이션이 적용되는 동안 (1) 배경색(dimmedBackView)을 다시 clear하게 만들고, (2) BottomSheetView를 다시 하단으로 위치하도록 레이아웃을 변경하고, (3) self.view.layoutIfNeeded 코드로 변경된 레이아웃이 즉시 적용될 수 있도록 만들었다.

차이점이 있다면 애니메이션이 모두 완료된 이후에 현재 모달로 표시된 bottomSheetVC를 dismiss 시켜주는 작업을 추가해줘야 한다는 점이다.

/// 바텀 시트 내리기
func hideBottomSheet() {
    UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: {
        self.dimmedBackView.backgroundColor = .clear
        self.bottomSheetView.snp.remakeConstraints {
            $0.bottom.leading.trailing.equalToSuperview()
        }
        self.view.layoutIfNeeded()
    }, completion: { _ in
        if self.presentingViewController != nil {
            self.dismiss(animated: false, completion: nil)
        }
    })
}

바텀시트를 내릴 수 있는 상황은 세 가지로 설정하고 추가해줬다.
closeButton을 눌렀을 때, 바텀시트가 아닌 흐린 부분을 탭했을 때(TapGestureRecognizer), 아래로 스와이프 하는 제스처가 들어왔을 때(SwipeGestureRecognizer) 상황에 추가를 해준 아래 코드이다.

func setupDismissAction() {
    // x 버튼 누를 때, 바텀시트를 내리는 Action Target
    closeButton.addTarget(self, action: #selector(hideBottomSheetAction), for: .touchUpInside)
    
    // 흐린 부분 탭할 때, 바텀시트를 내리는 TapGesture
    let dimmedTap = UITapGestureRecognizer(target: self, action: #selector(hideBottomSheetAction))
    dimmedBackView.addGestureRecognizer(dimmedTap)
    dimmedBackView.isUserInteractionEnabled = true
    
    // 아래로 스와이프 했을 때, 바텀시트를 내리는 swipeGesture
    let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(hideBottomSheetAction))
    swipeGesture.direction = .down
    view.addGestureRecognizer(swipeGesture)
}

@objc
func hideBottomSheetAction() {
    hideBottomSheet()
}

 

4️⃣ 전체 코드는 이런 느낌!

import UIKit

import SnapKit
import Then

final class BottomSheetViewController: UIViewController {
    
    // MARK: - Properties
    
    private var bottomHeight: CGFloat = 100
    
    // MARK: - UI Properties
    
    private var insertView = UIView()
    private let dimmedBackView = UIView()
    private let bottomSheetView = UIView()
    
    private let titleLabel = UILabel()
    private let closeButton = UIButton()
    
    // MARK: - Life Cycle
    
    init(bottomType: BottomType,
         bottomTitle: String,
         height: CGFloat,
         insertView: UIView) {
        super.init(nibName: nil, bundle: nil)
        
        self.bottomSheetView.backgroundColor = bottomType.color
        self.titleLabel.text = bottomTitle
        self.bottomHeight = height
        self.insertView = insertView
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupStyle()
        setupHierarchy()
        setupLayout()
        setupDismissAction()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        showBottomSheet()
    }
}

// MARK: - Networks

extension BottomSheetViewController {
    /// 바텀 시트 표출
    func showBottomSheet() {
        UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: {
            self.dimmedBackView.backgroundColor = .black.withAlphaComponent(0.5)
            self.bottomSheetView.snp.remakeConstraints {
                $0.bottom.leading.trailing.equalToSuperview()
                $0.top.equalToSuperview().inset(self.view.frame.height - self.bottomHeight)
            }
            self.view.layoutIfNeeded()
        })
    }
    
    /// 바텀 시트 내리기
    func hideBottomSheet() {
        UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: {
            self.dimmedBackView.backgroundColor = .clear
            self.bottomSheetView.snp.remakeConstraints {
                $0.bottom.leading.trailing.equalToSuperview()
            }
            self.view.layoutIfNeeded()
        }, completion: { _ in
            if self.presentingViewController != nil {
                self.dismiss(animated: false, completion: nil)
            }
        })
    }
}

// MARK: - Private Extensions

private extension BottomSheetViewController {
    
    func setupStyle() {
        bottomSheetView.do {
            $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
            $0.layer.cornerRadius = 20
            $0.backgroundColor = .white
        }
        
        titleLabel.do {
            $0.font = .boldSystemFont(ofSize: 18)
            $0.textColor = .black
        }
        
        closeButton.do {
            $0.setImage(UIImage(systemName: "xmark"), for: .normal)
            $0.tintColor = .black
        }
    }
    
    func setupHierarchy() {
        [dimmedBackView, bottomSheetView].forEach { view.addSubview($0) }
        [titleLabel, closeButton, insertView].forEach { bottomSheetView.addSubview($0) }
        
    }
    
    func setupLayout() {
        dimmedBackView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        bottomSheetView.snp.makeConstraints {
            $0.leading.trailing.bottom.equalToSuperview()
        }
        
        insertView.snp.makeConstraints {
            $0.leading.trailing.bottom.equalToSuperview()
            $0.top.equalToSuperview().inset(64)
        }
        
        titleLabel.snp.makeConstraints {
            $0.top.leading.trailing.equalToSuperview().inset(20)
        }
        
        closeButton.snp.makeConstraints {
            $0.top.trailing.equalToSuperview().inset(20)
        }
    }
    
    func setupDismissAction() {
        // x 버튼 누를 때, 바텀시트를 내리는 Action Target
        closeButton.addTarget(self, action: #selector(hideBottomSheetAction), for: .touchUpInside)
        
        // 흐린 부분 탭할 때, 바텀시트를 내리는 TapGesture
        let dimmedTap = UITapGestureRecognizer(target: self, action: #selector(hideBottomSheetAction))
        dimmedBackView.addGestureRecognizer(dimmedTap)
        dimmedBackView.isUserInteractionEnabled = true
        
        // 아래로 스와이프 했을 때, 바텀시트를 내리는 swipeGesture
        let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(hideBottomSheetAction))
        swipeGesture.direction = .down
        view.addGestureRecognizer(swipeGesture)
    }
    
    func updateBottomSheetLayout() {
        bottomSheetView.snp.remakeConstraints {
            $0.bottom.leading.trailing.equalToSuperview()
            $0.top.equalToSuperview().inset(self.view.frame.height - self.bottomHeight)
        }
    }
    
    @objc
    func hideBottomSheetAction() {
        hideBottomSheet()
    }
}