Skip to content

Commit 12160da

Browse files
authored
Merge pull request #31 from PureSwift/feature/form
2 parents 8112d09 + fbd47cd commit 12160da

5 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//
2+
// Form.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A Form is a grouped, scrolling container of Sections; a Section groups rows
6+
// under an optional header and footer. The interpreter renders sections as
7+
// inset, rounded groups with dividers between rows.
8+
//
9+
10+
public struct Form<Content: View>: View {
11+
internal let content: Content
12+
public init(@ViewBuilder content: () -> Content) { self.content = content() }
13+
public typealias Body = Never
14+
}
15+
16+
extension Form: PrimitiveView {
17+
public func _render(in context: ResolveContext) -> RenderNode {
18+
RenderNode(
19+
type: "Form",
20+
id: context.path,
21+
children: Evaluator.resolveChildren(content, context.descending("content"))
22+
)
23+
}
24+
}
25+
26+
public struct Section<Content: View>: View {
27+
28+
internal let header: String?
29+
internal let footer: String?
30+
internal let content: Content
31+
32+
public init(@ViewBuilder content: () -> Content) {
33+
self.header = nil
34+
self.footer = nil
35+
self.content = content()
36+
}
37+
38+
public init<S: StringProtocol>(_ header: S, @ViewBuilder content: () -> Content) {
39+
self.header = String(header)
40+
self.footer = nil
41+
self.content = content()
42+
}
43+
44+
public init<H: StringProtocol, F: StringProtocol>(header: H, footer: F, @ViewBuilder content: () -> Content) {
45+
self.header = String(header)
46+
self.footer = String(footer)
47+
self.content = content()
48+
}
49+
50+
public typealias Body = Never
51+
}
52+
53+
extension Section: PrimitiveView {
54+
public func _render(in context: ResolveContext) -> RenderNode {
55+
var props: [String: PropValue] = [:]
56+
if let header { props["header"] = .string(header) }
57+
if let footer { props["footer"] = .string(footer) }
58+
return RenderNode(
59+
type: "Section",
60+
id: context.path,
61+
props: props,
62+
children: Evaluator.resolveChildren(content, context.descending("content"))
63+
)
64+
}
65+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,23 @@ struct ControlTests {
115115
#expect(node.children.count == 2)
116116
}
117117

118+
@Test("Form nests Sections with their header and rows")
119+
func formSection() {
120+
let node = ViewHost(
121+
Form {
122+
Section("General") {
123+
Text("Wi-Fi")
124+
Text("Bluetooth")
125+
}
126+
}
127+
).evaluate()
128+
#expect(node.type == "Form")
129+
let section = node.children.first
130+
#expect(section?.type == "Section")
131+
#expect(section?.props["header"] == .string("General"))
132+
#expect(section?.children.count == 2)
133+
}
134+
118135
@Test("Environment objects reach @Environment properties in the subtree")
119136
func environmentInjection() {
120137
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
@@ -39,6 +39,7 @@ struct CatalogEntry: Identifiable {
3939
CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())),
4040
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
4141
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
42+
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
4243
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
4344
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
4445
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct FormPlayground: View {
8+
@State private var wifi = true
9+
@State private var bluetooth = false
10+
@State private var brightness = 0.6
11+
@State private var name = ""
12+
var body: some View {
13+
Form {
14+
Section("Connectivity") {
15+
Toggle("Wi-Fi", isOn: $wifi)
16+
Toggle("Bluetooth", isOn: $bluetooth)
17+
}
18+
Section(header: "Display", footer: "Drag to adjust the screen brightness.") {
19+
Slider(value: $brightness, in: 0...1)
20+
HStack { Text("Level"); Spacer(); Text("\(Int(brightness * 100))%") }
21+
}
22+
Section("Account") {
23+
TextField("Name", text: $name)
24+
HStack { Text("Status"); Spacer(); Text(name.isEmpty ? "Guest" : name) }
25+
}
26+
}
27+
}
28+
}

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import androidx.compose.material3.DropdownMenu
3434
import androidx.compose.material3.DropdownMenuItem
3535
import androidx.compose.material3.HorizontalDivider
3636
import androidx.compose.material3.LinearProgressIndicator
37+
import androidx.compose.material3.MaterialTheme
38+
import androidx.compose.material3.Surface
3739
import androidx.compose.material3.ExperimentalMaterial3Api
3840
import androidx.compose.material3.Icon
3941
import androidx.compose.material3.IconButton
@@ -240,6 +242,10 @@ fun Render(node: ViewNode) {
240242

241243
"TabView" -> RenderTabView(node)
242244

245+
"Form" -> RenderForm(node)
246+
247+
"Section" -> RenderSection(node)
248+
243249
"List" -> RenderList(node)
244250

245251
"LazyVGrid" -> RenderGrid(node, vertical = true)
@@ -313,6 +319,51 @@ private fun RenderStepper(node: ViewNode) {
313319
}
314320
}
315321

322+
// A Form scrolls its Sections; non-Section children still render, ungrouped.
323+
@Composable
324+
private fun RenderForm(node: ViewNode) {
325+
val state = rememberScrollState()
326+
Column(modifier = node.composeModifiers().fillMaxSize().verticalScroll(state).padding(vertical = 8.dp)) {
327+
RenderChildren(node)
328+
}
329+
}
330+
331+
// An inset, rounded group: uppercase header, rows separated by dividers, footer.
332+
@Composable
333+
private fun RenderSection(node: ViewNode) {
334+
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
335+
node.string("header")?.let {
336+
Text(
337+
it.uppercase(),
338+
fontSize = 13.sp,
339+
color = MaterialTheme.colorScheme.onSurfaceVariant,
340+
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp),
341+
)
342+
}
343+
Surface(
344+
color = MaterialTheme.colorScheme.surfaceVariant,
345+
shape = RoundedCornerShape(10.dp),
346+
modifier = Modifier.fillMaxWidth(),
347+
) {
348+
Column {
349+
val rows = node.children.filter { !it.isPresentation() }
350+
rows.forEachIndexed { index, row ->
351+
if (index > 0) HorizontalDivider()
352+
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { Render(row) }
353+
}
354+
}
355+
}
356+
node.string("footer")?.let {
357+
Text(
358+
it,
359+
fontSize = 12.sp,
360+
color = MaterialTheme.colorScheme.onSurfaceVariant,
361+
modifier = Modifier.padding(start = 4.dp, top = 6.dp),
362+
)
363+
}
364+
}
365+
}
366+
316367
// A trigger button opening a dropdown; each child Button becomes a menu item
317368
// firing its own tap callback.
318369
@Composable

0 commit comments

Comments
 (0)