diff --git a/docs/specs/validation.spec.md b/docs/specs/validation.spec.md index 4c5050c..ffaeab7 100644 --- a/docs/specs/validation.spec.md +++ b/docs/specs/validation.spec.md @@ -328,6 +328,13 @@ Deliver a **working, enforceable schema validation system** for backend-driven U } ``` +### List component runtime model + +- `props.items` defines the static template for each cell as an array of component definitions. +- Optional `data.items` may override the initial items at load time using the same component schema. +- Optional `data.itemsStoreKeyPath` (or `data.items.keyPath`) binds the list to a store key path. The store value must be an array (or object map) of component definitions that match the component schema above. +- The iOS renderer virtualizes rows with `UITableView` + diffable data source, so only the visible cells mount child components while still responding to live store updates. + --- ✅ This final spec ensures **end-to-end schema validation** for UI configs and scenario data, directly aligned with the **Store API doc** you shared. diff --git a/packages/render-ios-sdk/Sources/RenderEngine/Core/DependencyInjection/DIContainer.swift b/packages/render-ios-sdk/Sources/RenderEngine/Core/DependencyInjection/DIContainer.swift index e7c1485..92fd56f 100644 --- a/packages/render-ios-sdk/Sources/RenderEngine/Core/DependencyInjection/DIContainer.swift +++ b/packages/render-ios-sdk/Sources/RenderEngine/Core/DependencyInjection/DIContainer.swift @@ -81,7 +81,8 @@ class DIContainer { RatingRenderer(), NavbarRenderer(), SpacerRenderer(), - SafeAreaViewRenderer() + SafeAreaViewRenderer(), + ListRenderer() ] renderers.forEach { registry.register(renderer: $0) } return registry diff --git a/packages/render-ios-sdk/Sources/RenderEngine/UI/Renderers/ListRenderer.swift b/packages/render-ios-sdk/Sources/RenderEngine/UI/Renderers/ListRenderer.swift new file mode 100644 index 0000000..a6290d8 --- /dev/null +++ b/packages/render-ios-sdk/Sources/RenderEngine/UI/Renderers/ListRenderer.swift @@ -0,0 +1,299 @@ +import UIKit +import Combine + +// MARK: - List Renderer + +/// Renderer responsible for virtualized list rendering using UITableView +class ListRenderer: Renderer { + let type = "List" + + private let registry = DIContainer.shared.componentRegistry + private let logger = DIContainer.shared.currentLogger + + @MainActor + func render(component: Component, context: RendererContext) -> UIView? { + let staticDescriptors = parseStaticItems(from: component) + let storeKeyPath = resolveStoreKeyPath(from: component) + + return ListRendererView( + component: component, + context: context, + registry: registry, + logger: logger, + initialItems: staticDescriptors, + storeKeyPath: storeKeyPath + ) + } + + private func parseStaticItems(from component: Component) -> [ListItemDescriptor] { + // Prefer runtime data overrides, otherwise fallback to properties + let dataItems = component.data.getConfigArray(forKey: "items") + let propertyItems = component.properties.getConfigArray(forKey: "items") + let configs = dataItems.isEmpty ? propertyItems : dataItems + + return configs.compactMap { config in + guard let childComponent = try? Component.create(from: config) else { + logger.warning("ListRenderer failed to parse item config", category: "ListRenderer") + return nil + } + return ListItemDescriptor(component: childComponent) + } + } + + private func resolveStoreKeyPath(from component: Component) -> String? { + if let keyPath = component.data.getString(forKey: "itemsStoreKeyPath") { + return keyPath + } + if let nested = component.data.getString(forKey: "items.keyPath") { + return nested + } + if let propsNested = component.properties.getString(forKey: "itemsStoreKeyPath") { + return propsNested + } + return nil + } +} + +// MARK: - List Item Descriptor + +struct ListItemDescriptor: Hashable { + let id: String + let component: Component + + init(component: Component) { + self.id = component.id + self.component = component + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: ListItemDescriptor, rhs: ListItemDescriptor) -> Bool { + return lhs.id == rhs.id + } +} + +// MARK: - Virtualized List View + +@MainActor +final class ListRendererView: RenderableView { + enum Section: Hashable { + case main + } + + private let registry: ComponentRegistry + private let logger: Logger + private let storeKeyPath: String? + + private var staticDescriptors: [ListItemDescriptor] + private var descriptors: [ListItemDescriptor] + private var descriptorLookup: [String: ListItemDescriptor] = [:] + + private var storeCancellable: AnyCancellable? + + let tableView: UITableView = UITableView(frame: .zero, style: .plain) + + private lazy var dataSource: UITableViewDiffableDataSource = { + let dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemIdentifier in + guard + let self = self, + let descriptor = self.descriptorLookup[itemIdentifier] + else { + return UITableViewCell() + } + + let cell = tableView.dequeueReusableCell(withIdentifier: ListRendererCell.reuseIdentifier, for: indexPath) as? ListRendererCell ?? ListRendererCell() + cell.configure( + with: descriptor, + context: self.context, + registry: self.registry, + logger: self.logger + ) + return cell + } + return dataSource + }() + + init( + component: Component, + context: RendererContext, + registry: ComponentRegistry, + logger: Logger, + initialItems: [ListItemDescriptor], + storeKeyPath: String? + ) { + self.registry = registry + self.logger = logger + self.staticDescriptors = initialItems + self.descriptors = initialItems + self.storeKeyPath = storeKeyPath + + super.init(component: component, context: context) + + setupTableView() + applySnapshot(animated: false) + subscribeToStoreIfNeeded() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + storeCancellable?.cancel() + } + + private func setupTableView() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.allowsSelection = false + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 44 + tableView.tableFooterView = UIView() + tableView.register(ListRendererCell.self, forCellReuseIdentifier: ListRendererCell.reuseIdentifier) + + addSubview(tableView) + tableView.yoga.isEnabled = true + flex.define { flex in + flex.addItem(tableView) + .grow(1) + .shrink(1) + } + } + + private func applySnapshot(animated: Bool) { + descriptorLookup = Dictionary(uniqueKeysWithValues: descriptors.map { ($0.id, $0) }) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(descriptors.map { $0.id }) + dataSource.apply(snapshot, animatingDifferences: animated) + } + + private func subscribeToStoreIfNeeded() { + guard let keyPath = storeKeyPath, let store = context.store else { return } + + storeCancellable = store.publisher(for: keyPath) + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + guard let self = self else { return } + let dynamicDescriptors = self.makeDescriptors(from: value) + if dynamicDescriptors.isEmpty { + self.descriptors = self.staticDescriptors + } else { + self.descriptors = dynamicDescriptors + } + self.applySnapshot(animated: true) + } + } + + private func makeDescriptors(from value: StoreValue?) -> [ListItemDescriptor] { + guard let value = value else { return [] } + + switch value { + case .array(let array): + return array.compactMap { makeDescriptor(from: $0) } + case .object(let object): + return object + .sorted { $0.key < $1.key } + .compactMap { makeDescriptor(from: $0.value) } + default: + logger.warning("ListRenderer store value is not an array or object", category: "ListRenderer") + return [] + } + } + + private func makeDescriptor(from value: StoreValue) -> ListItemDescriptor? { + guard case .object(let object) = value else { + logger.warning("ListRenderer expected object value for list item", category: "ListRenderer") + return nil + } + + let raw = object.mapValues { $0.value } + let config = Config(raw) + + guard let component = try? Component.create(from: config) else { + logger.warning("ListRenderer failed to create component from store item", category: "ListRenderer") + return nil + } + + return ListItemDescriptor(component: component) + } +} + +// MARK: - Virtualized Cell + +@MainActor +final class ListRendererCell: UITableViewCell { + static let reuseIdentifier = "ListRendererCell" + + private var hostedView: UIView? + private var currentComponentId: String? + + static var renderedCount = 0 + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + resetHostedView() + } + + func configure( + with descriptor: ListItemDescriptor, + context: RendererContext, + registry: ComponentRegistry, + logger: Logger + ) { + guard descriptor.id != currentComponentId else { return } + + resetHostedView() + + guard let renderer = registry.renderer(for: descriptor.component.type) else { + logger.warning("ListRenderer missing renderer for type \(descriptor.component.type)", category: "ListRenderer") + return + } + + guard let renderedView = renderer.render(component: descriptor.component, context: context) else { + logger.warning("ListRenderer failed to render component id=\(descriptor.id)", category: "ListRenderer") + return + } + + hostedView = renderedView + currentComponentId = descriptor.id + + renderedView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(renderedView) + + NSLayoutConstraint.activate([ + renderedView.topAnchor.constraint(equalTo: contentView.topAnchor), + renderedView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + renderedView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + renderedView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + contentView.setNeedsLayout() + contentView.layoutIfNeeded() + + ListRendererCell.renderedCount += 1 + } + + private func resetHostedView() { + if let hostedView = hostedView { + StoreSubscriptionManager.unsubscribeAll(from: hostedView) + hostedView.removeFromSuperview() + } + hostedView = nil + currentComponentId = nil + } +} diff --git a/packages/render-ios-sdk/Sources/RenderEngine/UI/ViewTreeBuilder.swift b/packages/render-ios-sdk/Sources/RenderEngine/UI/ViewTreeBuilder.swift index 02e3602..d4e10ec 100644 --- a/packages/render-ios-sdk/Sources/RenderEngine/UI/ViewTreeBuilder.swift +++ b/packages/render-ios-sdk/Sources/RenderEngine/UI/ViewTreeBuilder.swift @@ -6,20 +6,26 @@ class ViewTreeBuilder { private let viewController: UIViewController? private let navigationController: UINavigationController? private let window: UIWindow? - + private let store: Store? + private let storeFactory: StoreFactory + private let registry = DIContainer.shared.componentRegistry - private let logger = DIContainer.shared.logger - + private let logger = DIContainer.shared.currentLogger + init( scenario: Scenario, viewController: UIViewController? = nil, navigationController: UINavigationController? = nil, - window: UIWindow? = nil + window: UIWindow? = nil, + store: Store? = nil, + storeFactory: StoreFactory = DIContainer.shared.storeFactory ) { self.scenario = scenario self.viewController = viewController self.navigationController = navigationController self.window = window + self.store = store + self.storeFactory = storeFactory } /** @@ -61,6 +67,9 @@ class ViewTreeBuilder { window: window, scenario: scenario, props: props, + store: store, + storeFactory: storeFactory, + logger: logger ) // 3. Use the renderer to create the UIView. The renderer is responsible diff --git a/packages/render-ios-sdk/Sources/RenderEngine/UI/Views/RootFlexView.swift b/packages/render-ios-sdk/Sources/RenderEngine/UI/Views/RootFlexView.swift index c979737..70e4922 100644 --- a/packages/render-ios-sdk/Sources/RenderEngine/UI/Views/RootFlexView.swift +++ b/packages/render-ios-sdk/Sources/RenderEngine/UI/Views/RootFlexView.swift @@ -39,10 +39,11 @@ fileprivate class ScenarioObserverObject: ScenarioObserver { class RootFlexView: UIView { private let logger = DIContainer.shared.currentLogger private let repository = DIContainer.shared.scenarioRepository - + private let storeFactory = DIContainer.shared.storeFactory + private var currentObserver: ScenarioObserverObject? private let safeArea: SafeAreaOptions - + var scenario: Scenario? { didSet { onScenarioChanged(scenario) } } @@ -150,7 +151,15 @@ class RootFlexView: UIView { // MARK: - Rendering private func renderScenario(scenario: Scenario) { - let builder = ViewTreeBuilder(scenario: scenario) + let store = storeFactory.makeStore( + scope: .scenario(id: scenario.id), + storage: .memory + ) + let builder = ViewTreeBuilder( + scenario: scenario, + store: store, + storeFactory: storeFactory + ) guard let view = builder.buildViewTree(from: scenario.mainComponent) else { logger.warning("NO VIEW RENDERED FOR SCENARIO", category: "RootFlexView") return diff --git a/packages/render-ios-sdk/Tests/RenderEngineTests/ListRendererTests.swift b/packages/render-ios-sdk/Tests/RenderEngineTests/ListRendererTests.swift new file mode 100644 index 0000000..bba59ad --- /dev/null +++ b/packages/render-ios-sdk/Tests/RenderEngineTests/ListRendererTests.swift @@ -0,0 +1,112 @@ +import Testing +import UIKit +@testable import RenderEngine + +@Suite("ListRenderer Tests") +struct ListRendererTests { + + @MainActor + @Test("ListRenderer virtualizes visible cells") + func testVirtualization() throws { + ListRendererCell.renderedCount = 0 + + let component = try makeListComponent(itemCount: 50) + let renderer = ListRenderer() + let context = RendererContext() + + guard let view = renderer.render(component: component, context: context) as? ListRendererView else { + Issue.record("Expected ListRendererView instance") + return + } + + view.frame = CGRect(x: 0, y: 0, width: 320, height: 200) + view.layoutIfNeeded() + view.tableView.layoutIfNeeded() + + #expect(view.tableView.numberOfRows(inSection: 0) == 50) + #expect(ListRendererCell.renderedCount > 0) + #expect(ListRendererCell.renderedCount < 50) + } + + @MainActor + @Test("ListRenderer streams updates from store") + func testStoreStreaming() async throws { + ListRendererCell.renderedCount = 0 + + let storeFactory = DefaultStoreFactory() + let store = storeFactory.makeStore(scope: .scenario(id: "list-test"), storage: .memory) + + let component = try makeListComponent( + itemCount: 1, + data: ["itemsStoreKeyPath": "todos"] + ) + + let context = RendererContext(store: store, storeFactory: storeFactory) + let renderer = ListRenderer() + + guard let view = renderer.render(component: component, context: context) as? ListRendererView else { + Issue.record("Expected ListRendererView instance") + return + } + + view.frame = CGRect(x: 0, y: 0, width: 320, height: 240) + view.layoutIfNeeded() + view.tableView.layoutIfNeeded() + + #expect(view.tableView.numberOfRows(inSection: 0) == 1) + + let dynamicItems: [StoreValue] = (0..<3).map { index in + StoreValue.object([ + "id": .string("dynamic_\(index)"), + "type": .string("Text"), + "style": .object([:] as [String: StoreValue]), + "properties": .object([ + "text": .string("Dynamic \(index)") + ]), + "data": .object([:] as [String: StoreValue]) + ]) + } + + await store.set("todos", .array(dynamicItems)) + try await Task.sleep(nanoseconds: 200_000_000) + + view.layoutIfNeeded() + view.tableView.layoutIfNeeded() + + #expect(view.tableView.numberOfRows(inSection: 0) == 3) + + guard let firstCell = view.tableView.visibleCells.first else { + Issue.record("Expected at least one visible cell") + return + } + + let label = (firstCell.contentView.subviews.first { $0 is UILabel }) as? UILabel + #expect(label?.text == "Dynamic 0") + } + + private func makeListComponent(itemCount: Int, data: [String: Any?]? = nil) throws -> Component { + let items: [[String: Any?]] = (0..