Handling View Creation for Heterogeneous Data

In my project (an Package), I have created an Manager (can be classified as an ViewModel) that will handle state updates throughout the Package Component view:

Note: The code is simplified for better understanding and to focus on principles behind things I did. The manager does complex things during state updates.

public class ComponentManager: ObservedObject {
    @Published var rows: [any RowProtocol] = []

    func updateState(_ newState: any RowProtocolData, id: String) {
        guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
        rows[index].updateState(newState)
    }

    func getState(id: String) -> any RowProtocolData? {
        guard let index = rows.firstIndex(where: { $0.id == id }) else { return nil }
        return rows[index].state
    }
} 

The RowProtocol is defined as follows:

public protocol RowStateProtocol {}

public protocol RowProtocol: Identifiable {
    associatedtype State: RowStateProtocol
    associatedtype RowView: View

    var id: String { get }
    var state: State { get }

    func updateState(_ newState: State)

    @MainActor
    @ViewBuilder
    func renderRow() -> RowView
}

extension RowProtocol {
    func updateState(_ newState: any RowProtocolData) {
        guard let newState = newState as? State else { return }
        self.updateState(newState)
    }
}

Then in Component View, I need to render the rows based on the underlying type of the row, this where the renderRow() comes in:


struct ComponentView: View {
    @ObservedObject var manager: ComponentManager

    var body: some View {
        List {
            ForEach(manager.rows, id: \.id) { row in
                HStack { // This HStack prevent List from initing all rows due to AnyView.
                    AnyView(row.renderRow())
                }
            }
        }
    }
}

The row views will be accepting binding to the state of the row and update their state, let says we have a TextRow and a ToggleRow:

struct TextRow: RowProtocol {
    var id: String
    var state: TextRowState


    func updateState(_ newState: TextRowState) {
        self.state = newState
    }
}
struct ToggleRow: RowProtocol {
    var id: String
    var state: ToggleRowState

    func updateState(_ newState: ToggleRowState) {
        self.state = newState
    }
}

In this, offcourse we cannot create an binding directly to the state of the row, since the state are through the manager and the row data won't have access to the manager. So I created an property wrapped that use the closures passed by the manager into environment to create the binding and an view that will give the binding to the content view:

extenstion EnvironmentValues {
    @Entry internal var getState: (String) -> any RowStateProtocol?
    @Entry internal var updateState: (any RowStateProtocol, String) -> Void
}

@propertyWrapper
struct RowStateBinding<State: RowStateProtocol & Equatable>: DynamicProperty {
    @Environment(\.getState) private var getState
    @Environment(\.updateState) private var updateState

    private let id: String 

    init(id: String) {
        self.id = id
    }

    var wrappedValue: State {
        get {
            getState(id) as! State
        }
        nonmutating set {
            if wrappedValue != newValue { // only update for an new change, since set can be triggered for any number of reasons.
                updateState(newValue, id)
            }
        }
    }

    var projectedValue: Binding<State> {
        Binding(
            get: { self.wrappedValue },
            set: { newValue in
                self.wrappedValue = newValue
            }
        )
    }
}

struct RowStateBindingView<Content: View, State: RowStateProtocol & Equatable>: View {
    @RowStateBinding<State> private var state: State

    private let content: (Binding<State>) -> Content

    init(id: String, @ViewBuilder content: @escaping (Binding<State>) -> Content) {
        self._state = RowStateBinding(id: id)
        self.content = content
    }

    var body: some View {
        content($state)
    }
}

and in the renderRows:

struct TextRowView: View {
    @Binding var text: TextRowState

    var body: some View {
        TextField("Enter text", text: $text.text)
    }
}

extension TextRow {
    func renderRow() -> some View {
        RowStateBindingView(id: id) { state in
            TextField("Enter text", text: state.text)
        }
    }
}


struct ToggleRowView: View {
    @Binding var state: ToggleRowState

    var body: some View {
        Toggle("Toggle", isOn: $state.isOn)
    }
}

extension ToggleRow {
    func renderRow() -> some View {
        RowStateBindingView(id: id) { state in
            Toggle("Toggle", isOn: state.isOn)
        }
    }
}

This way, I can adopt any view as an row view and most importantly, the view can be completely independent of the manager and used as an standalone view. Also clients of the library can create their own custom rows by just conforming to the RowProtocol and creating the view for it, without worrying about how the state management works. The manager will handle all the state updates.

I prefer using stucts over classes for rows and states, since its easier to manage state updates.

What do you think about this approach? Do you see any potential issues with this? Is there a better way to achieve this?

Handling View Creation for Heterogeneous Data
 
 
Q