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는 반드시 고유한 값으로 지정해야 의도치 않은 버그를 피할 수 있다.

 

 

💫 Git

https://github.com/lee-chance/CheckBoxTreeView