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,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<Label: View, Content: View>: View {

internal let label: Label
internal let content: Content
internal let isExpanded: Binding<Bool>?

public init(
isExpanded: Binding<Bool>? = 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<S: StringProtocol>(_ title: S, @ViewBuilder content: () -> Content) {
self.init(content: content) { Text(title) }
}

init<S: StringProtocol>(
_ title: S,
isExpanded: Binding<Bool>,
@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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
43 changes: 43 additions & 0 deletions Demo/App.swiftpm/Sources/DisclosurePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
40 changes: 40 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 @@ -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
Expand Down Expand Up @@ -351,6 +353,8 @@ private fun RenderResolved(node: ViewNode) {
}
}

"DisclosureGroup" -> RenderDisclosureGroup(node)

"Label" -> RenderLabel(node)

"Link" -> RenderLink(node)
Expand Down Expand Up @@ -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"

Expand Down
Loading