IOS/개발 프로젝트

[iOS / SwiftUI] .sheet 이것저것

버스트 캐넌 2024. 2. 25. 11:03

글 작성에 앞서 본 블로그의 모든 게시글은 블로그 주인의 개발 일지(일기) 형태의 게시글입니다.

정보를 나누는 방식보단, 제가 했던 방식을 공유하는 식의 글이라 읽어도 제대로 이해를 못 하실 수 있거나 더 좋은 다른 방법이 존재할 수 있습니다.

이 점 양해해 주시며 본 블로그의 게시글을 읽어주시면 감사하겠습니다.

 

안녕하세요 버스트캐넌입니다.

 

오늘은 iOS13부터 지원하는 SwiftUI의 .sheet에 대해 이것저것 해보면서 배워가는 시간을 가져보려고 합니다.

 

.sheet 란?


 

.sheet()는 SwiftUI에서 뷰를 띄우는 여러 방법 중 하나의 방법입니다.

 

먼저 예시를 보겠습니다.

 

 

struct ContentView: View {
    @State private var showingSheet = false
    
    var body: some View {
        Button("Show Sheet") {
            showingSheet.toggle()
        }
        .sheet(isPresented: $showingSheet) {
            ExampleSheet()
        }
    }
}

struct ExampleSheet: View {
    var body: some View {
        VStack {
            Text("SheetView")
        }
    }
}

 

위의 영상과 같이 위에서 올라오는 뷰를 sheet라고 합니다. 모달(Modal)이라고도 하죠.

이 sheet를 이것저것 맛볼 예정입니다.

 

sheet에 drag indicator 추가하기 / .presentationDragIndicator


다른 앱의 sheet를 보면 sheet 상단에 움직일 수 있다는 표시의 작대기가 달린 것을 볼 수 있습니다.

지금은 안 보이는데, 이것을 보이게 할 수 있습니다.

.presentationDragIndicator를 사용하면 가능합니다.

 

.sheet(isPresented: $showingSheet) {
            ExampleSheet()
                .presentationDragIndicator(.visible)
        }

 

 

이제 sheet에 작대기가 생긴 걸 볼 수 있죠??

당연한 말이지만,

 

.presentationDragIndicator(.hidden)

 

을 하면 drag indicator를 안 보이게 할 수 있습니다.

 

sheet 크기 조절 / .presentationDetents


sheet의 크기 또한 조절 가능합니다.

아무것도 설정하지 않으면 화면을 꽉 채우게 나오는데, 이것을 여러 가지 크기로 나오게 할 수 있습니다.

.presentationDetents를 쓰면 가능합니다.

 

.sheet(isPresented: $showingSheet) {
            ExampleSheet()
                .presentationDragIndicator(.visible)
                .presentationDetents([.large])
        }

 

 

기본값인 .presentationDetents([.large])를 했을 때 나오는 화면입니다. (sheet가 확실하게 보이기 위해 색을 줬습니다.)

sheet가 화면의 절반만 덮게도 가능합니다.

 

.sheet(isPresented: $showingSheet) {
            ExampleSheet()
                .presentationDragIndicator(.visible)
                .presentationDetents([.medium])
        }

 

 

이제 sheet 화면이 앱의 절반만 차지하게 되었습니다.

 

sheet 화면의 크기를 .medium이나 .large말고도 원하는 값만큼 띄울 수도 있습니다.

 

.sheet(isPresented: $showingSheet) {
            ExampleSheet()
                .presentationDragIndicator(.visible)
                .presentationDetents([.height(600)]) // 원하는 값 가능
        }

 

 

sheet 화면이 600만큼 올라간 것을 볼 수 있습니다.

 

다만, 이 방법은 추천하지 않습니다. 왜냐하면 아이폰의 화면 크기에 따라 화면 덮는 비율이 달라집니다.

