본문 바로가기

TIL

[TIL] SwiftUI에서 Bottom Sheet 구현기

기획

이번 프로젝트에서 앱의 시작부분을 아래 사진처럼 기획 중인데요. 그래서 이번 기회에 이 Bottom Sheet를 구현한 내용에 대해 작성해 보려고 합니다.

 

Bottom Sheet 만들기

하단 시트 구현

struct BottomSheetView<Content>: View where Content: View {
    
    @Binding var isPresented: Bool
    private var height: CGFloat
    private var content: Content
    
    @GestureState private var translation: CGFloat = .zero
    
    init(_ isPresented: Binding<Bool>, height: CGFloat, content: () -> Content) {
        self._isPresented = isPresented
        self.height = height
        self.content = content()
    }
    
    var body: some View { 
    	//... 
    }
}

 

우선 하단 시트를 위한 구조체를 구현해보겠습니다.

 

일단 하단 시트를 열어주기 위한 isPresented를 바인딩해주고

하단 시트에서 사용할 View를 지정을 위해 height 변수도 선언해줍니다.

그 다음 하단 시트 내부에 그릴 View를 위한 Content를 선언해 줍니다.

그리고 마지막으로 드래그해서 하단 시트를 닫기 위해 @GestureState를 하나 선언해 줍니다.

여기서 @GestureState는 제스처에 대한 상태를 나타내주는 Property Wrapper입니다.
실제 구현에 대해선 아래에서 더 구현해보도록 하겠습니다.

 

하단 시트 body View 구현

var body: some View {
    ZStack(alignment: .bottom) {

        Color.black.opacity(0.1)
            .opacity(isPresented ? 1 : 0)
            .onTapGesture {
                // 배경화면 터치 시 하단 시트 dismiss
                isPresented = false
            }

        // ...
    }
    .ignoresSafeArea()
}

 

위 부분 body View 부분의 가장 바깥쪽 부분인데요.

이부분에서는 유저가 배경화면을 터치 시 하단 시트는 내려갑니다.

 

var body: some View {
    ZStack(alignment: .bottom) {

       // ...

        VStack(spacing: .zero) {
            RoundedRectangle(cornerRadius: 20)
                .fill(.white)
                .frame(height: 30)
                .overlay(
                    BottomViewGrabber()
                )

            self.content
                .frame(height: self.height)
        }
        .frame(height: self.height+30)
        .background(
            Rectangle()
                .fill(.white)
                // 커스텀 cornerRadius modifier
                .cornerRadius(20, corners: [.topLeft, .topRight])
                .edgesIgnoringSafeArea([.bottom, .horizontal])
        )
        // ...
    }
    .ignoresSafeArea()
}

 

위 부분 실질적으로 하단 시트 내부에 보여지는 View를 구현한 부분입니다.

이 곳에서 cornerRaidus 라는 custom modifier를 정의하였는데 이 부분에 대해서 하단에 별첨으로 설명하도록 하겠습니다.

 

var body: some View {
    ZStack(alignment: .bottom) {

        // ...

        VStack(spacing: .zero) {
            // ...

            self.content
                .frame(height: self.height)
        }
        // ...
        .transition(.opacity.combined(with: .move(edge: .bottom)))
        .offset(y: translation)
        .gesture(
            DragGesture()
                .updating($translation) { value, state, _ in
                    if value.translation.height >= 0 {
                        state = value.translation.height
                    }
                }
                .onEnded({ value in
                    if value.translation.height >= height / 3 {
                        isPresented = false
                    }
                })
        )
    }
    .ignoresSafeArea()
}

 

위 부분은 유저의 제스처에 따른 하단 시트 변화 애니메이션을 정의한 부분입니다.

위 부분에 대해선 좀 더 자세히 설명해보도록 하겠습니다.

.transition(.opacity.combined(with: .move(edge: .bottom)))
  • .transition(_:) modifier은 뷰가 띄워지거나 사라질 때, 어떤 애니메이선으로 보일지 정의하는 modifier입니다.
  • 위 코드에서는 .transition(_:) modifier의 인자(arguement)로 받아진 .opacity는 구조체 AnyTransition 타입을 따르는 타입 프로퍼티입니다. 이 말은 화면이 보여지고 사라질 때 투명도 형태의 애니메이션으로 보여젔다가 사라진다는 의미입니다.
  • .opacity와 사용된 .combined(with:) 메서드는 구조체 AnyTransition의 메서드로 다른 transition과 같이 사용하게 해주는 메서드입니다.
  • 따라서 실제 transition은 아래와 같이 이루어집니다.

 

