ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI의 Custom Grid로 카테고리 뷰 구현하기
    SwiftUI 2024. 12. 23. 18:55

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 SwiftUI의 Custom Grid를 적절히 구현하여 우리가 많이 접할 수 있는 카테고리 뷰 시스템을 한번 만들어보려고 합니다 🙋🏻

     

    오늘의 포스팅은 새로운 지식의 습득보다는 실제 많이 쓰일 수 있는 뷰 개발에 초점을 맞춰 코드가 많습니다 😃

     


    SwiftUI의 Custom Grid로 카테고리 뷰 구현하기

    그럼 가장 먼저 오늘 어떤걸 구현해볼지 결과물부터 보고 가볼까요?

     

     

    자 요런걸 만드려고 합니다!

     

    일반적인 메인 카테고리와 그 메인 카테고리를 선택하면 나오는 서브 카테고리의 형태 구조입니다.

    물론, 카테고리 형태는 정말 다양하겠지만 이렇게 서브 카테고리에 대해서 그리드로 한번 작업해보려고 합니다.

    여기서 저는 예시로 우리가 많이 접할 수 있는 회원 정보를 입력하는 단계에서 지역들을 선택할 수 있도록 구현해봤어요.

    여기서 모든 지역을 다 넣기엔 개발보다 정보를 얻어오는 공수가 많이 들어 저는 일단 부분적으로만 지역 선정을 했으니 참고해주세요ㅎㅎ

    지역에 대한 차별이나 그런건 일절 없습니다!

     

    자 그럼 설계하고 구현해볼까요?

     

    먼저 지역에 대한 모델 타입을 구현해봐야 될것 같아요.

    현재 메인 시/도 지역과 그에 맞는 서브로 시/군/구를 구상했습니다.

     

    struct Region: Identifiable {
      let id = UUID()
      let name: String
      var districts: [District]
    }
    
    struct District: Identifiable {
      let id = UUID()
      let name: String
    }

     

    그렇기에 이렇게 Region과 District 모델 타입을 만듭니다.

    Region은 여러 District를 가질 수 있는 형태죠.

     

    그 후 이제 한 행의 카테고리 뷰를 만드려고 합니다.

    예를들어 현재는 한 행에 4개의 지역들을 보여주고 그 행에서 원하는 지역을 선택하면 바로 밑에 서브 지역들이 나타나는 형태입니다.

     

    그런 행의 뷰가 이제 여러 열로 쌓이는 그림이죠!

    그렇기에, 가장 먼저 한 행에 대해 컴포넌트화 시켜서 뷰를 만들어봐야 합니다.

     

    struct RegionRowView: View {
      let regions: [Region]
      let expandedRegion: String?
      let onRegionTap: (String) -> Void
      let selectedSubRegion: [String: [String]]
      let onSubRegionTap: (District, Region) -> Void
      let gridColumns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 4)
      
      var body: some View {
        VStack(spacing: 0) {
          LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 8) {
            ForEach(regions) { region in
              regionButton(for: region)
            }
            
            ForEach(0..<(4 - regions.count), id: \.self) { _ in
              Color.clear
                .frame(height: 44)
            }
          }
          .padding(.horizontal)
          
          if let selectedRegion = regions.first(where: { $0.name == expandedRegion }) {
            districtsGrid(for: selectedRegion)
          }
        }
      }
      
      @ViewBuilder
      private func regionButton(for region: Region) -> some View {
        Button(action: {
          onRegionTap(region.name)
        }) {
          Text(region.name)
            .font(.body)
            .frame(maxWidth: .infinity)
            .frame(height: 44)
            .background(
              RoundedRectangle(cornerRadius: 8)
                .fill(Color.gray.opacity(0.1))
            )
            .opacity(expandedRegion == region.name ? 0 : 1)
            .overlay(
              Text(region.name)
                .font(.body)
                .bold()
                .frame(maxWidth: .infinity)
                .frame(height: 44)
                .background(
                  RoundedRectangle(cornerRadius: 8)
                    .fill(Color.green.opacity(0.1))
                )
                .opacity(expandedRegion == region.name ? 1 : 0)
            )
        }
        .buttonStyle(PlainButtonStyle())
      }
      
      @ViewBuilder
      private func districtsGrid(for region: Region) -> some View {
        LazyVGrid(columns: gridColumns, spacing: 8) {
          ForEach(region.districts) { district in
            Button(action: {
              onSubRegionTap(district, region)
            }) {
              Text(district.name)
                .frame(maxWidth: .infinity)
                .frame(height: 44)
                .background(
                  RoundedRectangle(cornerRadius: 8)
                    .fill(
                      selectedSubRegion[region.name]?.contains(district.name) ?? false
                      ? Color.blue.opacity(0.1)
                      : Color.gray.opacity(0.3)
                    )
                )
            }
            .buttonStyle(PlainButtonStyle())
          }
        }
        .padding(.horizontal)
        .padding(.top, 16)
        .transition(.opacity)
      }
    }

     

    조금 코드가 길지만 어려울건 없어요!

    이게 오늘의 사실상 핵심 코드이기때문에 좀 길어보여요ㅎㅎ

    천천히 짚고 넘어가볼께요.

     

    먼저 우리는 메인 지역과 서브 지역에 대한 정보와 그에 따른 액션들을 잘 받아오면 됩니다.

    그렇기에 프로퍼티로 메인지역 / 서브지역, 선택되어 현재 서브를 보여주는 메인 지역, 선택된 서브 지역들 그리고 메인과 서브 지역을 탭했을때의 액션 클로저를 선언하고 사용합니다.

     

    그 뒤 이제 메인과 서브 지역에 대해 어떤 그리드 형태로 표현할지를 결정해주는데요.

    여기선 플렉시블하게 한 행에 4개씩 나올 수 있도록하며 간격은 8로 주었습니다.

    물론, 그리드 아이템의 크기도 고정으로 할 수 있긴 하니 이건 기획 의도에 따라 맞추면 될것 같습니다.

     

    그리고 본격적으로 뷰를 구현해보는데요.

     

    먼저 VStack으로 감싸고 안에서 전체 메인 지역들을 ForEach로 돌려줍니다.

    그리고 내부에선 이제 뒤에 구현될 regionButton 뷰 컴포넌트로 보여주면 되는거죠.

    그 후, 이제 현재 선택된 메인 지역이 있을 경우에 서브 지역들을 보여주도록 districtGrid 뷰를 구현해놓습니다.

     

    이제 두 regionButton과 districtGrid 뷰를 구현해볼까요?

     

    뷰빌더 프로퍼티 래퍼를 이용하여 해당 메서드들은 모두 뷰를 반환하도록 했어요.

    먼저 regionButton을 보면 메인 지역 버튼 뷰입니다.

    버튼의 액션은 클로저 형태이기에 상위에서 감지하고 처리할 수 있도록 해당 메인 지역의 이름을 넘겨줍니다.

    그리고 버튼은 적절히 폰트나 배경을 조절하며 만들면 됩니다.

     

    여기서 하나 짚어볼 점!

     

    저는 해당 메인 지역을 선택하면 볼드체로 나타내고 싶었어요.

    그런데 bold체에 분기를 걸어서 사용하면 withAnimation으로 애니메이션 동작을 할때 텍스트부터 적용되고 배경이 뒤따라 오기에 정상적이지 않습니다.

    이건 텍스트 애니메이션이 자연스럽지 않는 현상이기에 overlay를 이용해 선택되었을때 텍스트를 구성하여 자연스러운 애니메이션이 되게 구현했습니다.

     

    그리고 두번째로 districtGrid 뷰를 볼께요.

    여기선 LazyVGrid를 마찬가지로 이용하고 위에서 만든 gridColums를 넣어줍니다.

    여기서도 서브 지역이 선택되면 상위에서 받아온 클로저 액션을 구성하죠.

    마찬가지로 뷰도 버튼 형태로 어렵지 않게 적절히 폰트와 크기 그리고 배경을 넣어주면 됩니다.

     

    어렵지 않죠?

     

    이제 이렇게 만들어진 행에 대한 컴포넌트를 상위 뷰에서 사용해볼까요?

     

    struct RegionsView: View {
      @State private var expandedRegion: String?
      @State private var selectedDistricts: [String: [String]] = [:]
      
      var firstRowRegions = [
        Region(name: "서울", districts: [
          District(name: "전체"), District(name: "종로"),
          ...
        ]),
        Region(name: "인천", districts: [
          District(name: "전체"),
          ...
        ]),
        ...
      ]
      var secondRowRegions = [
        ...
      ]
      var thirdRowRegions = [
        ...
      ]
      
      var body: some View {
        ScrollView {
          VStack(spacing: 16) {
            RegionRowView(
              regions: firstRowRegions,
              expandedRegion: expandedRegion,
              onRegionTap: handleRegionTap,
              selectedSubRegion: selectedDistricts,
              onSubRegionTap: handleDistrictTap
            )
            
            RegionRowView(
              regions: secondRowRegions,
              expandedRegion: expandedRegion,
              onRegionTap: handleRegionTap,
              selectedSubRegion: selectedDistricts,
              onSubRegionTap: handleDistrictTap
            )
            
            RegionRowView(
              regions: thirdRowRegions,
              expandedRegion: expandedRegion,
              onRegionTap: handleRegionTap,
              selectedSubRegion: selectedDistricts,
              onSubRegionTap: handleDistrictTap
            )
          }
        }
      }
      
      private func handleRegionTap(_ regionName: String) {
        withAnimation {
          if expandedRegion == regionName {
            expandedRegion = nil
          } else {
            expandedRegion = regionName
          }
        }
      }
      
      private func handleDistrictTap(_ district: District, region: Region) {
        withAnimation {
          if district.name == "전체" {
            if selectedDistricts[region.name]?.contains("전체") ?? false {
              selectedDistricts[region.name]?.removeAll()
            } else {
              selectedDistricts[region.name] = ["전체"]
            }
          } else {
            if let index = selectedDistricts[region.name]?.firstIndex(of: district.name) {
              selectedDistricts[region.name]?.remove(at: index)
            } else {
              if selectedDistricts[region.name] == nil {
                selectedDistricts[region.name] = []
              }
              
              if selectedDistricts[region.name]?.contains("전체") ?? false {
                selectedDistricts[region.name]?.removeAll()
              }
              
              selectedDistricts[region.name]?.append(district.name)
            }
          }
        }
      }
    }

     

    가장 먼저 두 상태 프로퍼티가 있어야해요.

    선택된 메인 지역의 이름 그리고 이제 서브 지역은 여러개를 선택할 수 있도록 딕셔너리 형태로 메인 지역의 이름이 key로 그리고 서브 지역의 이름이 value로 선언되게 됩니다.

     

    그리고 저는 현재 모델링을 하드코딩하여 해줬기에 적절히 3개의 행을 만들도록 데이터를 넣어줬습니다.

     

    그 후 뷰를 구축하는데요.

    스크롤 뷰와 VStack으로 감싸고 아까 우리가 만든 컴포넌트인 RegionRowView를 구성해주면 끝이에요!

     

    그리고 우리 하위 컴포넌트에 액션 클로저 두개를 연결 시켜야 하잖아요?

     

    그걸 봐보겠습니다.

     

    먼저, handleRegionTap 메서드를 볼께요.

    메인 지역이 선택되었을때 현재 선택된 메인 지역이 있으면 제거하고 없으면 해당 탭 된 지역을 선택된 메인 지역으로 해줍니다.

    즉, 메인 지역은 중복이 안되고 한개만 선택할 수 있게하기 위함입니다.

     

    그리고 마지막으로 handleDistrictTap 메서드를 볼께요.

    먼저 전체로 선택했다면 이미 선택된 메인 지역내에 전체가 선택되어 있다면 현재 지역의 전체 선택을 해제합니다.

    만약 기존 선택된 전체가 없다면 전체를 선택하게 하는 동시에 현재 메인 지역에서 선택된 다른 서브 지역들을 제거해줘요.

    왜냐면, 전체가 다 포함하는건데 서울 전체와 서울 강동구를 같이 선택된 상태로 두는게 더 이상하니까요!

     

    그리고 선택한게 전체가 아닌 경우 이미 해당 선택한 서브 지역이 기존 선택된 지역이면 제거해줍니다.

    만약 기존 선택된 서브 지역이 아닐 경우 새로운 구역으로 키 값을 이용해 추가하고 현재 지역에서 전체 선택이 되어 있었다면 전체 선택도 해제해줍니다.

     

    그럼 구현은 끝이고 돌려보면 제일 처음 보여드린 결과물이 나올겁니다 ☺️

     


    마무리

    어떠셨나요?

    물론 discloureGroup 같은것으로도 사용해볼 수 있지만, 제약이 생각보다 까다롭습니다.

    그래서 이렇게 직접 컴포넌트들을 아토믹하게 만들어 사용하는게 SwiftUI에서 장점이라고 생각해요!

    해당 코드들은 아래 레포에서 다시 확인해볼 수 있습니다.

     

    playground/CategoryGrid at main · GREENOVER/playground

    학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

    github.com

Designed by Tistory.