iOS

[iOS] SwiftUI 약관 동의 화면, 전체 동의 Checkbox 만들기

DuncanKim 2024. 8. 9. 15:17
728x90

[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) 속성 설명

  1. @Published var allPermit: Bool = false
    '전체 동의'에 대한 상태를 관리한다. 이 값이 변경되면, 연결된 다른 동의 상태들도 영향을 받는다.
  2. @Published var fourteenPermit: Bool = false
    14세 이상 동의에 대한 상태를 관리한다.
  3. @Published var termsOfServicePermit: Bool = false
    서비스 이용 약관 동의에 대한 상태를 관리한다.
  4. @Published var informationPermit: Bool = false
    개인정보 처리방침 동의에 대한 상태를 관리한다.
  5. private var allSelected = false
    내부적으로 사용되는 플래그로, 전체 동의 체크박스의 상태를 추적한다.
    이를 두지 않으면 무한 루프 상태에 빠질 수 있다.

    ex.
    각각의 개별 동의 상태가 변경되면, Publishers.CombineLatest3을 사용하여 세 동의 상태가 모두 true인지 확인하고, 이 경우 allPermit을 다시 true로 설정하려고 시도함. 이때 allPermit의 현재 값이 이미 true인 경우에도 상태 설정을 시도하면 불필요한 상태 변경 호출이 발생할 수 있음.

    이러한 예상치 못한 상태를 방지하고 상태 변경 로직을 제어하는 데 사용된다.
  6. private var store: [AnyCancellable] = []
    Combine에서 생성된 구독을 관리하기 위한 배열.

 

2) 초기화 및 구독 로직 설명

 

init() 메서드 내에서는 두 개의 Combine 구독을 설정한다.

  1. $allPermit 구독:
    allPermit 변수의 변경을 관찰하고, 이 값이 true로 설정될 때 (전체 동의가 체크될 때) 다른 모든 동의 상태를 true로 설정한다. 만약 allPermit가 false로 변경되면 (전체 동의가 해제될 때), 모든 동의 상태를 false로 설정한다. 이 로직은 allSelected 플래그를 사용하여 조건부로 실행되므로, 사용자가 다른 동의를 해제하여 allPermit 상태가 변경될 때는 이 로직이 실행되지 않는다.

  2. CombineLatest3 구독:fourteenPermit, termsOfServicePermit, informationPermit의 세 변수가 모두 true일 때 allPermit을 true로 설정한다. 이는 사용자가 각각의 동의를 개별적으로 체크했을 때, 전체 동의 체크박스도 자동으로 체크되도록 하는 기능이다. 이 로직 역시 allSelected 플래그를 통해 제어되어, 불필요한 상태 변경을 방지한다.

 

이렇게 전체 동의 체크와 나머지 체크박스의 상태를 감지하는 부분을 나누어 관리를 해주면, 위와 같은 체크박스 로직을 구현할 수 있다.

728x90