Skip to content

Commit 8112d09

Browse files
authored
Merge pull request #30 from PureSwift/feature/more-controls
2 parents a687258 + 95e6f33 commit 8112d09

7 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// Menu.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A button that presents a dropdown of actions. The content's buttons become
6+
// the menu items; the interpreter shows them in a Compose DropdownMenu.
7+
//
8+
9+
public struct Menu<Label: View, Content: View>: View {
10+
11+
internal let label: Label
12+
internal let content: Content
13+
14+
public init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
15+
self.content = content()
16+
self.label = label()
17+
}
18+
19+
public typealias Body = Never
20+
}
21+
22+
public extension Menu where Label == Text {
23+
init<S: StringProtocol>(_ title: S, @ViewBuilder content: () -> Content) {
24+
self.init(content: content) { Text(title) }
25+
}
26+
}
27+
28+
extension Menu: PrimitiveView {
29+
public func _render(in context: ResolveContext) -> RenderNode {
30+
let labelNodes = Evaluator.resolveChildren(label, context.descending("label"))
31+
let title = labelNodes.lazy.compactMap(firstTextString).first ?? ""
32+
return RenderNode(
33+
type: "Menu",
34+
id: context.path,
35+
props: ["label": .string(title)],
36+
children: Evaluator.resolveChildren(content, context.descending("content"))
37+
)
38+
}
39+
}
40+
41+
/// The first Text content found in a node subtree, if any.
42+
internal func firstTextString(_ node: RenderNode) -> String? {
43+
if node.type == "Text", case .string(let text)? = node.props["text"] { return text }
44+
for child in node.children {
45+
if let text = firstTextString(child) { return text }
46+
}
47+
return nil
48+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// Stepper.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A control that increments or decrements a value. The core registers the two
6+
// edge actions as callbacks; the interpreter renders a label plus − / + buttons.
7+
//
8+
9+
public struct Stepper<Label: View>: View {
10+
11+
internal let label: Label
12+
internal let onIncrement: () -> Void
13+
internal let onDecrement: () -> Void
14+
15+
public init(
16+
onIncrement: @escaping () -> Void,
17+
onDecrement: @escaping () -> Void,
18+
@ViewBuilder label: () -> Label
19+
) {
20+
self.label = label()
21+
self.onIncrement = onIncrement
22+
self.onDecrement = onDecrement
23+
}
24+
25+
public typealias Body = Never
26+
}
27+
28+
public extension Stepper where Label == Text {
29+
30+
init<S: StringProtocol, V: Strideable>(
31+
_ title: S,
32+
value: Binding<V>,
33+
in bounds: ClosedRange<V>,
34+
step: V.Stride = 1
35+
) {
36+
self.init(
37+
onIncrement: {
38+
let next = value.wrappedValue.advanced(by: step)
39+
if next <= bounds.upperBound { value.wrappedValue = next }
40+
},
41+
onDecrement: {
42+
let previous = value.wrappedValue.advanced(by: -step)
43+
if previous >= bounds.lowerBound { value.wrappedValue = previous }
44+
},
45+
label: { Text(title) }
46+
)
47+
}
48+
49+
init<S: StringProtocol, V: Strideable>(
50+
_ title: S,
51+
value: Binding<V>,
52+
step: V.Stride = 1
53+
) {
54+
self.init(
55+
onIncrement: { value.wrappedValue = value.wrappedValue.advanced(by: step) },
56+
onDecrement: { value.wrappedValue = value.wrappedValue.advanced(by: -step) },
57+
label: { Text(title) }
58+
)
59+
}
60+
}
61+
62+
extension Stepper: PrimitiveView {
63+
public func _render(in context: ResolveContext) -> RenderNode {
64+
let incrementID = context.callbacks.register(.void(onIncrement))
65+
let decrementID = context.callbacks.register(.void(onDecrement))
66+
return RenderNode(
67+
type: "Stepper",
68+
id: context.path,
69+
props: [
70+
"onIncrement": .int(Int(incrementID)),
71+
"onDecrement": .int(Int(decrementID)),
72+
],
73+
children: Evaluator.resolveChildren(label, context.descending("label"))
74+
)
75+
}
76+
}

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,38 @@ extension TextField: PrimitiveView {
162162
}
163163
}
164164

