Trang chủ

Tự viết một Layout

Hai phương thức, và cuộc thương lượng trở thành của bạn

Protocol Layout chính là cái protocol mà HStack tuân thủ. Cài đặt nó nghĩa là viết ra hai nửa của cuộc thương lượng ở các chương trước, lần này từ phía view cha.

protocol Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
                       subviews: Subviews, cache: inout Cache)
}

sizeThatFits trả lời lời đề nghị của view cha bạn. placeSubviews đặt các con xuống bên trong khoảng không gian bạn được cho. Đó là toàn bộ protocol.

Một layout kiểu dòng chảy

Trường hợp kinh điển mà SwiftUI không có sẵn: các con xếp từ trái sang phải, xuống dòng mới khi hết bề rộng. Nói cách khác là các chip thẻ.

struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews,
                      cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var rowWidth: CGFloat = 0
        var rowHeight: CGFloat = 0
        var totalHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if rowWidth > 0, rowWidth + spacing + size.width > maxWidth {
                totalHeight += rowHeight + spacing
                rowWidth = size.width
                rowHeight = size.height
            } else {
                rowWidth += rowWidth > 0 ? spacing + size.width : size.width
                rowHeight = max(rowHeight, size.height)
            }
        }

        return CGSize(width: maxWidth, height: totalHeight + rowHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
                       subviews: Subviews, cache: inout ()) {
        var x = bounds.minX
        var y = bounds.minY
        var rowHeight: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > bounds.minX, x + size.width > bounds.maxX {
                x = bounds.minX
                y += rowHeight + spacing
                rowHeight = 0
            }
            subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
        }
    }
}

Dùng như mọi container khác:

FlowLayout(spacing: 8) {
    ForEach(tags, id: \.self) { Chip(text: $0) }
}

Hai chi tiết trong đó mới là phần đáng kể. subview.sizeThatFits(.unspecified) là bạn hỏi một đứa con về kích thước lý tưởng của nó — đúng câu hỏi mà một stack vẫn hỏi. Còn place(at:proposal:) là bạn đưa ra lời đề nghị sau khi đã quyết định nó nằm ở đâu; đứa con vẫn tự chọn kích thước của mình, và truyền ProposedViewSize(size) là bạn mời đúng bằng thứ nó vừa xin.

Cảnh báo

Mặc định place(at:) đặt góc trên bên trái của đứa con. Nếu bạn đang đặt mọi thứ quanh một điểm tâm, hãy truyền anchor: .center thay vì tự trừ đi một nửa kích thước — cái anchor được áp dụng sau khi đứa con đã chọn, nên phép tính của bạn sẽ dùng nhầm con số.

Cái cache tồn tại là có lý do

Cả sizeThatFits và placeSubviews đều chạy, cả hai đều gọi sizeThatFits trên mọi đứa con, và toàn bộ chuyện này có thể chạy vài lần trong một khung hình khi một view tổ tiên dò bằng những lời đề nghị khác nhau. Cái layout ở trên đo mỗi đứa con ít nhất hai lần mỗi lượt.

Với dăm ba cái chip thì miễn phí. Với một trăm cái thì hãy dùng cache:

struct FlowLayout: Layout {
    struct Cache {
        var sizes: [CGSize]
    }

    func makeCache(subviews: Subviews) -> Cache {
        Cache(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
    }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
    }
}

makeCache chạy một lần, updateCache chạy khi các view con thay đổi. Hãy đọc cache.sizes[index] thay vì đo lại.

Layout value, để cấu hình theo từng con

Một đứa con có thể chuyển thông tin lên cho layout của bạn, và đó chính là cách layoutPriority hoạt động:

struct Weight: LayoutValueKey {
    static let defaultValue: CGFloat = 1
}

extension View {
    func weight(_ value: CGFloat) -> some View {
        layoutValue(key: Weight.self, value: value)
    }
}

Bên trong layout, đọc nó bằng subview[Weight.self]. Đây là cơ chế cho kiểu “để riêng đứa con này hành xử khác đi” mà không phải thêm một tham số vào container mà mọi đứa con khác đều lờ đi.

Khi nào đừng viết

Layout viết ra thì đã tay, và nó thường không phải câu trả lời.

Nếu một VStack chứa các HStack làm được thì cứ dùng thế. Nếu bạn cần một lưới, Grid đã lo được cột rộng không đều và ô trải dài. Nếu bạn cần các cột bằng nhau, LazyVGrid có sẵn và nó lười, thứ mà một Layout tự viết thì không — mọi đứa con của một Layout đều bị tạo ra và đo đạc, nên nó là công cụ sai cho một nghìn phần tử.

Hãy viết một cái khi quy tắc sắp xếp thật sự là của riêng bạn — dòng chảy, tỏa tròn, xếp gạch, một trục biểu đồ — và các container có sẵn sẽ phải cần đến GeometryReader cùng một vòng preference để giả lập. Đó là trường hợp mà Layout thay một khung hình trễ và một đống trạng thái bằng hai phương thức lương thiện.