SwiftUI Layout 실전 - 가운데 정렬 Flex Wrap 만들기

SteadySlower·2023년 6월 12일
0

SwiftUI

목록 보기
47/64
post-custom-banner

실전: 가운데 정렬 flex-wrap 만들어 보기

지난 포스팅에서는 왼쪽 정렬이 되어 있는 flex-wrap을 만들어 보았습니다. 이번 시간에는 약간 응용을 해서 가운데 정렬이 되어 있는 flex-wrap을 만들어보도록 하겠습니다. 아래 그림과 같은 뷰입니다.

initializer 만들기

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
    }
}

sizeThatFits

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)
}

placeSubviews

왼쪽 정렬과 차이

왼쪽 정렬 flex box를 만들기 위해서는 subview들을 부모뷰의 좌상단 부터 하나하나 배치했습니다. 그리고 배치하다가 줄 바꿈을 해야하면 y값에 height와 verticalSpacing을 더해서 줄바꿈을 해주면 됩니다.

가운데 정렬도 줄 바꿈을 할 때의 원리는 동일합니다. 하지만 row을 배치하는 것에서는 결정적인 차이가 있습니다. 왼쪽 정렬의 경우 한 row의 subview를 부모뷰의 좌측에서 부터 배열하면 됩니다. 따라서 subview를 하나 배치하면 다음 subview의 위치를 바로 구할 수 있습니다. (+= view 크기 + verticalSpacing)

하지만 가운데 정렬의 경우 한 row에 배치할 모든 subview가 정해지지 않으면 row의 첫번째 subview의 위치를 특정할 수 없습니다.

한줄에 배치할 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
    }
}
profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.
post-custom-banner

0개의 댓글