지난 포스팅에서는 왼쪽 정렬이 되어 있는 flex-wrap을 만들어 보았습니다. 이번 시간에는 약간 응용을 해서 가운데 정렬이 되어 있는 flex-wrap을 만들어보도록 하겠습니다. 아래 그림과 같은 뷰입니다.
initializer는 지난 번에 만든 것과 동일합니다. 수평, 수직 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의 크기는 동일합니다. 지난번 포스팅과 완전히 동일한 코드를 사용했습니다.
자세한 내용은 코드의 주석을 참고해주세요.
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)
}
왼쪽 정렬 flex box를 만들기 위해서는 subview들을 부모뷰의 좌상단 부터 하나하나 배치했습니다. 그리고 배치하다가 줄 바꿈을 해야하면 y값에 height와 verticalSpacing을 더해서 줄바꿈을 해주면 됩니다.
가운데 정렬도 줄 바꿈을 할 때의 원리는 동일합니다. 하지만 row을 배치하는 것에서는 결정적인 차이가 있습니다. 왼쪽 정렬의 경우 한 row의 subview를 부모뷰의 좌측에서 부터 배열하면 됩니다. 따라서 subview를 하나 배치하면 다음 subview의 위치를 바로 구할 수 있습니다. (+= view 크기 + verticalSpacing)
하지만 가운데 정렬의 경우 한 row에 배치할 모든 subview가 정해지지 않으면 row의 첫번째 subview의 위치를 특정할 수 없습니다.
따라서 subview를 순회할 때마다 바로바로 배치하는 것이 아니라, 일단 별도의 배열을 선언해서 한 row에 배치할 subview들을 모아둡니다. 그리고 나서 배열에 있는 subview들이 한 row를 이루는 길이에 도달하면 그 때 배치하는 것입니다.
임시 배열 안에 있는 subview을 배치하는 시작점은 어디 일까요? 아래 그림을 참고해서 설명하겠습니다. 아래 그림에서 rowWidth는 row의 길이입니다. (subview들의 길이 + spacing) 그리고 startingX는 부모뷰의 가장 왼쪽 x좌표입니다.
가운데 정렬을 위해서는 부모 뷰에 배치된 row 양쪽의 여백이 동일해야 합니다. 따라서 부모뷰의 너비 (아래 그림에서 bounds.width)에서 rowWidth를 빼줍니다. 그 값을 2등분합니다. 이 값을 startX에 더해주면 row의 첫번째 subview의 위치를 구할 수 있습니다. 이 후에는 왼쪽 정렬과 동일하게 다음 subview의 위치를 구할 수 있습니다.
자세한 내용은 아래 코드의 주석을 참고해주세요.
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let height = subviews.map { $0.dimensions(in: proposal).height }.max() ?? 0
guard !subviews.isEmpty else { return }
// 일단 y좌표 최상단에서 시작
var y = bounds.minY
// 한 row에 들어갈 subview들을 임시 저장하는 배열
var row = [LayoutSubviews.Element]()
var rowWidth: CGFloat = 0 // 현재 row의 길이
// subview 순회
for subview in subviews {
// 아직 한 row의 길이가 다 안차면 row에 넣고 continue
if rowWidth + subview.dimensions(in: proposal).width < bounds.width {
row.append(subview)
rowWidth += subview.dimensions(in: proposal).width + horizontalSpacing
continue
}
// 한 row가 다 차면 일단 지금 있는 줄 place 시작한다.
rowWidth -= horizontalSpacing //👉 마지막 horizontalSpacing 하나는 빼준다
// topLeading 기준 x축 출발점
var x = bounds.minX + (bounds.width - rowWidth) / 2
// row에 저장되어 있는 것 배열 시작
for sv in row {
sv.place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(
width: sv.dimensions(in: proposal).width,
height: sv.dimensions(in: proposal).height
)
)
x += sv.dimensions(in: proposal).width + horizontalSpacing
}
// 임시 배열 및 길이 초기화
row = []
rowWidth = 0
// 줄바꿈
y += height + verticalSpacing
// 새로운 row 시작
row.append(subview)
rowWidth += subview.dimensions(in: proposal).width + horizontalSpacing
}
// 반복문 내에서 place 되지 않은 row 배열
rowWidth -= horizontalSpacing
var x = bounds.minX + (bounds.width - rowWidth) / 2
for sv in row {
sv.place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(
width: sv.dimensions(in: proposal).width,
height: sv.dimensions(in: proposal).height
)
)
x += sv.dimensions(in: proposal).width + horizontalSpacing
}
}