165+
/// A text field that obscures its contents. Shares the TextField node with a
166+
/// `secure` flag; the interpreter applies a password transformation.
167+
public struct SecureField: View {
168+
169+
internal let placeholder: String
170+
internal let text: Binding<String>
171+
172+
public init<S: StringProtocol>(_ placeholder: S, text: Binding<String>) {
173+
self.placeholder = String(placeholder)
174+
self.text = text
175+
}
176+
177+
public typealias Body = Never
178+
}
179+
180+
extension SecureField: PrimitiveView {
181+
public func _render(in context: ResolveContext) -> RenderNode {
182+
let binding = text
183+
let callbackID = context.callbacks.register(.string { binding.wrappedValue = $0 })
184+
return RenderNode(
185+
type: "TextField",
186+
id: context.path,
187+
props: [
188+
"text": .string(text.wrappedValue),
189+
"placeholder": .string(placeholder),
190+
"onChange": .int(Int(callbackID)),
191+
"secure": .bool(true),
192+
]
193+
)
194+
}
195+
}
196+
165197
/// Eager stand-ins: laziness is an optimization the lazy container path (R7)
166198
/// provides; semantics match the eager stacks.
167199
public typealias LazyVStack = VStack

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,45 @@ struct ControlTests {
7676
#expect(node.props["selection"] == .string("Banana"))
7777
}
7878

79+
@Test("Stepper increments and decrements within bounds, updating its label")
80+
func stepper() {
81+
struct Screen: View {
82+
@State var count = 5
83+
var body: some View { Stepper("Count: \(count)", value: $count, in: 0...10) }
84+
}
85+
let host = ViewHost(Screen())
86+
var node = host.evaluate()
87+
#expect(node.type == "Stepper")
88+
if case .int(let inc)? = node.props["onIncrement"] { host.callbacks.invokeVoid(Int64(inc)) }
89+
node = host.evaluate()
90+
#expect(firstTextString(node) == "Count: 6")
91+
if case .int(let dec)? = node.props["onDecrement"] { host.callbacks.invokeVoid(Int64(dec)) }
92+
node = host.evaluate()
93+
#expect(firstTextString(node) == "Count: 5")
94+
}
95+
96+
@Test("SecureField emits a secure TextField node")
97+
func secureField() {
98+
struct Screen: View {
99+
@State var password = ""
100+
var body: some View { SecureField("Password", text: $password) }
101+
}
102+
let node = ViewHost(Screen()).evaluate()
103+
#expect(node.type == "TextField")
104+
#expect(node.props["secure"] == .bool(true))
105+
}
106+
107+
@Test("Menu emits its label and item children")
108+
func menu() {
109+
let node = ViewHost(Menu("Options") {
110+
Button("First") {}
111+
Button("Second") {}
112+
}).evaluate()
113+
#expect(node.type == "Menu")
114+
#expect(node.props["label"] == .string("Options"))
115+
#expect(node.children.count == 2)
116+
}
117+
79118
@Test("Environment objects reach @Environment properties in the subtree")
80119
func environmentInjection() {
81120
final class Model { var value = 42 }

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ struct CatalogEntry: Identifiable {
3131
CatalogEntry(id: "textfield", title: "TextField", screen: AnyCatalogScreen(TextFieldPlayground())),
3232
CatalogEntry(id: "picker", title: "Picker", screen: AnyCatalogScreen(PickerPlayground())),
3333
CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())),
34+
CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())),
3435
CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())),
3536
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
3637
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct MoreControlsPlayground: View {
8+
@State private var quantity = 1
9+
@State private var password = ""
10+
@State private var choice = "None"
11+
var body: some View {
12+
ScrollView {
13+
VStack(alignment: .leading, spacing: 0) {
14+
Example("Stepper") {
15+
Stepper("Quantity: \(quantity)", value: $quantity, in: 0...10)
16+
}
17+
Example("SecureField") {
18+
VStack(alignment: .leading, spacing: 8) {
19+
SecureField("Password", text: $password)
20+
Text(password.isEmpty ? "Nothing typed yet" : "\(password.count) characters")
21+
}
22+
}
23+
Example("Menu") {
24+
VStack(alignment: .leading, spacing: 8) {
25+
Menu("Actions") {
26+
Button("Rename") { choice = "Rename" }
27+
Button("Duplicate") { choice = "Duplicate" }
28+
Button("Delete") { choice = "Delete" }
29+
}
30+
Text("Chose: \(choice)")
31+
}
32+
}
33+
}
34+
}
35+
}
36+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ import androidx.compose.foundation.shape.CircleShape
100100
import androidx.compose.ui.text.font.FontStyle
101101
import androidx.compose.ui.text.font.FontWeight
102102
import androidx.compose.ui.text.style.TextAlign
103+
import androidx.compose.ui.text.input.PasswordVisualTransformation
103104
import androidx.compose.ui.text.input.TextFieldValue
105+
import androidx.compose.ui.text.input.VisualTransformation
104106
import androidx.compose.ui.unit.TextUnit
105107
import androidx.compose.ui.unit.sp
106108
import androidx.compose.ui.unit.IntOffset
@@ -223,6 +225,10 @@ fun Render(node: ViewNode) {
223225

224226
"TextField" -> RenderTextField(node)
225227

228+
"Stepper" -> RenderStepper(node)
229+
230+
"Menu" -> RenderMenu(node)
231+
226232
"Picker" -> RenderPicker(node)
227233

228234
"NavStack" -> RenderNavStack(node)
@@ -289,10 +295,47 @@ private fun RenderTextField(node: ViewNode) {
289295
}
290296
},
291297
label = { Text(node.string("placeholder") ?: "") },
298+
visualTransformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None,
292299
modifier = node.composeModifiers().fillMaxWidth(),
293300
)
294301
}
295302

