Skip to content

Commit db4169e

Browse files
authored
Merge pull request #47 from PureSwift/feature/confirmation-dialog
2 parents eb0e2a0 + a038ed6 commit db4169e

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,89 @@ public extension View {
163163
_AlertView(title: String(title), isPresented: isPresented, message: message, buttons: buttons, content: self)
164164
}
165165
}
166+
167+
// MARK: - Confirmation dialog
168+
169+
/// Whether a chrome element is shown. Mirrors SwiftUI's `Visibility`; here it
170+
/// only governs a confirmation dialog's title.
171+
public enum Visibility: Sendable {
172+
case automatic, visible, hidden
173+
}
174+
175+
/// An action sheet of choices presented from the bottom of the screen while
176+
/// `isPresented` is true. Like `alert`, each button writes the bound flag back
177+
/// to false, but any number of buttons are laid out vertically.
178+
public struct _ConfirmationDialogView<Content: View>: View {
179+
internal let title: String
180+
internal let titleVisibility: Visibility
181+
internal let isPresented: Binding<Bool>
182+
internal let message: String?
183+
internal let buttons: [AlertButton]
184+
internal let content: Content
185+
public typealias Body = Never
186+
}
187+
188+
extension _ConfirmationDialogView: PrimitiveView {
189+
190+
public func _render(in context: ResolveContext) -> RenderNode {
191+
var node = Evaluator.resolve(content, context.descending("content"))
192+
guard isPresented.wrappedValue else { return node }
193+
194+
let binding = isPresented
195+
var buttonNodes: [PropValue] = []
196+
for button in buttons {
197+
let action = button.action
198+
let id = context.callbacks.register(.void {
199+
action()
200+
binding.wrappedValue = false
201+
})
202+
buttonNodes.append(.array([
203+
.string(button.title),
204+
.string(role(button.role)),
205+
.int(Int(id)),
206+
]))
207+
}
208+
let dismissID = context.callbacks.register(.void { binding.wrappedValue = false })
209+
210+
// The title only shows when explicitly made visible (matching iOS, where
211+
// a confirmation dialog hides its title by default).
212+
var props: [String: PropValue] = [
213+
"title": .string(title),
214+
"showsTitle": .bool(titleVisibility == .visible),
215+
"buttons": .array(buttonNodes),
216+
"onDismiss": .int(Int(dismissID)),
217+
]
218+
if let message { props["message"] = .string(message) }
219+
220+
node.children.append(RenderNode(type: "ConfirmationDialog", id: context.path + "/confirmationDialog", props: props))
221+
node.props["hasConfirmationDialog"] = .bool(true)
222+
return node
223+
}
224+
225+
private func role(_ role: AlertButton.Role) -> String {
226+
switch role {
227+
case .normal: return "normal"
228+
case .cancel: return "cancel"
229+
case .destructive: return "destructive"
230+
}
231+
}
232+
}
233+
234+
public extension View {
235+
func confirmationDialog<S: StringProtocol>(
236+
_ title: S,
237+
isPresented: Binding<Bool>,
238+
titleVisibility: Visibility = .automatic,
239+
message: String? = nil,
240+
buttons: [AlertButton]
241+
) -> _ConfirmationDialogView<Self> {
242+
_ConfirmationDialogView(
243+
title: String(title),
244+
titleVisibility: titleVisibility,
245+
isPresented: isPresented,
246+
message: message,
247+
buttons: buttons,
248+
content: self
249+
)
250+
}
251+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,41 @@ struct NavigationTests {
116116
#expect(node.children.contains { $0.type == "Sheet" })
117117
}
118118

119+
@Test("Confirmation dialog presents its buttons and a chosen action dismisses it")
120+
func confirmationDialog() {
121+
struct Screen: View {
122+
@State var shown = false
123+
@State var picked = ""
124+
var body: some View {
125+
Button("Show") { shown = true }
126+
.confirmationDialog("Manage", isPresented: $shown, titleVisibility: .visible, buttons: [
127+
AlertButton("Duplicate") { picked = "dup" },
128+
AlertButton("Delete", role: .destructive) { picked = "del" },
129+
])
130+
}
131+
}
132+
let host = ViewHost(Screen())
133+
var node = host.evaluate()
134+
#expect(node.props["hasConfirmationDialog"] == nil)
135+
host.callbacks.invokeVoid(findOnTap(node)!)
136+
node = host.evaluate()
137+
#expect(node.props["hasConfirmationDialog"] == .bool(true))
138+
let dialog = node.children.first { $0.type == "ConfirmationDialog" }
139+
#expect(dialog?.props["showsTitle"] == .bool(true))
140+
guard case .array(let buttons)? = dialog?.props["buttons"], buttons.count == 2 else {
141+
Issue.record("expected two buttons"); return
142+
}
143+
// second button (destructive) fires its action and dismisses the dialog
144+
guard case .array(let second) = buttons[1], case .string(let role) = second[1],
145+
case .int(let id) = second[2] else {
146+
Issue.record("malformed button"); return
147+
}
148+
#expect(role == "destructive")
149+
host.callbacks.invokeVoid(Int64(id))
150+
node = host.evaluate()
151+
#expect(node.props["hasConfirmationDialog"] == nil) // dismissed
152+
}
153+
119154
@Test("TabView emits tabs with their item labels and selection")
120155
func tabs() {
121156
struct Screen: View {

Demo/App.swiftpm/Sources/PresentationPlaygrounds.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,14 @@ struct SheetBody: View {
8989
struct AlertPlayground: View {
9090
@State private var simple = false
9191
@State private var confirm = false
92+
@State private var sheet = false
9293
@State private var result = "No choice yet"
9394
var body: some View {
9495
ScrollView {
9596
VStack(spacing: 16) {
9697
Button("Show simple alert") { simple = true }
9798
Button("Show confirmation") { confirm = true }
99+
Button("Show confirmation dialog") { sheet = true }
98100
Text(result)
99101
}
100102
.padding()
@@ -104,5 +106,13 @@ struct AlertPlayground: View {
104106
AlertButton("Cancel", role: .cancel) { result = "Cancelled" },
105107
AlertButton("Delete", role: .destructive) { result = "Deleted" },
106108
])
109+
.confirmationDialog("Manage item", isPresented: $sheet,
110+
titleVisibility: .visible,
111+
message: "Choose an action", buttons: [
112+
AlertButton("Duplicate") { result = "Duplicated" },
113+
AlertButton("Share") { result = "Shared" },
114+
AlertButton("Delete", role: .destructive) { result = "Deleted from dialog" },
115+
AlertButton("Cancel", role: .cancel) { result = "Dialog cancelled" },
116+
])
107117
}
108118
}

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,8 @@ private fun RenderResolved(node: ViewNode) {
409409

410410
// Sheets and alerts ride as hidden children; the presentation layer shows
411411
// them, so the normal child loop skips them.
412-
private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert"
412+
private fun ViewNode.isPresentation(): Boolean =
413+
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog"
413414

414415
@Composable
415416
private fun RenderChildren(node: ViewNode) {
@@ -1297,6 +1298,53 @@ private fun RenderSheetsAndAlerts(screen: ViewNode?) {
12971298
}
12981299
}
12991300
"Alert" -> RenderAlert(child)
1301+
"ConfirmationDialog" -> RenderConfirmationDialog(child)
1302+
}
1303+
}
1304+
}
1305+
1306+
// An action sheet: a title/message header and one vertical button per choice,
1307+
// destructive choices in the error color, presented from the bottom edge.
1308+
@OptIn(ExperimentalMaterial3Api::class)
1309+
@Composable
1310+
private fun RenderConfirmationDialog(node: ViewNode) {
1311+
val onDismiss = node.long("onDismiss")
1312+
val buttons = (node.props["buttons"] as? kotlinx.serialization.json.JsonArray) ?: kotlinx.serialization.json.JsonArray(emptyList())
1313+
val parsed = buttons.mapNotNull { entry ->
1314+
val arr = entry as? kotlinx.serialization.json.JsonArray ?: return@mapNotNull null
1315+
val title = (arr[0] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@mapNotNull null
1316+
val role = (arr[1] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "normal"
1317+
val id = (arr[2] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull() ?: return@mapNotNull null
1318+
Triple(title, role, id)
1319+
}
1320+
ModalBottomSheet(onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } }) {
1321+
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
1322+
val header = node.string("message")
1323+
if (node.string("showsTitle") == "true") {
1324+
Text(
1325+
node.string("title") ?: "",
1326+
style = MaterialTheme.typography.titleMedium,
1327+
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
1328+
)
1329+
}
1330+
if (header != null) {
1331+
Text(
1332+
header,
1333+
style = MaterialTheme.typography.bodyMedium,
1334+
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
1335+
)
1336+
}
1337+
for ((title, role, id) in parsed) {
1338+
TextButton(
1339+
onClick = { SwiftBridge.sink.invokeVoid(id) },
1340+
modifier = Modifier.fillMaxWidth(),
1341+
) {
1342+
Text(
1343+
title,
1344+
color = if (role == "destructive") MaterialTheme.colorScheme.error else Color.Unspecified,
1345+
)
1346+
}
1347+
}
13001348
}
13011349
}
13021350
}

0 commit comments

Comments
 (0)