SwiftUI

SwiftUI로 동적 뷰 레이아웃 구성하기

GREEN.1229 2024. 1. 18. 18:33

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

이번 포스팅에서는 SwiftUI로 동적 뷰 레이아웃을 구성하는 방법에 대해 소개해볼까 합니다 🙋🏻

 

본격적으로 알아보기에 앞서 어떤걸 해보고 싶은지 한번 스포하고 갈께요!

 

 

요런 뷰를 구성해보려 하거든요! 😃

 

언뜻보기에는 간단해보이나, 세부 조건들이 있어요.

 

1️⃣ 해당 칩스(뱃지)에 들어간 Text의 길이만큼 칩스가 다 노출되어야함

2️⃣ 화면을 벗어나거나 짤리면 안됨

3️⃣ Text의 길이가 길어서 배치할 수 없으면 다음 행으로 이동하여 배치

4️⃣ 스크롤 없이 화면에 모두 노출해야함

 

이런 Text 길이에 따라 유동적으로 칩스 뷰를 배치하는 그런 구현을 해보려고 합니다!

 

여러분들이라면 이 뷰를 어떻게 구현하실것 같으신가요? 🤔

 

음... 그냥 LazyVGrid 적절히 잘 쓰면 되지 않을까요?

 

LazyVGrid로 한번 그려볼까요?

 

우선 ChipsView의 구현 코드는 아래와 같아요.

 


ChipsView

import SwiftUI

public struct ChipsView: View {
  private var title: String
  
  public init(title: String) {
    self.title = title
  }
  
  public var body: some View {
    Text(title)
      .font(.caption)
      .foregroundColor(.black)
      .padding(.horizontal, 10)
      .padding(.vertical, 3)
      .background(.white)
      .cornerRadius(16)
      .overlay(
        RoundedRectangle(cornerRadius: 16)
          .stroke(.green, lineWidth: 1)
      )
      .frame(height: 24)
  }
}

 

title을 유동적으로 받아 해당 칩스(뱃지)를 구성하는 간단한 코드이니 큰 설명없이 스무스하게 넘어가봅니다ㅎㅎ

 

그 다음으로 사전 작업인 ChipsType을 만들어서 활용해볼거에요!


ChipsType

public struct ChipsType: Equatable {
  let title: String
  let priority: Int
  
  public init(
    title: String,
    priority: Int = 0
  ) {
    self.title = title
    self.priority = priority
  }
  
  public static func == (lhs: ChipsType, rhs: ChipsType) -> Bool {
    lhs.title == rhs.title
  }
}

 

칩스(뱃지)들 마다 title과 우선순위를 가질 priority 프로퍼티가 있습니다.

이제 반복을 돌릴때 활용하기위해 Equatable 해야겠네요!

 

그럼 이제 실제 LazyVGrid를 사용해서 뷰를 그려볼까요?


import SwiftUI

struct ContentView: View {
  var items: [ChipsType] = [
    .init(title: "첫번째"),
    .init(title: "두번째", priority: 1),
    .init(title: "세번째", priority: 2),
    .init(title: "네번째", priority: 3),
    .init(title: "서른마흔다섯번째", priority: 4),
    .init(title: "여섯번째", priority: 5),
    .init(title: "일곱번째", priority: 6),
    .init(title: "여덟번째", priority: 7),
    .init(title: "아홉번째", priority: 8),
  ]
  
  var body: some View {
    let columns = [GridItem(.flexible(minimum: 100, maximum: .infinity)), GridItem(.flexible(minimum: 10, maximum: .infinity))]

    LazyVGrid(columns: columns, spacing: 5) {
      ForEach(items, id: \.title) { item in
        ChipsView(title: item.title)
      }
    }
  }
}

 

LazyVGrid에서 GridItem을 만들때 크게 flexible, fixed, adaptive로 만들 수 있어요.

혹시 LazyVGrid에 대해 기본적인것이 궁금하시다면 아래 포스팅을 참고해주세요 😃

 

SwiftUI - LazyVGrid & LazyHGrid

안녕하세요. 그린입니다🍏 이번 포스팅에서는 SwiftUI에서의 LazyVGrid와 LazyHGrid에 대해 알아보겠습니다🙌 우선 UIKit 세상에서는 CollectionView라는것이 있습니다. SwiftUI에서도 쉽게 컬렉션뷰라고 생

green1229.tistory.com

 

flexible하게 GridItem을 만드는것은 최소/최대값을 정해두고 뷰 크기에 따라 사이즈를 조절하겠다는 의미입니다.

코드에선 최소를 100으로 최대를 무한으로 줬으니 100보다는 칩스가 크게 그려질거고 현재 colums에 GridItem이 두개이니 한 행에 칩스가 최대 2개만 표시되겠죠?

 

 

즉 이런 형태로요!

이건 우리가 원한모양이 아니잖아요?

Text에 따라 칩스 크기는 유동적이지만 한 행에 더 들어갈 수 있는데 최대 2개까지만 그려지니 다음 행으로 이동해서 그려주니까요.

 

