본문 바로가기
iOS/구현

[SwiftUI] 스유로 섹션 접었다폈다 구현하기

by Callie_ 2024. 6. 21.

 

 

기아 타이거즈 팬 특혜: 더미 데이터 고민 안 해도 됨..ㅎ

 

 

요즘 회사에서 스유로 리팩토링하는 작업을 담당하고 있는데, 섹션 폴딩을 구현해야할 일이 생겼었다.

 

찾아보면 list를 활용해서 만드는 예시 위주만 나와서 내가 구현하고 하는 것과 맞지 않았다. 내가 구현하고자 한 폴딩 부분은 디자인 가이드상 컬렉션뷰로 구현해야 나오는 뷰였기 때문에 GirdItem을 활용해서 커스텀으로 섹션을 만들어 쓰기로 했다. 마침 참고하기 좋은 해외 포스트도 발견해서 참조했다.

 

아직 스유에서 사용하는 propertyWrapper가 어색하다면 아래 포스팅을 참고하는 걸 추천! --- propertyWrapper 글 작업 중

 

 

 

 

 

 


 

 

 

 

 

 

 

1. 섹션 상태를 섹션 이름으로 구별해서 folding 기능을 토글처럼 값을 관찰하는 ObservableObject를 만든다.

 

import SwiftUI

// 섹션 상태를 관리하는 모델
class TigersSectionModel: ObservableObject {
    @Published var sections: [String: Bool]
    
    let constants = TigersMenuList.Constants()

    init() {
        self.sections = [
            constants.outfield: true,
            constants.staff: true,
            constants.infield: true,
            constants.peacher: true
        ]
    }
    
    func isOpen(title: String) -> Bool {
        return sections[title] ?? false
    }
    
    func toggle(title: String) {
        let current = sections[title] ?? false
        sections[title] = !current
    }
}

 

- ObservableObject 클래스를 사용해서 값의 변화를 관찰하도록 했다.

 

- @Published로 sections이란 딕셔너리를 만들어서 섹션 이름과 bool값으로 섹션이 열렸는지 닫혔는지를 관찰한다. @Published의 특성 상 값의 변화가 감지되면 곧바로 뷰를 업데이트 해준다.

 

- 회사 디자인 가이드상 섹션이 열려 있어야해서 init에서 딕셔너리 값을 true로 반환해서 열어뒀다. (그런데 코드 상 true가 default라 열려있긴 했다.)

 

 

 

 

2. 커스텀으로 섹션헤더뷰 만들기

 

import SwiftUI

struct TigersSectionHeader: View {
    
    var title: String
    
    @StateObject var model: TigersSectionModel

    var body: some View {
        
        HStack {
            Text(title)
                .font(Font.system(size: 18, weight: .medium))
                .foregroundStyle(.black)
            Spacer()
            Image(systemName: model.isOpen(title: title) ? "chevron.up" : "chevron.down")
            
        }
        .contentShape(Rectangle())
        
        .onTapGesture {
            self.model.toggle(title: self.title)
        }
    }
}

 

- 이제 sectionModel을 활용해서 커스텀한 섹션 뷰를 만들어준다.

 

- TigersSectionModel을 @StateObject로 관찰중인 객체에 변화를 감지하면 뷰를 업데이트하게 해준다. 참고했던 블로그에서는 @ObservedObject로 설정해두었는데, 그부분을 @StateObject로 변경했다.

------ 두 개의 차이점이 정말,, 교묘해서 조금 더 공부해야겠지만, 내가 공부했을 때 생각한 차이점은 뷰 라이프사이클에 의존적이냐, 아니냐였다. 뷰가 갱신 될 때 값이 파괴되거나 재생성 되는 @ObservedObject는 내게 안정성 면에서 떨어지는 것 같아서, @StateObject로 수정했다. 애플도 후자를 사용하기를 권장하고 있어서 학습을 더해야겠지만, 약간 weak self같은 역할을 해주는 것 같았다.

 

- 섹션 부분을 탭하면 폴딩 기능이 되면서 화살표 방향도 바뀌어야해서, contentShape으로 섹션 부분을 잡아주고 그 부분을 터치영역이 될 수 있게 해주었다.

 

- 터치하면 모델의 toggle값이 바뀌어서 폴딩의 open/close 값을 업데이트하고 뷰갱신 할 수 있게 했다. (이 부분을 @StateObject가 해준다.) 그리고 섹션을 이름으로 나누어서 이름과 동일한 섹션을 탭했을 때 값을 갱신 시켰다.

 



 

 

 

3. ListView에서 1,2 뷰를 합쳐서 활용하기

 

- ListView가 contentView역할을 한 예제 코드이므로, 전체 코드는 아래와 같다.

(해당 코드는 단순히 UI 및 로직 구현을 위한 코드로, 중복 코드를 간소화 하지 않음)


import SwiftUI

struct TigersMenuList: View {
    
    let constants = Constants()
    
    // Section Expandable Model
    @StateObject var sectionModel = TigersSectionModel()
    
