Skip to content

Commit d13cfaa

Browse files
committed
Sheet presentation and nesting added to AppKitBackend
1 parent 6ddacb9 commit d13cfaa

File tree

4 files changed

+309
-0
lines changed

4 files changed

+309
-0
lines changed

Examples/Sources/WindowingExample/WindowingApp.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,56 @@ struct AlertDemo: View {
6464
}
6565
}
6666

67+
struct SheetDemo: View {
68+
@State var isPresented = false
69+
@State var isShortTermSheetPresented = false
70+
71+
var body: some View {
72+
Button("Open Sheet") {
73+
isPresented = true
74+
}
75+
Button("Show Sheet for 5s") {
76+
isShortTermSheetPresented = true
77+
Task {
78+
try? await Task.sleep(nanoseconds: 1_000_000_000 * 5)
79+
isShortTermSheetPresented = false
80+
}
81+
}
82+
.sheet(isPresented: $isPresented) {
83+
print("sheet dismissed")
84+
} content: {
85+
SheetBody()
86+
}
87+
.sheet(isPresented: $isShortTermSheetPresented) {
88+
Text("I'm only here for 5s")
89+
.padding(20)
90+
}
91+
}
92+
93+
struct SheetBody: View {
94+
@State var isPresented = false
95+
96+
var body: some View {
97+
ZStack {
98+
Color.blue
99+
VStack {
100+
Text("Nice sheet content")
101+
.padding(20)
102+
Button("I want more sheet") {
103+
isPresented = true
104+
print("should get presented")
105+
}
106+
}
107+
}
108+
.sheet(isPresented: $isPresented) {
109+
print("nested sheet dismissed")
110+
} content: {
111+
Text("I'm nested. Its claustrophobic in here.")
112+
}
113+
}
114+
}
115+
}
116+
67117
@main
68118
@HotReloadable
69119
struct WindowingApp: App {
@@ -92,6 +142,10 @@ struct WindowingApp: App {
92142
Divider()
93143

94144
AlertDemo()
145+
146+
Divider()
147+
148+
SheetDemo()
95149
}
96150
.padding(20)
97151
}

Sources/AppKitBackend/AppKitBackend.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public final class AppKitBackend: AppBackend {
1616
public typealias Menu = NSMenu
1717
public typealias Alert = NSAlert
1818
public typealias Path = NSBezierPath
19+
public typealias Sheet = NSCustomSheet
1920

2021
public let defaultTableRowContentHeight = 20
2122
public let defaultTableCellVerticalPadding = 4
@@ -1685,6 +1686,67 @@ public final class AppKitBackend: AppBackend {
16851686
let request = URLRequest(url: url)
16861687
webView.load(request)
16871688
}
1689+
1690+
public func createSheet() -> NSCustomSheet {
1691+
// Initialize with a default contentRect, similar to window creation (lines 58-68)
1692+
let sheet = NSCustomSheet(
1693+
contentRect: NSRect(
1694+
x: 0,
1695+
y: 0,
1696+
width: 400, // Default width
1697+
height: 300 // Default height
1698+
),
1699+
styleMask: [.titled, .closable],
1700+
backing: .buffered,
1701+
defer: true
1702+
)
1703+
return sheet
1704+
}
1705+
1706+
public func updateSheet(
1707+
_ sheet: NSCustomSheet, content: NSView, onDismiss: @escaping () -> Void
1708+
) {
1709+
let contentSize = naturalSize(of: content)
1710+
1711+
let width = max(contentSize.x, 80)
1712+
let height = max(contentSize.y, 80)
1713+
sheet.setContentSize(NSSize(width: width, height: height))
1714+
1715+
sheet.contentView = content
1716+
sheet.onDismiss = onDismiss
1717+
}
1718+
1719+
public func showSheet(_ sheet: NSCustomSheet, window: NSCustomWindow?) {
1720+
guard let window else {
1721+
print("warning: Cannot show sheet without a parent window")
1722+
return
1723+
}
1724+
// critical sheets stack
1725+
// beginSheet only shows a nested
1726+
// sheet after its parent gets dismissed
1727+
window.beginCriticalSheet(sheet)
1728+
}
1729+
1730+
public func dismissSheet(_ sheet: NSCustomSheet, window: NSCustomWindow?) {
1731+
if let window {
1732+
window.endSheet(sheet)
1733+
} else {
1734+
NSApplication.shared.stopModal()
1735+
}
1736+
}
1737+
}
1738+
1739+
public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate {
1740+
public var onDismiss: (() -> Void)?
1741+
1742+
public func dismiss() {
1743+
onDismiss?()
1744+
self.contentViewController?.dismiss(self)
1745+
}
1746+
1747+
@objc override public func cancelOperation(_ sender: Any?) {
1748+
dismiss()
1749+
}
16881750
}
16891751

