Skip to content

Commit 8223c61

Browse files
authored
Merge pull request #64 from PureSwift/feature/disclosure-group
2 parents 8b08220 + ac3abe2 commit 8223c61

5 files changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//
2+
// DisclosureGroup.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A label that expands to reveal its content. Expansion can be left to the
6+
// interpreter (it remembers the open/closed state) or driven by a binding —
7+
// when bound, the header's tap round-trips through a callback so Swift stays
8+
// the source of truth, exactly like a Toggle.
9+
//
10+
11+
public struct DisclosureGroup<Label: View, Content: View>: View {
12+
13+
internal let label: Label
14+
internal let content: Content
15+
internal let isExpanded: Binding<Bool>?
16+
17+
public init(
18+
isExpanded: Binding<Bool>? = nil,
19+
@ViewBuilder content: () -> Content,
20+
@ViewBuilder label: () -> Label
21+
) {
22+
self.isExpanded = isExpanded
23+
self.content = content()
24+
self.label = label()
25+
}
26+
27+
public typealias Body = Never
28+
}
29+
30+
public extension DisclosureGroup where Label == Text {
31+
32+
init<S: StringProtocol>(_ title: S, @ViewBuilder content: () -> Content) {
33+
self.init(content: content) { Text(title) }
34+
}
35+
36+
init<S: StringProtocol>(
37+
_ title: S,
38+
isExpanded: Binding<Bool>,
39+
@ViewBuilder content: () -> Content
40+
) {
41+
self.init(isExpanded: isExpanded, content: content) { Text(title) }
42+
}
43+
}
44+
45+
extension DisclosureGroup: PrimitiveView {
46+
47+
public func _render(in context: ResolveContext) -> RenderNode {
48+
// label first, then content; `labelCount` tells the interpreter where the
49+
// header ends and the body begins
50+
let labelNodes = Evaluator.resolveChildren(label, context.descending("label"))
51+
let contentNodes = Evaluator.resolveChildren(content, context.descending("content"))
52+
53+
var props: [String: PropValue] = ["labelCount": .int(labelNodes.count)]
54+
if let isExpanded {
55+
props["isExpanded"] = .bool(isExpanded.wrappedValue)
56+
let binding = isExpanded
57+
let id = context.callbacks.register(.bool { binding.wrappedValue = $0 })
58+
props["onToggle"] = .int(Int(id))
59+
}
60+
return RenderNode(
61+
type: "DisclosureGroup",
62+
id: context.path,
63+
props: props,
64+
children: labelNodes + contentNodes
65+
)
66+
}
67+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,3 +854,55 @@ struct AccessibilityTests {
854854
#expect(firstTextString(plain) == firstTextString(described))
855855
}
856856
}
857+
858+
@Suite("DisclosureGroup")
859+
struct DisclosureGroupTests {
860+
861+
@Test("Emits its label then its content, marking the boundary")
862+
func labelThenContent() {
863+
let node = ViewHost(
864+
DisclosureGroup("Advanced") {
865+
Text("row one")
866+
Text("row two")
867+
}
868+
).evaluate()
869+
#expect(node.type == "DisclosureGroup")
870+
#expect(node.props["labelCount"] == .int(1))
871+
#expect(node.children.count == 3) // 1 label + 2 content
872+
#expect(firstTextString(node.children[0]) == "Advanced")
873+
#expect(firstTextString(node.children[1]) == "row one")
874+
// unbound: the interpreter owns expansion, so no state crosses
875+
#expect(node.props["isExpanded"] == nil)
876+
#expect(node.props["onToggle"] == nil)
877+
}
878+
879+
@Test("A bound group round-trips its expansion")
880+
func boundExpansion() {
881+
struct Screen: View {
882+
@State var open = false
883+
var body: some View {
884+
DisclosureGroup("Details", isExpanded: $open) { Text("hidden") }
885+
}
886+
}
887+
let host = ViewHost(Screen())
888+
var node = host.evaluate()
889+
#expect(node.props["isExpanded"] == .bool(false))
890+
guard case .int(let id)? = node.props["onToggle"] else {
891+
Issue.record("missing toggle callback"); return
892+
}
893+
host.callbacks.invokeBool(Int64(id), true)
894+
node = host.evaluate()
895+
#expect(node.props["isExpanded"] == .bool(true))
896+
}
897+
898+
@Test("Accepts a view label, not only a title")
899+
func viewLabel() {
900+
let node = ViewHost(
901+
DisclosureGroup(content: { Text("body") }, label: {
902+
Label("Section", systemImage: "star")
903+
})
904+
).evaluate()
905+
#expect(node.props["labelCount"] == .int(1))
906+
#expect(node.children[0].type == "Label")
907+
}
908+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ struct CatalogEntry: Identifiable {
5151
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
5252
CatalogEntry(id: "lazystack", title: "Lazy Stacks", screen: AnyCatalogScreen(LazyStackPlayground())),
5353
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
54+
CatalogEntry(id: "disclosure", title: "DisclosureGroup", screen: AnyCatalogScreen(DisclosurePlayground())),
5455
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
5556
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
5657
CatalogEntry(id: "representable", title: "Custom Views", screen: AnyCatalogScreen(RepresentablePlayground())),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct DisclosurePlayground: View {
8+
9+
@State private var advancedOpen = false
10+
11+
var body: some View {
12+
ScrollView {
13+
VStack(alignment: .leading, spacing: 0) {
14+
Example("Self-managed expansion") {
15+
DisclosureGroup("What's included") {
16+
Text("• Unlimited projects")
17+
Text("• Priority support")
18+
Text("• Early access")
19+
}
20+
}
21+
Example("Bound expansion") {
22+
VStack(alignment: .leading, spacing: 8) {
23+
DisclosureGroup("Advanced settings", isExpanded: $advancedOpen) {
24+
Text("Experimental features live here.")
25+
Toggle("Beta channel", isOn: .constant(true))
26+
}
27+
Button(advancedOpen ? "Collapse from outside" : "Expand from outside") {
28+
advancedOpen.toggle()
29+
}
30+
}
31+
}
32+
Example("With a Label header") {
33+
DisclosureGroup(content: {
34+
Text("Grouped under a labeled header.")
35+
}, label: {
36+
Label("Notifications", systemImage: "star.fill")
37+
})
38+
}
39+
}
40+
}
41+
.navigationTitle("DisclosureGroup")
42+
}
43+
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ import androidx.compose.material3.TextButton
103103
import androidx.compose.material3.TopAppBar
104104
import androidx.compose.material.icons.Icons
105105
import androidx.compose.material.icons.automirrored.filled.ArrowBack
106+
import androidx.compose.material.icons.filled.KeyboardArrowDown
107+
import androidx.compose.material.icons.filled.KeyboardArrowUp
106108
import androidx.compose.material.icons.filled.Add
107109
import androidx.compose.material.icons.filled.Check
108110
import androidx.compose.material.icons.filled.Close
@@ -351,6 +353,8 @@ private fun RenderResolved(node: ViewNode) {
351353
}
352354
}
353355

356+
"DisclosureGroup" -> RenderDisclosureGroup(node)
357+
354358
"Label" -> RenderLabel(node)
355359

356360
"Link" -> RenderLink(node)
@@ -629,6 +633,42 @@ private fun RenderLabel(node: ViewNode) {
629633
}
630634
}
631635

636+
// A tappable header (label + chevron) over collapsible content. Expansion is
637+
// interpreter-local unless a binding is present, in which case the tap
638+
// round-trips through Swift like any other control.
639+
@Composable
640+
private fun RenderDisclosureGroup(node: ViewNode) {
641+
val labelCount = node.long("labelCount")?.toInt() ?: 0
642+
val onToggle = node.long("onToggle")
643+
val bound = node.props.containsKey("isExpanded")
644+
645+
var localExpanded by remember(node.id) { mutableStateOf(false) }
646+
val expanded = if (bound) node.string("isExpanded") == "true" else localExpanded
647+
648+
Column(modifier = node.composeModifiers().fillMaxWidth()) {
649+
Row(
650+
verticalAlignment = Alignment.CenterVertically,
651+
modifier = Modifier.fillMaxWidth().clickable {
652+
if (bound) onToggle?.let { SwiftBridge.sink.invokeBool(it, !expanded) }
653+
else localExpanded = !localExpanded
654+
}.padding(vertical = 6.dp),
655+
) {
656+
Row(modifier = Modifier.weight(1f)) {
657+
node.children.take(labelCount).forEach { RenderChild(it) }
658+
}
659+
Icon(
660+
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
661+
contentDescription = if (expanded) "Collapse" else "Expand",
662+
)
663+
}
664+
if (expanded) {
665+
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp)) {
666+
node.children.drop(labelCount).forEach { RenderChild(it) }
667+
}
668+
}
669+
}
670+
}
671+
632672
private fun ViewNode.isPresentation(): Boolean =
633673
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"
634674

0 commit comments

Comments
 (0)