UISheetPresentationController

업데이트 내역

  • 2023.01.18 - animateChanges, selectedDetentIdentifier를 활용하여 두 버튼을 Tap했을 때 sheet detent값 변경

지도 앱을 실행하면 화면 하단에 검색, 즐겨찾기, 최근 항목 등을 볼 수 있는 Bottom Sheet가 있다. Bottom Sheet는 위로 드래그하면 유동적으로 Sheet 크기가 변하는데, UISheetPresentationController 클래스를 통해 구현할 수 있다.

구조

@MainActor class UISheetPresentationController: UIPresentationController

UISheetPresentationController를 사용하여 우리의 view controller를 하나의 sheet로 present 할 수 있다. 그전에 먼저 sheetPresentationController 프로퍼티를 통해 sheet의 속성 및 동작구성해야 한다.

sheetPresentationController 프로퍼티

iOS 15부터 UIViewController는 sheetPresentationController 프로퍼티를 지원하며, UIViewController를 상속하는 모든 곳에서 UISheetPresentationController 타입의 sheetPresentationController 프로퍼티에 접근할 수 있다.

private func showSecondViewControllerSheet() {
 	let vc = SecondViewController() // Sheet에 보여줄 view controller
 	// sheetPresentationController 프로퍼티 접근
 	if let sheet = vc.sheetPresentationController { 
 		... 
 		code
 		...
 	}
 	present(vc, animated: true)
}

높이 설정

private func showSecondViewControllerSheet() {
 	let vc = SecondViewController()
 	if let sheet = vc.sheetPresentationController {
 	sheet.detents = [.medium(), .large()] // Sheet 높이 설정
 	}
 	present(vc, animated: true)
}

detents프로퍼티를 통해 대략 화면의 절반 높이를 띄워주는 .medium() 또는 화면 전체 높이만큼 띄워주는 .large() 높이의 Sheet를 설정할 수 있다. 기본값은 large()이다.

detentMediumAndLarge

Shows a grabber

private func showSecondViewControllerSheet() {
 	let vc = SecondViewController()
 	if let sheet = vc.sheetPresentationController {
 	sheet.detents = [.medium(), .large()]
 	sheet.prefersGrabberVisible = false // Grabber Show/Hide 설정
 	}
 	present(vc, animated: true)
}

Bool 타입 prefersGrabberVisible 프로퍼티에 true 값을 주어, 사용자가 Sheet 크기를 조절할 수 있다는 걸 인식하고 유도하는 Grabber를 Sheet 상단에 표시할 수 있다.

sheet 뒤 화면, 음영 처리 및 상호 작용 활성화 여부

private func showSecondViewControllerSheet() {
	let vc = SecondViewController()
	if let sheet = vc.sheetPresentationController {
	...
	sheet.largestUndimmedDetentIdentifier = .large 
	// large 사이즈 detent 까지 음영 처리하지 않고, 상호 작용 활성화
	...
	}
	present(vc, animated: true)
}

화면에 sheet가 나타나면서 뒤에 위치한 view를 음영 처리하거나, sheet가 올라온 상태에서도 뒤에 있는 view를 터치했을 때 상호 작용 가능 여부를 제어하는 largestUndimmedDetentIdentifier 프로퍼티가 있다.

largestUndimmedDetentIdentifier 프로퍼티를 사용하여 특정 사이즈의 detent를 설정하면, 지정된 사이즈의 sheet까지 뒤 화면을 어둡게 하지 않고, 해당 view에 UI 터치 상호 작용을 활성화한다.

기본값(nil) 일 때

largestUndimmedDetentIdentifier = nil 복사본

  • 기본값 nil 일 때, 모든 detent의 sheet 뒤 화면은 모두 음영 처리되고 사용자 상호 작용 불가능
  • sheet 뒤 화면 터치 시, sheet dismiss 됨

.medium 일 때

private func showSecondViewControllerSheet() {
	let vc = SecondViewController()
	if let sheet = vc.sheetPresentationController {
	...
	sheet.largestUndimmedDetentIdentifier = .medium
	// medium 사이즈 detent 까지 음영 처리하지 않고, 상호 작용 활성화
	...
	}
	present(vc, animated: true)
}

largestUndimmedDetentIdentifier =  medium

largestUndimmedDetentIdentifier 프로퍼티를 사용하여 특정 사이즈의 detent를 설정하면, 지정된 사이즈의 sheet까지 뒤 화면을 어둡게 하지 않고, 해당 view에 UI 터치 상호 작용을 활성화한다.

.large 일 때

  • medium, large detent sheet 뒤 화면이 음영 처리 되지 않고, 상호 작용이 활성화된다.

스크롤 시 sheet 확장 가능 여부 설정

