diff --git a/Playground/DynamicUI Playground/ContentView.swift b/Playground/DynamicUI Playground/ContentView.swift index a66fefe56f..25744a19f9 100644 --- a/Playground/DynamicUI Playground/ContentView.swift +++ b/Playground/DynamicUI Playground/ContentView.swift @@ -25,6 +25,14 @@ struct ContentView: View { @State private var error: Error? + private var editorPreviewLayout: AnyLayout { + if selectedDemo == .custom { + AnyLayout(HStackLayout(spacing: 1)) + } else { + AnyLayout(VStackLayout(spacing: 1)) + } + } + var body: some View { VStack(spacing: 1) { Picker("Demo", selection: $selectedDemo) { @@ -36,9 +44,10 @@ struct ContentView: View { .pickerStyle(.segmented) .padding() - HStack(spacing: 1) { + editorPreviewLayout { TextEditor(text: $text) .frame(maxWidth: .infinity) + .frame(height: selectedDemo == .custom ? nil : 100) .border(error == nil ? .green : .red) Divider() @@ -75,6 +84,7 @@ struct ContentView: View { // The exhaustive JSON fixture intentionally makes this enum longer than production types. // swiftlint:disable type_body_length private enum Demo: String, CaseIterable, Identifiable { + case custom case basic case allSupportedOptions @@ -84,6 +94,8 @@ private enum Demo: String, CaseIterable, Identifiable { var title: String { switch self { + case .custom: + return "Custom" case .basic: return "Basic" case .allSupportedOptions: @@ -93,6 +105,8 @@ private enum Demo: String, CaseIterable, Identifiable { var json: String { switch self { + case .custom: + return Self.customJSON case .basic: return Self.basicJSON case .allSupportedOptions: @@ -100,6 +114,15 @@ private enum Demo: String, CaseIterable, Identifiable { } } + private static let customJSON = """ + [ + { + "type": "Text", + "title": "Hello DynamicUI" + } + ] + """ + private static let basicJSON = """ [ { @@ -140,6 +163,11 @@ private enum Demo: String, CaseIterable, Identifiable { "type": "Label", "title": "{$myIdentifier ? Ready to party : Waiting}", "url": "{$myIdentifier ? balloon.fill : balloon}" + }, + { + "type": "Text", + "title": "This view is shown conditionally", + "if": "$myIdentifier" } ] } @@ -180,6 +208,36 @@ private enum Demo: String, CaseIterable, Identifiable { "foregroundColor": "yellow" } }, + { + "type": "AsyncImage", + "title": "Remote avatar", + "url": "https://wesleydegroot.nl/assets/avatar/avatar.webp", + "parameters": { + "contentMode": "fit" + }, + "modifiers": { + "frame": { + "width": 80, + "height": 80 + } + } + }, + { + "type": "Link", + "title": "Open DynamicUI", + "url": "https://github.com/0xWDG/DynamicUI", + "parameters": { + "systemImage": "link" + } + }, + { + "type": "ShareLink", + "title": "Share DynamicUI", + "url": "https://github.com/0xWDG/DynamicUI", + "parameters": { + "systemImage": "square.and.arrow.up" + } + }, { "type": "Divider" }, @@ -211,6 +269,23 @@ private enum Demo: String, CaseIterable, Identifiable { "identifier": "button", "eventHandler": "buttonPressed" }, + { + "type": "Menu", + "title": "Actions", + "url": "ellipsis.circle", + "children": [ + { + "type": "Button", + "title": "Duplicate", + "eventHandler": "duplicatePressed" + }, + { + "type": "Button", + "title": "Delete", + "eventHandler": "deletePressed" + } + ] + }, { "type": "Toggle", "title": "Toggle", @@ -249,6 +324,35 @@ private enum Demo: String, CaseIterable, Identifiable { "maximumValue": 100, "defaultValue": 35 }, + { + "type": "Stepper", + "title": "Guests", + "identifier": "guests", + "defaultValue": 2, + "minimumValue": 1, + "maximumValue": 10, + "parameters": { + "step": 1 + } + }, + { + "type": "DatePicker", + "title": "Starts", + "identifier": "startDate", + "defaultValue": "2026-08-20T09:00:00Z", + "parameters": { + "displayedComponents": "dateAndTime" + } + }, + { + "type": "ColorPicker", + "title": "Accent color", + "identifier": "accentColor", + "defaultValue": "#3366FFFF", + "parameters": { + "supportsOpacity": true + } + }, { "type": "Picker", "title": "Picker", @@ -374,6 +478,130 @@ private enum Demo: String, CaseIterable, Identifiable { } ] }, + { + "type": "LazyVStack", + "parameters": { + "alignment": "leading", + "spacing": 8 + }, + "children": [ + { + "type": "Text", + "title": "LazyVStack first" + }, + { + "type": "Text", + "title": "LazyVStack second" + } + ] + }, + { + "type": "LazyHStack", + "parameters": { + "alignment": "center", + "spacing": 12 + }, + "children": [ + { + "type": "Text", + "title": "LazyHStack first" + }, + { + "type": "Text", + "title": "LazyHStack second" + } + ] + }, + { + "type": "LazyVGrid", + "parameters": { + "columns": 2, + "spacing": 12 + }, + "children": [ + { + "type": "Text", + "title": "VGrid one" + }, + { + "type": "Text", + "title": "VGrid two" + }, + { + "type": "Text", + "title": "VGrid three" + }, + { + "type": "Text", + "title": "VGrid four" + } + ] + }, + { + "type": "LazyHGrid", + "parameters": { + "rows": 2, + "spacing": 12 + }, + "children": [ + { + "type": "Text", + "title": "HGrid one" + }, + { + "type": "Text", + "title": "HGrid two" + }, + { + "type": "Text", + "title": "HGrid three" + }, + { + "type": "Text", + "title": "HGrid four" + } + ], + "modifiers": { + "frame": { + "height": 80 + } + } + }, + { + "type": "Grid", + "parameters": { + "horizontalSpacing": 16, + "verticalSpacing": 8 + }, + "children": [ + { + "type": "GridRow", + "children": [ + { + "type": "Text", + "title": "Name" + }, + { + "type": "Text", + "title": "Value" + } + ] + }, + { + "type": "GridRow", + "children": [ + { + "type": "Text", + "title": "Status" + }, + { + "type": "Text", + "title": "Ready" + } + ] + } + ] + }, { "type": "List", "children": [ @@ -441,6 +669,27 @@ private enum Demo: String, CaseIterable, Identifiable { "height": 80 } } + }, + { + "type": "NavigationStack", + "children": [ + { + "type": "NavigationLink", + "title": "Open details", + "url": "chevron.right", + "children": [ + { + "type": "Text", + "title": "NavigationStack detail" + } + ] + } + ], + "modifiers": { + "frame": { + "height": 80 + } + } } ], "modifiers": { @@ -617,7 +866,8 @@ private enum Demo: String, CaseIterable, Identifiable { }, { "type": "Text", - "title": "Conditional expression: {$toggle ? enabled : disabled}" + "title": "Conditional expression: {$toggle ? enabled : disabled}", + "if": "$toggle" } ], "modifiers": { diff --git a/Playground/DynamicUI Watch App/ContentView.swift b/Playground/DynamicUI Watch App/ContentView.swift index 852f765cc8..5fe0fb6a22 100644 --- a/Playground/DynamicUI Watch App/ContentView.swift +++ b/Playground/DynamicUI Watch App/ContentView.swift @@ -10,34 +10,39 @@ import DynamicUI struct ContentView: View { @State - var text: String = """ + private var text: String = """ [ { "type": "VStack", "children": [ - { - "type": "Button", - "title": "Click me", - "eventHandler": "customHandler" - }, - { - "type": "Toggle", - "title": "Toggle me", - "identifier": "my.toggle.1" - }, - { - "type": "Text", - "title": "_Wait_, am i generating views from JSON?", - "modifiers": { - "foregroundStyle": "red", - "opacity": 0.5 + { + "type": "Button", + "title": "Click me", + "eventHandler": "customHandler" }, - }, - { - "type": "Label", - "title": "Shine", - "url": "star" - } + { + "type": "Toggle", + "title": "Toggle me", + "identifier": "myIdentifier" + }, + { + "type": "Text", + "title": "_Wait_, am i generating views from JSON?", + "modifiers": { + "foregroundStyle": "red", + "opacity": 0.5 + } + }, + { + "type": "Label", + "title": "Shine", + "url": "{$myIdentifier ? star.fill : star}" + }, + { + "type": "Text", + "title": "The toggle is enabled", + "if": "$myIdentifier" + } ] } ] diff --git a/README.md b/README.md index b2d0365d74..a4de628f41 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # DynamicUI Create SwiftUI interfaces from JSON component trees. DynamicUI supports nested layouts, -interactive controls, runtime value updates, conditional strings, and a focused set of SwiftUI -modifiers. +interactive controls, runtime value updates, conditional strings and views, and a focused set of +SwiftUI modifiers. [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F0xWDG%2FDynamicUI%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/0xWDG/DynamicUI) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F0xWDG%2FDynamicUI%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/0xWDG/DynamicUI) [![Swift Package Manager](https://img.shields.io/badge/SPM-compatible-brightgreen.svg)](https://swift.org/package-manager) @@ -87,6 +87,11 @@ Conditional expressions can select a string value using another component's curr "type": "Label", "title": "Shine", "url": "{$myIdentifier ? star.fill : star}" + }, + { + "type": "Text", + "title": "The toggle is enabled", + "if": "$myIdentifier" } ] ``` @@ -94,6 +99,9 @@ Conditional expressions can select a string value using another component's curr The syntax is `{$identifier ? valueWhenTrue : valueWhenFalse}`. It works in string-valued component fields, including `title`, `text`, `url`, modifiers, and parameters. +Set `"if": "$identifier"` to render an entire component only while the referenced value is +truthy. Missing identifiers and empty, zero, `false`, or `null` values hide the component. + ## Handle interactions The `component` binding receives the latest interacted-with component. Stateful controls include @@ -158,6 +166,7 @@ Every component requires a case-sensitive `type`. Common optional fields are: | Field | Purpose | | --- | --- | | `title` | Label, title, placeholder, or accessibility label | +| `if` | Identifier condition such as `$myIdentifier` that controls whether the component renders | | `identifier` | Stable key for updates and conditional expressions | | `eventHandler` | Application-defined event name returned on interaction | | `defaultValue` | Initial value for stateful controls | @@ -167,8 +176,9 @@ Every component requires a case-sensitive `type`. Common optional fields are: | `modifiers` | Visual and behavioral modifiers | | `minimumValue`, `maximumValue` | Numeric bounds for sliders and progress views | -Unknown component types render no view. Decode failures are written to the optional `error` -binding and display a fallback error view. +Unknown component types are logged and skipped, allowing valid sibling components to keep rendering. +Decode failures are written to the optional `error` binding and display a fallback error view. +Objects without a string `type` field are treated as metadata and silently ignored in component arrays. ## Playground application diff --git a/Sources/DynamicUI/DynamicUI.docc/DynamicUI.md b/Sources/DynamicUI/DynamicUI.docc/DynamicUI.md index 4b6e2b65a1..3190b57100 100644 --- a/Sources/DynamicUI/DynamicUI.docc/DynamicUI.md +++ b/Sources/DynamicUI/DynamicUI.docc/DynamicUI.md @@ -45,14 +45,16 @@ contains the new value for controls such as toggles, text fields, pickers, and s ## Component Schema -Every object requires a case-sensitive `type`. All other fields are optional and are used only -by components that support them. +Every component object requires a case-sensitive string `type`. Objects without a string `type` +are treated as metadata and ignored, including inside `children`. All other fields are optional +and are used only by components that support them. | Field | JSON type | Purpose | | --- | --- | --- | -| `type` | String | SwiftUI view type to render. Unknown types render no view. | +| `type` | String | SwiftUI view type to render. Unknown types are logged and render no view. | | `title` | String | Label, title, placeholder, or accessibility label, depending on the view. | | `text` | String | Additional string value available to components. | +| `if` | String | Renders the component only when the referenced identifier is truthy. | | `identifier` | String | Stable key used in interaction updates and conditional expressions. | | `eventHandler` | String | Application-defined event name returned with the component. | | `defaultValue` | String, number, Boolean, or object | Initial value for stateful controls. | @@ -108,45 +110,76 @@ entire string. They work in `title`, `text`, `url`, `minimum`, `maximum`, and st in `modifiers` or `parameters`. Missing identifiers and empty, zero, `false`, or `null` values select the false branch. +## Conditional Views + +Use `if` with an identifier prefixed by `$` to include a component only while that identifier's +current value is truthy: + +```json +{ + "type": "Text", + "title": "The option is enabled", + "if": "$enabled" +} +``` + +Missing identifiers and empty, zero, `false`, or `null` values hide the component. Omitting `if` +renders the component normally. + ## Supported Views ### Content -- -- -- -- -- +- +- +- +- +- +- +- ### Controls -- -- -- -- -- -- -- -- -- +- +- +- +- +- +- +- +- +- +- +- +- +- +- ### Containers -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- ## Platform Behavior @@ -157,6 +190,13 @@ Some views adapt when their SwiftUI equivalent is unavailable: | `NavigationSplitView` | Uses `NavigationView` before iOS 16, macOS 13, and tvOS 16, and on watchOS. | | `HSplitView` | Uses `HStack` outside macOS. | | `VSplitView` | Renders only on macOS. | +| `NavigationStack` | Uses `NavigationView` before iOS 16, macOS 13, tvOS 16, and watchOS 9. | +| `Grid`, `GridRow` | Use stack-based layouts before iOS 16, macOS 13, tvOS 16, and watchOS 9. | +| `Menu` | Uses an accessible vertical group where native menus are unavailable. | +| `ShareLink` | Uses `Link` on tvOS and older systems. | +| `ColorPicker` | Renders only on iOS and macOS. | +| `DatePicker` | Renders no control on tvOS and requires watchOS 10. | +| `Stepper` | Renders no control on tvOS and requires watchOS 9. | | `DisclosureGroup`, `Group`, `GroupBox` | Use `VStack` on tvOS and watchOS. | | `TextEditor` | Uses `TextField` outside iOS and macOS. | | `Slider` | Renders no view on tvOS. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/AsyncImage.md b/Sources/DynamicUI/DynamicUI.docc/Views/AsyncImage.md new file mode 100644 index 0000000000..d202257bf2 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/AsyncImage.md @@ -0,0 +1,23 @@ +# AsyncImage Example + +Load a remote image with a progress placeholder and an accessible failure state. + +```json +[ + { + "type": "AsyncImage", + "title": "DynamicUI logo", + "url": "https://example.com/image.png", + "parameters": { + "contentMode": "fit" + } + } +] +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessibility label and loading description. | +| `url` | String | Remote HTTP or HTTPS image URL. | +| `parameters.contentMode` | String | `fit` (default) or `fill`. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/ColorPicker.md b/Sources/DynamicUI/DynamicUI.docc/Views/ColorPicker.md new file mode 100644 index 0000000000..077f9f9d24 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/ColorPicker.md @@ -0,0 +1,26 @@ +# ColorPicker Example + +```json +[ + { + "type": "ColorPicker", + "title": "Accent color", + "identifier": "accent", + "defaultValue": "#3366FFFF", + "parameters": { + "supportsOpacity": true + } + } +] +``` + +Changes emit an eight-digit `#RRGGBBAA` hexadecimal string in `state`. ColorPicker renders on iOS +and macOS and is unavailable on tvOS and watchOS. + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessible picker label. | +| `identifier` | String | Key used for updates and conditional expressions. | +| `defaultValue` | String | Named color, `#RRGGBB`, or `#RRGGBBAA`. | +| `parameters.supportsOpacity` | Boolean | Whether the picker includes opacity. Defaults to `true`. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/DatePicker.md b/Sources/DynamicUI/DynamicUI.docc/Views/DatePicker.md new file mode 100644 index 0000000000..6c88d071b2 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/DatePicker.md @@ -0,0 +1,29 @@ +# DatePicker Example + +```json +[ + { + "type": "DatePicker", + "title": "Starts", + "identifier": "startDate", + "defaultValue": "2026-08-20T09:00:00Z", + "minimum": "2026-01-01T00:00:00Z", + "maximum": "2026-12-31T23:59:59Z", + "parameters": { + "displayedComponents": "dateAndTime" + } + } +] +``` + +Dates use ISO 8601 strings. A numeric `defaultValue` is interpreted as seconds since 1970. Changes +emit an ISO 8601 string in `state`. DatePicker is unavailable on tvOS and requires watchOS 10. + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessible picker label. | +| `identifier` | String | Key used for updates and conditional expressions. | +| `defaultValue` | String or Number | Initial ISO 8601 date or Unix timestamp. | +| `minimum`, `maximum` | String | Optional inclusive ISO 8601 bounds. | +| `parameters.displayedComponents` | String | `date`, `hourAndMinute`, or `dateAndTime` (default). | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/Grid.md b/Sources/DynamicUI/DynamicUI.docc/Views/Grid.md new file mode 100644 index 0000000000..410e64e684 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/Grid.md @@ -0,0 +1,31 @@ +# Grid Example + +```json +[ + { + "type": "Grid", + "parameters": { + "horizontalSpacing": 12, + "verticalSpacing": 8 + }, + "children": [ + { + "type": "GridRow", + "children": [ + { "type": "Text", "title": "Name" }, + { "type": "Text", "title": "Value" } + ] + } + ] + } +] +``` + +Grid requires iOS 16, macOS 13, tvOS 16, or watchOS 9 and falls back to VStack on older systems. + +| Parameter | Type | Description | +| --- | --- | --- | +| `parameters.horizontalSpacing` | Number | Optional spacing between columns. | +| `parameters.verticalSpacing` | Number | Optional spacing between rows. | +| `children` | Array | GridRow components. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/GridRow.md b/Sources/DynamicUI/DynamicUI.docc/Views/GridRow.md new file mode 100644 index 0000000000..1522e4196b --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/GridRow.md @@ -0,0 +1,20 @@ +# GridRow Example + +```json +[ + { + "type": "GridRow", + "children": [ + { "type": "Text", "title": "Name" }, + { "type": "Text", "title": "Value" } + ] + } +] +``` + +GridRow requires iOS 16, macOS 13, tvOS 16, or watchOS 9 and falls back to HStack on older systems. + +| Parameter | Type | Description | +| --- | --- | --- | +| `children` | Array | Cells in the row. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/LazyHGrid.md b/Sources/DynamicUI/DynamicUI.docc/Views/LazyHGrid.md new file mode 100644 index 0000000000..177551c843 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/LazyHGrid.md @@ -0,0 +1,29 @@ +# LazyHGrid Example + +```json +[ + { + "type": "LazyHGrid", + "parameters": { + "rows": 2, + "minimum": 80, + "maximum": 240, + "spacing": 12 + }, + "children": [ + { "type": "Text", "title": "One" }, + { "type": "Text", "title": "Two" } + ] + } +] +``` + +All rows use flexible sizing. + +| Parameter | Type | Description | +| --- | --- | --- | +| `parameters.rows` | Integer | Flexible row count from `1` through `50`. | +| `parameters.minimum`, `parameters.maximum` | Number | Flexible item size bounds. | +| `parameters.spacing` | Number | Grid and item spacing. | +| `children` | Array | Lazily created grid items. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/LazyHStack.md b/Sources/DynamicUI/DynamicUI.docc/Views/LazyHStack.md new file mode 100644 index 0000000000..70ed7e528a --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/LazyHStack.md @@ -0,0 +1,26 @@ +# LazyHStack Example + +```json +[ + { + "type": "ScrollView", + "children": [ + { + "type": "LazyHStack", + "parameters": { "alignment": "top", "spacing": 12 }, + "children": [ + { "type": "Text", "title": "First" }, + { "type": "Text", "title": "Second" } + ] + } + ] + } +] +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `parameters.alignment` | String | `top`, `center` (default), or `bottom`. | +| `parameters.spacing` | Number | Optional spacing between children. | +| `children` | Array | Lazily created horizontal content. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/LazyVGrid.md b/Sources/DynamicUI/DynamicUI.docc/Views/LazyVGrid.md new file mode 100644 index 0000000000..00b898fb1e --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/LazyVGrid.md @@ -0,0 +1,29 @@ +# LazyVGrid Example + +```json +[ + { + "type": "LazyVGrid", + "parameters": { + "columns": 2, + "minimum": 80, + "maximum": 240, + "spacing": 12 + }, + "children": [ + { "type": "Text", "title": "One" }, + { "type": "Text", "title": "Two" } + ] + } +] +``` + +All columns use flexible sizing. + +| Parameter | Type | Description | +| --- | --- | --- | +| `parameters.columns` | Integer | Flexible column count from `1` through `50`. | +| `parameters.minimum`, `parameters.maximum` | Number | Flexible item size bounds. | +| `parameters.spacing` | Number | Grid and item spacing. | +| `children` | Array | Lazily created grid items. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/LazyVStack.md b/Sources/DynamicUI/DynamicUI.docc/Views/LazyVStack.md new file mode 100644 index 0000000000..0bea0e4ef8 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/LazyVStack.md @@ -0,0 +1,26 @@ +# LazyVStack Example + +```json +[ + { + "type": "ScrollView", + "children": [ + { + "type": "LazyVStack", + "parameters": { "alignment": "leading", "spacing": 12 }, + "children": [ + { "type": "Text", "title": "First" }, + { "type": "Text", "title": "Second" } + ] + } + ] + } +] +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `parameters.alignment` | String | `leading`, `center` (default), or `trailing`. | +| `parameters.spacing` | Number | Optional spacing between children. | +| `children` | Array | Lazily created vertical content. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/Link.md b/Sources/DynamicUI/DynamicUI.docc/Views/Link.md new file mode 100644 index 0000000000..2a9652d894 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/Link.md @@ -0,0 +1,21 @@ +# Link Example + +```json +[ + { + "type": "Link", + "title": "Open DynamicUI", + "url": "https://github.com/0xWDG/DynamicUI", + "parameters": { + "systemImage": "link" + } + } +] +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessible link label. Defaults to the URL. | +| `url` | String | Destination URL. | +| `parameters.systemImage` | String | Optional SF Symbol shown beside the title. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/Menu.md b/Sources/DynamicUI/DynamicUI.docc/Views/Menu.md new file mode 100644 index 0000000000..2a6cf647cf --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/Menu.md @@ -0,0 +1,25 @@ +# Menu Example + +```json +[ + { + "type": "Menu", + "title": "Actions", + "url": "ellipsis.circle", + "children": [ + { "type": "Button", "title": "Duplicate", "identifier": "duplicate" }, + { "type": "Button", "title": "Delete", "identifier": "delete" } + ] + } +] +``` + +The `url` is an optional SF Symbol for the menu label. On platforms without native Menu support, +the label and children render as an accessible vertical group. + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessible menu label. | +| `url` | String | Optional SF Symbol for the label. | +| `children` | Array | Menu actions, normally Button components. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/NavigationLink.md b/Sources/DynamicUI/DynamicUI.docc/Views/NavigationLink.md new file mode 100644 index 0000000000..71c6ebe387 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/NavigationLink.md @@ -0,0 +1,21 @@ +# NavigationLink Example + +```json +[ + { + "type": "NavigationLink", + "title": "Settings", + "url": "gear", + "children": [ + { "type": "Text", "title": "Settings content" } + ] + } +] +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessible link label. | +| `url` | String | Optional SF Symbol shown beside the title. | +| `children` | Array | Destination content. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/NavigationStack.md b/Sources/DynamicUI/DynamicUI.docc/Views/NavigationStack.md new file mode 100644 index 0000000000..0ac8c86d23 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/NavigationStack.md @@ -0,0 +1,25 @@ +# NavigationStack Example + +```json +[ + { + "type": "NavigationStack", + "children": [ + { + "type": "NavigationLink", + "title": "Details", + "children": [ + { "type": "Text", "title": "Detail content" } + ] + } + ] + } +] +``` + +NavigationStack falls back to NavigationView before iOS 16, macOS 13, tvOS 16, and watchOS 9. + +| Parameter | Type | Description | +| --- | --- | --- | +| `children` | Array | Root navigation content. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/ShareLink.md b/Sources/DynamicUI/DynamicUI.docc/Views/ShareLink.md new file mode 100644 index 0000000000..882c1dbe23 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/ShareLink.md @@ -0,0 +1,23 @@ +# ShareLink Example + +```json +[ + { + "type": "ShareLink", + "title": "Share DynamicUI", + "url": "https://github.com/0xWDG/DynamicUI", + "parameters": { + "systemImage": "square.and.arrow.up" + } + } +] +``` + +ShareLink requires iOS 16, macOS 13, or watchOS 9. Earlier systems and tvOS render a normal Link. + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessible share action label. | +| `url` | String | URL to share. | +| `parameters.systemImage` | String | Optional SF Symbol shown beside the title. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.docc/Views/Stepper.md b/Sources/DynamicUI/DynamicUI.docc/Views/Stepper.md new file mode 100644 index 0000000000..0f49a4d838 --- /dev/null +++ b/Sources/DynamicUI/DynamicUI.docc/Views/Stepper.md @@ -0,0 +1,28 @@ +# Stepper Example + +```json +[ + { + "type": "Stepper", + "title": "Guests", + "identifier": "guests", + "defaultValue": 2, + "minimumValue": 1, + "maximumValue": 10, + "parameters": { + "step": 1 + } + } +] +``` + +Changes emit the selected number in `state`. Stepper is unavailable on tvOS and requires watchOS 9. + +| Parameter | Type | Description | +| --- | --- | --- | +| `title` | String | Accessible label displayed with the current value. | +| `identifier` | String | Key used for updates and conditional expressions. | +| `defaultValue` | Number | Initial value, clamped to the configured range. | +| `minimumValue`, `maximumValue` | Number | Inclusive range. Defaults to `0...100`. | +| `parameters.step` | Number | Increment size. Defaults to `1`. | +| `modifiers` | Object | Visual and behavioral modifiers. | diff --git a/Sources/DynamicUI/DynamicUI.swift b/Sources/DynamicUI/DynamicUI.swift index 267d8b4068..f5cb7fd3ae 100644 --- a/Sources/DynamicUI/DynamicUI.swift +++ b/Sources/DynamicUI/DynamicUI.swift @@ -10,11 +10,17 @@ // MIT LICENCE import SwiftUI +import OSLog /// DynamicUI /// /// DynamicUI is a SwiftUI View that can be used to display an interface based on JSON. public struct DynamicUI: View { + private static let logger = Logger( + subsystem: "nl.wesleydegroot.DynamicUI", + category: "Rendering" + ) + /// DynamicUIComponent state change handler public typealias Callback = (DynamicUIComponent) -> Void @@ -130,7 +136,10 @@ public struct DynamicUI: View { Text("Generating interface...") } } - .task(id: json) { + .onAppear { + decodeJSON() + } + .dynamicUIOnChange(of: json) { _ in decodeJSON() } } @@ -141,11 +150,9 @@ public struct DynamicUI: View { error = nil do { - layout = try JSONDecoder().decode( - [DynamicUIComponent].self, - from: json - ) - values = initialValues(in: layout ?? []) + let decodedLayout = try DynamicUILayoutDecoder.decode(from: json) + layout = decodedLayout + values = initialValues(in: decodedLayout) } catch { layout = nil internalError = error @@ -161,103 +168,154 @@ public struct DynamicUI: View { /// - Returns: A SwiftUI View func buildView(for components: [DynamicUIComponent]) -> some View { // swiftlint:disable:previous cyclomatic_complexity function_body_length - ForEach(components.indices, id: \.self) { index in - let component = components[index].resolvingStrings(values: values) + let visibleComponents = components.filter { $0.shouldRender(values: values) } + return ForEach(visibleComponents.indices, id: \.self) { index in + let component = visibleComponents[index].resolvingStrings(values: values) + + switch DynamicUIViewType(rawValue: component.type) { + case .asyncImage: + DynamicAsyncImage(component) - switch component.type { - case "Button": + case .button: DynamicButton(component) - case "VStack": + case .colorPicker: + DynamicColorPicker(component) + + case .datePicker: + DynamicDatePicker(component) + + case .vStack: DynamicVStack(component) - case "HStack": + case .hStack: DynamicHStack(component) - case "ZStack": + case .zStack: DynamicZStack(component) - case "List": + case .list: DynamicList(component) - case "ScrollView": + case .scrollView: DynamicScrollView(component) - case "NavigationView": + case .navigationView: DynamicNavigationView(component) - case "Form": + case .navigationStack: + DynamicNavigationStack(component) + + case .navigationLink: + DynamicNavigationLink(component) + + case .form: DynamicForm(component) - case "Text": + case .text: DynamicText(component) - case "Image": + case .image: DynamicImage(component) - case "Divider": + case .divider: DynamicDivider(component) - case "Spacer": + case .spacer: DynamicSpacer(component) - case "Section": + case .section: DynamicSection(component) - case "Label": + case .label: DynamicLabel(component) - case "TextField": + case .textField: DynamicTextField(component) - case "SecureField": + case .secureField: DynamicSecureField(component) - case "TextEditor": + case .textEditor: DynamicTextEditor(component) - case "Toggle": + case .toggle: DynamicToggle(component) - case "Gauge": + case .gauge: DynamicGauge(component) - case "ProgressView": + case .progressView: DynamicProgressView(component) - case "Slider": + case .slider: DynamicSlider(component) - case "GroupBox": + case .stepper: + DynamicStepper(component) + + case .groupBox: DynamicGroupBox(component) - case "Group": + case .group: DynamicGroup(component) - case "DisclosureGroup": + case .disclosureGroup: DynamicDisclosureGroup(component) - case "HSplitView": + case .hSplitView: DynamicHSplitView(component) - case "VSplitView": + case .vSplitView: DynamicVSplitView(component) - case "Picker": + case .picker: DynamicPicker(component) - case "NavigationSplitView": + case .navigationSplitView: DynamicNavigationSplitView(component) - case "TabView": + case .tabView: DynamicTabView(component) - default: - EmptyView() + case .link: + DynamicLink(component) + + case .menu: + DynamicMenu(component) + + case .shareLink: + DynamicShareLink(component) + + case .lazyVStack: + DynamicLazyVStack(component) + + case .lazyHStack: + DynamicLazyHStack(component) + + case .lazyVGrid: + DynamicLazyVGrid(component) + + case .lazyHGrid: + DynamicLazyHGrid(component) + + case .grid: + DynamicGrid(component) + + case .gridRow: + DynamicGridRow(component) + + case nil: + unsupportedView(for: component.type) } } } + private func unsupportedView(for type: String) -> EmptyView { + Self.logger.error("Unsupported DynamicUI view type: \(type, privacy: .public)") + return EmptyView() + } + /// Store a component update and forward it to the public binding or callback. func sendUpdate(_ updatedComponent: DynamicUIComponent) { if let identifier = updatedComponent.identifier, @@ -289,3 +347,18 @@ extension EnvironmentValues { set { self[InternalDynamicUIKey.self] = newValue } } } + +#if DEBUG +#Preview("DynamicUI") { + DynamicUIPreviewFixtures.view(""" + { + "type": "VStack", + "children": [ + { "type": "Text", "title": "DynamicUI preview" }, + { "type": "Button", "title": "Action" }, + { "type": "Toggle", "title": "Enabled", "defaultValue": true } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/DynamicUIComponent.swift b/Sources/DynamicUI/DynamicUIComponent.swift index 7e8dbb452d..b269c480fc 100644 --- a/Sources/DynamicUI/DynamicUIComponent.swift +++ b/Sources/DynamicUI/DynamicUIComponent.swift @@ -12,6 +12,26 @@ import SwiftUI /// This struct constructs a UI Component from JSON. public struct DynamicUIComponent: Codable, Hashable { + private enum CodingKeys: String, CodingKey { + case type + case title + case text + case condition = "if" + case identifier + case eventHandler + case defaultValue + case modifiers + case parameters + case url + case children + case minimum + case minimumValue + case maximum + case maximumValue + case disabled + case state + } + /// Type of component /// /// This is the evaqulent of a SwiftUI View @@ -23,6 +43,9 @@ public struct DynamicUIComponent: Codable, Hashable { /// Text within component (if any) public let text: String? + /// Identifier expression that determines whether the component is rendered. + public let condition: String? + /// Component identifier /// /// The component identifier can be used to have an identifier if you need react on callback calls @@ -53,25 +76,25 @@ public struct DynamicUIComponent: Codable, Hashable { // TODO: Find a way to move this to parameters /// Minimum value description /// - /// - Note: This may be removed in the future in favor of ``UIComponent.parameters`` + /// - Note: This may be removed in the future in favor of ``parameters``. public let minimum: String? // TODO: Find a way to move this to parameters /// Minumum value /// - /// - Note: This may be removed in the future in favor of ``UIComponent.parameters`` + /// - Note: This may be removed in the future in favor of ``parameters``. public let minimumValue: Double? // TODO: Find a way to move this to parameters /// Maximum value description /// - /// - Note: This may be removed in the future in favor of ``UIComponent.parameters`` + /// - Note: This may be removed in the future in favor of ``parameters``. public let maximum: String? // TODO: Find a way to move this to parameters /// Maximum value /// - /// - Note: This may be removed in the future in favor of ``UIComponent.parameters`` + /// - Note: This may be removed in the future in favor of ``parameters``. public let maximumValue: Double? /// Is the component disabled? @@ -85,6 +108,63 @@ public struct DynamicUIComponent: Codable, Hashable { public var state: AnyCodable? } +extension DynamicUIComponent { + /// Decode a component and discard metadata-only entries from its children. + /// + /// - Parameter decoder: Decoder containing a DynamicUI component object. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + type = try container.decode(String.self, forKey: .type) + title = try container.decodeIfPresent(String.self, forKey: .title) + text = try container.decodeIfPresent(String.self, forKey: .text) + condition = try container.decodeIfPresent(String.self, forKey: .condition) + identifier = try container.decodeIfPresent(String.self, forKey: .identifier) + eventHandler = try container.decodeIfPresent(String.self, forKey: .eventHandler) + defaultValue = try container.decodeIfPresent(AnyCodable.self, forKey: .defaultValue) + modifiers = try container.decodeIfPresent([String: AnyCodable].self, forKey: .modifiers) + parameters = try container.decodeIfPresent([String: AnyCodable].self, forKey: .parameters) + url = try container.decodeIfPresent(String.self, forKey: .url) + children = try container.decodeIfPresent( + [DynamicUIComponentEntry].self, + forKey: .children + )?.compactMap(\.component) + minimum = try container.decodeIfPresent(String.self, forKey: .minimum) + minimumValue = try container.decodeIfPresent(Double.self, forKey: .minimumValue) + maximum = try container.decodeIfPresent(String.self, forKey: .maximum) + maximumValue = try container.decodeIfPresent(Double.self, forKey: .maximumValue) + disabled = try container.decodeIfPresent(Bool.self, forKey: .disabled) + state = try container.decodeIfPresent(AnyCodable.self, forKey: .state) + } +} + +/// Decodes a component only when an array entry declares a string `type` discriminator. +struct DynamicUIComponentEntry: Decodable { + let component: DynamicUIComponent? + + private enum CodingKeys: String, CodingKey { + case type + } + + init(from decoder: Decoder) throws { + guard let container = try? decoder.container(keyedBy: CodingKeys.self), + (try? container.decode(String.self, forKey: .type)) != nil else { + component = nil + return + } + + component = try DynamicUIComponent(from: decoder) + } +} + +enum DynamicUILayoutDecoder { + static func decode(from data: Data) throws -> [DynamicUIComponent] { + try JSONDecoder() + .decode([DynamicUIComponentEntry].self, from: data) + .compactMap(\.component) + } +} + extension DynamicUIComponent { /// Returns a component with conditional string expressions resolved. func resolvingStrings(values: [String: AnyCodable]) -> DynamicUIComponent { @@ -92,6 +172,7 @@ extension DynamicUIComponent { type: type, title: title.map { DynamicUIExpression.resolve($0, values: values) }, text: text.map { DynamicUIExpression.resolve($0, values: values) }, + condition: condition, identifier: identifier, eventHandler: eventHandler, defaultValue: defaultValue, @@ -107,6 +188,15 @@ extension DynamicUIComponent { state: state ) } + + /// Returns whether this component should be included in the rendered hierarchy. + func shouldRender(values: [String: AnyCodable]) -> Bool { + guard let condition else { + return true + } + + return DynamicUIExpression.evaluateCondition(condition, values: values) + } } private extension AnyCodable { diff --git a/Sources/DynamicUI/DynamicUIViewType.swift b/Sources/DynamicUI/DynamicUIViewType.swift new file mode 100644 index 0000000000..399757698f --- /dev/null +++ b/Sources/DynamicUI/DynamicUIViewType.swift @@ -0,0 +1,57 @@ +// +// DynamicUIViewType.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +/// The SwiftUI views that can be constructed from DynamicUI JSON. +enum DynamicUIViewType: String, CaseIterable { + case asyncImage = "AsyncImage" + case button = "Button" + case colorPicker = "ColorPicker" + case datePicker = "DatePicker" + case disclosureGroup = "DisclosureGroup" + case divider = "Divider" + case form = "Form" + case gauge = "Gauge" + case grid = "Grid" + case gridRow = "GridRow" + case group = "Group" + case groupBox = "GroupBox" + case hSplitView = "HSplitView" + case hStack = "HStack" + case image = "Image" + case label = "Label" + case lazyHGrid = "LazyHGrid" + case lazyHStack = "LazyHStack" + case lazyVGrid = "LazyVGrid" + case lazyVStack = "LazyVStack" + case link = "Link" + case list = "List" + case menu = "Menu" + case navigationLink = "NavigationLink" + case navigationSplitView = "NavigationSplitView" + case navigationStack = "NavigationStack" + case navigationView = "NavigationView" + case picker = "Picker" + case progressView = "ProgressView" + case scrollView = "ScrollView" + case section = "Section" + case secureField = "SecureField" + case shareLink = "ShareLink" + case slider = "Slider" + case spacer = "Spacer" + case stepper = "Stepper" + case tabView = "TabView" + case text = "Text" + case textEditor = "TextEditor" + case textField = "TextField" + case toggle = "Toggle" + case vSplitView = "VSplitView" + case vStack = "VStack" + case zStack = "ZStack" +} diff --git a/Sources/DynamicUI/Extensions/View.modifiers.swift b/Sources/DynamicUI/Extensions/View.modifiers.swift index 618d4e3e53..4838f694a8 100644 --- a/Sources/DynamicUI/Extensions/View.modifiers.swift +++ b/Sources/DynamicUI/Extensions/View.modifiers.swift @@ -9,7 +9,6 @@ // MIT LICENCE import SwiftUI -import OSLog struct DynamicUIModifier: ViewModifier { /// The modifiers to apply @@ -148,6 +147,21 @@ struct DynamicUIModifier: ViewModifier { } extension View { + /// Observe a value with the modern `onChange` API while retaining support for older systems. + @ViewBuilder + func dynamicUIOnChange( + of value: Value, + action: @escaping (Value) -> Void + ) -> some View { + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + onChange(of: value) { _, newValue in + action(newValue) + } + } else { + onChange(of: value, perform: action) + } + } + /// DynamicUIModifiers /// /// This function adds modifiers to a DynamicUIView diff --git a/Sources/DynamicUI/Helpers/DynamicUIHelper.swift b/Sources/DynamicUI/Helpers/DynamicUIHelper.swift index 809d917853..74c8dc7759 100644 --- a/Sources/DynamicUI/Helpers/DynamicUIHelper.swift +++ b/Sources/DynamicUI/Helpers/DynamicUIHelper.swift @@ -10,6 +10,12 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + // swiftlint:disable cyclomatic_complexity function_body_length /// DynamicUIHelper /// @@ -22,6 +28,10 @@ class DynamicUIHelper { /// /// - Returns: SwiftUI ``Color`` static func translateColor(_ input: String) -> Color? { + if let color = translateHexColor(input) { + return color + } + switch input.lowercased() { case "red": return .red @@ -97,6 +107,53 @@ class DynamicUIHelper { } } + /// Translate a SwiftUI color to an eight-digit hexadecimal RGBA string. + static func hexadecimalString(from color: Color) -> String? { +#if canImport(UIKit) + let platformColor = UIColor(color) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard platformColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return nil + } +#elseif canImport(AppKit) + guard let platformColor = NSColor(color).usingColorSpace(.sRGB) else { + return nil + } + let red = platformColor.redComponent + let green = platformColor.greenComponent + let blue = platformColor.blueComponent + let alpha = platformColor.alphaComponent +#else + return nil +#endif + + return String( + format: "#%02X%02X%02X%02X", + Int(round(red * 255)), + Int(round(green * 255)), + Int(round(blue * 255)), + Int(round(alpha * 255)) + ) + } + + private static func translateHexColor(_ input: String) -> Color? { + let hex = input.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 6 || hex.count == 8, + let value = UInt64(hex, radix: 16) else { + return nil + } + + let hasAlpha = hex.count == 8 + let red = Double((value >> (hasAlpha ? 24 : 16)) & 0xFF) / 255 + let green = Double((value >> (hasAlpha ? 16 : 8)) & 0xFF) / 255 + let blue = Double((value >> (hasAlpha ? 8 : 0)) & 0xFF) / 255 + let alpha = hasAlpha ? Double(value & 0xFF) / 255 : 1 + return Color(red: red, green: green, blue: blue, opacity: alpha) + } + /// Translate a string font weight to a native ``Font.Weight`` /// /// - Parameter input: Font weight as string @@ -209,6 +266,26 @@ class DynamicUIHelper { /// Resolves conditional expressions used in string values from the JSON layout. enum DynamicUIExpression { + /// Evaluate a component condition such as `$toggle`. + /// + /// - Parameters: + /// - input: An identifier prefixed with `$`. + /// - values: Current component values keyed by identifier. + /// - Returns: Whether the referenced value is truthy. + static func evaluateCondition(_ input: String, values: [String: AnyCodable]) -> Bool { + let expression = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard expression.hasPrefix("$") else { + return false + } + + let identifier = expression.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) + guard !identifier.isEmpty else { + return false + } + + return values[identifier]?.isTruthy == true + } + /// Resolve an expression such as `{$toggle ? star.fill : star}`. /// /// - Parameters: diff --git a/Sources/DynamicUI/Helpers/PreviewFixtures.swift b/Sources/DynamicUI/Helpers/PreviewFixtures.swift new file mode 100644 index 0000000000..64d07ba9ad --- /dev/null +++ b/Sources/DynamicUI/Helpers/PreviewFixtures.swift @@ -0,0 +1,32 @@ +// +// PreviewFixtures.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +#if DEBUG +import Foundation +import SwiftUI + +/// Shared, self-contained fixtures for DynamicUI previews. +enum DynamicUIPreviewFixtures { + static func view(_ componentJSON: String) -> some View { + DynamicUI( + json: "[\(componentJSON)]", + component: .constant(nil) + ) + .padding() + } + + static func component(_ componentJSON: String) -> DynamicUIComponent? { + try? JSONDecoder().decode( + DynamicUIComponent.self, + from: Data(componentJSON.utf8) + ) + } +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicAsyncImage.swift b/Sources/DynamicUI/Views/DynamicAsyncImage.swift new file mode 100644 index 0000000000..69053b9b96 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicAsyncImage.swift @@ -0,0 +1,111 @@ +// +// DynamicAsyncImage.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: AsyncImage +struct DynamicAsyncImage: View { + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + @ViewBuilder + var body: some View { + if let url = remoteURL { +#if os(tvOS) + if #available(tvOS 15.0, *) { + remoteImage(url: url) + } else { + unavailableImage + } +#else + remoteImage(url: url) +#endif + } else { + Label(component.title ?? "Invalid image URL", systemImage: "photo") + .accessibilityHint("The remote image URL is invalid") + .set(modifiers: component) + } + } + + @available(tvOS 15.0, *) + private func remoteImage(url: URL) -> some View { + AsyncImage(url: url) { phase in + image(for: phase) + } + .set(modifiers: component) + } + + @ViewBuilder + @available(tvOS 15.0, *) + private func image(for phase: AsyncImagePhase) -> some View { + switch phase { + case .empty: + ProgressView(component.title ?? "Loading image") + + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: contentMode) + .accessibilityLabel(component.title ?? "Image") + + case .failure: + Label(component.title ?? "Image unavailable", systemImage: "photo") + .accessibilityHint("The remote image could not be loaded") + + @unknown default: + EmptyView() + } + } + + private var unavailableImage: some View { + Label(component.title ?? "Image unavailable", systemImage: "photo") + .accessibilityHint("Remote images require tvOS 15 or newer") + .set(modifiers: component) + } + + private var remoteURL: URL? { + guard let value = component.url, + let url = URL(string: value), + url.scheme == "http" || url.scheme == "https" else { + return nil + } + return url + } + + private var contentMode: ContentMode { + component.parameters?["contentMode"]?.toString() == "fill" ? .fill : .fit + } +} + +#if DEBUG +#Preview("AsyncImage") { + DynamicUIPreviewFixtures.view(""" + { + "type": "VStack", + "children": [ + { + "type": "AsyncImage", + "title": "Wesley's avatar", + "url": "https://wesleydegroot.nl/assets/avatar/avatar.webp" + }, + { "type": "Divider" }, + { + "type": "AsyncImage", + "title": "Remote image preview", + "url": "invalid-preview-url" + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicColorPicker.swift b/Sources/DynamicUI/Views/DynamicColorPicker.swift new file mode 100644 index 0000000000..3918a1d475 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicColorPicker.swift @@ -0,0 +1,66 @@ +// +// DynamicColorPicker.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: ColorPicker +struct DynamicColorPicker: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + @State private var state: Color + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self._state = State( + initialValue: component.defaultValue?.toString() + .flatMap(DynamicUIHelper.translateColor) ?? .accentColor + ) + self.component = component + } + + @ViewBuilder + var body: some View { +#if os(tvOS) || os(watchOS) + EmptyView() +#else + ColorPicker(component.title ?? "Color", selection: $state, supportsOpacity: supportsOpacity) + .dynamicUIOnChange(of: state, action: sendUpdate) + .set(modifiers: component) +#endif + } + + private var supportsOpacity: Bool { + component.parameters?["supportsOpacity"]?.toBool() ?? true + } + + private func sendUpdate(_ color: Color) { + guard let hexadecimalColor = DynamicUIHelper.hexadecimalString(from: color) else { + return + } + + var updatedComponent = component + updatedComponent.state = .string(hexadecimalColor) + dynamicUIEnvironment.sendUpdate(updatedComponent) + } +} + +#if DEBUG +#Preview("ColorPicker") { + DynamicUIPreviewFixtures.view(""" + { + "type": "ColorPicker", + "title": "Accent color", + "defaultValue": "#3366FFFF" + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicDatePicker.swift b/Sources/DynamicUI/Views/DynamicDatePicker.swift new file mode 100644 index 0000000000..9621e8afd3 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicDatePicker.swift @@ -0,0 +1,116 @@ +// +// DynamicDatePicker.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import Foundation +import SwiftUI + +/// DynamicUI: DatePicker +struct DynamicDatePicker: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + @State private var state: Date + + private let component: DynamicUIComponent + private let range: ClosedRange + + init(_ component: DynamicUIComponent) { + let minimum = DynamicUIDateCoding.date(from: component.minimum) ?? .distantPast + let maximum = max(DynamicUIDateCoding.date(from: component.maximum) ?? .distantFuture, minimum) + let initial = DynamicUIDateCoding.date(from: component.defaultValue) ?? Date() + + self._state = State(initialValue: min(max(initial, minimum), maximum)) + self.component = component + self.range = minimum...maximum + } + + @ViewBuilder + var body: some View { +#if os(tvOS) + EmptyView() +#elseif os(watchOS) + if #available(watchOS 10.0, *) { + datePicker + } else { + Text(state, style: .date) + .accessibilityLabel(component.title ?? "Date") + .set(modifiers: component) + } +#else + datePicker +#endif + } + +#if !os(tvOS) + @available(watchOS 10.0, *) + private var datePicker: some View { + DatePicker( + component.title ?? "Date", + selection: $state, + in: range, + displayedComponents: displayedComponents + ) + .dynamicUIOnChange(of: state, action: sendUpdate) + .set(modifiers: component) + } + + @available(watchOS 10.0, *) + private var displayedComponents: DatePickerComponents { + switch component.parameters?["displayedComponents"]?.toString() { + case "date": + return .date + case "hourAndMinute": + return .hourAndMinute + default: + return [.date, .hourAndMinute] + } + } +#endif + + private func sendUpdate(_ date: Date) { + var updatedComponent = component + updatedComponent.state = .string(DynamicUIDateCoding.string(from: date)) + dynamicUIEnvironment.sendUpdate(updatedComponent) + } +} + +private enum DynamicUIDateCoding { + static let formatter = ISO8601DateFormatter() + + static func date(from value: AnyCodable?) -> Date? { + if let seconds = value?.toDouble() { + return Date(timeIntervalSince1970: seconds) + } + return date(from: value?.toString()) + } + + static func date(from value: String?) -> Date? { + value.flatMap(formatter.date(from:)) + } + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} + +#if DEBUG +#Preview("DatePicker") { + DynamicUIPreviewFixtures.view(""" + { + "type": "DatePicker", + "title": "Starts", + "defaultValue": "2026-08-20T09:00:00Z", + "parameters": { + "displayedComponents": "dateAndTime" + } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicGrid.swift b/Sources/DynamicUI/Views/DynamicGrid.swift new file mode 100644 index 0000000000..af47bcf2b3 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicGrid.swift @@ -0,0 +1,76 @@ +// +// DynamicGrid.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: Grid +struct DynamicGrid: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + @ViewBuilder + var body: some View { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + Grid(horizontalSpacing: horizontalSpacing, verticalSpacing: verticalSpacing) { + content + } + .set(modifiers: component) + } else { + VStack(spacing: verticalSpacing) { + content + } + .set(modifiers: component) + } + } + + private var content: some View { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } + + private var horizontalSpacing: CGFloat? { + component.parameters?["horizontalSpacing"]?.toDouble().map { CGFloat($0) } + } + + private var verticalSpacing: CGFloat? { + component.parameters?["verticalSpacing"]?.toDouble().map { CGFloat($0) } + } +} + +#if DEBUG +#Preview("Grid") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Grid", + "children": [ + { + "type": "GridRow", + "children": [ + { "type": "Text", "title": "Name" }, + { "type": "Text", "title": "Value" } + ] + }, + { + "type": "GridRow", + "children": [ + { "type": "Text", "title": "Status" }, + { "type": "Text", "title": "Ready" } + ] + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicGridRow.swift b/Sources/DynamicUI/Views/DynamicGridRow.swift new file mode 100644 index 0000000000..e436ccff0e --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicGridRow.swift @@ -0,0 +1,61 @@ +// +// DynamicGridRow.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: GridRow +struct DynamicGridRow: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + @ViewBuilder + var body: some View { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + GridRow { + content + } + .set(modifiers: component) + } else { + HStack { + content + } + .set(modifiers: component) + } + } + + private var content: some View { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } +} + +#if DEBUG +#Preview("GridRow") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Grid", + "children": [ + { + "type": "GridRow", + "children": [ + { "type": "Text", "title": "First cell" }, + { "type": "Text", "title": "Second cell" } + ] + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicImage.swift b/Sources/DynamicUI/Views/DynamicImage.swift index 824e1994c0..366348313f 100644 --- a/Sources/DynamicUI/Views/DynamicImage.swift +++ b/Sources/DynamicUI/Views/DynamicImage.swift @@ -44,3 +44,18 @@ struct DynamicImage: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("Image") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Image", + "title": "Favorite", + "url": "star.fill", + "modifiers": { + "foregroundStyle": "yellow" + } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicInvalidTest.swift b/Sources/DynamicUI/Views/DynamicInvalidTest.swift new file mode 100644 index 0000000000..0df84e4754 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicInvalidTest.swift @@ -0,0 +1,33 @@ +// +// DynamicInvalidTest.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// + +import SwiftUI + +#if DEBUG +#Preview("DynamicInvalidTest") { + DynamicUIPreviewFixtures.view(""" + { + "type": "VStack", + "children": [ + { + "type": "AsyncImage", + "title": "Wesley's avatar", + "url": "https://wesleydegroot.nl/assets/avatar/avatar.webp" + }, + { "type": "Divider" }, + { "type": "D", "comment": "This should NOT FAIL" }, + { "test": true, "comment": "This should be ignored and not fail" }, + { + "type": "AsyncImage", + "title": "Remote image preview", + "url": "invalid-preview-url" + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicLabel.swift b/Sources/DynamicUI/Views/DynamicLabel.swift index 4cdb26b768..3ba6dfa432 100644 --- a/Sources/DynamicUI/Views/DynamicLabel.swift +++ b/Sources/DynamicUI/Views/DynamicLabel.swift @@ -46,3 +46,15 @@ struct DynamicLabel: View { } } } + +#if DEBUG +#Preview("Label") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Label", + "title": "Favorites", + "url": "star.fill" + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicLazyHGrid.swift b/Sources/DynamicUI/Views/DynamicLazyHGrid.swift new file mode 100644 index 0000000000..6e60482f9c --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicLazyHGrid.swift @@ -0,0 +1,74 @@ +// +// DynamicLazyHGrid.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: LazyHGrid +struct DynamicLazyHGrid: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + var body: some View { + LazyHGrid( + rows: DynamicGridConfiguration.items(for: component, countKey: "rows"), + spacing: DynamicGridConfiguration.spacing(for: component) + ) { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } + .set(modifiers: component) + } +} + +enum DynamicGridConfiguration { + static func items(for component: DynamicUIComponent, countKey: String) -> [GridItem] { + let count = min(max(component.parameters?[countKey]?.toInt() ?? 1, 1), 50) + let minimum = max(component.parameters?["minimum"]?.toDouble() ?? 10, 0) + let maximum = max(component.parameters?["maximum"]?.toDouble() ?? .infinity, minimum) + let spacing = spacing(for: component) + let item = GridItem( + .flexible(minimum: minimum, maximum: maximum), + spacing: spacing + ) + return Array(repeating: item, count: count) + } + + static func spacing(for component: DynamicUIComponent) -> CGFloat? { + component.parameters?["spacing"]?.toDouble().map { CGFloat($0) } + } +} + +#if DEBUG +#Preview("LazyHGrid") { + DynamicUIPreviewFixtures.view(""" + { + "type": "ScrollView", + "children": [ + { + "type": "LazyHGrid", + "parameters": { "rows": 2, "spacing": 12 }, + "children": [ + { "type": "Text", "title": "One" }, + { "type": "Text", "title": "Two" }, + { "type": "Text", "title": "Three" }, + { "type": "Text", "title": "Four" } + ], + "modifiers": { "frame": { "height": 100 } } + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicLazyHStack.swift b/Sources/DynamicUI/Views/DynamicLazyHStack.swift new file mode 100644 index 0000000000..b5db9ce25c --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicLazyHStack.swift @@ -0,0 +1,63 @@ +// +// DynamicLazyHStack.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: LazyHStack +struct DynamicLazyHStack: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + var body: some View { + LazyHStack(alignment: alignment, spacing: spacing) { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } + .set(modifiers: component) + } + + private var alignment: VerticalAlignment { + switch component.parameters?["alignment"]?.toString() { + case "top": .top + case "bottom": .bottom + default: .center + } + } + + private var spacing: CGFloat? { + component.parameters?["spacing"]?.toDouble().map { CGFloat($0) } + } +} + +#if DEBUG +#Preview("LazyHStack") { + DynamicUIPreviewFixtures.view(""" + { + "type": "ScrollView", + "children": [ + { + "type": "LazyHStack", + "parameters": { "spacing": 16 }, + "children": [ + { "type": "Text", "title": "First" }, + { "type": "Text", "title": "Second" }, + { "type": "Text", "title": "Third" } + ] + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicLazyVGrid.swift b/Sources/DynamicUI/Views/DynamicLazyVGrid.swift new file mode 100644 index 0000000000..da8c8e967a --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicLazyVGrid.swift @@ -0,0 +1,50 @@ +// +// DynamicLazyVGrid.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: LazyVGrid +struct DynamicLazyVGrid: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + var body: some View { + LazyVGrid( + columns: DynamicGridConfiguration.items(for: component, countKey: "columns"), + spacing: DynamicGridConfiguration.spacing(for: component) + ) { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } + .set(modifiers: component) + } +} + +#if DEBUG +#Preview("LazyVGrid") { + DynamicUIPreviewFixtures.view(""" + { + "type": "LazyVGrid", + "parameters": { "columns": 2, "spacing": 12 }, + "children": [ + { "type": "Text", "title": "One" }, + { "type": "Text", "title": "Two" }, + { "type": "Text", "title": "Three" }, + { "type": "Text", "title": "Four" } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicLazyVStack.swift b/Sources/DynamicUI/Views/DynamicLazyVStack.swift new file mode 100644 index 0000000000..1bb10c5427 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicLazyVStack.swift @@ -0,0 +1,58 @@ +// +// DynamicLazyVStack.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: LazyVStack +struct DynamicLazyVStack: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + var body: some View { + LazyVStack(alignment: alignment, spacing: spacing) { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } + .set(modifiers: component) + } + + private var alignment: HorizontalAlignment { + switch component.parameters?["alignment"]?.toString() { + case "leading": .leading + case "trailing": .trailing + default: .center + } + } + + private var spacing: CGFloat? { + component.parameters?["spacing"]?.toDouble().map { CGFloat($0) } + } +} + +#if DEBUG +#Preview("LazyVStack") { + DynamicUIPreviewFixtures.view(""" + { + "type": "LazyVStack", + "parameters": { "alignment": "leading", "spacing": 12 }, + "children": [ + { "type": "Text", "title": "First" }, + { "type": "Text", "title": "Second" }, + { "type": "Text", "title": "Third" } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicLink.swift b/Sources/DynamicUI/Views/DynamicLink.swift new file mode 100644 index 0000000000..921774faf7 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicLink.swift @@ -0,0 +1,51 @@ +// +// DynamicLink.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: Link +struct DynamicLink: View { + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + @ViewBuilder + var body: some View { + if let value = component.url, let url = URL(string: value) { + Link(destination: url) { + if let systemImage = component.parameters?["systemImage"]?.toString() { + Label(component.title ?? value, systemImage: systemImage) + } else { + Text(component.title ?? value) + } + } + .set(modifiers: component) + } else { + Text(component.title ?? "Invalid link") + .accessibilityHint("The link URL is invalid") + .set(modifiers: component) + } + } +} + +#if DEBUG +#Preview("Link") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Link", + "title": "Open DynamicUI", + "url": "https://github.com/0xWDG/DynamicUI", + "parameters": { "systemImage": "link" } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicMenu.swift b/Sources/DynamicUI/Views/DynamicMenu.swift new file mode 100644 index 0000000000..b2f06d1e1e --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicMenu.swift @@ -0,0 +1,81 @@ +// +// DynamicMenu.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: Menu +struct DynamicMenu: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + @ViewBuilder + var body: some View { +#if os(watchOS) + fallback +#elseif os(tvOS) + if #available(tvOS 17.0, *) { + menu + } else { + fallback + } +#else + menu +#endif + } + +#if !os(watchOS) + @available(tvOS 17.0, *) + private var menu: some View { + Menu { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } label: { + if let systemImage = component.url { + Label(component.title ?? "Menu", systemImage: systemImage) + } else { + Text(component.title ?? "Menu") + } + } + .set(modifiers: component) + } +#endif + + private var fallback: some View { + VStack(alignment: .leading) { + Text(component.title ?? "Menu") + .font(.headline) + dynamicUIEnvironment.buildView(for: component.children ?? []) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(component.title ?? "Menu") + .set(modifiers: component) + } +} + +#if DEBUG +#Preview("Menu") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Menu", + "title": "Actions", + "url": "ellipsis.circle", + "children": [ + { "type": "Button", "title": "Duplicate" }, + { "type": "Button", "title": "Delete" } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicNavigationLink.swift b/Sources/DynamicUI/Views/DynamicNavigationLink.swift new file mode 100644 index 0000000000..dd2eeb1656 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicNavigationLink.swift @@ -0,0 +1,56 @@ +// +// DynamicNavigationLink.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: NavigationLink +struct DynamicNavigationLink: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + var body: some View { + NavigationLink { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } label: { + if let systemImage = component.url { + Label(component.title ?? "Open", systemImage: systemImage) + } else { + Text(component.title ?? "Open") + } + } + .set(modifiers: component) + } +} + +#if DEBUG +#Preview("NavigationLink") { + DynamicUIPreviewFixtures.view(""" + { + "type": "NavigationStack", + "children": [ + { + "type": "NavigationLink", + "title": "Open details", + "url": "chevron.right", + "children": [ + { "type": "Text", "title": "Detail content" } + ] + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicNavigationSplitView.swift b/Sources/DynamicUI/Views/DynamicNavigationSplitView.swift index 3c552587d0..1a27e83612 100644 --- a/Sources/DynamicUI/Views/DynamicNavigationSplitView.swift +++ b/Sources/DynamicUI/Views/DynamicNavigationSplitView.swift @@ -72,3 +72,22 @@ struct DynamicNavigationSplitView: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("NavigationSplitView") { + DynamicUIPreviewFixtures.view(""" + { + "type": "NavigationSplitView", + "children": [ + { + "type": "List", + "children": [ + { "type": "Label", "title": "Sidebar", "url": "sidebar.left" } + ] + }, + { "type": "Text", "title": "Detail content" } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicNavigationStack.swift b/Sources/DynamicUI/Views/DynamicNavigationStack.swift new file mode 100644 index 0000000000..43f45d6566 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicNavigationStack.swift @@ -0,0 +1,61 @@ +// +// DynamicNavigationStack.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: NavigationStack +struct DynamicNavigationStack: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + @ViewBuilder + var body: some View { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + NavigationStack { + content + } + .set(modifiers: component) + } else { + NavigationView { + content + } + .set(modifiers: component) + } + } + + private var content: some View { + dynamicUIEnvironment.buildView(for: component.children ?? []) + } +} + +#if DEBUG +#Preview("NavigationStack") { + DynamicUIPreviewFixtures.view(""" + { + "type": "NavigationStack", + "children": [ + { + "type": "NavigationLink", + "title": "Details", + "children": [ + { "type": "Text", "title": "Destination" } + ] + } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicNavigationView.swift b/Sources/DynamicUI/Views/DynamicNavigationView.swift index bd8a35cf8a..e298bdfce7 100644 --- a/Sources/DynamicUI/Views/DynamicNavigationView.swift +++ b/Sources/DynamicUI/Views/DynamicNavigationView.swift @@ -47,3 +47,16 @@ struct DynamicNavigationView: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("NavigationView") { + DynamicUIPreviewFixtures.view(""" + { + "type": "NavigationView", + "children": [ + { "type": "Text", "title": "Navigation content" } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicPicker.swift b/Sources/DynamicUI/Views/DynamicPicker.swift index 838c73ba7b..59ab7e68b5 100644 --- a/Sources/DynamicUI/Views/DynamicPicker.swift +++ b/Sources/DynamicUI/Views/DynamicPicker.swift @@ -56,7 +56,7 @@ struct DynamicPicker: View { } } } - .onChange(of: state, perform: sendUpdate) + .dynamicUIOnChange(of: state, action: sendUpdate) .set(modifiers: component) } @@ -66,3 +66,20 @@ struct DynamicPicker: View { dynamicUIEnvironment.sendUpdate(updatedComponent) } } + +#if DEBUG +#Preview("Picker") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Picker", + "title": "Selection", + "defaultValue": 1, + "children": [ + { "type": "Text", "title": "First" }, + { "type": "Text", "title": "Second" }, + { "type": "Text", "title": "Third" } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicScrollView.swift b/Sources/DynamicUI/Views/DynamicScrollView.swift index 4aa3ce7fff..169a33d3fe 100644 --- a/Sources/DynamicUI/Views/DynamicScrollView.swift +++ b/Sources/DynamicUI/Views/DynamicScrollView.swift @@ -47,3 +47,24 @@ struct DynamicScrollView: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("ScrollView") { + DynamicUIPreviewFixtures.view(""" + { + "type": "ScrollView", + "children": [ + { + "type": "VStack", + "children": [ + { "type": "Text", "title": "First row" }, + { "type": "Text", "title": "Second row" }, + { "type": "Text", "title": "Third row" } + ] + } + ], + "modifiers": { "frame": { "height": 120 } } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicSecureTextField.swift b/Sources/DynamicUI/Views/DynamicSecureTextField.swift index ab133624c0..944c0d07bd 100644 --- a/Sources/DynamicUI/Views/DynamicSecureTextField.swift +++ b/Sources/DynamicUI/Views/DynamicSecureTextField.swift @@ -49,7 +49,7 @@ struct DynamicSecureField: View { "\(component.title ?? "")", text: $state ) - .onChange(of: state, perform: sendUpdate) + .dynamicUIOnChange(of: state, action: sendUpdate) .set(modifiers: component) } @@ -59,3 +59,15 @@ struct DynamicSecureField: View { dynamicUIEnvironment.sendUpdate(updatedComponent) } } + +#if DEBUG +#Preview("SecureField") { + DynamicUIPreviewFixtures.view(""" + { + "type": "SecureField", + "title": "Password", + "defaultValue": "secret" + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicShareLink.swift b/Sources/DynamicUI/Views/DynamicShareLink.swift new file mode 100644 index 0000000000..42b7b437a0 --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicShareLink.swift @@ -0,0 +1,59 @@ +// +// DynamicShareLink.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: ShareLink +struct DynamicShareLink: View { + private let component: DynamicUIComponent + + init(_ component: DynamicUIComponent) { + self.component = component + } + + @ViewBuilder + var body: some View { + if let value = component.url, let url = URL(string: value) { +#if os(tvOS) + DynamicLink(component) +#else + if #available(iOS 16.0, macOS 13.0, watchOS 9.0, *) { + ShareLink(item: url) { + if let systemImage = component.parameters?["systemImage"]?.toString() { + Label(component.title ?? "Share", systemImage: systemImage) + } else { + Text(component.title ?? "Share") + } + } + .set(modifiers: component) + } else { + DynamicLink(component) + } +#endif + } else { + Text(component.title ?? "Invalid share link") + .accessibilityHint("The share URL is invalid") + .set(modifiers: component) + } + } +} + +#if DEBUG +#Preview("ShareLink") { + DynamicUIPreviewFixtures.view(""" + { + "type": "ShareLink", + "title": "Share DynamicUI", + "url": "https://github.com/0xWDG/DynamicUI", + "parameters": { "systemImage": "square.and.arrow.up" } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicSlider.swift b/Sources/DynamicUI/Views/DynamicSlider.swift index 316af80bd1..37125b6901 100644 --- a/Sources/DynamicUI/Views/DynamicSlider.swift +++ b/Sources/DynamicUI/Views/DynamicSlider.swift @@ -60,7 +60,7 @@ struct DynamicSlider: View { } maximumValueLabel: { Text("\(component.maximum ?? "")") } - .onChange(of: state, perform: sendUpdate) + .dynamicUIOnChange(of: state, action: sendUpdate) .set(modifiers: component) #else EmptyView() diff --git a/Sources/DynamicUI/Views/DynamicStepper.swift b/Sources/DynamicUI/Views/DynamicStepper.swift new file mode 100644 index 0000000000..c878655d8e --- /dev/null +++ b/Sources/DynamicUI/Views/DynamicStepper.swift @@ -0,0 +1,81 @@ +// +// DynamicStepper.swift +// DynamicUI +// +// Created by Wesley de Groot on 20/08/2026. +// https://wesleydegroot.nl +// +// https://github.com/0xWDG/DynamicUI +// MIT LICENCE + +import SwiftUI + +/// DynamicUI: Stepper +struct DynamicStepper: View { + @Environment(\.internalDynamicUIEnvironment) + private var dynamicUIEnvironment + + @State private var state: Double + + private let component: DynamicUIComponent + private let range: ClosedRange + private let step: Double + + init(_ component: DynamicUIComponent) { + let minimum = component.minimumValue ?? 0 + let maximum = max(component.maximumValue ?? 100, minimum) + let initial = component.defaultValue?.toDouble() ?? minimum + + self._state = State(initialValue: min(max(initial, minimum), maximum)) + self.component = component + self.range = minimum...maximum + self.step = max(component.parameters?["step"]?.toDouble() ?? 1, .leastNonzeroMagnitude) + } + + @ViewBuilder + var body: some View { +#if os(tvOS) + EmptyView() +#elseif os(watchOS) + if #available(watchOS 9.0, *) { + stepper + } else { + Text("\(component.title ?? "Value"): \(state.formatted())") + .set(modifiers: component) + } +#else + stepper +#endif + } + +#if !os(tvOS) + @available(watchOS 9.0, *) + private var stepper: some View { + Stepper(value: $state, in: range, step: step) { + Text("\(component.title ?? "Value"): \(state.formatted())") + } + .dynamicUIOnChange(of: state, action: sendUpdate) + .set(modifiers: component) + } +#endif + + private func sendUpdate(_ value: Double) { + var updatedComponent = component + updatedComponent.state = .double(value) + dynamicUIEnvironment.sendUpdate(updatedComponent) + } +} + +#if DEBUG +#Preview("Stepper") { + DynamicUIPreviewFixtures.view(""" + { + "type": "Stepper", + "title": "Guests", + "defaultValue": 2, + "minimumValue": 1, + "maximumValue": 10 + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicTEMPLATE.swift b/Sources/DynamicUI/Views/DynamicTEMPLATE.swift index 23d06cda7d..82a83ee341 100644 --- a/Sources/DynamicUI/Views/DynamicTEMPLATE.swift +++ b/Sources/DynamicUI/Views/DynamicTEMPLATE.swift @@ -39,3 +39,18 @@ struct DynamicTEMPLATE: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("Template") { + if let component = DynamicUIPreviewFixtures.component(""" + { + "type": "TEMPLATE", + "title": "Template" + } + """) { + DynamicTEMPLATE(component) + } else { + Text("Invalid template preview") + } +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicTabView.swift b/Sources/DynamicUI/Views/DynamicTabView.swift index e549be2dba..8122b8295c 100644 --- a/Sources/DynamicUI/Views/DynamicTabView.swift +++ b/Sources/DynamicUI/Views/DynamicTabView.swift @@ -58,3 +58,18 @@ struct DynamicTabView: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("TabView") { + DynamicUIPreviewFixtures.view(""" + { + "type": "TabView", + "children": [ + { "type": "Text", "title": "Home", "url": "house" }, + { "type": "Text", "title": "Favorites", "url": "star" } + ], + "modifiers": { "frame": { "height": 160 } } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicTextEditor.swift b/Sources/DynamicUI/Views/DynamicTextEditor.swift index 7f3b4b4b90..55060c19e2 100644 --- a/Sources/DynamicUI/Views/DynamicTextEditor.swift +++ b/Sources/DynamicUI/Views/DynamicTextEditor.swift @@ -47,7 +47,7 @@ struct DynamicTextEditor: View { var body: some View { #if os(iOS) || os(macOS) TextEditor(text: $state) - .onChange(of: state, perform: sendUpdate) + .dynamicUIOnChange(of: state, action: sendUpdate) .set(modifiers: component) #else DynamicTextField(component) @@ -60,3 +60,15 @@ struct DynamicTextEditor: View { dynamicUIEnvironment.sendUpdate(updatedComponent) } } + +#if DEBUG +#Preview("TextEditor") { + DynamicUIPreviewFixtures.view(""" + { + "type": "TextEditor", + "defaultValue": "Editable multiline text", + "modifiers": { "frame": { "height": 100 } } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicTextField.swift b/Sources/DynamicUI/Views/DynamicTextField.swift index 23bf2fbcf4..b5e9ac9114 100644 --- a/Sources/DynamicUI/Views/DynamicTextField.swift +++ b/Sources/DynamicUI/Views/DynamicTextField.swift @@ -49,7 +49,7 @@ struct DynamicTextField: View { "\(component.title ?? "")", text: $state ) - .onChange(of: state, perform: sendUpdate) + .dynamicUIOnChange(of: state, action: sendUpdate) .set(modifiers: component) } @@ -59,3 +59,15 @@ struct DynamicTextField: View { dynamicUIEnvironment.sendUpdate(updatedComponent) } } + +#if DEBUG +#Preview("TextField") { + DynamicUIPreviewFixtures.view(""" + { + "type": "TextField", + "title": "Name", + "defaultValue": "Wesley" + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicToggle.swift b/Sources/DynamicUI/Views/DynamicToggle.swift index 77463c9eed..097c0657e9 100644 --- a/Sources/DynamicUI/Views/DynamicToggle.swift +++ b/Sources/DynamicUI/Views/DynamicToggle.swift @@ -52,7 +52,7 @@ struct DynamicToggle: View { Toggle(isOn: $state) { Text(title) } - .onChange(of: state, perform: sendUpdate) + .dynamicUIOnChange(of: state, action: sendUpdate) .set(modifiers: component) } diff --git a/Sources/DynamicUI/Views/DynamicVSplitView.swift b/Sources/DynamicUI/Views/DynamicVSplitView.swift index 4055795d29..059918445d 100644 --- a/Sources/DynamicUI/Views/DynamicVSplitView.swift +++ b/Sources/DynamicUI/Views/DynamicVSplitView.swift @@ -51,3 +51,18 @@ struct DynamicVSplitView: View { #endif } } + +#if DEBUG +#Preview("VSplitView") { + DynamicUIPreviewFixtures.view(""" + { + "type": "VSplitView", + "children": [ + { "type": "Text", "title": "Top" }, + { "type": "Text", "title": "Bottom" } + ], + "modifiers": { "frame": { "height": 120 } } + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicVStack.swift b/Sources/DynamicUI/Views/DynamicVStack.swift index c5ede68354..f46f5bd4ba 100644 --- a/Sources/DynamicUI/Views/DynamicVStack.swift +++ b/Sources/DynamicUI/Views/DynamicVStack.swift @@ -47,3 +47,18 @@ struct DynamicVStack: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("VStack") { + DynamicUIPreviewFixtures.view(""" + { + "type": "VStack", + "children": [ + { "type": "Text", "title": "First" }, + { "type": "Text", "title": "Second" }, + { "type": "Text", "title": "Third" } + ] + } + """) +} +#endif diff --git a/Sources/DynamicUI/Views/DynamicZStack.swift b/Sources/DynamicUI/Views/DynamicZStack.swift index 40acc4544b..0d24174dd6 100644 --- a/Sources/DynamicUI/Views/DynamicZStack.swift +++ b/Sources/DynamicUI/Views/DynamicZStack.swift @@ -47,3 +47,25 @@ struct DynamicZStack: View { .set(modifiers: component) } } + +#if DEBUG +#Preview("ZStack") { + DynamicUIPreviewFixtures.view(""" + { + "type": "ZStack", + "children": [ + { + "type": "Text", + "title": "Background", + "modifiers": { "background": "blue", "padding": 20 } + }, + { + "type": "Text", + "title": "Foreground", + "modifiers": { "foregroundStyle": "white" } + } + ] + } + """) +} +#endif diff --git a/Tests/DynamicUITests/DynamicUITests.swift b/Tests/DynamicUITests/DynamicUITests.swift index 796e70c84d..85cd12c197 100644 --- a/Tests/DynamicUITests/DynamicUITests.swift +++ b/Tests/DynamicUITests/DynamicUITests.swift @@ -2,6 +2,63 @@ import XCTest @testable import DynamicUI final class DynamicUITests: XCTestCase { + func testSupportedViewRegistryContainsEveryDocumentedView() { + let expectedTypes: Set = [ + "AsyncImage", "Button", "ColorPicker", "DatePicker", "DisclosureGroup", "Divider", + "Form", "Gauge", "Grid", "GridRow", "Group", "GroupBox", "HSplitView", "HStack", + "Image", "Label", "LazyHGrid", "LazyHStack", "LazyVGrid", "LazyVStack", "Link", + "List", "Menu", "NavigationLink", "NavigationSplitView", "NavigationStack", + "NavigationView", "Picker", "ProgressView", "ScrollView", "Section", "SecureField", + "ShareLink", "Slider", "Spacer", "Stepper", "TabView", "Text", "TextEditor", + "TextField", "Toggle", "VSplitView", "VStack", "ZStack" + ] + + XCTAssertEqual(Set(DynamicUIViewType.allCases.map(\.rawValue)), expectedTypes) + } + + func testLayoutDecoderIgnoresMetadataEntriesWithoutType() throws { + let json = """ + [ + { "test": true, "comment": "This should be ignored and not fail" }, + { "type": "Text", "title": "Visible" } + ] + """ + + let components = try DynamicUILayoutDecoder.decode(from: Data(json.utf8)) + + XCTAssertEqual(components.count, 1) + XCTAssertEqual(components.first?.type, "Text") + XCTAssertEqual(components.first?.title, "Visible") + } + + func testComponentDecoderIgnoresNestedMetadataEntriesWithoutType() throws { + let json = """ + { + "type": "VStack", + "children": [ + { "comment": "This should be ignored" }, + { "type": "Text", "title": "Visible child" } + ] + } + """ + + let component = try JSONDecoder().decode( + DynamicUIComponent.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(component.children?.count, 1) + XCTAssertEqual(component.children?.first?.title, "Visible child") + } + + func testTypedComponentsStillReportMalformedFields() { + let json = #"[{"type":"Text","disabled":"not-a-boolean"}]"# + + XCTAssertThrowsError( + try DynamicUILayoutDecoder.decode(from: Data(json.utf8)) + ) + } + func testAnyCodableRoundTripsNaturalJSONValues() throws { let json = """ { @@ -48,6 +105,10 @@ final class DynamicUITests: XCTestCase { XCTAssertEqual(component.type, "VStack") XCTAssertEqual(component.modifiers?["padding"], .bool(true)) XCTAssertEqual(component.children?.first?.title, "Hello") + + let encoded = try JSONEncoder().encode(component) + let roundTripped = try JSONDecoder().decode(DynamicUIComponent.self, from: encoded) + XCTAssertEqual(roundTripped, component) } func testIntegerConvertsToDoubleForNumericModifiers() { @@ -113,6 +174,39 @@ final class DynamicUITests: XCTestCase { ) } + func testComponentConditionUsesIdentifierState() throws { + let json = """ + { + "type": "Text", + "title": "Conditional view", + "if": "$myIdentifier" + } + """ + let component = try JSONDecoder().decode( + DynamicUIComponent.self, + from: Data(json.utf8) + ) + + XCTAssertEqual(component.condition, "$myIdentifier") + XCTAssertTrue(component.shouldRender(values: ["myIdentifier": .bool(true)])) + XCTAssertFalse(component.shouldRender(values: ["myIdentifier": .bool(false)])) + XCTAssertFalse(component.shouldRender(values: [:])) + + let encoded = try JSONEncoder().encode(component) + let roundTripped = try JSONDecoder().decode(DynamicUIComponent.self, from: encoded) + XCTAssertEqual(roundTripped.condition, "$myIdentifier") + } + + func testComponentWithoutConditionRenders() throws { + let json = #"{"type":"Text","title":"Always visible"}"# + let component = try JSONDecoder().decode( + DynamicUIComponent.self, + from: Data(json.utf8) + ) + + XCTAssertTrue(component.shouldRender(values: [:])) + } + func testComponentResolvesConditionalStringsRecursively() throws { let json = """ {