지금은 iPhone 15 Pro로 구동을 해보았는데 지금은 어림잡아 화면의 75% 정도 채웠지만, iPhone 15 Pro보다 작은 화면인 iPhone mini 13이나 iPhone SE3로 이 앱을 구동시킨다면 75%보다 더 높게 올라오거나, 화면을 완전 덮을 수도 있습니다.

혹은 iPhone 15 Pro Max로 구동시킨다면 화면을 절반만 덮을 수도 있죠.

 

그렇기에 height로 지정하는 방법은 추천하지 않고, 다음 방법을 더 추천드립니다.

 

.sheet(isPresented: $showingSheet) {
            ExampleSheet()
                .presentationDragIndicator(.visible)
                .presentationDetents([.fraction(0.8)]) // 원하는 값 가능
        }

 

 

.fraction()을 이용한 방법입니다.

.fraction()에서 () 안에 0~1 (0% ~ 100%)의 숫자를 적어주면 그 값만큼 화면을 차지하게 됩니다.

예시 화면에서는 0.8을 설정해서, sheet가 화면의 80%를 차지하는 모습을 볼 수 있습니다.

.large나 .medium 말고 다른 크기로 띄우기 위해서는 .fraction을 사용하는 게 모든 iPhone 기종에서 일관된 높이로 보이게 될 겁니다.

 

여기에 더해서, sheet 크기를 여러 개로 지정할 수 있습니다.

당장 유튜브 앱의 댓글창만 생각해 봐도 처음에는 동영상 밑까지만 오다가 더 위로 당기면 화면을 가득 채우잖아요??

 

이 방법도 간단합니다.

 

.sheet(isPresented: $showingSheet) {
            ExampleSheet()
                .presentationDragIndicator(.visible)
                .presentationDetents([.fraction(0.2), .medium, .large]) // 방식은 커스텀 가능
        }

 

 

.presentationDetents 에서 원하는 크기를 적어주면 됩니다.

최초에 뜨는 sheet 높이는 작성한 순서와 상관없이, 가장 작게 설정한 크기로 뜨게 되고, 쓸어 올릴 때는 쓸어 올린 만큼 sheet 높이가 조절됩니다.

 

사실 3가지 이상 sheet높이가 조절되는 건 UX상 별로일 것 같고, '.presentationDetents([.large, .medium])' 과 같이 두 가지의 크기만 해서 sheet 높이 조절 가능하게 하면 좋을 것 같습니다.

 

sheet 제스처 닫기 비활성화 / .interactiveDismissDisabled


기본 sheet는 닫을 때 쓸어내리는 제스처로 닫을 수 있습니다.

sheet에 scrollView가 있거나 (사실 sheet에 scrollView가 존재하면 안 된다는 입장이지만), sheet에서 저장이나 확정이 필요해서 제스처로 닫는 걸 원치 않은 상황일 때는 제스처로 닫는 방식을 비활성화할 수 있습니다.

이것도 간단한데, 예시를 들어 설명하겠습니다.

 

struct ExampleSheet: View {
    @State private var isAbleClosed = false
    
    var body: some View {
        ZStack {
            Color.gray
                .ignoresSafeArea()
            
            VStack {
                Text("SheetView")
            }
        }
        .interactiveDismissDisabled(!isAbleClosed)
    }
}

 

 

우선 .interactiveDismissDisabled사용에 필요한 Bool 타입의 변수를 선언하고, sheetView에 .interactiveDismissDisabled를 선언하면 됩니다.

그러면 아무리 제스처로 쓸어내려도 sheet가 닫히지 않습니다.

 

sheet 닫기 버튼 / dismiss()


interactiveDismissDisabled를 사용해서 제스처 닫기를 비활성화하면 어떻게 sheet를 닫아야 하나?

이것도 간단합니다. 뷰를 닫는 가장 쉬운 방법인 dismiss()를 이용하면 됩니다.

 

struct ExampleSheet: View {
    @Environment(\.dismiss) var dismiss
    @State private var isAbleClosed = false
    
