[iOS] SwiftUI 약관 동의 화면, 전체 동의 Checkbox 만들기
[iOS] SwiftUI 약관 동의 화면, 전체 동의 Checkbox 만들기
회원가입이 있는 서비스나 뭔가 전체 선택을 하게 하는 체크박스가 있는 경우, 위와 같은 동작을 생각하고 코드를 구현해야 한다. 처음엔 쉬울 줄 알았는데, 만들다 보니 난도가 있는 것 같아 정리하기 위해 포스팅을 한다.
/// TODO:
/// - 모두 선택되어 있지 않을 때, 전체 동의를 누를 때 모든 체크박스가 on 상태가 된다
/// - 모두 선택되어 있을 때, 전체 동의를 누르면 모든 체크박스가 off 상태가 된다.
/// - 모두 선택되어 있을 때, 전체 동의하기 체크박스는 on 상태이다.
/// - 모두 선택되어 있을 때, 전체 동의를 제외한 체크박스 하나를 누르면 전체 동의하기 체크박스는 해제된다.
네 가지 요건을 만족시키기 위한 멀티 체크박스 로직을 구현해보겠다.
1. 동의하기 뷰 구현
// 이용약관 동의 뷰
private var termsOfServiceView: some View {
VStack(spacing: 0) {
HStack {
Toggle("전체 동의하기", isOn: $permitVM.allPermit)
.toggleStyle(CheckboxToggleStyle(style: .circle))
.foregroundColor(Color.mainTextColor.opacity(0.8))
.font(.system(size: 20, weight: .bold))
Spacer()
}
.padding(.bottom, 14)
HStack {
Toggle("만 14세 이상입니다", isOn: $permitVM.fourteenPermit)
.toggleStyle(CheckboxToggleStyle(style: .circle))
.foregroundColor(Color.mainTextColor.opacity(0.8))
Spacer()
}
.padding(.bottom, 8)
HStack {
Toggle("서비스 이용 약관 동의", isOn: $permitVM.termsOfServicePermit)
.toggleStyle(CheckboxToggleStyle(style: .circle))
.foregroundColor(Color.mainTextColor.opacity(0.8))
Button {
nicknameViewModel.send(action: .moveToWebView(where: "termsOfService"))
} label: {
Text("보기")
.underline()
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.mainSection3)
}
Spacer()
}
.padding(.bottom, 8)
HStack {
Toggle("개인정보 처리방침 동의", isOn: $permitVM.informationPermit)
.toggleStyle(CheckboxToggleStyle(style: .circle))
.foregroundColor(Color.mainTextColor.opacity(0.8))
Button {
nicknameViewModel.send(action: .moveToWebView(where: "privateInfo"))
} label: {
Text("보기")
.underline()
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.mainSection3)
}
Spacer()
}
}
}
기본적으로 토글을 누르면 액션이 되도록 만들었다. "보기" 버튼을 누르는 경우, 웹뷰로 내비게이션 전환이 되면서 약관을 보여주고, 다시 화면으로 넘어올 수 있도록 하였다.
여기에서 추가적으로 볼 것은 permitVM과 toggleStyle이다.
2. CheckboxToggleStyle
import SwiftUI
struct CheckboxToggleStyle: ToggleStyle {
@Environment(\.isEnabled) var isEnabled
let style: Style // custom param
func makeBody(configuration: Configuration) -> some View {
Button(action: {
configuration.isOn.toggle() // toggle the state binding
}, label: {
HStack {
Image(systemName: configuration.isOn ? "checkmark.\(style.sfSymbolName).fill" : style.sfSymbolName)
.imageScale(.large)
.foregroundColor(Color.bunnyColor)
configuration.label
}
})
.buttonStyle(PlainButtonStyle()) // remove any implicit styling from the button
.disabled(!isEnabled)
}
enum Style {
case square, circle
var sfSymbolName: String {
switch self {
case .square:
return "square"
case .circle:
return "circle"
}
}
}
}
내가 원하는 스타일로 토글 스타일을 만들어놓은 것이 구글링을 하니 나왔다. 기본 SF Symbol을 이용하여 스타일을 정리해 두어서 편리하게 쓸 수 있었다. 이 코드를 활용해서 스타일을 자유자재로 만들 수 있을 것 같다.
3. PermitViewModel
동의 여부를 관리하는 VM이다. 상태 관리를 상황별로 해야 하기 때문에, 원래 상위의 VM인 NicknameViewModel에 두는 것이 의미상 애매해서 따로 분할시켜 보았다.
import Combine
import SwiftUI
class PermitViewModel: ObservableObject {
@Published var allPermit: Bool = false
@Published var fourteenPermit: Bool = false
@Published var termsOfServicePermit: Bool = false
@Published var informationPermit: Bool = false
private var allSelected = false
private var store: [AnyCancellable] = []
init() {
$allPermit
.sink { [weak self] newValue in
guard let self = self else { return }
if newValue && !allSelected {
allSelected = true
self.fourteenPermit = true
self.termsOfServicePermit = true
self.informationPermit = true
} else if !newValue && allSelected { // 전체 동의가 체크되어 있는데 끈 경우
allSelected = false
self.fourteenPermit = false
self.termsOfServicePermit = false
self.informationPermit = false
}
}
.store(in: &store)
Publishers.CombineLatest3($fourteenPermit, $termsOfServicePermit, $informationPermit)
.sink { [weak self] fourteen, terms, info in
guard let self = self else { return }
if allSelected { // 모두 선택되었을 때,
if self.allPermit {
allSelected = false
self.allPermit = false
}
} else {
if fourteen && terms && info {
self.allPermit = true
}
}
}
.store(in: &store)
}
}
1) 속성 설명
- @Published var allPermit: Bool = false
'전체 동의'에 대한 상태를 관리한다. 이 값이 변경되면, 연결된 다른 동의 상태들도 영향을 받는다. - @Published var fourteenPermit: Bool = false
14세 이상 동의에 대한 상태를 관리한다. - @Published var termsOfServicePermit: Bool = false
서비스 이용 약관 동의에 대한 상태를 관리한다. - @Published var informationPermit: Bool = false
개인정보 처리방침 동의에 대한 상태를 관리한다. - private var allSelected = false
내부적으로 사용되는 플래그로, 전체 동의 체크박스의 상태를 추적한다.
이를 두지 않으면 무한 루프 상태에 빠질 수 있다.
ex.
각각의 개별 동의 상태가 변경되면, Publishers.CombineLatest3을 사용하여 세 동의 상태가 모두 true인지 확인하고, 이 경우 allPermit을 다시 true로 설정하려고 시도함. 이때 allPermit의 현재 값이 이미 true인 경우에도 상태 설정을 시도하면 불필요한 상태 변경 호출이 발생할 수 있음.
이러한 예상치 못한 상태를 방지하고 상태 변경 로직을 제어하는 데 사용된다. - private var store: [AnyCancellable] = []
Combine에서 생성된 구독을 관리하기 위한 배열.
2) 초기화 및 구독 로직 설명
init() 메서드 내에서는 두 개의 Combine 구독을 설정한다.
- $allPermit 구독:
allPermit 변수의 변경을 관찰하고, 이 값이 true로 설정될 때 (전체 동의가 체크될 때) 다른 모든 동의 상태를 true로 설정한다. 만약 allPermit가 false로 변경되면 (전체 동의가 해제될 때), 모든 동의 상태를 false로 설정한다. 이 로직은 allSelected 플래그를 사용하여 조건부로 실행되므로, 사용자가 다른 동의를 해제하여 allPermit 상태가 변경될 때는 이 로직이 실행되지 않는다. - CombineLatest3 구독:fourteenPermit, termsOfServicePermit, informationPermit의 세 변수가 모두 true일 때 allPermit을 true로 설정한다. 이는 사용자가 각각의 동의를 개별적으로 체크했을 때, 전체 동의 체크박스도 자동으로 체크되도록 하는 기능이다. 이 로직 역시 allSelected 플래그를 통해 제어되어, 불필요한 상태 변경을 방지한다.
이렇게 전체 동의 체크와 나머지 체크박스의 상태를 감지하는 부분을 나누어 관리를 해주면, 위와 같은 체크박스 로직을 구현할 수 있다.