303+
// Label, then − / + edge buttons pushed to the trailing side.
304+
@Composable
305+
private fun RenderStepper(node: ViewNode) {
306+
val onIncrement = node.long("onIncrement")
307+
val onDecrement = node.long("onDecrement")
308+
Row(verticalAlignment = Alignment.CenterVertically, modifier = node.composeModifiers().fillMaxWidth()) {
309+
RenderChildren(node)
310+
Spacer(modifier = Modifier.weight(1f))
311+
TextButton(onClick = { onDecrement?.let { SwiftBridge.sink.invokeVoid(it) } }) { Text("", fontSize = 20.sp) }
312+
TextButton(onClick = { onIncrement?.let { SwiftBridge.sink.invokeVoid(it) } }) { Text("+", fontSize = 20.sp) }
313+
}
314+
}
315+
316+
// A trigger button opening a dropdown; each child Button becomes a menu item
317+
// firing its own tap callback.
318+
@Composable
319+
private fun RenderMenu(node: ViewNode) {
320+
var expanded by remember { mutableStateOf(false) }
321+
Box {
322+
TextButton(onClick = { expanded = true }) { Text(node.string("label") ?: "") }
323+
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
324+
for (child in node.children) {
325+
if (child.isPresentation()) continue
326+
val onTap = child.long("onTap")
327+
DropdownMenuItem(
328+
text = { Text(pickerLabel(child)) },
329+
onClick = {
330+
expanded = false
331+
onTap?.let { SwiftBridge.sink.invokeVoid(it) }
332+
},
333+
)
334+
}
335+
}
336+
}
337+
}
338+
296339
@Composable
297340
private fun RenderPicker(node: ViewNode) {
298341
val onChange = node.long("onChange")

0 commit comments

Comments
 (0)