diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/DisclosureGroup.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/DisclosureGroup.swift new file mode 100644 index 0000000..96ca036 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/DisclosureGroup.swift @@ -0,0 +1,67 @@ +// +// DisclosureGroup.swift +// AndroidSwiftUICore +// +// A label that expands to reveal its content. Expansion can be left to the +// interpreter (it remembers the open/closed state) or driven by a binding — +// when bound, the header's tap round-trips through a callback so Swift stays +// the source of truth, exactly like a Toggle. +// + +public struct DisclosureGroup: View { + + internal let label: Label + internal let content: Content + internal let isExpanded: Binding? + + public init( + isExpanded: Binding? = nil, + @ViewBuilder content: () -> Content, + @ViewBuilder label: () -> Label + ) { + self.isExpanded = isExpanded + self.content = content() + self.label = label() + } + + public typealias Body = Never +} + +public extension DisclosureGroup where Label == Text { + + init(_ title: S, @ViewBuilder content: () -> Content) { + self.init(content: content) { Text(title) } + } + + init( + _ title: S, + isExpanded: Binding, + @ViewBuilder content: () -> Content + ) { + self.init(isExpanded: isExpanded, content: content) { Text(title) } + } +} + +extension DisclosureGroup: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + // label first, then content; `labelCount` tells the interpreter where the + // header ends and the body begins + let labelNodes = Evaluator.resolveChildren(label, context.descending("label")) + let contentNodes = Evaluator.resolveChildren(content, context.descending("content")) + + var props: [String: PropValue] = ["labelCount": .int(labelNodes.count)] + if let isExpanded { + props["isExpanded"] = .bool(isExpanded.wrappedValue) + let binding = isExpanded + let id = context.callbacks.register(.bool { binding.wrappedValue = $0 }) + props["onToggle"] = .int(Int(id)) + } + return RenderNode( + type: "DisclosureGroup", + id: context.path, + props: props, + children: labelNodes + contentNodes + ) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index 35fd013..eb0b7fe 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -854,3 +854,55 @@ struct AccessibilityTests { #expect(firstTextString(plain) == firstTextString(described)) } } + +@Suite("DisclosureGroup") +struct DisclosureGroupTests { + + @Test("Emits its label then its content, marking the boundary") + func labelThenContent() { + let node = ViewHost( + DisclosureGroup("Advanced") { + Text("row one") + Text("row two") + } + ).evaluate() + #expect(node.type == "DisclosureGroup") + #expect(node.props["labelCount"] == .int(1)) + #expect(node.children.count == 3) // 1 label + 2 content + #expect(firstTextString(node.children[0]) == "Advanced") + #expect(firstTextString(node.children[1]) == "row one") + // unbound: the interpreter owns expansion, so no state crosses + #expect(node.props["isExpanded"] == nil) + #expect(node.props["onToggle"] == nil) + } + + @Test("A bound group round-trips its expansion") + func boundExpansion() { + struct Screen: View { + @State var open = false + var body: some View { + DisclosureGroup("Details", isExpanded: $open) { Text("hidden") } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.props["isExpanded"] == .bool(false)) + guard case .int(let id)? = node.props["onToggle"] else { + Issue.record("missing toggle callback"); return + } + host.callbacks.invokeBool(Int64(id), true) + node = host.evaluate() + #expect(node.props["isExpanded"] == .bool(true)) + } + + @Test("Accepts a view label, not only a title") + func viewLabel() { + let node = ViewHost( + DisclosureGroup(content: { Text("body") }, label: { + Label("Section", systemImage: "star") + }) + ).evaluate() + #expect(node.props["labelCount"] == .int(1)) + #expect(node.children[0].type == "Label") + } +} diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 1e1e953..4c4be58 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -51,6 +51,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())), CatalogEntry(id: "lazystack", title: "Lazy Stacks", screen: AnyCatalogScreen(LazyStackPlayground())), CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())), + CatalogEntry(id: "disclosure", title: "DisclosureGroup", screen: AnyCatalogScreen(DisclosurePlayground())), CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())), CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())), CatalogEntry(id: "representable", title: "Custom Views", screen: AnyCatalogScreen(RepresentablePlayground())), diff --git a/Demo/App.swiftpm/Sources/DisclosurePlaygrounds.swift b/Demo/App.swiftpm/Sources/DisclosurePlaygrounds.swift new file mode 100644 index 0000000..d053181 --- /dev/null +++ b/Demo/App.swiftpm/Sources/DisclosurePlaygrounds.swift @@ -0,0 +1,43 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct DisclosurePlayground: View { + + @State private var advancedOpen = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Self-managed expansion") { + DisclosureGroup("What's included") { + Text("• Unlimited projects") + Text("• Priority support") + Text("• Early access") + } + } + Example("Bound expansion") { + VStack(alignment: .leading, spacing: 8) { + DisclosureGroup("Advanced settings", isExpanded: $advancedOpen) { + Text("Experimental features live here.") + Toggle("Beta channel", isOn: .constant(true)) + } + Button(advancedOpen ? "Collapse from outside" : "Expand from outside") { + advancedOpen.toggle() + } + } + } + Example("With a Label header") { + DisclosureGroup(content: { + Text("Grouped under a labeled header.") + }, label: { + Label("Notifications", systemImage: "star.fill") + }) + } + } + } + .navigationTitle("DisclosureGroup") + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 59a83eb..e2be34d 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -103,6 +103,8 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close @@ -351,6 +353,8 @@ private fun RenderResolved(node: ViewNode) { } } + "DisclosureGroup" -> RenderDisclosureGroup(node) + "Label" -> RenderLabel(node) "Link" -> RenderLink(node) @@ -629,6 +633,42 @@ private fun RenderLabel(node: ViewNode) { } } +// A tappable header (label + chevron) over collapsible content. Expansion is +// interpreter-local unless a binding is present, in which case the tap +// round-trips through Swift like any other control. +@Composable +private fun RenderDisclosureGroup(node: ViewNode) { + val labelCount = node.long("labelCount")?.toInt() ?: 0 + val onToggle = node.long("onToggle") + val bound = node.props.containsKey("isExpanded") + + var localExpanded by remember(node.id) { mutableStateOf(false) } + val expanded = if (bound) node.string("isExpanded") == "true" else localExpanded + + Column(modifier = node.composeModifiers().fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { + if (bound) onToggle?.let { SwiftBridge.sink.invokeBool(it, !expanded) } + else localExpanded = !localExpanded + }.padding(vertical = 6.dp), + ) { + Row(modifier = Modifier.weight(1f)) { + node.children.take(labelCount).forEach { RenderChild(it) } + } + Icon( + imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse" else "Expand", + ) + } + if (expanded) { + Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp)) { + node.children.drop(labelCount).forEach { RenderChild(it) } + } + } + } +} + private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"