16901752
final class NSCustomTapGestureTarget: NSView {

Sources/SwiftCrossUI/Backend/AppBackend.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public protocol AppBackend: Sendable {
4747
associatedtype Menu
4848
associatedtype Alert
4949
associatedtype Path
50+
associatedtype Sheet
5051

5152
/// Creates an instance of the backend.
5253
init()
@@ -603,6 +604,35 @@ public protocol AppBackend: Sendable {
603604
/// ``showAlert(_:window:responseHandler:)``.
604605
func dismissAlert(_ alert: Alert, window: Window?)
605606

607+
/// Creates a sheet object (without showing it yet). Sheets contain View Content.
608+
/// They optionally execute provied code on dismiss and
609+
/// prevent users from interacting with the parent window until dimissed.
610+
func createSheet() -> Sheet
611+
612+
/// Updates the content and appearance of a sheet
613+
func updateSheet(
614+
_ sheet: Sheet,
615+
content: Widget,
616+
onDismiss: @escaping () -> Void
617+
)
618+
619+
/// Shows a sheet as a modal on top of or within the given window.
620+
/// Users should be unable to interact with the parent window until the
621+
/// sheet gets dismissed. The sheet will be closed once onDismiss gets called
622+
///
623+
/// Must only get called once for any given sheet.
624+
///
625+
/// If `window` is `nil`, the backend can either make the sheet a whole
626+
/// app modal, a standalone window, or a modal for a window of its choosing.
627+
func showSheet(
628+
_ sheet: Sheet,
629+
window: Window?
630+
)
631+
632+
/// Dismisses a sheet programmatically.
633+
/// Gets used by the SCUI sheet implementation to close a sheet.
634+
func dismissSheet(_ sheet: Sheet, window: Window?)
635+
606636
/// Presents an 'Open file' dialog to the user for selecting files or
607637
/// folders.
608638
///
@@ -1162,4 +1192,27 @@ extension AppBackend {
11621192
) {
11631193
todo()
11641194
}
1195+
1196+
public func createSheet() -> Sheet {
1197+
todo()
1198+
}
1199+
1200+
public func updateSheet(
1201+
_ sheet: Sheet,
1202+
content: Widget,
1203+
onDismiss: @escaping () -> Void
1204+
) {
1205+
todo()
1206+
}
1207+
1208+
public func showSheet(
1209+
_ sheet: Sheet,
1210+
window: Window?
1211+
) {
1212+
todo()
1213+
}
1214+
1215+
public func dismissSheet(_ sheet: Sheet, window: Window?) {
1216+
todo()
1217+
}
11651218
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
extension View {
2+
/// presents a conditional modal overlay
3+
/// onDismiss optional handler gets executed before
4+
/// dismissing the sheet
5+
public func sheet<SheetContent: View>(
6+
isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil,
7+
@ViewBuilder content: @escaping () -> SheetContent
8+
) -> some View {
9+
SheetModifier(
10+
isPresented: isPresented, body: TupleView1(self), onDismiss: onDismiss,
11+
sheetContent: content)
12+
}
13+
}
14+
15+
struct SheetModifier<Content: View, SheetContent: View>: TypeSafeView {
16+
typealias Children = SheetModifierViewChildren<Content, SheetContent>
17+
18+
var isPresented: Binding<Bool>
19+
var body: TupleView1<Content>
20+
var onDismiss: (() -> Void)?
21+
var sheetContent: () -> SheetContent
22+
23+
var sheet: Any?
24+
25+
func children<Backend: AppBackend>(
26+
backend: Backend,
27+
snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
28+
environment: EnvironmentValues
29+
) -> Children {
30+
let bodyViewGraphNode = ViewGraphNode(
31+
for: body.view0,
32+
backend: backend,
33+
environment: environment
34+
)
35+
let bodyNode = AnyViewGraphNode(bodyViewGraphNode)
36+
37+
let sheetViewGraphNode = ViewGraphNode(
38+
for: sheetContent(),
39+
backend: backend,
40+
environment: environment
41+
)
42+
let sheetContentNode = AnyViewGraphNode(sheetViewGraphNode)
43+
44+
return SheetModifierViewChildren(
45+
childNode: bodyNode,
46+
sheetContentNode: sheetContentNode,
47+
sheet: nil
48+
)
49+
}
50+
51+
func asWidget<Backend: AppBackend>(
52+
_ children: Children,
53+
backend: Backend
54+
) -> Backend.Widget {
55+
children.childNode.widget.into()
56+
}
57+
58+
func update<Backend: AppBackend>(
59+
_ widget: Backend.Widget,
60+
children: Children,
61+
proposedSize: SIMD2<Int>,
62+
environment: EnvironmentValues,
63+
backend: Backend,
64+
dryRun: Bool
65+
) -> ViewUpdateResult {
66+
let childResult = children.childNode.update(
67+
with: body.view0,
68+
proposedSize: proposedSize,
69+
environment: environment,
70+
dryRun: dryRun
71+
)
72+
73+
if isPresented.wrappedValue && children.sheet == nil {
74+
let dryRunResult = children.sheetContentNode.update(
75+
with: sheetContent(),
76+
proposedSize: proposedSize,
77+
environment: environment,
78+
dryRun: true
79+
)
80+
81+
let sheetSize = dryRunResult.size.idealSize
82+
83+
let _ = children.sheetContentNode.update(
84+
with: sheetContent(),
85+
proposedSize: sheetSize,
86+
environment: environment,
87+
dryRun: false
88+
)
89+
90+
let sheet = backend.createSheet()
91+
92+
backend.updateSheet(
93+
sheet,
94+
content: children.sheetContentNode.widget.into(),
95+
onDismiss: handleDismiss
96+
)
97+
backend.showSheet(
98+
sheet,
99+
window: .some(environment.window! as! Backend.Window)
100+
)
101+
children.sheet = sheet
102+
} else if !isPresented.wrappedValue && children.sheet != nil {
103+
backend.dismissSheet(
104+
children.sheet as! Backend.Sheet,
105+
window: .some(environment.window! as! Backend.Window)
106+
)
107+
children.sheet = nil
108+
}
109+
return childResult
110+
}
111+
112+
func handleDismiss() {
113+
onDismiss?()
114+
isPresented.wrappedValue = false
115+
}
116+
}
117+
118+
class SheetModifierViewChildren<Child: View, SheetContent: View>: ViewGraphNodeChildren {
119+
var widgets: [AnyWidget] {
120+
[childNode.widget]
121+
}
122+
123+
var erasedNodes: [ErasedViewGraphNode] {
124+
[ErasedViewGraphNode(wrapping: childNode), ErasedViewGraphNode(wrapping: sheetContentNode)]
125+
}
126+
127+
var childNode: AnyViewGraphNode<Child>
128+
var sheetContentNode: AnyViewGraphNode<SheetContent>
129+
var sheet: Any?
130+
131+
init(
132+
childNode: AnyViewGraphNode<Child>,
133+
sheetContentNode: AnyViewGraphNode<SheetContent>,
134+
sheet: Any?
135+
) {
136+
self.childNode = childNode
137+
self.sheetContentNode = sheetContentNode
138+
self.sheet = sheet
139+
}
140+
}

0 commit comments

Comments
 (0)