Search code examples
swiftlistgenericsswiftuiviewbuilder

What is the correct method for passing data into a ViewBuilder closure in SwiftUI?


I'm playing around with generics in SwiftUI, and ran into a data persistence issue when attempting to leverage a ViewBuilder closure to pass data into a generic View. My goal is to have a shell view that manages receiving data from an API and passing it to a generic view, as defined in a ViewBuilder block. All of the data seems to be passed to the inits successfully, including to my generic BasicListView, however when the body gets called none of the list data is persisted.

I think it will be easier to explain the issue through code. Apologies for the long code dump here:

import SwiftUI
import Combine

// This is the blank "shell" View that manages passing the data into the viewBuilder through the @ViewBuilder block

struct BlankView<ListItem, Content:View>: View where ListItem: Listable {
    
    let api = GlobalAPI.shared
    
    @State var list: [ListItem] = []
    
    @State var viewBuilder: ([ListItem]) -> Content // Passing in generic [ListItem] here
    
    init(@ViewBuilder builder: @escaping ([ListItem]) -> Content) {
        self._viewBuilder = State<([ListItem]) -> Content>(initialValue: builder)
    }
    
    var body: some View {
        
        viewBuilder(list) // List contained in Blank View passed into viewBuilder Block here
            .multilineTextAlignment(.center)
            .onReceive(GlobalAPI.shared.listDidChange) { item in
                if let newItem = item as? ListItem {
                    self.list.append(newItem) // Handle API updates here
                }
            }
    }
}

// And Here is the implementation of the Blank View
struct TestView: View {

    public var body: some View {
        BlankView<MockListItem, VStack>() { items in // A list of items will get passed into the block
            VStack {
                Text("Add a row") // Button to add row via API singleton
                    .onTapGesture {
                        GlobalAPI.shared.addListItem()
                    }
                
                BasicListView(items: items) { // List view init'd with items
                    Text("Hold on to your butts") // Destination
                }
            }
        }
    }
}


// Supporting code

// The generic list view/cell

struct BasicListView<Content: View, ListItem:Listable>: View {
    
    @State var items: [ListItem]
    
    var destination: () -> Content
    
    init(items: [ListItem], @ViewBuilder builder: @escaping () -> Content) {
        self._items = State<[ListItem]>(initialValue: items) // Items successfully init'd here
        self.destination = builder
    }
    
    var body: some View {
        List(items) { item in // Items that were passed into init no longer present here, this runs on a blank [ListItem] array
            BasicListCell(item: item, destination: self.destination)
        }
    }
}

struct BasicListCell<Content: View, ListItem:Listable>: View {
    
    @State var item: ListItem
    
    var destination: () -> Content
    
    var body: some View {
        
        NavigationLink(destination: destination()) {
            HStack {
                item.photo
                    .resizable()
                    .frame(width: 50.0, height: 50.0)
                    .font(.largeTitle)
                    .cornerRadius(25.0)
                VStack (alignment: .leading) {
                    Text(item.title)
                        .font(.headline)
                    Text(item.description)
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            }
        }
    }
}

// The protocol and mock data struct
protocol Listable: Identifiable {
        
    var id: UUID { get set }
    var title: String { get set }
    var description: String { get set }
    var photo: Image { get set }
}

public struct MockListItem: Listable {
    
    public var photo: Image = Image(systemName:"photo")
    public var id = UUID()
    public var title: String = "Title"
    public var description: String = "This is the description"

    static let all = [MockListItem(), MockListItem(), MockListItem(), MockListItem()]
}

// A global API singleton for testing data updates
class GlobalAPI {
    
    static let shared = GlobalAPI()
    
    var listDidChange = PassthroughSubject<MockListItem, Never>()
    
    var newListItem:MockListItem? = nil {
        didSet {
            if let item = newListItem {
                listDidChange.send(item)
            }
        }
    }
    
    func addListItem() {
        newListItem = MockListItem()
    }
}

Is this a proper implementation of the ViewBuilder block, or is it not encouraged to try to pass data through a View builder block?

NOTE: WHAT DOES WORK

The view will properly draw itself if I directly pass in static Mock data as shown below:

struct TestView: View {

    public var body: some View {
        BlankView<MockListItem, VStack>() { items in // A list of items will get passed into the block
            VStack {
                Text("Add a row") // Button to add row via API singleton
                    .onTapGesture {
                        GlobalAPI.shared.addListItem()
                    }
                
                BasicListView(items: MockListItem.all) { // List view init'd with items
                    Text("Hold on to your butts") // Destination
                }
            }
        }
    }
}

Any ideas? Thanks for the help and feedback everyone.


Solution

  • Here is fixed view. You provide model externally, but state is for internal changes and once created it persist for same view. So in this scenario state is wrong - the view rebuild is managed by outer injected data.

    Tested with Xcode 11.4 / iOS 13.4

    demo

    struct BasicListView<Content: View, ListItem:Listable>: View {
    
        var items: [ListItem]
        var destination: () -> Content
    
        init(items: [ListItem], @ViewBuilder builder: @escaping () -> Content) {
            self.items = items // Items successfully init'd here
            self.destination = builder
        }
    
        var body: some View {
            List(items) { item in // Items that were passed into init no longer present here, this runs on a blank [ListItem] array
                BasicListCell(item: item, destination: self.destination)
            }
        }
    }