Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// Menu.swift
// AndroidSwiftUICore
//
// A button that presents a dropdown of actions. The content's buttons become
// the menu items; the interpreter shows them in a Compose DropdownMenu.
//

public struct Menu<Label: View, Content: View>: View {

internal let label: Label
internal let content: Content

public init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
self.content = content()
self.label = label()
}

public typealias Body = Never
}

public extension Menu where Label == Text {
init<S: StringProtocol>(_ title: S, @ViewBuilder content: () -> Content) {
self.init(content: content) { Text(title) }
}
}

extension Menu: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
let labelNodes = Evaluator.resolveChildren(label, context.descending("label"))
let title = labelNodes.lazy.compactMap(firstTextString).first ?? ""
return RenderNode(
type: "Menu",
id: context.path,
props: ["label": .string(title)],
children: Evaluator.resolveChildren(content, context.descending("content"))
)
}
}

/// The first Text content found in a node subtree, if any.
internal func firstTextString(_ node: RenderNode) -> String? {
if node.type == "Text", case .string(let text)? = node.props["text"] { return text }
for child in node.children {
if let text = firstTextString(child) { return text }
}
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// Stepper.swift
// AndroidSwiftUICore
//
// A control that increments or decrements a value. The core registers the two
// edge actions as callbacks; the interpreter renders a label plus − / + buttons.
//

public struct Stepper<Label: View>: View {

internal let label: Label
internal let onIncrement: () -> Void
internal let onDecrement: () -> Void

public init(
onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void,
@ViewBuilder label: () -> Label
) {
self.label = label()
self.onIncrement = onIncrement
self.onDecrement = onDecrement
}

public typealias Body = Never
}

public extension Stepper where Label == Text {

init<S: StringProtocol, V: Strideable>(
_ title: S,
value: Binding<V>,
in bounds: ClosedRange<V>,
step: V.Stride = 1
) {
self.init(
onIncrement: {
let next = value.wrappedValue.advanced(by: step)
if next <= bounds.upperBound { value.wrappedValue = next }
},
onDecrement: {
let previous = value.wrappedValue.advanced(by: -step)
if previous >= bounds.lowerBound { value.wrappedValue = previous }
},
label: { Text(title) }
)
}

init<S: StringProtocol, V: Strideable>(
_ title: S,
value: Binding<V>,
step: V.Stride = 1
) {
self.init(
onIncrement: { value.wrappedValue = value.wrappedValue.advanced(by: step) },
onDecrement: { value.wrappedValue = value.wrappedValue.advanced(by: -step) },
label: { Text(title) }
)
}
}

extension Stepper: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
let incrementID = context.callbacks.register(.void(onIncrement))
let decrementID = context.callbacks.register(.void(onDecrement))
return RenderNode(
type: "Stepper",
id: context.path,
props: [
"onIncrement": .int(Int(incrementID)),
"onDecrement": .int(Int(decrementID)),
],
children: Evaluator.resolveChildren(label, context.descending("label"))
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,38 @@ extension TextField: PrimitiveView {
}
}

/// A text field that obscures its contents. Shares the TextField node with a
/// `secure` flag; the interpreter applies a password transformation.
public struct SecureField: View {

internal let placeholder: String
internal let text: Binding<String>

public init<S: StringProtocol>(_ placeholder: S, text: Binding<String>) {
self.placeholder = String(placeholder)
self.text = text
}

public typealias Body = Never
}

extension SecureField: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
let binding = text
let callbackID = context.callbacks.register(.string { binding.wrappedValue = $0 })
return RenderNode(
type: "TextField",
id: context.path,
props: [
"text": .string(text.wrappedValue),
"placeholder": .string(placeholder),
"onChange": .int(Int(callbackID)),
"secure": .bool(true),
]
)
}
}

/// Eager stand-ins: laziness is an optimization the lazy container path (R7)
/// provides; semantics match the eager stacks.
public typealias LazyVStack = VStack
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,45 @@ struct ControlTests {
#expect(node.props["selection"] == .string("Banana"))
}