    var body: some View {
        ZStack {
            Color.gray
                .ignoresSafeArea()
            
            VStack {
                Text("SheetView")
                
                Button("Close") {
                    dismiss()
                }
                .padding()
            }
        }
        .interactiveDismissDisabled(!isAbleClosed)
    }
}

 

 

우선 dismiss()를 사용하기 위해 @Environment(\.dismiss) var dismiss 를 선언해주었고, 버튼을 하나 만들어 sheet가 닫힐 수 있게 해 주었습니다.

 

여기에서 .interactiveDismissDisabled 와 dismiss() 방식을 결합해서 응용할 수 있는 방법이 있습니다.

예를 들자면, .interactiveDismissDisabled에 조건을 달아서 약관을 한번 봐야만 닫을 수 있게 할 수도 있습니다.

 

sheet 닫기 응용 (1) /  처음에는 닫기 비활성화, 어떠한 작업 후 활성화


...
	VStack {
              	Text("SheetView")
                
                Toggle("isAbleClosed", isOn: $isAbleClosed)
                
                Button("Close") {
                    if isAbleClosed {
                        dismiss()
                    }
                }
                .padding()
            }
            .padding()
...

 

 

 

(약관 부분은 간단하게 토글버튼으로만 구현해 보았습니다)

Button에 if로 조건을 달아서 isAbleClosed가 true 일 때만 닫을 수 있게 하였습니다.

이렇게 하면 함부로 닫지 못하고 어떠한 작업을 해야만 닫을 수 있는 sheet 가 완성 되었습니다.

 

이번에는 반대로, 처음에는 닫을 수 있지만 어떠한 변화가 생기면 안 닫히게 해 보겠습니다. (사실 오늘 글 적은 최종 이유입니다ㅎ)

 

sheet 닫기 응용 (2) / 처음에는 닫기 활성화, 어떠한 작업 후 비활성화


struct ExampleSheet: View {
    @Environment(\.dismiss) var dismiss
    @State private var isAbleClosed = true
    @State private var userInput = ""
    @State private var showingConfirmationDialog = false
    
    var body: some View {
        ZStack {
            Color.gray
                .ignoresSafeArea()
            
            VStack {
                TextField("Enter text here", text: $userInput)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .onChange(of: userInput) {
                        isAbleClosed = false
                    }
                
                Toggle("isAbleClosed", isOn: $isAbleClosed)
                
                Button("Close") {
                    if isAbleClosed {
                        dismiss()
                    } else {
                        showingConfirmationDialog = true
                    }
                }
            }
            .padding()
            .interactiveDismissDisabled(!isAbleClosed)
            .confirmationDialog("변경 사항을 폐기 하겠습니까?", isPresented: $showingConfirmationDialog, titleVisibility: .visible) {
                Button("변경 사항 폐기", role: .destructive) {
                    dismiss()
                }
                Button("계속 편집하기", role: .cancel) { }
            }
        }
    }
}

 

 

갑자기 코드가 좀 길어졌죠? ㅎㅎ 일단 간단히 설명하자면, 텍스트 입력이 없으면 (변화가 없으면) sheet를 닫을 수 있고,

텍스트를 입력해서 sheet에 변화가 생기면 제스처로 닫을 수 없고, 버튼으로 닫으려 해도 진짜 닫을건지 알림을 주게 했습니다.

 

천천히 설명하자면,

@State private var isAbleClosed = true

 

을 해서 처음에는 닫을 수 있게 하고,

 

TextField("Enter text here", text: $userInput)
	.textFieldStyle(RoundedBorderTextFieldStyle())
	.onChange(of: userInput) {
		isAbleClosed = false
}

 

을 해서 텍스트 입력에 변화가 생기면 isAbleClosed를 false로 해서 안 닫히게 했습니다.

 

Button("Close") {
	if isAbleClosed {
		dismiss()
	} else {
		showingConfirmationDialog = true
	}
}

 

