Trang chủ

PreferenceKey, giải thích bằng cách tự viết một cái

Environment gửi giá trị đi xuống cây view. PreferenceKey gửi chúng đi lên — từ các con tới một tổ tiên — và nó là cơ chế đứng sau navigationTitle, toolbar cùng mọi API mà ở đó một view con nằm sâu lại cấu hình một thứ gần gốc.

Nó trông lạ lùng cho tới khi bạn tự dựng một cái.

Protocol

Hai yêu cầu:

protocol PreferenceKey {
    associatedtype Value
    static var defaultValue: Value { get }
    static func reduce(value: inout Value, nextValue: () -> Value)
}

defaultValue là thứ một tổ tiên thấy khi không có con cháu nào đặt gì. reduce gộp các giá trị từ nhiều con thành một — vì nhiều con có thể mỗi đứa đặt một preference, và view cha nhận về một câu trả lời duy nhất.

Dựng một cái: đo đứa con rộng nhất

Trường hợp kinh điển. Bạn muốn một cột nhãn đều lấy kích thước theo cái rộng nhất, và không modifier có sẵn nào làm được.

struct MaxWidthKey: PreferenceKey {
    static let defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

reduce ở đây là max — trong mọi bề rộng mà các con báo về, view cha muốn cái lớn nhất.

Các con báo bề rộng của mình:

extension View {
    func reportWidth() -> some View {
        background(
            GeometryReader { proxy in
                Color.clear.preference(key: MaxWidthKey.self, value: proxy.size.width)
            }
        )
    }
}

Cái GeometryReader nằm bên trong một background, điều đó quan trọng — một background lấy kích thước của view mà nó nằm phía sau, nên cái reader đo đúng cái nhãn thay vì nở ra lấp đầy view cha.

Tổ tiên lắng nghe rồi áp dụng:

struct LabelColumn: View {
    let rows: [(String, String)]
    @State private var labelWidth: CGFloat = 0

    var body: some View {
        VStack(alignment: .leading) {
            ForEach(rows, id: \.0) { label, value in
                HStack {
                    Text(label)
                        .reportWidth()
                        .frame(width: labelWidth, alignment: .leading)
                    Text(value)
                }
            }
        }
        .onPreferenceChange(MaxWidthKey.self) { labelWidth = $0 }
    }
}

Điểm gài, và vì sao tôi thường không dùng cách này

Đoạn code đó chạy được, và nó có hai vấn đề thật.

Nó chậm một khung hình. Trình tự là: bố cục với labelWidth = 0, các con báo bề rộng của chúng, onPreferenceChange nổ, trạng thái đổi, bố cục lại. Khung hình đầu tiên là sai. Thường thì vô hình; thỉnh thoảng là một cú nhấp nháy thấy được lúc xuất hiện.

Nó có thể lặp vô hạn. Nếu giá trị bạn đặt từ preference lại làm đổi kích thước vốn sinh ra preference đó, SwiftUI sẽ dao động và cuối cùng ghi log “Bound preference tried to update multiple times per frame”. Ví dụ trên né được vì labelWidth chỉ có tăng, nhưng viết ra một cái không như vậy thì rất dễ.

Cảnh báo

Với đúng bài toán này — căn thẳng hàng các thứ qua nhiều dòng — các đường dẫn căn chỉnh tùy biến là công cụ tốt hơn. Chúng chạy ngay trong lượt bố cục, nên không có khung hình dư, không có trạng thái, và không có khả năng lặp vô hạn. PreferenceKey là cơ chế tổng quát; đường dẫn căn chỉnh là thứ chuyên dụng.

Đây là cùng kết quả với một đường dẫn căn chỉnh:

extension HorizontalAlignment {
    private enum ValueColumn: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat { context[.leading] }
    }
    static let valueColumn = HorizontalAlignment(ValueColumn.self)
}

VStack(alignment: .valueColumn) {
    ForEach(rows, id: \.0) { label, value in
        HStack {
            Text(label)
            Text(value).alignmentGuide(.valueColumn) { $0[.leading] }
        }
    }
}

Không trạng thái, không GeometryReader, không độ trễ.

Chỗ PreferenceKey thật sự đúng

Nó vẫn là công cụ đúng cho dữ liệu không phải hình học chảy ngược lên — thứ nó được thiết kế để làm, và là chỗ không gì khác chạy được.

struct ToolbarItemsKey: PreferenceKey {
    static let defaultValue: [ToolbarEntry] = []

    static func reduce(value: inout [ToolbarEntry], nextValue: () -> [ToolbarEntry]) {
        value.append(contentsOf: nextValue())
    }
}

Giờ bất kỳ con cháu nào cũng đóng góp được một mục thanh công cụ, và một container gần gốc gom tất cả lại rồi vẽ ra. Đó chính xác là cách toolbar và navigationTitle của SwiftUI hoạt động — một view nằm sâu khai báo một thứ và một tổ tiên chẳng biết gì về nó sẽ hành động theo.

Các cách dùng chính đáng khác: một view con khai báo “tôi đang nạp dữ liệu” để một vòng xoay ở gốc xuất hiện, một trường biểu mẫu báo một lỗi hợp lệ lên phần tóm tắt ở đầu trang, một trang báo tiêu đề của nó cho một thanh điều hướng tùy biến.

Quy tắc tôi rút ra

  • Hình học chảy lên? Hãy thử đường dẫn căn chỉnh trước, rồi onGeometryChange, rồi mới PreferenceKey.
  • Thứ khác chảy lên? PreferenceKey, và đó là câu trả lời duy nhất.
  • Bất cứ thứ gì chảy xuống? Environment.

Sai lầm tôi mắc suốt một năm là coi PreferenceKey như công cụ đo đạc, vì mọi bài hướng dẫn đều dùng nó cho việc đó. Nó là công cụ giao tiếp ngược lên, còn đo đạc chỉ là ví dụ ăn ảnh nhất của nó.