Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d13cfaa
Sheet presentation and nesting added to AppKitBackend
MiaKoring Oct 2, 2025
19d772d
Added basic UIKit Sheet functionality and preparation for presentatio…
MiaKoring Oct 2, 2025
daca3e4
added presentationDetents and Radius to UIKitBackend
MiaKoring Oct 3, 2025
d6a63f2
improved sheet rendering
MiaKoring Oct 3, 2025
79837ed
added presentationDragIndicatorVisibility modifier
MiaKoring Oct 3, 2025
b1e436b
added presentationBackground
MiaKoring Oct 3, 2025
3108f77
UIKit sheet parent dismissals now dismiss both all children and the p…
MiaKoring Oct 3, 2025
7df381c
added interactiveDismissDisabled modifier
MiaKoring Oct 4, 2025
8669cb6
renamed size Parameter of SheetImplementation Protocol to sheetSize
MiaKoring Oct 8, 2025
ed300d2
GtkBackend Sheets save
MiaKoring Oct 8, 2025
a76ca7c
finished GtkBackendSheets
MiaKoring Oct 8, 2025
941131e
documentation improvements
MiaKoring Oct 8, 2025
9d6542e
Should fix UIKitBackend compile issue with visionOS and AppBackend Co…
MiaKoring Oct 9, 2025
602ab54
maybe ease gh actions gtk compile fix?
MiaKoring Oct 9, 2025
af6da88
fixed winUI AppBackend Conformance
MiaKoring Oct 9, 2025
bb8b228
removed SheetImplementation Protocol, replaced with backend.sizeOf(_:)
MiaKoring Oct 20, 2025
0f53ec8
Adding sheet content in createSheet() instead of update (UIKit, AppKit)
MiaKoring Oct 20, 2025
bf91e02
adding sheet content on create (Gtk)
MiaKoring Oct 20, 2025
9bba11e
comment improvements
MiaKoring Oct 20, 2025
b3cfc04
maybe part of letting scui dictate size?
MiaKoring Oct 20, 2025
7d0a030
changes
MiaKoring Oct 20, 2025
d5bf60b
moved signals to Gtk/Widgets/Window
MiaKoring Oct 20, 2025
8c9d96f
removed now unecessary old code
MiaKoring Oct 20, 2025
022274b
mostly comment changes
MiaKoring Oct 20, 2025
17875d2
onAppear now get called correctly, onDisappear only on interactive di…
MiaKoring Oct 20, 2025
0447fa0
should fix AppKit and Gtk interactive dismissal
MiaKoring Oct 20, 2025
2d97d73
using preferences from final result instead of dryRunResult
MiaKoring Oct 20, 2025
7ec8b6d
Merge branch 'main' into feat/sheet
MiaKoring Oct 22, 2025
fa884c7
Fixed sheet rendering with GtkBackend on Linux
MiaKoring Oct 22, 2025
3f3bb3d
UIKit Sheet changes
MiaKoring Oct 22, 2025
6c013b7
Window uses EventControllerKey generated class instead of c interop
MiaKoring Oct 22, 2025
f7750d7
Replaced Window Escape key press c interop with generated class
MiaKoring Oct 22, 2025
40ba6ae
Formatting and comments
MiaKoring Oct 22, 2025
12198f7
Trying GdkModifierType instead of UInt
MiaKoring Oct 22, 2025
0d175e7
Merge remote-tracking branch 'origin/feat/sheet' into feat/sheet
MiaKoring Oct 22, 2025
eca589b
Made setPresentationBackground save to run/update n times on AppKitBa…
MiaKoring Oct 22, 2025
dce5be6
Fixed parent sheet dismissal leaves child sheet behind (GtkBackend te…
MiaKoring Oct 22, 2025
45a34bd
Merge remote-tracking branch 'origin/feat/sheet' into feat/sheet
MiaKoring Oct 22, 2025
64c69a9
beginCriticalSheet replaced with beginSheet on AppKitBackend
MiaKoring Oct 22, 2025
d2fb6e5
fix tvOS (and visionOS) compilation error
MiaKoring Oct 22, 2025
30feb1e
removed .formSheet modalPresentationStyle for tvOS 26 due to CI failure
MiaKoring Oct 22, 2025
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
133 changes: 118 additions & 15 deletions Examples/Sources/WindowingExample/WindowingApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,103 @@ struct AlertDemo: View {
}
}

