🦋

SwiftUI: 複数選択ができるチェックボックスを作ろう!

2024/12/19に公開

SwiftUIでiOS向けにはチェックボックスのコンポーネントは基本的に提供されていません。
(macOSだとToggleStylecheckboxが存在していますが。)

一応、複数選択ではなく単数選択であれば、FormPickerを組み合わせれば選択状態をチェックマークで表すUIが提供されています。

struct ContentView: View {
    @State var selection = "Apple"
    var items = ["Apple", "Banana", "Cherry"]

    var body: some View {
        Form {
            Picker(selection: $selection) {
                ForEach(items, id: \.self) { item in
                    Text(item).tag(item)
                }
            } label: {
                Text("Fruits")
            }
            .pickerStyle(.inline)
        }
    }
}

ただ、複数選択でかつチェックボックスのようなUIとなると自作するしかありません。
自作するならPickerと同様のインターフェースで使えるものがほしいですよね。
ということで、Variadic Viewを用いて良い感じのチェックボックスを自作します。
(iOS 18以上でればVariadic Viewを使わずに公式のAPIでできるようになりました。)

Variadic Viewについてはこちらの記事を参考にすると良いです。
https://zenn.dev/ribilynn/articles/3f1df059e67b85

成果物

struct ContentView: View {
    @State var selection = Set<String>()
    var items = ["Apple", "Banana", "Cherry"]

    var body: some View {
        Form {
            Checkboxes(axis: .horizontal, selection: $selection) {
                ForEach(items, id: \.self) { item in
                    Text(item).tag(item)
                }
            } label: {
                Text("Fruits")
            }
        }
    }
}

こんな感じで使えます。

実装

まず、macOSにはCheckboxToggleStyleがあるのですが、iOSにはないので作ります。

struct CheckboxToggleStyle: ToggleStyle {
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        HStack {
            Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square")
                .foregroundStyle(isEnabled ? Color.accentColor : Color.secondary)
            configuration.label
                .foregroundStyle(isEnabled ? Color.primary : Color.secondary)
        }
        .onTapGesture {
            configuration.isOn.toggle()
        }
    }
}

次に、Lumisilkさんの記事にもある通り、Variadic Viewを使いやすくする拡張をしておきます。

private struct MultiRoot<Result: View>: _VariadicView_MultiViewRoot {
    var childrenHandler: (_VariadicView_Children) -> Result

    func body(children: _VariadicView.Children) -> some View {
        childrenHandler(children)
    }
}

extension View {
    func variadic(@ViewBuilder childrenHandler: @escaping (_VariadicView_Children) -> some View) -> some View {
        _VariadicView.Tree(MultiRoot(childrenHandler: childrenHandler)) {
            self
        }
    }
}

また、tagを取得できるようにします。

extension View {
    func tag<Value: Hashable>() -> Value? {
        guard let _traits = Mirror(reflecting: self).descendant("traits", "storage"),
              let traits = _traits as? (any BidirectionalCollection) else {
            return nil
        }
        for element in traits {
            guard let _value = Mirror(reflecting: element).descendant("value", "tagged"),
                  let value = _value as? Value else {
                continue
            }
            return value
        }
        return nil
    }
}

そうしたらPickerのインターフェースを真似しつつCheckboxesを作ります。
PickerselectionHashableな値と一致するtagがあるかどうかで選択状態を判別しているので内部的にそれを再現します。あとはSetの制御をすれば良いだけです。

struct Checkboxes<Label, SelectionValue, Content>: View where Label: View, SelectionValue: Hashable, Content: View {
    var axis: Axis
    @Binding var selection: Set<SelectionValue>
    var content: () -> Content
    var label: () -> Label

    init(
        axis: Axis = .horizontal,
        selection: Binding<Set<SelectionValue>>,
        @ViewBuilder content: @escaping () -> Content,
        @ViewBuilder label: @escaping () -> Label
    ) {
        self.axis = axis
        _selection = selection
        self.content = content
        self.label = label
    }

    var body: some View {
        LabeledContent {
            switch axis {
            case .horizontal:
                HStack(alignment: .firstTextBaseline) {
                    mainBody
                }
            case .vertical:
                VStack(alignment: .leading) {
                    mainBody
                }
            }
        } label: {
            label()
        }
    }

    var mainBody: some View {
        content().variadic { children in
            ForEach(children) { child in
                let tag: SelectionValue? = child.tag()
                Toggle(isOn: Binding<Bool>(
                    get: {
                        if let tag {
                            selection.contains(tag)
                        } else {
                            false
                        }
                    },
                    set: { value in
                        guard let tag else { return }
                        if value {
                            selection.insert(tag)
                        } else {
                            selection.remove(tag)
                        }
                    }
                )) {
                    child.lineLimit(1).truncationMode(.tail)
                }
                .toggleStyle(CheckboxToggleStyle())
            }
        }
    }
}

iOS 18以降だとどう実装できる?

が追加されたので超簡単に実装できます。
CheckboxToggleStyleは変わらずそのまま使います。)

Checkboxes
struct Checkboxes<Label, SelectionValue, Content>: View where Label: View, SelectionValue: Hashable, Content: View {
    var axis: Axis
    @Binding var selection: Set<SelectionValue>
    var content: () -> Content
    var label: () -> Label

    init(
        axis: Axis = .horizontal,
        selection: Binding<Set<SelectionValue>>,
        @ViewBuilder content: @escaping () -> Content,
        @ViewBuilder label: @escaping () -> Label
    ) {
        self.axis = axis
        _selection = selection
        self.content = content
        self.label = label
    }

    var body: some View {
        LabeledContent {
            switch axis {
            case .horizontal:
                HStack(alignment: .firstTextBaseline) {
                    mainBody
                }
            case .vertical:
                VStack(alignment: .leading) {
                    mainBody
                }
            }
        } label: {
            label()
        }
    }

    var mainBody: some View {
        ForEach(subviews: content()) { subview in
            let tag = subview.containerValues.tag(for: SelectionValue.self)
            Toggle(isOn: Binding<Bool>(
                get: {
                    if let tag {
                        selection.contains(tag)
                    } else {
                        false
                    }
                },
                set: { value in
                    guard let tag else { return }
                    if value {
                        selection.insert(tag)
                    } else {
                        selection.remove(tag)
                    }
                }
            )) {
                subview.lineLimit(1).truncationMode(.tail)
            }
            .toggleStyle(CheckboxToggleStyle())
        }
    }
}

Discussion