Skip to content

Commit e62f6d6

Browse files
committed
Add Stepper view
New Stepper view supporting: - value: Double with optional range (min/max) - step: configurable increment amount (default 1.0) - labelFormat: printf-style format string embedding current value (e.g. "Qty: %.0f" → "Qty: 5"). Label is reactive — no host code needed. - label: static string label (ignored if labelFormat is set) - actionID: fired on user-initiated value changes with new value as context Syncing with TextField/Text (example): 1. Stepper with id=1, TextField with id=2 2. Register handler for stepper's actionID: ActionUISwift.registerActionHandler(actionID: "stepper.qty") { _, windowUUID, _, _, context in // context is the new Double value from Stepper ActionUISwift.setElementValue(windowUUID: windowUUID, viewID: 2, value: context) } 3. Bidirectional: also handle TextField's actionID to update Stepper value with setElementValue Stepper.json in ActionUISwiftTestApp shows 5 variants: integer (%0f), percentage, decimal (%.1f), unbounded, static label. All 11 unit tests passing.
1 parent 86e7a97 commit e62f6d6

6 files changed

Lines changed: 520 additions & 0 deletions

File tree

ActionUI/Common/ActionUIRegistry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ public class ActionUIRegistry {
8484
registerView(ShareLink.self)
8585
registerView(Slider.self)
8686
registerView(Spacer.self)
87+
registerView(Stepper.self)
8788
registerView(Table.self)
8889
registerView(Tab.self)
8990
registerView(TabView.self)

ActionUI/Views/Stepper.swift

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
Sample JSON for Stepper:
3+
{
4+
"type": "Stepper",
5+
"id": 1, // Optional: Non-zero positive integer for runtime programmatic interaction
6+
"properties": {
7+
"value": 5.0, // Optional: Initial value (Double), defaults to 0.0
8+
"range": { "min": 0.0, "max": 10.0 }, // Optional: Dictionary with min/max values; no range clamping if omitted
9+
"step": 1.0, // Optional: Step increment (Double), defaults to 1.0
10+
"label": "Quantity", // Optional: Static string label; ignored when labelFormat is set
11+
"labelFormat": "Quantity: %.0f", // Optional: printf-style format string embedding the current value; use float
12+
// specifiers (%g, %f, %.0f, %.1f, etc.) since the value is always a Double.
13+
// Examples: "Count: %.0f" → "Count: 5", "Rating: %.1f" → "Rating: 2.5",
14+
// "Volume: %g%%" → "Volume: 50%"
15+
// Takes precedence over "label" when both are present.
16+
"actionID": "stepper.changed", // Optional: String for action triggered on user-initiated value change
17+
}
18+
// Note: These properties are specific to Stepper. Baseline View properties (padding, hidden, foregroundColor, font, background, frame, opacity, cornerRadius, disabled, etc.) are inherited and applied via ActionUIRegistry.shared.applyModifiers.
19+
}
20+
*/
21+
22+
import SwiftUI
23+
24+
struct Stepper: ActionUIViewConstruction {
25+
static var applyModifiers: (any SwiftUI.View, any ActionUIElementBase, String, [String: Any], any ActionUILogger) -> any SwiftUI.View = { view, _, _, _, _ in view }
26+
static var initialStates: (ViewModel) -> [String: Any] = { model in model.states }
27+
28+
static var valueType: Any.Type = Double.self
29+
30+
static var validateProperties: ([String: Any], any ActionUILogger) -> [String: Any] = { properties, logger in
31+
var validatedProperties = properties
32+
33+
// Validate value
34+
if let value = validatedProperties.double(forKey: "value") {
35+
validatedProperties["value"] = value
36+
} else if validatedProperties["value"] != nil {
37+
logger.log("Stepper value must be a number; defaulting to 0.0", .warning)
38+
validatedProperties["value"] = 0.0
39+
} else {
40+
validatedProperties["value"] = 0.0
41+
}
42+
43+
// Validate range
44+
if let range = validatedProperties["range"] as? [String: Any] {
45+
if let min = range.double(forKey: "min"), let max = range.double(forKey: "max"), min <= max {
46+
validatedProperties["range"] = ["min": min, "max": max]
47+
} else {
48+
logger.log("Stepper range must have valid min/max numbers with min <= max; ignoring range", .warning)
49+
validatedProperties["range"] = nil
50+
}
51+
} else if validatedProperties["range"] != nil {
52+
logger.log("Stepper range must be a dictionary with min/max numbers; ignoring range", .warning)
53+
validatedProperties["range"] = nil
54+
}
55+
56+
// Validate step
57+
if let step = validatedProperties.double(forKey: "step"), step > 0 {
58+
validatedProperties["step"] = step
59+
} else if validatedProperties["step"] != nil {
60+
logger.log("Stepper step must be a positive number; defaulting to 1.0", .warning)
61+
validatedProperties["step"] = 1.0
62+
} else {
63+
validatedProperties["step"] = 1.0
64+
}
65+
66+
// Validate label
67+
if let label = validatedProperties["label"], !(label is String) {
68+
logger.log("Stepper label must be a String; ignoring", .warning)
69+
validatedProperties["label"] = nil
70+
}
71+
72+
// Validate labelFormat
73+
if let labelFormat = validatedProperties["labelFormat"], !(labelFormat is String) {
74+
logger.log("Stepper labelFormat must be a String; ignoring", .warning)
75+
validatedProperties["labelFormat"] = nil
76+
}
77+
78+
return validatedProperties
79+
}
80+
81+
static var buildView: (any ActionUIElementBase, ViewModel, String, [String: Any], any ActionUILogger) -> any SwiftUI.View = { element, model, windowUUID, properties, logger in
82+
let initialValue = Self.initialValue(model) as? Double ?? 0.0
83+
let step = properties.double(forKey: "step") ?? 1.0
84+
85+
// labelFormat takes precedence; re-evaluated each body pass so label stays current.
86+
// This works because ActionUIView.body is re-run whenever model.value (@Published) changes,
87+
// causing buildView to be called again with the updated value.
88+
let currentValue = model.value as? Double ?? initialValue
89+
let label: String
90+
if let labelFormat = properties["labelFormat"] as? String {
91+
label = String(format: labelFormat, currentValue)
92+
} else {
93+
label = properties["label"] as? String ?? ""
94+
}
95+
96+
let valueBinding = Binding(
97+
get: { model.value as? Double ?? initialValue },
98+
set: { newValue in
99+
guard model.value as? Double != newValue else { return }
100+
// DispatchQueue.main.async avoids "publishing changes from within view updates" warning.
101+
// actionID fires only on user interaction (binding setter), not programmatic updates.
102+
DispatchQueue.main.async {
103+
model.value = newValue
104+
if let actionID = properties["actionID"] as? String {
105+
ActionUIModel.shared.actionHandler(actionID, windowUUID: windowUUID, viewID: element.id, viewPartID: 0, context: newValue)
106+
}
107+
}
108+
}
109+
)
110+
111+
if let range = properties["range"] as? [String: Any],
112+
let min = range.double(forKey: "min"),
113+
let max = range.double(forKey: "max") {
114+
return SwiftUI.Stepper(label, value: valueBinding, in: min...max, step: step)
115+
}
116+
return SwiftUI.Stepper(label, value: valueBinding, step: step)
117+
}
118+
119+
static var initialValue: (ViewModel) -> Any? = { model in
120+
if let initialValue = model.value as? Double {
121+
return initialValue
122+
}
123+
return model.validatedProperties.double(forKey: "value") ?? 0.0
124+
}
125+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
{
2+
"type": "VStack",
3+
"properties": {
4+
"spacing": 16.0,
5+
"alignment": "leading",
6+
"padding": "default"
7+
},
8+
"children": [
9+
{
10+
"type": "Text",
11+
"properties": {
12+
"text": "Stepper Variants",
13+
"font": "title2"
14+
}
15+
},
16+
{
17+
"type": "Text",
18+
"properties": {
19+
"text": "Integer value (%.0f), range 0–10",
20+
"font": "caption"
21+
}
22+
},
23+
{
24+
"type": "Stepper",
25+
"id": 1,
26+
"properties": {
27+
"value": 5.0,
28+
"range": { "min": 0.0, "max": 10.0 },
29+
"step": 1.0,
30+
"labelFormat": "Quantity: %.0f",
31+
"actionID": "stepper.quantity.changed"
32+
}
33+
},
34+
{
35+
"type": "Text",
36+
"properties": {
37+
"text": "Large step (10), range 0–100",
38+
"font": "caption"
39+
}
40+
},
41+
{
42+
"type": "Stepper",
43+
"id": 2,
44+
"properties": {
45+
"value": 50.0,
46+
"range": { "min": 0.0, "max": 100.0 },
47+
"step": 10.0,
48+
"labelFormat": "Volume: %.0f%%",
49+
"actionID": "stepper.volume.changed"
50+
}
51+
},
52+
{
53+
"type": "Text",
54+
"properties": {
55+
"text": "Decimal step (0.5), one decimal place",
56+
"font": "caption"
57+
}
58+
},
59+
{
60+
"type": "Stepper",
61+
"id": 3,
62+
"properties": {
63+
"value": 2.5,
64+
"range": { "min": 0.0, "max": 5.0 },
65+
"step": 0.5,
66+
"labelFormat": "Rating: %.1f",
67+
"actionID": "stepper.rating.changed"
68+
}
69+
},
70+
{
71+
"type": "Text",
72+
"properties": {
73+
"text": "Unbounded (no range), %g strips trailing zeros",
74+
"font": "caption"
75+
}
76+
},
77+
{
78+
"type": "Stepper",
79+
"id": 4,
80+
"properties": {
81+
"value": 0.0,
82+
"step": 1.0,
83+
"labelFormat": "Count: %g",
84+
"actionID": "stepper.count.changed"
85+
}
86+
},
87+
{
88+
"type": "Text",
89+
"properties": {
90+
"text": "Static label (no labelFormat)",
91+
"font": "caption"
92+
}
93+
},
94+
{
95+
"type": "Stepper",
96+
"id": 5,
97+
"properties": {
98+
"value": 0.0,
99+
"range": { "min": 0.0, "max": 20.0 },
100+
"step": 1.0,
101+
"label": "Adjust",
102+
"actionID": "stepper.nolabel.changed"
103+
}
104+
}
105+
]
106+
}

0 commit comments

Comments
 (0)