.offset(y: translation)
  • .offset(x:y:) modifier는 View를 기존 위치에서 x축, y축으로 이동시키는 역할을 합니다.

  • gesture(_:including:) modifier특정 뷰에 제스처를 등록하는 modifier이다.
  • DragGesture()는 이름 그대로 드래그 제스처를 나타냅니다.
  • updating(_:body:) 메서드는 Gusture 프로토콜에 정의되어 있는 메서드로 제스처가 발생하는동안 호출되는 메서드입니다.
  • onEnded(_:) 메서드도 마찬가지로 Gusture 프로토콜에 정의되어 있는 메서드로 제스처가 끝나면 호출되는 메서드입니다. 여기서 호출 후 수행할 동작에 대해 정의해줍니다.

이제 updating(_:body:) 메서드onEnded(_:) 메서드에 구현된 내용을 좀더 상세히 살펴보도록 하겠습니다.

.updating($translation) { value, state, _ in
    if value.translation.height >= 0 {
        state = value.translation.height
    }
}
  • updating(_:body:)의 body 클로저에서 인자로 value, state가 들어오는데 여기서 value갱신된 제스처 상태를, state이전 제스처 상태를 나타냅니다.
  • 따라서 위 코드는 value의 값(드래그된 정도)에 따라 state값을 갱신시켜주는 코드입니다.
  • 여기서 value.translation.height >= 0 조건문은 하단 시트가 드래그에 따라 화면 위로 따라가는 현상을 방지해줍니다.

 

.onEnded({ value in
    if value.translation.height >= height / 3 {
        isPresented = false
    }
})
  • .onEnded(_:) 메서드의 action 클로저에서 받아오는 인자 value는 제스처의 최종 값을 가져옵니다.
  • 위 구문에서 value.translation.hight >= height / 3  조건문은 드래그로 이동한 위치가 전체 높이의 1/3을 넘었을 경우 하단 시트가 닫힌도록 해주는 구문입니다.

 

최종 코드

import SwiftUI

struct BottomSheetView<Content>: View where Content: View {
    
    @Binding var isPresented: Bool
    private var height: CGFloat
    private var content: Content
    
    @GestureState private var translation: CGFloat = .zero
    
    init(_ isPresented: Binding<Bool>, height: CGFloat, content: () -> Content) {
        self._isPresented = isPresented
        self.height = height
        self.content = content()
    }
    
    var body: some View {
        ZStack(alignment: .bottom) {
            
            Color.black.opacity(0.1)
                .opacity(isPresented ? 1 : 0)
                .onTapGesture {
                    isPresented = false
                }
            
            VStack(spacing: .zero) {
                RoundedRectangle(cornerRadius: 20)
                    .fill(.white)
                    .frame(height: 30)
                    .overlay(
                        BottomViewGrabber()
                    )
                
                self.content
                    .frame(height: self.height)
            }
            .frame(height: self.height+30)
            .background(
                Rectangle()
                    .fill(.white)
                    .cornerRadius(20, corners: [.topLeft, .topRight])
                    .edgesIgnoringSafeArea([.bottom, .horizontal])
            )
            .transition(.opacity.combined(with: .move(edge: .bottom)))
            .offset(y: translation)
            .gesture(
                DragGesture()
                    .updating($translation) { value, state, _ in
                        if value.translation.height >= 0 {
                            state = value.translation.height
                        }
                    }
                    .onEnded({ value in
                        if value.translation.height >= height / 3 {
                            isPresented = false
                        }
                    })
            )
        }
        .ignoresSafeArea()
    }
}

 

여기까지 포스트를 읽어주셔서 감사합니다.

 

참고 사이트

https://iosangbong.tistory.com/14 (주 참고 사이트)

https://velog.io/@juneyj1/SwiftUI%EC%97%90%EC%84%9C-%ED%8A%B9%EC%A0%95-%EB%AA%A8%EC%84%9C%EB%A6%AC%EB%A7%8C-%EB%91%A5%EA%B8%80%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0 (Corner Radius Custom Modifier)