@Test("Stepper increments and decrements within bounds, updating its label")
func stepper() {
struct Screen: View {
@State var count = 5
var body: some View { Stepper("Count: \(count)", value: $count, in: 0...10) }
}
let host = ViewHost(Screen())
var node = host.evaluate()
#expect(node.type == "Stepper")
if case .int(let inc)? = node.props["onIncrement"] { host.callbacks.invokeVoid(Int64(inc)) }
node = host.evaluate()
#expect(firstTextString(node) == "Count: 6")
if case .int(let dec)? = node.props["onDecrement"] { host.callbacks.invokeVoid(Int64(dec)) }
node = host.evaluate()
#expect(firstTextString(node) == "Count: 5")
}

@Test("SecureField emits a secure TextField node")
func secureField() {
struct Screen: View {
@State var password = ""
var body: some View { SecureField("Password", text: $password) }
}
let node = ViewHost(Screen()).evaluate()
#expect(node.type == "TextField")
#expect(node.props["secure"] == .bool(true))
}

@Test("Menu emits its label and item children")
func menu() {
let node = ViewHost(Menu("Options") {
Button("First") {}
Button("Second") {}
}).evaluate()
#expect(node.type == "Menu")
#expect(node.props["label"] == .string("Options"))
#expect(node.children.count == 2)
}

@Test("Environment objects reach @Environment properties in the subtree")
func environmentInjection() {
final class Model { var value = 42 }
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct CatalogEntry: Identifiable {
CatalogEntry(id: "textfield", title: "TextField", screen: AnyCatalogScreen(TextFieldPlayground())),
CatalogEntry(id: "picker", title: "Picker", screen: AnyCatalogScreen(PickerPlayground())),
CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())),
CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())),
CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())),
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
Expand Down
36 changes: 36 additions & 0 deletions Demo/App.swiftpm/Sources/MoreControlsPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
#endif

struct MoreControlsPlayground: View {
@State private var quantity = 1
@State private var password = ""
@State private var choice = "None"
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("Stepper") {
Stepper("Quantity: \(quantity)", value: $quantity, in: 0...10)
}
Example("SecureField") {
VStack(alignment: .leading, spacing: 8) {
SecureField("Password", text: $password)
Text(password.isEmpty ? "Nothing typed yet" : "\(password.count) characters")
}
}
Example("Menu") {
VStack(alignment: .leading, spacing: 8) {
Menu("Actions") {
Button("Rename") { choice = "Rename" }
Button("Duplicate") { choice = "Duplicate" }
Button("Delete") { choice = "Delete" }
}
Text("Chose: \(choice)")
}
}
}
}
}
}
43 changes: 43 additions & 0 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.IntOffset
Expand Down Expand Up @@ -223,6 +225,10 @@ fun Render(node: ViewNode) {

"TextField" -> RenderTextField(node)

"Stepper" -> RenderStepper(node)

"Menu" -> RenderMenu(node)

"Picker" -> RenderPicker(node)

"NavStack" -> RenderNavStack(node)
Expand Down Expand Up @@ -289,10 +295,47 @@ private fun RenderTextField(node: ViewNode) {
}
},
label = { Text(node.string("placeholder") ?: "") },
visualTransformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None,
modifier = node.composeModifiers().fillMaxWidth(),
)
}

// Label, then − / + edge buttons pushed to the trailing side.
@Composable
private fun RenderStepper(node: ViewNode) {
val onIncrement = node.long("onIncrement")
val onDecrement = node.long("onDecrement")
Row(verticalAlignment = Alignment.CenterVertically, modifier = node.composeModifiers().fillMaxWidth()) {
RenderChildren(node)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = { onDecrement?.let { SwiftBridge.sink.invokeVoid(it) } }) { Text("−", fontSize = 20.sp) }
TextButton(onClick = { onIncrement?.let { SwiftBridge.sink.invokeVoid(it) } }) { Text("+", fontSize = 20.sp) }
}
}

// A trigger button opening a dropdown; each child Button becomes a menu item
// firing its own tap callback.
@Composable
private fun RenderMenu(node: ViewNode) {
var expanded by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { expanded = true }) { Text(node.string("label") ?: "") }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
for (child in node.children) {
if (child.isPresentation()) continue
val onTap = child.long("onTap")
DropdownMenuItem(
text = { Text(pickerLabel(child)) },
onClick = {
expanded = false
onTap?.let { SwiftBridge.sink.invokeVoid(it) }
},
)
}
}
}
}

@Composable
private fun RenderPicker(node: ViewNode) {
val onChange = node.long("onChange")
Expand Down
Loading