sheet가 selectedDentent Identifier보다 더 큰 Detent로 확장할 수 있는 경우, prefersScrollingExpandsWhenScrolledToEdge 프로퍼티 값에 따라 sheet를 위로 스크롤 했을 때 sheet의 content가 스크롤되거나 detent가 증가하여 가장 큰 detent가 됐을 때 content 스크롤이 이루어진다.

prefersScrollingExpandsWhenScrolledToEdge 프로퍼티 기본값은 true이다, 즉 sheet를 위로 스크롤 했을 때 detent가 증가하고, 가장 큰 detent가 됐을 때 내부 content 스크롤이 가능하다면 content 스크롤

  • true -> sheet detent 증가 및 최대 detent 도달 시 내부 content 스크롤 (예: UITableView, UICollectionView)
  • false -> sheet detent가 확장되지 않고, 내부 content 스크롤

true 일 때

prefersScrollingExpandsWhenScrolledToEdge = true

  • large detent로 확장 가능한 상태에서 sheet를 위로 드래그했을 때 먼저 large detent로 확장된 후 테이블뷰 스크롤이 가능한 것을 확인할 수 있다.

false 일 때

prefersScrollingExpandsWhenScrolledToEdge = false

  • large detent로 확장 가능한 상태에서 sheet를 위로 드래그했을 때 large detent로 확장되지 않고 테이블뷰 스크롤이 가능한 것을 확인할 수 있다.

prefersEdgeAttachedInCompactHeight

prefersEdgeAttachedInCompactHeight 프로퍼티를 통해서 sheet가 compact-height size class 에서 화면 아래쪽 가장자리에 붙어 있는지 여부를 결정할 수 있다.

기본값 false

prefersEdgeAttachedInCompactHeight = false

  • 기본값 false는 compact-height size class에서 full screen 상태

true

prefersEdgeAttachedInCompactHeight = true

  • true일 때는 compact-height size class 에서 하단부만 붙어 있는 상태

widthFollowsPreferredContentSizeWhenEdgeAttached

widthFollowsPreferredContentSizeWhenEdgeAttached 프로퍼티는 sheet의 width가 view controller의 preferred content size와 일치하는지 여부를 판단하는 Bool 값이다.

  • sheet width가 view controller의 preferred content size와 일치하는지 여부를 결정하는 Bool 값의 프로퍼티
  • 기본값 false일 때 sheet의 width는 기본적으로 container의 safe area와 같다.
  • true일 때 view controller의 width 값을 preferredContentSize를 사용하여 결정할 수 있다.

compact-width, regular-height size class 또는 prefersEdgeAttachedInCompactHeight 값이 false 일 때 적용되지 않는다.

프로그래밍 방식으로 sheet 높이 조절

NavigationBar에 버튼 두 개를 만들고, 각 버튼을 탭했을 때 sheet 높이가 변화시킬 수 있다.

  • animateChanges(_:) 메소드 및 selectedDententIdentifier 프로퍼티 활용
    • animateChanges(_:) 메소드.
      • sheet 속성을 변경할 때, 애니메이션도 함께 적용하기 위해 해다 메소드 클로저 내부에 속성 변경 코드를 작성
    • selectedDententIdentifier 프로퍼티.
      • 사용자가 선택하거나 프로그래밍 방식으로 설정한 가장 최근 detent를 나타내는 프로퍼티
      • 기본값은 nil이다. sheet가 가장 작은 detent를 표시함을 의미한다.
override func viewDidLoad() {
    setupBarButtonItems()
    setupSheetPresentationController()
}

private func setupBarButtonItems() {
    let rightButton = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissView))
    let mediumButton = UIBarButtonItem(image: UIImage(systemName: "m.circle"), style: .plain, target: self, action: #selector(setMedium))
    let largeButton = UIBarButtonItem(image: UIImage(systemName: "l.circle"), style: .done, target: self, action: #selector(setLarge))
    navigationItem.rightBarButtonItems = [rightButton, largeButton, mediumButton]
}

private func setupSheetPresentationController() {
    if let sheet = sheetPresentationController {
        sheet.detents = [.medium(), .large()]
        sheet.prefersGrabberVisible = true
        sheet.prefersScrollingExpandsWhenScrolledToEdge = true
        sheet.prefersEdgeAttachedInCompactHeight = true
        sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
    }
}

@objc private func setMedium() {
    if let sheet = sheetPresentationController {
        sheet.animateChanges {
            sheet.selectedDetentIdentifier = .medium
        }
    }
}

@objc private func setLarge() {
    if let sheet = sheetPresentationController {
        sheet.animateChanges {
            sheet.selectedDetentIdentifier = .large
        }
    }
}

animateChanges selectedDetentIdentifier