// kind of a stress test for the dismiss action
struct SheetDemo: View {
@State var isPresented = false
@State var isShortTermSheetPresented = false

var body: some View {
Button("Open Sheet") {
isPresented = true
}
Button("Show Sheet for 5s") {
isShortTermSheetPresented = true
Task {
try? await Task.sleep(nanoseconds: 1_000_000_000 * 5)
isShortTermSheetPresented = false
}
}
.sheet(isPresented: $isPresented) {
print("sheet dismissed")
} content: {
SheetBody()
.presentationDetents([.height(150), .medium, .large])
.presentationDragIndicatorVisibility(.visible)
.presentationBackground(.green)
}
.sheet(isPresented: $isShortTermSheetPresented) {
Text("I'm only here for 5s")
.padding(20)
.presentationDetents([.height(150), .medium, .large])
.presentationCornerRadius(10)
.presentationBackground(.red)
}
}

struct SheetBody: View {
@State var isPresented = false
@Environment(\.dismiss) var dismiss

var body: some View {
VStack {
Text("Nice sheet content")
.padding(20)
Button("I want more sheet") {
isPresented = true
print("should get presented")
}
Button("Dismiss") {
dismiss()
}
Spacer()
}
.sheet(isPresented: $isPresented) {
print("nested sheet dismissed")
} content: {
NestedSheetBody(dismissParent: { dismiss() })
.presentationCornerRadius(35)
}
}

struct NestedSheetBody: View {
@Environment(\.dismiss) var dismiss
var dismissParent: () -> Void
@State var showNextChild = false

var body: some View {
Text("I'm nested. Its claustrophobic in here.")
Button("New Child Sheet") {
showNextChild = true
}
.sheet(isPresented: $showNextChild) {
DoubleNestedSheetBody(dismissParent: { dismiss() })
.interactiveDismissDisabled()
}
Button("dismiss parent sheet") {
dismissParent()
}
Button("dismiss") {
dismiss()
}
}
}
struct DoubleNestedSheetBody: View {
@Environment(\.dismiss) var dismiss
var dismissParent: () -> Void

var body: some View {
Text("I'm nested. Its claustrophobic in here.")
Button("dismiss parent sheet") {
dismissParent()
}
Button("dismiss") {
dismiss()
}
}
}
}
}

@main
@HotReloadable
struct WindowingApp: App {
Expand Down Expand Up @@ -92,6 +189,11 @@ struct WindowingApp: App {
Divider()

AlertDemo()

Divider()

SheetDemo()
.padding(.bottom, 20)
}
.padding(20)
}
Expand All @@ -108,23 +210,24 @@ struct WindowingApp: App {
}
}
}

WindowGroup("Secondary window") {
#hotReloadable {
Text("This a secondary window!")
.padding(10)
#if !os(iOS)
WindowGroup("Secondary window") {
#hotReloadable {
Text("This a secondary window!")
.padding(10)
}
}
}
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)