flexible에서 인자에 최소, 최대값을 주지 않으면 해당 뷰의 크기를 아이템 수로 나눠서 계산해주기도 하는데요.

 

그럼 강제로 한 행에 4개씩 그려질 수 있도록 한번 넣어볼까요?

 

let columns = [
  GridItem(.flexible()), 
  GridItem(.flexible()), 
  GridItem(.flexible()), 
  GridItem(.flexible()),
]

 

 

이제는 한 행에 네개씩 배치되기는 하나 두번째 행의 첫번째 칩스를 보시면 Title이 짤립니다.

즉, 해당 뷰가 가질 수 있는 최대 크기가 아이템 갯수와 뷰의 크기에 따라 지정되어서 그런거죠.

 

그러니까 flexible로는 원하는 구현을 할 수 없어요 🥲

 

그럼 adaptive로 GridItem을 만들어볼까요?

 

adaptive는 뷰의 최소, 최대값을 정해놓고 이 사이의 사이즈로 가장 많이 배치할 수 있습니다.

 

let columns = [GridItem(.adaptive(minimum: 50, maximum: .infinity))]

 

최소를 50 그리고 최대를 무한으로 줘볼께요.

 

 

음 확실히 한 행에 최대 많이 그려주려고 하다보니 50으로 크기를 가져가버리면서 Title이 잘립니다ㅎ..

 

 

그럼 이 최소값을 좀 늘려볼까요? (한 100으로 늘려볼께요.)

 

 

또 문제가 발생합니다.

Title은 다 나오지만, 한 행에 의도치 않게 삼등분되어 크기를 잡아먹게 됩니다.

또한, 칩스 뷰 사이 간격들도 들쑥날쑥하구요!

 

즉, adaptive로도 원하는 구현을 자유롭게 할 순 없을것 같네요 🥲

 

 

그래서 저희가 하려고 했던 구현은 LazyVGrid와 같은 컴포넌트들의 도움은 받을 수 없습니다.

 

결국 오늘 본격적인 주제였던 동적으로 뷰 레이아웃을 구현하기 위해 직접 커스텀하게 만들어 볼 필요가 있어요.

 

여태까지 삽질이였고 이제 해결책 공개합니다 🚀


동적으로 뷰 레이아웃 구현해보기

우선 저는 칩스들을 담아 표현할 ChipsContainerView를 통해 직접 만들어보겠습니다.

 

코드부터 보면서 설명을 곁들여 보겠습니다~

 

ChipsContainerView

import SwiftUI

public struct ChipsContainerView: View {
  @State var totalHeight: CGFloat
  let verticalSpacing: CGFloat
  let horizontalSpacing: CGFloat
  let items: [ChipsType]
  var sortedItems: [ChipsType] {
    items.sorted(by: { $0.priority < $1.priority })
  }
  
  public init(
    totalHeight: CGFloat = .zero,
    verticalSpacing: CGFloat = 4,
    horizontalSpacing: CGFloat = 4,
    items: [ChipsType]
  ) {
    self.totalHeight = totalHeight
    self.verticalSpacing = verticalSpacing
    self.horizontalSpacing = horizontalSpacing
    self.items = items
  }
  
  public var body: some View {
    var width = CGFloat.zero
    var height = CGFloat.zero
    
    GeometryReader { geomety in
      ZStack(alignment: .topLeading) {
        ForEach(self.sortedItems, id: \.title) { item in
          ChipsView(title: item.title)
          .id(item.title)
          .alignmentGuide(.leading) { view in
            if abs(width - view.width) > geomety.size.width {
              width = 0
              height -= view.height
              height -= verticalSpacing
            }
            let result = width
            
            if item == sortedItems.last {
              width = 0
            } else {
              width -= view.width
              width -= horizontalSpacing
            }
            
            return result
          }
          .alignmentGuide(.top) { _ in
            let result = height
            
            if item == sortedItems.last {
              height = 0
            }
            return result
          }
        }
      }
      .background(
        GeometryReader { geometry in
          Color.clear
            .onAppear {
              self.totalHeight = geometry.size.height
            }
        }
      )
    }
    .frame(height: totalHeight)
  }
}

 

코드가 길어보이지만 크게 어려운 부분은 없어요.

 

프로퍼티들부터 볼까요?

 

1️⃣ totalHeight - 컨테이너 뷰의 총 높이를 저장하는 변수로, 뷰의 높이를 동적으로 조정하기 위해 사용됩니다.

2️⃣ verticalSpacing - 행 간 간격을 지정합니다.

3️⃣ horizontalSpacing - 칩스 즉, 뷰 간 간격을 지정합니다.

4️⃣ items - 해당 컨테이너 뷰 구조체에 아이템들을 넣어서 실제 그려줍니다.

5️⃣ sortedItems - 아이템을 우선순위를 기준으로 재정렬하여 뷰가 그려질 순서를 정해줘요.

 

