SwiftUI에서는 .sheet 를 iOS13부터 지원하고 있지만,
sheet의 크기 조절을 하여 BottomSheet처럼 사용하기 위한 presentationDetents는 iOS16부터 지원됨.
iOS16 미만 버전을 타겟으로 하는 BottomSheet의 필요성이 있음.
구현 요구사항
- content에 viewModifier를 통해 bottomSheet를 붙일 수 있고,
- bottomSheet는 상단의 핸들을 가지고 있고, 하단에 sheetContent를 주입받을 수 있어야 함.
- bottomSheet는 sheetContent의 높이+핸들의 높이 만큼 높이를 가지되, bottomSheetTopOffset을 받아서 화면높이-bottomSheetTopOffset를 최대 높이로 가짐.
- bottomSheet의 배경은 dimmed된 뷰를 가지고 터치시 bottomSheet가 닫힘.
BottomSheetModifier의 구현
사실 BottomSheetModifier의 구현은
이전 포스팅들 내용의 종합편.
- frame에 대한 정리: https://swifty-cody.tistory.com/160
- 레이아웃 프로세스: https://swifty-cody.tistory.com/157
- Gesture에 대한 정리: https://swifty-cody.tistory.com/159
- view의 크기를 알아내고 GeometryReader로 안 그리고 싶을 때: https://swifty-cody.tistory.com/162
위 내용들을 바탕으로 BottomSheetModifier를 구현.
struct BottomSheetModifier<SheetContent: View>: ViewModifier {
@Binding var isShowing: Bool
let topOffset: CGFloat // 상단 offset
let sheetContent: () -> SheetContent // bottomSheet의 내용
@State private var dragOffset: CGFloat = 0 // 핸들 제스쳐에 필요한 값
@State var bottomSheetSize: CGSize = .zero
/// bottomSheetOffset: isShowing이 false일 때는, bottomSheetSize만큼 화면 아래에 그려짐.
private var bottomSheetOffset: CGFloat {
isShowing ? 0 : bottomSheetSize.height
}
func body(content: Content) -> some View {
ZStack { // content와 dim배경과 bottomSheet가 ZStack으로 그려짐
content
if isShowing {
// BottomSheet의 dim배경
Color.black.opacity(0.1)
.ignoresSafeArea()
.onTapGesture { // Tap하면 애니메이션과 함께 닫힘
withAnimation {
isShowing = false
}
}
.transition(.opacity)
}
// BottomSheet
VStack {
Spacer()
VStack(spacing: 0) {
Rectangle() // BottomSheet의 핸들
.frame(width: 50, height: 4)
.cornerRadius(2, corners: [.allCorners])
.padding(.vertical, 8)
.gesture(
DragGesture()
.onChanged { value in
if dragOffset + value.translation.height > 0 {
dragOffset += value.translation.height
}
}
.onEnded { value in
if value.translation.height > 0 {
isShowing = false
} else {
isShowing = true
}
dragOffset = 0
}
)
sheetContent() // BottomSheet의 content
.frame(maxHeight: UIScreen.main.bounds.height - topOffset) // 최대높이를 제한
.frame(width: UIScreen.main.bounds.width) // 너비는 스크린 너비만큼
.fixedSize(horizontal: false, vertical: true) // 자식뷰들에게 높이(vertical)는 고정사이즈로 그리기를 제안함. 이 제안으로 sheetContent의 높이가 작을 때 사이즈에 맞게 bottomSheet가 그려짐.
.padding(.top)
}
.sizeState(size: $bottomSheetSize)
.background(Color.white)
.cornerRadius(20, corners: [.topLeft, .topRight])
.offset(y: bottomSheetOffset + dragOffset)
.animation(.easeInOut(duration: 0.25), value: isShowing)
.shadow(radius: 10)
}
.ignoresSafeArea(edges: .bottom)
}
}
}
extension으로 bottomSheet viewModifier를 제공.
extension View {
func bottomSheet<SheetContent: View>(isShowing: Binding<Bool>, topOffset: CGFloat = .zero, @ViewBuilder sheetContent: @escaping () -> SheetContent) -> some View {
self.modifier(BottomSheetModifier(isShowing: isShowing, topOffset: topOffset, sheetContent: sheetContent))
}
}
사용예시.
struct BottomSheetExample: View {
@State private var isShowing = false
var body: some View {
VStack {
Button {
isShowing.toggle()
} label: {
Text("isShowing: \(isShowing)")
}
Spacer()
}
.bottomSheet(isShowing: $isShowing, topOffset: 150.0) {
ShareToFriendsView(friendsList: sampleFriends, isShowing: $isShowing)
}
}
}