버튼에도 else 조건을 추가해서, 처음 isAbleClosed가 true (변화가 없을 때)는 그냥 닫히게 했고, 텍스트 입력이 되어서 isAbleClosed가 false가 되면 진짜 sheet를 닫을건지 confirmationDialog를 뜨게 합니다.

 

.confirmationDialog("변경 사항을 폐기 하겠습니까?", isPresented: $showingConfirmationDialog, titleVisibility: .visible) {
	Button("변경 사항 폐기", role: .destructive) {
		dismiss()
	}
	Button("계속 편집하기", role: .cancel) { }
}

 

최종적으로 confirmationDialog를 작성해서 진짜 닫을건지(폐기) 안 닫고 계속 진행할 건지(계속) 정하게 했습니다.

 

실제로 코드를 완성하게 된다면 다음과 같을 것입니다.

 

import SwiftUI

struct ContentView: View {
    @State private var showingSheet = false
    @State private var myNickname = "버스트캐넌"
    
    var body: some View {
        Text("닉네임: \(myNickname)")
        
        Button("이름 변경하기") {
            showingSheet.toggle()
        }
        .padding()
        .sheet(isPresented: $showingSheet) {
            ExampleSheet(myNickname: $myNickname)
                .presentationDragIndicator(.visible)
        }
    }
}

struct ExampleSheet: View {
    @Environment(\.dismiss) var dismiss
    @State private var isAbleClosed = true
    @State private var showingConfirmationDialog = false
    @State private var userInput: String
    @Binding var myNickname: String
    
    init(myNickname: Binding<String>) {
            self._myNickname = myNickname
            self._userInput = State(initialValue: myNickname.wrappedValue)
        }
    
    var body: some View {
        VStack {
            HStack {
                Text("닉네임: ")
                
                TextField("Enter text here", text: $userInput)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .onChange(of: userInput) {
                        isAbleClosed = false
                }
            }
            
            HStack(spacing: 50) {
                Button("취소") {
                    if isAbleClosed {
                        dismiss()
                    } else {
                        showingConfirmationDialog = true
                    }
                }
                
                Button("저장") {
                    myNickname = userInput
                    dismiss()
                }
            }
            .padding()
        }
        .padding()
        .interactiveDismissDisabled(!isAbleClosed)
        .confirmationDialog("변경 사항을 폐기 하겠습니까?", isPresented: $showingConfirmationDialog, titleVisibility: .visible) {
            Button("변경 사항 폐기", role: .destructive) {
                dismiss()
            }
            Button("계속 편집하기", role: .cancel) { }
        }
    }
}

#Preview {
    ContentView()
}

 

 

이것도 간단하게 설명하자면 닉네임 수정하는 화면을 sheet로 만든 것입니다.

sheet로 넘어갔을 때 닉네임을 수정하지 않으면 sheet를 바로 닫을 수 있지만, 변경 사항이 있으면 sheet를 닫지 못하고, 취소 버튼을 눌러서 sheet를 닫으려 해도 진짜 닫을 건지 물어봅니다.

저장을 하면 변경된 데이터가 저장 되는것이구요.

 

사실 진짜 하고 싶었던 것은, 애플 기본 캘린더 앱처럼 변경 사항이 있고 sheet를 밑으로 당길 때 취소버튼과 동일하게 변경사항을 폐기하겠냐는 알림이 뜨는 것인데...

아무리 찾아봐도 어떻게 해야 하는지 모르겠네요 ㅠㅠ 만약 방법을 찾게 되면 제가 추가해두겠습니다.

그전 까지는 취소버튼으로 변경사항을 폐기할 건지라고 알림을 띄우는 방법이 최선이네요.

 

이상 마무리 하겠습니다

 

허접한 포스팅 읽어주셔서 감사합니다.

 

좋은 하루 보내세요~

 

궁금한 점이나 지적해야 할 부분이 있으시면 댓글 남겨주세요. 블로그 주인의 상황에 따라 답변이 없을 수 있으나, 최대한 피드백해 드리겠습니다.