iOS/SwiftUI
SwiftUI로 CheckBoxTreeView 만들기!
이기회
2022. 7. 1. 20:02
💫 완성화면
git: https://github.com/lee-chance/CheckBoxTreeView
💫 각각의 데이터가 될 객체 만들기
struct Node: Identifiable {
typealias ID = String
let id: ID
let label: String
let children: [Node]?
init(id: ID = UUID().uuidString, label: String, children: [Node]? = nil) {
self.id = id
self.label = label
self.children = children
}
init(_ label: String, children: [Node]? = nil) {
self.id = label
self.label = label
self.children = children
}
var childrenIDs: Set<ID> {
getChildrenID(node: self)
}
private func getChildrenID(node: Node) -> Set<ID> {
var temp = Set([node.id])
if let children = node.children {
let childrenIDs = children.reduce(Set<ID>()) { $0.union(getChildrenID(node: $1)) }
temp = temp.union(childrenIDs)
}
return temp
}
}
각 Node는 고유한 id를 갖고있고 화면에 표시될 label, 하위 노드인 children을 받고있다. 하위 노드가 없는 마지막 노드일 경우 children은 nil이 된다.
💫 데이터셋
let continents: [Node] = [
Node(label: "아프로-유라시아", children: [
Node(label: "아프리카"),
Node(label: "유라시아", children: [
Node(label: "유럽"),
Node(label: "아시아")
]),
]),
Node(label: "오스트레일리아", children: [
Node(label: "오세아니아")
]),
Node(label: "아메리카", children: [
Node(label: "북아메리카", children: [
Node(label: "북아메리카"),
Node(label: "중앙아메리카"),
Node(label: "카리브")
]),
Node(label: "남아메리카")
]),
Node(label: "남극")
]
지구의 대륙들을 트리뷰로 나타내보자!
💫 CheckBoxTreeView
struct CheckBoxTreeView: View {
private let nodes: [Node] // 👈 Node 리스트
@Binding var checked: Set<Node.ID> // 👈 선택된 Node의 ID Set
@State private var expanded: Set<Node.ID> = [] // 👈 열려있는 Node의 ID Set
init(nodes: [Node], selectedSet: Binding<Set<Node.ID>>) {
self.nodes = nodes
self._checked = selectedSet
}
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 4) {
// 👇 Row 객체
CheckBoxTreeRow(nodes: nodes, depth: 0, checked: $checked, expanded: $expanded)
}
}
}
}
가장 루트가 되는 CheckBoxTreeView이다. 생성자로 데이터셋인 nodes와 선택된 리스트를 외부에서 접근하기위해 Binding으로 selectedSet을 받는다. body에서는 CheckBoxTreeRow객체를 보여준다.
💫 CheckBoxTreeRow
// 👇 외부에서의 접근을 막기위해 접근자를 private로 설정했다.
private struct CheckBoxTreeRow: View {
let nodes: [Node]
let depth: Int // 👈 상위 노드와 하위 노드를 시각적으로 구분하기 위한 값
@Binding var checked: Set<Node.ID>
@Binding var expanded: Set<Node.ID>
var body: some View {
// 👇 외부에서 받아온 nodes를 반복문으로 보여준다.
ForEach(nodes) { node in
// 👇 Node 객체
CheckBoxTreeNode(node: node, depth: depth, checked: $checked, expanded: $expanded)
// 👇 해당 노드에 children이 있고 expanded한 상태이면 하위 노드를 보여준다.
if let children = node.children, expanded.contains(node.id) {
// 👇 children은 상위 노드와 같은 타입이므로 CheckBoxTreeRow를 재귀 호출한다.
// 👇 또 상위 노드와 구분하기 위해 depth에 1을 더해서 값을 넘겨준다.
CheckBoxTreeRow(nodes: children, depth: depth + 1, checked: $checked, expanded: $expanded)
}
}
}
}
💫 CheckBoxTreeNode
변수선언
private struct CheckBoxTreeNode: View {
let node: Node
let depth: Int
@Binding var checked: Set<Node.ID>
@Binding var expanded: Set<Node.ID>
// toggle off일때 Image View
private var collapsedImage: some View {
Image(systemName: "arrowtriangle.right.fill")
.resizable()
.frame(width: 8, height: 8)
.foregroundColor(.gray)
}
// toggle on일때 Image View
private var expandedImage: some View {
Image(systemName: "arrowtriangle.down.fill")
.resizable()
.frame(width: 8, height: 8)
.foregroundColor(.gray)
}
// 모든 하위 노드나 현재 노드가 체크되어 있을 때 Image View
private var checkBoxSelectedImage: some View {
Image(systemName: "checkmark.square")
}
// 하위 노드 일부가 체크되어 있을 때 Image View
private var checkBoxHalfImage: some View {
Image(systemName: "minus.square")
}
// 하위 노드, 현재 노드가 체크되어 있지 않을 때 Image View
private var checkBoxUnselectedImage: some View {
Image(systemName: "square")
}
}
메소드
private struct CheckBoxTreeNode: View {
...
// 하위 노드 보기
private func onExpand(node: Node) {
expand(id: node.id)
}
// 하위 노드 보기
private func expand(id: Node.ID) {
expanded.insert(id)
}
// 하위 노드 숨기기
private func onCollapse(node: Node) {
collapse(id: node.id)
}
// 하위 노드 숨기기
private func collapse(id: Node.ID) {
expanded = expanded.filter { $0 != id }
}
// 현재 노드, 하위 노드 체크
private func onCheck(node: Node) {
check(id: node.id)
if let children = node.children {
children.forEach { onCheck(node: $0) }
}
}
// 현재 노드 체크
private func check(id: Node.ID) {
DispatchQueue.main.async {
checked.insert(id)
}
}
// 현재 노드, 하위 노드 체크 해제
private func onUncheck(node: Node) {
uncheck(id: node.id)
if let children = node.children {
children.forEach { onUncheck(node: $0) }
}
}
// 현재 노드 체크 해제
private func uncheck(id: Node.ID) {
DispatchQueue.main.async {
checked = checked.filter { $0 != id }
}
}
}
body 및 컴포넌트
private struct CheckBoxTreeNode: View {
...
var body: some View {
HStack(spacing: 8) {
// 👇 depth 만큼 뷰 띄우기
ForEach(0..<depth, id: \.self) { _ in
NodeSpacerView()
}
// 👇 Toggle View 컴포넌트
ExpandToggleView()
// 👇 Check Box 컴포넌트
CheckBoxView()
// 👇 노드의 label
Text(node.label)
}
}
// 👇 공백 뷰, expandedImage와 사이즈가 같으면 이쁘므로 해당뷰를 투명으로 하여 보여줬다.
private func NodeSpacerView() -> some View {
expandedImage
.opacity(0)
}
@ViewBuilder
private func ExpandToggleView() -> some View {
// 👇 하위 노드가 있으면
if let _ = node.children {
// 👇 해당 노드가 열려있으면 expandedImage 아니면 collapsedImage을 보여준다.
if expanded.contains(node.id) {
expandedImage
.onTapGesture {
onCollapse(node: node) // 👈 하위 노드 숨기기
}
} else {
collapsedImage
.onTapGesture {
onExpand(node: node) // 👈 하위 노드 보기
}
}
} else {
// 👇 하위 노드가 없으면 공백 뷰
NodeSpacerView()
}
}
@ViewBuilder
private func CheckBoxView() -> some View {
// 👇 하위 노드가 있으면
if let children = node.children {
// ⭐️ 중요❗️
// 👇 하위 노드가 모두 체크 되어있다면 현재 노드도 체크된 상태가 되어야한다.
if children.map({ $0.id }).reduce(true, { $0 && checked.contains($1) }) {
checkBoxSelectedImage
.onTapGesture {
onUncheck(node: node)
}
.onAppear {
check(id: node.id) // 👈 현재 노드를 체킹
}
} else {
// 👇 하위 모든 노드중 하나라도 체크 되어있다면 checkBoxHalfImage
// 👇 아니면 checkBoxUnselectedImage를 보여준다.
if checked.intersection(node.childrenIDs).count > 0 {
checkBoxHalfImage
.onTapGesture {
onUncheck(node: node)
}
.onAppear {
uncheck(id: node.id) // 👈 현재 노드를 체킹 해제
}
} else {
checkBoxUnselectedImage
.onTapGesture {
onCheck(node: node)
}
.onAppear {
uncheck(id: node.id) // 👈 현재 노드를 체킹 해제
}
}
}
} else {
// 👇 child가 없는 최하위 노드는 현재노드만으로 상태를 구분한다.
if checked.contains(node.id) {
checkBoxSelectedImage
.onTapGesture {
onUncheck(node: node)
}
.onAppear {
check(id: node.id)
}
} else {
checkBoxUnselectedImage
.onTapGesture {
onCheck(node: node)
}
.onAppear {
uncheck(id: node.id)
}
}
}
}
}
💫 CheckBoxTreeView 사용하기
struct ContentView: View {
@State private var checked: Set<Node.ID> = []
var body: some View {
VStack {
CheckBoxTreeView(nodes: continents, selectedSet: $checked)
Text("\(String(describing: checked))")
}
.padding()
}
}
checked를 State변수로 선언하고 CheckBoxTreeView에 바인딩하여 넣어주면 사용할 수 있다. 내부에 접근하지 않아도 checked에 들어있는 ID를 사용하여 현재 체크된 리스트를 알 수 있다.
💫 다시 완성화면
🚧 주의사항 🚧
지금은 Node의 id값을 label과 똑같게 해 주었는데 '북아메리카'의 하위에 '북아메리카'노드가 있으면 같은 id가 되므로 하위의 '북아메리카'는 선택이 되지 않는다. 이렇기 때문에 id는 반드시 고유한 값으로 지정해야 의도치 않은 버그를 피할 수 있다.