Skip to content

Commit 0e7db5c

Browse files
authored
Merge pull request #33 from PureSwift/feature/datepicker
DatePicker
2 parents 7e3521a + 15ecaa1 commit 0e7db5c

4 files changed

Lines changed: 120 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// DatePicker.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A control for choosing a calendar date. The selection round-trips the
6+
// bridge as milliseconds since the Unix epoch — Compose's Material3
7+
// DatePickerState currency, and a trivial conversion from Foundation's Date.
8+
//
9+
10+
import Foundation
11+
12+
public struct DatePicker<Label: View>: View {
13+
14+
internal let label: Label
15+
internal let selection: Binding<Date>
16+
17+
public init(selection: Binding<Date>, @ViewBuilder label: () -> Label) {
18+
self.label = label()
19+
self.selection = selection
20+
}
21+
22+
public typealias Body = Never
23+
}
24+
25+
public extension DatePicker where Label == Text {
26+
init<S: StringProtocol>(_ title: S, selection: Binding<Date>) {
27+
self.init(selection: selection) { Text(title) }
28+
}
29+
}
30+
31+
extension DatePicker: PrimitiveView {
32+
public func _render(in context: ResolveContext) -> RenderNode {
33+
let binding = selection
34+
let callbackID = context.callbacks.register(.double { millis in
35+
binding.wrappedValue = Date(timeIntervalSince1970: millis / 1000)
36+
})
37+
return RenderNode(
38+
type: "DatePicker",
39+
id: context.path,
40+
props: [
41+
"millis": .double(selection.wrappedValue.timeIntervalSince1970 * 1000),
42+
"onChange": .int(Int(callbackID)),
43+
],
44+
children: Evaluator.resolveChildren(label, context.descending("label"))
45+
)
46+
}
47+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//
55

66
import Testing
7+
import Foundation
78
@testable import AndroidSwiftUICore
89

910
#if canImport(Observation)
@@ -194,4 +195,23 @@ struct ControlTests {
194195
#expect(node.props["text"] == .string("Coleman"))
195196
}
196197
#endif
198+
199+
@Test("DatePicker emits its selection in milliseconds and round-trips a change")
200+
func datePicker() {
201+
let initial = Date(timeIntervalSince1970: 1_768_435_200) // 2026-01-15T00:00:00Z
202+
let changed = Date(timeIntervalSince1970: 1_772_323_200) // 2026-03-01T00:00:00Z
203+
struct Screen: View {
204+
@State var date: Date
205+
var body: some View { DatePicker("Birthday", selection: $date) }
206+
}
207+
let host = ViewHost(Screen(date: initial))
208+
var node = host.evaluate()
209+
#expect(node.type == "DatePicker")
210+
#expect(node.props["millis"] == .double(initial.timeIntervalSince1970 * 1000))
211+
if case .int(let id)? = node.props["onChange"] {
212+
host.callbacks.invokeDouble(Int64(id), changed.timeIntervalSince1970 * 1000)
213+
}
214+
node = host.evaluate()
215+
#expect(node.props["millis"] == .double(changed.timeIntervalSince1970 * 1000))
216+
}
197217
}

Demo/App.swiftpm/Sources/MoreControlsPlaygrounds.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ import AndroidSwiftUI
44
import SwiftUI
55
#endif
66

7+
import Foundation
8+
79
struct MoreControlsPlayground: View {
810
@State private var quantity = 1
911
@State private var password = ""
1012
@State private var choice = "None"
13+
@State private var birthday = Date(timeIntervalSince1970: 946_684_800) // 2000-01-01T00:00:00Z
1114
var body: some View {
1215
ScrollView {
1316
VStack(alignment: .leading, spacing: 0) {
1417
Example("Stepper") {
1518
Stepper("Quantity: \(quantity)", value: $quantity, in: 0...10)
1619
}
20+
Example("DatePicker") {
21+
DatePicker("Birthday", selection: $birthday)
22+
}
1723
Example("SecureField") {
1824
VStack(alignment: .leading, spacing: 8) {
1925
SecureField("Password", text: $password)

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
3131
import androidx.compose.material3.AlertDialog
3232
import androidx.compose.material3.Button
3333
import androidx.compose.material3.CircularProgressIndicator
34+
import androidx.compose.material3.DatePicker
35+
import androidx.compose.material3.DatePickerDialog
36+
import androidx.compose.material3.rememberDatePickerState
3437
import androidx.compose.material3.DropdownMenu
3538
import androidx.compose.material3.DropdownMenuItem
3639
import androidx.compose.material3.HorizontalDivider
@@ -247,6 +250,8 @@ fun Render(node: ViewNode) {
247250

248251
"Menu" -> RenderMenu(node)
249252

253+
"DatePicker" -> RenderDatePicker(node)
254+
250255
"Picker" -> RenderPicker(node)
251256

252257
"NavStack" -> RenderNavStack(node)
@@ -380,6 +385,48 @@ private fun RenderSection(node: ViewNode) {
380385
}
381386
}
382387

388+
// A label row with a trailing formatted-date button; tapping opens a Material3
389+
// date picker dialog. Selection round-trips as UTC epoch millis, matching both
390+
// Date's representation and DatePickerState's currency, so no conversion.
391+
@OptIn(ExperimentalMaterial3Api::class)
392+
@Composable
393+
private fun RenderDatePicker(node: ViewNode) {
394+
val onChange = node.long("onChange")
395+
val millis = (node.double("millis") ?: 0.0).toLong()
396+
var showDialog by remember { mutableStateOf(false) }
397+
Row(verticalAlignment = Alignment.CenterVertically, modifier = node.composeModifiers().fillMaxWidth()) {
398+
RenderChildren(node)
399+
Spacer(modifier = Modifier.weight(1f))
400+
TextButton(onClick = { showDialog = true }) { Text(formatDateMillis(millis)) }
401+
}
402+
if (showDialog) {
403+
val state = rememberDatePickerState(initialSelectedDateMillis = millis)
404+
DatePickerDialog(
405+
onDismissRequest = { showDialog = false },
406+
confirmButton = {
407+
TextButton(onClick = {
408+
showDialog = false
409+
state.selectedDateMillis?.let { picked -> onChange?.let { SwiftBridge.sink.invokeDouble(it, picked.toDouble()) } }
410+
}) { Text("OK") }
411+
},
412+
dismissButton = { TextButton(onClick = { showDialog = false }) { Text("Cancel") } },
413+
) {
414+
DatePicker(state = state)
415+
}
416+
}
417+
}
418+
419+
private val monthAbbreviations = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
420+
421+
private fun formatDateMillis(millis: Long): String {
422+
val calendar = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
423+
calendar.timeInMillis = millis
424+
val month = monthAbbreviations[calendar.get(java.util.Calendar.MONTH)]
425+
val day = calendar.get(java.util.Calendar.DAY_OF_MONTH)
426+
val year = calendar.get(java.util.Calendar.YEAR)
427+
return "$month $day, $year"
428+
}
429+
383430
// A trigger button opening a dropdown; each child Button becomes a menu item
384431
// firing its own tap callback.
385432
@Composable

0 commit comments

Comments
 (0)