    // Cell
    let gridItems = [
        GridItem(.flexible(), alignment: .leading),
        GridItem(.flexible(), alignment: .leading)
    ]
    
    var body: some View {
        
        ScrollView {
            VStack {
                Group {
                    
                    // MARK: - 감독
                    
                    VStack {
                        TigersSectionHeader(title: constants.staff, model: sectionModel)
                        
                        if sectionModel.isOpen(title: constants.staff) {
                            LazyVGrid(columns: gridItems, spacing: 16) {
                                Button(action: {
                                    print("버튼 동작")
                                }) {
                                    Text(constants.dummy6)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                            }
                        }
                    }
                    .padding(.vertical, 24)
                    
                    Divider()
                    
                    // MARK: - 외야수
                    
                    VStack {
                        
                        TigersSectionHeader(title: constants.outfield, model: sectionModel)
                        
                        if sectionModel.isOpen(title: constants.outfield) {
                            LazyVGrid(columns: gridItems, spacing: 16) {
                                Button(action: {
                                    print("버튼 동작")
                                    
                                }) {
                                    Text(constants.dummy1)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                    
                                }) {
                                    Text(constants.dummy2)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                    
                                }) {
                                    Text(constants.dummy3)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                    
                                }) {
                                    Text(constants.dummy4)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                }) {
                                    Text(constants.dummy5)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                            }
                        }
                    }
                    .padding(.top, 32)
                    .padding(.bottom, 24)
                    
                    Divider()
                    
                    // MARK: - 내야수
                    
                    VStack {
                        TigersSectionHeader(title: constants.infield, model: sectionModel)
                        
                        if sectionModel.isOpen(title: constants.infield) {
                            LazyVGrid(columns: gridItems, spacing: 16) {
                                Button(action: {
                                    print("버튼 동작")
                                }) {
                                    Text(constants.dummy7)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                    
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                }) {
                                    Text(constants.dummy8)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                    
                                }) {
                                    Text(constants.dummy9)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                }) {
                                    Text(constants.dummy10)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                            }
                        }
                    }
                    .padding(.vertical, 24)
                    
                    Divider()
                    
                    // MARK: - 투수
                    
                    VStack {
                        TigersSectionHeader(title: constants.peacher, model: sectionModel)
                        
                        if sectionModel.isOpen(title: constants.peacher) {
                            LazyVGrid(columns: gridItems, spacing: 16) {
                                Button(action: {
                                    print("버튼 동작")
                                }) {
                                    Text(constants.dummy11)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                }) {
                                    Text(constants.dummy12)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                                
                                Button(action: {
                                    print("버튼 동작")
                                    
                                }) {
                                    Text(constants.dummy13)
                                        .font(Font.system(size: 16, weight: .regular))
                                        .foregroundStyle(.gray)
                                }
                            }
                        }
                    }
                    .padding(.top, 24)
                    .padding(.bottom, 32)
                    
                } // * group {}
                .padding(.horizontal, 20)
                
            } // * VStack
            
            .background(Color.white)
            .cornerRadius(16, corners: .allCorners)
            
            // 섹션 폴딩 애니메이션
            .animation(.linear(duration: 0.1), value: self.sectionModel.sections)
            
        } // *scroll {}
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        
    }
    
    
}

extension TigersMenuList {
    
    struct Constants {
        
        // 주문
        let outfield = "외야수"
        let dummy1 = "나성범"
        let dummy2 = "최원준"
        let dummy3 = "테스형"
        let dummy4 = "김호령"
        let dummy5 = "이창진"
        
        // 감독
        let staff = "감독"
        let dummy6 = "이범호"
        
        // 내야수
        let infield = "내야수"
        let dummy7 = "김선빈"
        let dummy8 = "이우성"
        let dummy9 = "박찬호"
        let dummy10 = "김도영"
        
        // 투수
        let peacher = "투수"
        let dummy11 = "양현종"
        let dummy12 = "네일"
        let dummy13 = "정해영"
    }
    
    
}

#Preview {
    TigersMenuList()
}

 

- Vstack으로 만들어서 섹션과 lazyVGrid로 list처럼 보이게 했다.

- 위처럼 구현하면 섹션별로 접었다펼쳤다를 커스텀으로 구현할 수 있다.

 

 

 

 

 

References

- https://www.keaura.com/blog/swiftui-collapsable-lists

 

Collapsable Section Headers in SwiftUI — KeAura.com

Have a SwiftUI list with a lot of items? Break it into sections and make each section collapsable. Read on….

www.keaura.com

- https://imjhk03.github.io/posts/swiftui-tappable-area-using-contentshape/

 

SwiftUI에서 contentShape()을 이용해서 뷰를 탭하게 하는 방법

일반 Text나 Image을 사용하면 탭 제스처를 추가해서 탭 했을 때의 동작을 정의할 수 있다. 하지만 VStack이나 HStack 같은 container view에 제스처를 추가하면 생각처럼 잘 안될 때가 있다. 예를 들어, HSta

imjhk03.github.io