지난 포스팅에서 SwiftUI의 Layout 프로토콜에 대해서 기초적인 부분을 알아봤는데요. 이번에는
css에 보면 아래와 같은 layout이 있습니다. 5개의 subview가 주어지는데요. 계속 HStack처럼 우측으로 쌓다가 길이가 부족하면 아래로 줄을 바꾸어서 쌓는 뷰입니다. Layout을 활용해서 구현해보겠습니다.initializer 만들기
새로운 Layout 객체는 struct로 만듭니다. 그리고 수평, 수직 spacing을 인자로 받도록 하겠습니다. 기존의 HStack, VStack 처럼 spacing의 기본값이 없습니다.
import SwiftUI
struct LeadingFlexBox: Layout {
private var horizontalSpacing: CGFloat
private var verticalSpacing: CGFloat
public init(horizontalSpacing: CGFloat, verticalSpacing: CGFloat) {
self.horizontalSpacing = horizontalSpacing
self.verticalSpacing = verticalSpacing
}
}
저번 포스팅에 설명했듯이 Layout이 차지할 전체 사이즈를 결정하는 함수입니다.
현재 원하는 View의 크기를 구하기 위해서는 subview들을 각각의 row로 구분해야 합니다. subview들을 부모뷰의 width를 넘지 않는 선에서 하나하나 row로 묶습니다. 각각의 row의 높이는 subview이 높이 중에서 최댓값으로 합니다.
그리고 전체 View의 너비는 row의 너비 중에 최댓값으로 하면됩니다. 또한 전체 View의 높이는 (row의 높이) (row의 갯수) + verticalSpacing (row의 갯수 - 1)입니다.
위에 설명한 방법대로 전체 View의 너비를 구하기 위해서는 subview의 너비와 높이를 알아야 합니다. 이 때 사용하는 메소드가 sizeThatFits입니다. 인자로 부모뷰의 ProposedViewSize를 전달하면 됩니다.
자세한 내용은 코드의 주석을 참고해주세요.
public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
// subview가 없으면 .zero를 리턴
guard !subviews.isEmpty else { return .zero }
// subview들의 높이 중에서 최대값을 구한다.
let height = subviews.map { $0.sizeThatFits(proposal).height }.max() ?? 0
// 너비 중에서 최대값을 구하는 과정
var rowWidths = [CGFloat]() // 각 row의 너비들
var currentRowWidth: CGFloat = 0 // 현재 너비
// 모든 subview를 순회하면서 너비를 구한다.
subviews.forEach { subview in
// 현재 너비에 subview의 너비를 더했을 때 부모 view 보다 큰 경우 -> 줄 바꿈
if currentRowWidth + horizontalSpacing + subview.sizeThatFits(proposal).width >= proposal.width ?? 0 {
rowWidths.append(currentRowWidth) // 현재까지의 너비 기록하고
currentRowWidth = subview.sizeThatFits(proposal).width // 현재 subview부터 다시 너비 측정
// 줄바꾸지 않고 너비 누적
} else {
currentRowWidth += horizontalSpacing + subview.sizeThatFits(proposal).width
}
}
// 남은 currentRowWidth 배열에 넣기
rowWidths.append(currentRowWidth)
let rowCount = CGFloat(rowWidths.count)
// 너비: row의 너비 중에 가장 큰 값
// 높이: subview의 높이 * row의 갯수 + 수직 간격 * (row의 갯수 - 1)
return CGSize(width: max(rowWidths.max() ?? 0, proposal.width ?? 0), height: rowCount * height + (rowCount - 1) * verticalSpacing)
}
이제 실제로 subview들을 배치할 차례입니다. 원하는 view를 만들기 위해서는 subview들을 부모뷰의 좌상단 부터 하나하나 배치합니다. 배치하다가 줄 바꿈을 해야하면 y값에 height와 verticalSpacing을 더해서 줄바꿈을 해주면 됩니다.
sizeThatFits가 크기만 알 수 있는 method라면 dimensions는 크기 이외의 정보를 얻을 수 있습니다. 아래 코드에서는 사실 사이즈만 구하기 때문에 sizeThatFits를 사용해도 무방합니다.
실제로 view에 subview를 위치시키는 함수입니다. 좌표와 anchor 그리고 proposal을 인자로 받습니다. proposal의 경우 아래 코드처럼 subview의 크기를 넣어주면 됩니다.
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let height = subviews.map { $0.dimensions(in: proposal).height }.max() ?? 0 // subview의 높이 중 최대값
guard !subviews.isEmpty else { return } // subview가 없으면 리턴
// 첫줄 시작점
var x = bounds.minX // 부모뷰의 가장 왼쪽
var y = bounds.minY // 첫 row의 위치
subviews.forEach { subview in
// 줄바꿈을 하는 경우: (현재 x좌표 + 현재 subview의 너비) > 부모뷰의 오른쪽 끝 x좌표
if x + subview.dimensions(in: proposal).width > bounds.maxX {
x = bounds.minX // 다시 부모뷰의 가장 왼쪽
y += height + verticalSpacing // 다음 row의 위치
}
// subview 위치
subview.place(
at: CGPoint(x: x, y: y),
anchor: .topLeading, // 좌표의 기준 (좌상단)
proposal: ProposedViewSize(
width: subview.dimensions(in: proposal).width,
height: subview.dimensions(in: proposal).height
)
)
// 다음 subview의 x좌표 = 현재 subview의 x좌표 + 현재 subview의 너비 + 수평 간격
x += subview.dimensions(in: proposal).width + horizontalSpacing
}
}