Trang chủ

UIViewRepresentable làm cho tử tế

Bọc một view UIKit cho SwiftUI là bốn dòng protocol và một sai lầm thật sự nguy hiểm. Đây là hình dạng chạy được, và kiểu hỏng đã lấy của tôi một ngày.

Ba phần

struct TextEditor: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        textView.font = .preferredFont(forTextStyle: .body)
        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
        if textView.text != text {          // cái guard quan trọng
            textView.text = text
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text)
    }

    final class Coordinator: NSObject, UITextViewDelegate {
        @Binding var text: String

        init(text: Binding<String>) {
            _text = text
        }

        func textViewDidChange(_ textView: UITextView) {
            text = textView.text
        }
    }
}
  • makeUIView chạy một lần. Phần cấu hình một lần nằm ở đây.
  • updateUIView chạy ở mọi lần SwiftUI cập nhật. Hãy coi nó là một đường chạy nóng.
  • makeCoordinator tạo ra đối tượng sở hữu các delegate, target và closure — bất cứ thứ gì mà UIKit cần một tham chiếu bền vững tới.

Cái vòng lặp vô hạn

Cái guard if textView.text != text không phải sự cẩn thận cho gọn gàng. Không có nó:

  1. Người dùng gõ → textViewDidChange → text thay đổi
  2. SwiftUI vẽ lại → updateUIView chạy → gán textView.text
  3. Gán text lên một UITextView có thể bắn delegate lần nữa → quay lại bước 1

Triệu chứng hoặc là ứng dụng đơ, hoặc một cảnh báo lúc chạy “Modifying state during view update”, hoặc — tệ nhất — con trỏ nhảy về cuối đoạn chữ ở mỗi phím gõ, vì gán text sẽ đặt lại vùng chọn.

Luôn so sánh trước khi gán trong updateUIView. Mọi thuộc tính, mọi lần. Đó là quy tắc quan trọng nhất trong protocol này.

Cảnh báo

Ngay cả khi có cái guard, việc gán text vẫn làm con trỏ dịch chỗ. Nếu các giá trị thật sự khác nhau nhưng người dùng đang gõ dở, hãy lưu rồi khôi phục vùng chọn:

let selection = textView.selectedRange
textView.text = text
textView.selectedRange = selection

Coordinator là đối tượng sống lâu

Cái struct của bạn được tạo lại liên tục — nó là một view SwiftUI. Coordinator được tạo một lần cho mỗi danh tính view và sống sót, thứ khiến nó thành mái nhà đúng đắn cho bất cứ gì có vòng đời:

final class Coordinator: NSObject, MKMapViewDelegate {
    var parent: MapView
    private var cancellables: Set<AnyCancellable> = []
    private var lastRegion: MKCoordinateRegion?

    init(_ parent: MapView) {
        self.parent = parent
    }
}

Hãy để ý var parent được gán lại trong updateUIView — vì cái struct là mới ở mỗi lần, một coordinator giữ cái struct cũ là đang giữ những binding cũ và những closure cũ. Đây là con bug phổ biến thứ hai trong protocol này:

func updateUIView(_ mapView: MKMapView, context: Context) {
    context.coordinator.parent = self        // làm mới cái struct đã bắt
    …
}

Kích thước

Mặc định một representable thì tham lam — nó lấy hết chỗ được mời. sizeThatFits cho bạn quyền kiểm soát:

func sizeThatFits(_ proposal: ProposedViewSize,
                  uiView: UITextView,
                  context: Context) -> CGSize? {
    let width = proposal.width ?? UIView.layoutFittingCompressedSize.width
    let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
    return CGSize(width: width, height: size.height)
}

Trả về nil nghĩa là “dùng hành vi mặc định”. Phương thức này là thứ khiến một UILabel được bọc hành xử như một Text thay vì lấp đầy màn hình.

Dọn dẹp

dismantleUIView là static và chạy khi view bị gỡ đi:

static func dismantleUIView(_ uiView: MKMapView, coordinator: Coordinator) {
    uiView.delegate = nil
    coordinator.stopUpdates()
}

Hãy dùng nó cho bất cứ thứ gì nếu không sẽ tiếp tục chạy — một đồng hồ, một trình quản lý vị trí, một observer. Nó là static vì cái struct đã biến mất từ lâu vào lúc nó được gọi, và đó là một lời nhắc tốt về việc những kiểu này thật ra là gì.

Khi nào đừng bọc

Hai trường hợp mà lớp bọc là câu trả lời sai.

Cả một màn hình. Bọc một UIViewController cho cả một màn hình nghĩa là việc điều hướng, vòng đời và trạng thái của SwiftUI đang đánh nhau với của UIKit. UIHostingController theo chiều ngược lại thường sạch hơn — hãy giữ màn hình đó trong UIKit rồi nhúng các view SwiftUI vào trong nó.

Thứ mà SwiftUI giờ đã có. TextEditor, Map, PhotosPicker, ShareLink và WebView đều tồn tại sẵn. Bọc thứ tương đương bên UIKit cho một tính năng mà bản gốc đã lo được là chuốc lấy gánh nặng bảo trì mà chẳng được lợi gì.

Những trường hợp tốt là những cái không có thứ tương đương trong SwiftUI và có bề mặt API nhỏ: PDFView, MFMailComposeViewController, AVPlayerLayer, một control bên thứ ba. Lớp bọc nhỏ, một coordinator, các lần cập nhật đều có guard.