요렇게 프로퍼티들이 구성되어 있고, 적절한 이니셜라이저를 통해 손쉽게 호출하여 사용할 수 있어요.

 

그럼 body를 보면서 핵심 구성 코드들을 살펴보겠습니다!

 

우선 동적으로 계산해야하니 width와 height가 계속 변화할거에요.

그렇기에 먼저 초기값으로 zero를 선언해줍니다.

 

GeometryReader

GeometryReder를 전체적으로 감싸 사용함으로 부모 뷰의 크기 정보를 얻어옵니다.

결국 칩스 뷰가 배치될 수 있는 영역의 넓이를 파악하고 이 정보들을 가지고 칩스인 뷰들을 적절히 동적으로 배치할 수 있어요.

 

ZStack & alignmentGuide

그 다음 ZStack을 통해 이제 뷰들을 겹쳐서 배치하는 형식입니다.

이게 포인트에요!

겹쳐서 배치하면서 해당 위치 자체를 alignmentGuide를 사용해 조정하게 됩니다.

alignmentGuide는 SwiftUI에서 뷰의 정렬을 동적으로 수정할 때 사용하기에 지금 상황에 아주 적절하죠!

 

이제 이 안에서 뷰를 배치하는 로직들이 구현되어 있어요.

width와 height 변수를 사용해 현재 배치 중인 뷰의 위치를 계산합니다.

만약 현재 뷰의 넓이와 이전에 계산된 width를 더한 값이 전체 부모 뷰의 넓이보다 크면, 다 표현할 수 없으니 다음 행으로 이동시켜 그려줍니다.

이 과정이 width를 0으로 재설정하고 height를 verticalSpacing을 포함해 감소 시킴으로써 배치할 수 있게됩니다.

 

마지막 뷰가 배치되면 다시 width와 height를 초기화 시켜줍니다.

 

여기서 alignmentGuide를 사용할때 leading과 top으로 나눴는데요.

2차원 그리드와 같이 정렬해줘야 하기 때문입니다.

 

즉, 가로와 세로 방향에서 각각의 위치를 조정해야 하죠.

그렇기에 leading 부분에선 가로 방향 조정을 위해 현재 뷰를 추가할 공간이 부족하면 width를 0으로 재설정하고 다음 행으로 넘어가는것이고, top 부분에선 뷰의 세로 방향 위치 조정을 합니다.

현재 처리중인 뷰의 세로 방향 위치를 height 변수 값으로 설정하고 지정해주는것입니다.

 

결국 정리하면 두 방향에 대해 alignmentGuide를 설정함으로 뷰들을 격자 형태로 정렬하여 동적으로 배치할 수가 있습니다.

 

background & frame

뷰가 화면에 나타날 때, GeometryReader의 또 다른 인스턴스가 백그라운드에 배치되어 뷰의 높이를 totalHeight에 업데이트 해줍니다!

그러면서 frame으로 뷰의 총 높이를 totalHeight를 이용하여 동적으로 조정해주는것이죠!

 

자 그럼 한번 어떻게 실제로 뷰가 그려지는지 볼까요?

 

import SwiftUI

struct ContentView: View {
  var items: [ChipsType] = [
    .init(title: "첫번째"),
    .init(title: "두번째", priority: 1),
    .init(title: "세번째", priority: 2),
    .init(title: "네번째", priority: 3),
    .init(title: "서른마흔다섯번째", priority: 4),
    .init(title: "여섯번째", priority: 5),
    .init(title: "일곱번째", priority: 6),
    .init(title: "여덟번째", priority: 7),
    .init(title: "아홉번째", priority: 8),
  ]
  
  var body: some View {
    VStack {
      ChipsContainerView(items: items)
    }
    .padding()
  }
}

 

사용은 정말 단순히 items들을 넣어주기만 하면 됩니다.

물론, 간격들을 디폴트값이 아닌 다른 값으로 넣어주려면 같이 초기화 인자를 넘기면 되죠!

 

 

짜잔~ 😀 처음 구상했던것처럼 잘 표현됩니다.

 

즉, 칩스 뷰의 width가 커서 다음에 배치할 수 없을때 다음 행으로 넘어가서 동적으로 그려주죠!

이러면 한 행에 몇개가 배치된다는것도 동적으로 가져갈 수 있습니다.

 


마무리

이렇게 SwiftUI의 컴포넌트 및 기능들만 활용하여 동적으로 뷰 레이아웃을 그려봤습니다!

해당 구현 코드들은 아래 제 깃헙 레포에 올려두었으니 필요하시면 참고하셔도 좋습니다 🎉

https://github.com/GREENOVER/playground/tree/main/chipsPractice

 

아..! 해당 삽질과 해결에 전적으로 많은 도움을 준 동료 개발자 토니에게도 감사의 인사를 드립니다 😉

 

Monsteel - Overview

삼산텍 CTO가 꿈입니다. Monsteel has 6 repositories available. Follow their code on GitHub.

github.com