How to make a UIViewRepresentable @ViewBuilder work with dynamic content?

Viewed 1604

Is it possible to make a UIViewRepresentable view which takes a ViewBuilder argument work with dynamic content such as ForEach loops?

I have the following UIViewRepresentable view which I’m using to drop down to UIKit and get some custom UIScrollView behaviour:

struct CustomScrollView<Content:View>: UIViewRepresentable {

    private let content: UIView
    private let scrollView = CustomUIScrollView()

    init(@ViewBuilder content: () -> Content) {
        self.content = UIHostingController(rootView: content()).view
        self.content.backgroundColor = .clear
    }

    func makeUIView(context: Context) -> UIView {
        scrollView.addSubview(content)
        // ...
        return scrollView
    }

    func updateUIView(_ uiView: UIView, context: Context) {}

}

This works fine with static content as follows:

var body: some View {
    CustomScrollView {
        VStack {
            ForEach(1..<50) { number in
                Text(String(number))
            }
        }
    }
}

But it fails with dynamic content, showing a blank view:

var body: some View {
    CustomScrollView {
        VStack {
            ForEach(self.numbers) { number in
                Text(String(number))
            }
        }
    }
}

I understand that this is because when makeUIView() is called my dynamic data is empty, and it is later filled or updated. I evaluate my UIViewRepresentable’s content at init, and don’t update it in updateUIView().


How do you go about updating dynamic child content in updateUIView()? I tried capturing the @ViewBuilder parameter as an @escaping closure and evaluating it every time updateUIView() is called, which seems like the right solution (albeit inefficient?), but no luck so far.

2 Answers

Evaluation of @ViewBuilder fails because you mutating the wrong copy of the struct here

func updateUIView(_ uiView: UIView, context: Context) {}

You should mutate uiView's subviews directly with the content()

Updated Answer: Removed solution with a coordinator, because in some cases it works not as expected

The following might be helpful. It is not clear how absent CustomUIScrollView behaves (probably the issue is there), but using standard UIScrollView works with dynamic ForEach. Tested with Xcode 11.4 / iOS 13.4

demo

struct CustomScrollView<Content:View>: UIViewRepresentable {

    private let content: UIView
    private let scrollView = UIScrollView()

    init(@ViewBuilder content: () -> Content) {
        self.content = UIHostingController(rootView: content()).view
        self.content.backgroundColor = .clear
    }

    func makeUIView(context: Context) -> UIView {
        content.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(content)
        let constraints = [
            content.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            content.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            content.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
            content.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
            content.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
        ]
        scrollView.addConstraints(constraints)
        return scrollView
    }

    func updateUIView(_ uiView: UIView, context: Context) {}

}

struct TestCustomScrollView: View {
    private var items = Array(repeating: "Test", count: 50)
    var body: some View {
        CustomScrollView {
            VStack {
                ForEach(Array(items.enumerated()), id: \.0) { i, item in
                    Text("\(item) - \(i)")
                }
            }
        }
    }
}
Related