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
86 changes: 86 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,89 @@ public extension View {
_AlertView(title: String(title), isPresented: isPresented, message: message, buttons: buttons, content: self)
}
}

// MARK: - Confirmation dialog

/// Whether a chrome element is shown. Mirrors SwiftUI's `Visibility`; here it
/// only governs a confirmation dialog's title.
public enum Visibility: Sendable {
case automatic, visible, hidden
}

/// An action sheet of choices presented from the bottom of the screen while
/// `isPresented` is true. Like `alert`, each button writes the bound flag back
/// to false, but any number of buttons are laid out vertically.
public struct _ConfirmationDialogView<Content: View>: View {
internal let title: String
internal let titleVisibility: Visibility
internal let isPresented: Binding<Bool>
internal let message: String?
internal let buttons: [AlertButton]
internal let content: Content
public typealias Body = Never
}

extension _ConfirmationDialogView: PrimitiveView {

public func _render(in context: ResolveContext) -> RenderNode {
var node = Evaluator.resolve(content, context.descending("content"))
guard isPresented.wrappedValue else { return node }

let binding = isPresented
var buttonNodes: [PropValue] = []
for button in buttons {
let action = button.action
let id = context.callbacks.register(.void {
action()
binding.wrappedValue = false
})
buttonNodes.append(.array([
.string(button.title),
.string(role(button.role)),
.int(Int(id)),
]))
}
let dismissID = context.callbacks.register(.void { binding.wrappedValue = false })

// The title only shows when explicitly made visible (matching iOS, where
// a confirmation dialog hides its title by default).
var props: [String: PropValue] = [
"title": .string(title),
"showsTitle": .bool(titleVisibility == .visible),
"buttons": .array(buttonNodes),
"onDismiss": .int(Int(dismissID)),
]
if let message { props["message"] = .string(message) }

node.children.append(RenderNode(type: "ConfirmationDialog", id: context.path + "/confirmationDialog", props: props))
node.props["hasConfirmationDialog"] = .bool(true)
return node
}

private func role(_ role: AlertButton.Role) -> String {
switch role {
case .normal: return "normal"
case .cancel: return "cancel"
case .destructive: return "destructive"
}
}
}

public extension View {
func confirmationDialog<S: StringProtocol>(
_ title: S,
isPresented: Binding<Bool>,
titleVisibility: Visibility = .automatic,
message: String? = nil,
buttons: [AlertButton]
) -> _ConfirmationDialogView<Self> {
_ConfirmationDialogView(
title: String(title),
titleVisibility: titleVisibility,
isPresented: isPresented,
message: message,
buttons: buttons,
content: self
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,41 @@ struct NavigationTests {
#expect(node.children.contains { $0.type == "Sheet" })
}

@Test("Confirmation dialog presents its buttons and a chosen action dismisses it")
func confirmationDialog() {
struct Screen: View {
@State var shown = false
@State var picked = ""
var body: some View {
Button("Show") { shown = true }
.confirmationDialog("Manage", isPresented: $shown, titleVisibility: .visible, buttons: [
AlertButton("Duplicate") { picked = "dup" },
AlertButton("Delete", role: .destructive) { picked = "del" },
])
}
}
let host = ViewHost(Screen())
var node = host.evaluate()
#expect(node.props["hasConfirmationDialog"] == nil)
host.callbacks.invokeVoid(findOnTap(node)!)
node = host.evaluate()
#expect(node.props["hasConfirmationDialog"] == .bool(true))
let dialog = node.children.first { $0.type == "ConfirmationDialog" }
#expect(dialog?.props["showsTitle"] == .bool(true))
guard case .array(let buttons)? = dialog?.props["buttons"], buttons.count == 2 else {
Issue.record("expected two buttons"); return
}
// second button (destructive) fires its action and dismisses the dialog
guard case .array(let second) = buttons[1], case .string(let role) = second[1],
case .int(let id) = second[2] else {
Issue.record("malformed button"); return
}
#expect(role == "destructive")
host.callbacks.invokeVoid(Int64(id))
node = host.evaluate()
#expect(node.props["hasConfirmationDialog"] == nil) // dismissed
}

@Test("TabView emits tabs with their item labels and selection")
func tabs() {
struct Screen: View {
Expand Down
10 changes: 10 additions & 0 deletions Demo/App.swiftpm/Sources/PresentationPlaygrounds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,14 @@ struct SheetBody: View {
struct AlertPlayground: View {
@State private var simple = false
@State private var confirm = false
@State private var sheet = false
@State private var result = "No choice yet"
var body: some View {
ScrollView {
VStack(spacing: 16) {
Button("Show simple alert") { simple = true }
Button("Show confirmation") { confirm = true }
Button("Show confirmation dialog") { sheet = true }
Text(result)
}
.padding()
Expand All @@ -104,5 +106,13 @@ struct AlertPlayground: View {
AlertButton("Cancel", role: .cancel) { result = "Cancelled" },
AlertButton("Delete", role: .destructive) { result = "Deleted" },
])
.confirmationDialog("Manage item", isPresented: $sheet,
titleVisibility: .visible,
message: "Choose an action", buttons: [
AlertButton("Duplicate") { result = "Duplicated" },
AlertButton("Share") { result = "Shared" },
AlertButton("Delete", role: .destructive) { result = "Deleted from dialog" },
AlertButton("Cancel", role: .cancel) { result = "Dialog cancelled" },
])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,8 @@ private fun RenderResolved(node: ViewNode) {

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

@Composable
private fun RenderChildren(node: ViewNode) {
Expand Down Expand Up @@ -1297,6 +1298,53 @@ private fun RenderSheetsAndAlerts(screen: ViewNode?) {
}
}
"Alert" -> RenderAlert(child)
"ConfirmationDialog" -> RenderConfirmationDialog(child)
}
}
}

// An action sheet: a title/message header and one vertical button per choice,
// destructive choices in the error color, presented from the bottom edge.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RenderConfirmationDialog(node: ViewNode) {
val onDismiss = node.long("onDismiss")
val buttons = (node.props["buttons"] as? kotlinx.serialization.json.JsonArray) ?: kotlinx.serialization.json.JsonArray(emptyList())
val parsed = buttons.mapNotNull { entry ->
val arr = entry as? kotlinx.serialization.json.JsonArray ?: return@mapNotNull null
val title = (arr[0] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@mapNotNull null
val role = (arr[1] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "normal"
val id = (arr[2] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull() ?: return@mapNotNull null
Triple(title, role, id)
}
ModalBottomSheet(onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } }) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
val header = node.string("message")
if (node.string("showsTitle") == "true") {
Text(
node.string("title") ?: "",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
}
if (header != null) {
Text(
header,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
}
for ((title, role, id) in parsed) {
TextButton(
onClick = { SwiftBridge.sink.invokeVoid(id) },
modifier = Modifier.fillMaxWidth(),
) {
Text(
title,
color = if (role == "destructive") MaterialTheme.colorScheme.error else Color.Unspecified,
)
}
}
}
}
}
Expand Down
Loading