🦋
SwiftUI: 複数選択ができるチェックボックスを作ろう!
SwiftUIでiOS向けにはチェックボックスのコンポーネントは基本的に提供されていません。
(macOSだとToggleStyle
にcheckboxが存在していますが。)
一応、複数選択ではなく単数選択であれば、Form
とPicker
を組み合わせれば選択状態をチェックマークで表す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についてはこちらの記事を参考にすると良いです。
成果物
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
を作ります。
Picker
はselection
のHashable
な値と一致する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