WindowGroup("Tertiary window") {
#hotReloadable {
Text("This a tertiary window!")
.padding(10)
WindowGroup("Tertiary window") {
#hotReloadable {
Text("This a tertiary window!")
.padding(10)
}
}
}
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)
#endif
}
}
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 108 additions & 0 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public final class AppKitBackend: AppBackend {
public typealias Menu = NSMenu
public typealias Alert = NSAlert
public typealias Path = NSBezierPath
public typealias Sheet = NSCustomSheet

public let defaultTableRowContentHeight = 20
public let defaultTableCellVerticalPadding = 4
Expand Down Expand Up @@ -1685,6 +1686,113 @@ public final class AppKitBackend: AppBackend {
let request = URLRequest(url: url)
webView.load(request)
}

public func createSheet() -> NSCustomSheet {
// Initialize with a default contentRect, similar to window creation (lines 58-68)
let sheet = NSCustomSheet(
contentRect: NSRect(
x: 0,
y: 0,
width: 400, // Default width
height: 300 // Default height
),
styleMask: [.titled, .closable],
backing: .buffered,
defer: true
)
return sheet
}

public func updateSheet(
_ sheet: NSCustomSheet, content: NSView, onDismiss: @escaping () -> Void
) {
let contentSize = naturalSize(of: content)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content size should be passed in from SwiftCrossUI because even if this works for AppKitBackend, not all backends will be able to infer the sizing that SwiftCrossUI has assigned to their sheet content.

This may resolve the Gtk issues you were facing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do I get the correct size from?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just before you call updateSheet in your sheet modifier implementation, you compute result. You should be able to pass the size from that result (result.size.size) into updateSheet in order to use that size in backend implementations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let result = children.sheetContentNode!.update(
    with: sheetContent(),
    proposedSize: SIMD2(x: 400, y: 0),
    environment: sheetEnvironment,
    dryRun: false
)

backend.updateSheet(
    sheet,
    size: result.size.size,
    onDismiss: { handleDismiss(children: children) }
)

this works.
Before I had proposedSize: backend.size(ofSheet: sheet)
pretty logical it didn’t work. When the size was read it was still (0,0). That lead to SCUI squishing it as small as possible. I first tried setting y to 400 as well, but this lead to the sheet keeping 400 as height even though the content was smaller. 0 for y results in a sheet only as high as it needs to be. This renders correctly in WindowingExample.

Imo the y value can stay constant 0 for now and changed if it needs to be. For the x value however we need to determine a good constant. It needs to be big enough its not dictating the width while being small enough there are no integer overflow problems (Int.max for example crashes).

I’m going to set it to 10k for now. That should temporarily be fine for almost any resolution, but may need further investigation and optimization.


let width = max(contentSize.x, 10)
let height = max(contentSize.y, 10)
sheet.setContentSize(NSSize(width: width, height: height))

sheet.contentView = content
sheet.onDismiss = onDismiss
}

public func showSheet(_ sheet: NSCustomSheet, window: NSCustomWindow?) {
guard let window else {
print("warning: Cannot show sheet without a parent window")
return
}
// critical sheets stack
// beginSheet only shows a nested
// sheet after its parent gets dismissed
window.beginCriticalSheet(sheet)
}

public func dismissSheet(_ sheet: NSCustomSheet, window: NSCustomWindow?) {
if let window {
window.endSheet(sheet)
} else {
NSApplication.shared.stopModal()
}
}

public func setPresentationBackground(of sheet: NSCustomSheet, to color: Color) {
let backgroundView = NSView()
backgroundView.wantsLayer = true
backgroundView.layer?.backgroundColor = color.nsColor.cgColor

if let existingContentView = sheet.contentView {
let container = NSView()
container.translatesAutoresizingMaskIntoConstraints = false

container.addSubview(backgroundView)
backgroundView.translatesAutoresizingMaskIntoConstraints = false
backgroundView.leadingAnchor.constraint(equalTo: container.leadingAnchor).isActive =
true
backgroundView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
backgroundView.trailingAnchor.constraint(equalTo: container.trailingAnchor).isActive =
true
backgroundView.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true

container.addSubview(existingContentView)
existingContentView.translatesAutoresizingMaskIntoConstraints = false
existingContentView.leadingAnchor.constraint(equalTo: container.leadingAnchor)
.isActive = true
existingContentView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
existingContentView.trailingAnchor.constraint(equalTo: container.trailingAnchor)
.isActive = true
existingContentView.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive =
true

sheet.contentView = container
}
}

public func setInteractiveDismissDisabled(for sheet: NSCustomSheet, to disabled: Bool) {
sheet.interactiveDismissDisabled = disabled
}
}

public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate, SheetImplementation {
public var sheetSize: SIMD2<Int> {
guard let size = self.contentView?.frame.size else {
return SIMD2(x: 0, y: 0)
}
return SIMD2(x: Int(size.width), y: Int(size.height))
}
public var onDismiss: (() -> Void)?

public var interactiveDismissDisabled: Bool = false

public func dismiss() {
onDismiss?()
self.contentViewController?.dismiss(self)
}

@objc override public func cancelOperation(_ sender: Any?) {
if !interactiveDismissDisabled {
dismiss()
}
}
}

final class NSCustomTapGestureTarget: NSView {
Expand Down
7 changes: 7 additions & 0 deletions Sources/Gtk3Backend/Gtk3Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public final class Gtk3Backend: AppBackend {
public typealias Widget = Gtk3.Widget
public typealias Menu = Gtk3.Menu
public typealias Alert = Gtk3.MessageDialog
public typealias Sheet = Gtk3.Window

public final class Path {
var path: SwiftCrossUI.Path?
Expand Down Expand Up @@ -1516,3 +1517,9 @@ struct Gtk3Error: LocalizedError {
"gerror: code=\(code), domain=\(domain), message=\(message)"
}
}

extension Gtk3.Window: SheetImplementation {
public var sheetSize: SIMD2<Int> {
SIMD2(x: size.width, y: size.height)
}
}
Loading
Loading