From d13cfaa81e1f489b4e925656cb8f9fe13e3ae4a4 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 3 Oct 2025 00:22:33 +0200 Subject: [PATCH 01/15] Sheet presentation and nesting added to AppKitBackend --- .../WindowingExample/WindowingApp.swift | 54 +++++++ Sources/AppKitBackend/AppKitBackend.swift | 62 ++++++++ Sources/SwiftCrossUI/Backend/AppBackend.swift | 53 +++++++ .../Views/Modifiers/SheetModifier.swift | 140 ++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index c17417b053..171d94bcd0 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -64,6 +64,56 @@ struct AlertDemo: View { } } +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() + } + .sheet(isPresented: $isShortTermSheetPresented) { + Text("I'm only here for 5s") + .padding(20) + } + } + + struct SheetBody: View { + @State var isPresented = false + + var body: some View { + ZStack { + Color.blue + VStack { + Text("Nice sheet content") + .padding(20) + Button("I want more sheet") { + isPresented = true + print("should get presented") + } + } + } + .sheet(isPresented: $isPresented) { + print("nested sheet dismissed") + } content: { + Text("I'm nested. Its claustrophobic in here.") + } + } + } +} + @main @HotReloadable struct WindowingApp: App { @@ -92,6 +142,10 @@ struct WindowingApp: App { Divider() AlertDemo() + + Divider() + + SheetDemo() } .padding(20) } diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 4f23de39d1..f351050e65 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -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 @@ -1685,6 +1686,67 @@ 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) + + let width = max(contentSize.x, 80) + let height = max(contentSize.y, 80) + 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 final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { + public var onDismiss: (() -> Void)? + + public func dismiss() { + onDismiss?() + self.contentViewController?.dismiss(self) + } + + @objc override public func cancelOperation(_ sender: Any?) { + dismiss() + } } final class NSCustomTapGestureTarget: NSView { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 31a412cf2f..c4f852d09b 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -47,6 +47,7 @@ public protocol AppBackend: Sendable { associatedtype Menu associatedtype Alert associatedtype Path + associatedtype Sheet /// Creates an instance of the backend. init() @@ -603,6 +604,35 @@ public protocol AppBackend: Sendable { /// ``showAlert(_:window:responseHandler:)``. func dismissAlert(_ alert: Alert, window: Window?) + /// Creates a sheet object (without showing it yet). Sheets contain View Content. + /// They optionally execute provied code on dismiss and + /// prevent users from interacting with the parent window until dimissed. + func createSheet() -> Sheet + + /// Updates the content and appearance of a sheet + func updateSheet( + _ sheet: Sheet, + content: Widget, + onDismiss: @escaping () -> Void + ) + + /// Shows a sheet as a modal on top of or within the given window. + /// Users should be unable to interact with the parent window until the + /// sheet gets dismissed. The sheet will be closed once onDismiss gets called + /// + /// Must only get called once for any given sheet. + /// + /// If `window` is `nil`, the backend can either make the sheet a whole + /// app modal, a standalone window, or a modal for a window of its choosing. + func showSheet( + _ sheet: Sheet, + window: Window? + ) + + /// Dismisses a sheet programmatically. + /// Gets used by the SCUI sheet implementation to close a sheet. + func dismissSheet(_ sheet: Sheet, window: Window?) + /// Presents an 'Open file' dialog to the user for selecting files or /// folders. /// @@ -1162,4 +1192,27 @@ extension AppBackend { ) { todo() } + + public func createSheet() -> Sheet { + todo() + } + + public func updateSheet( + _ sheet: Sheet, + content: Widget, + onDismiss: @escaping () -> Void + ) { + todo() + } + + public func showSheet( + _ sheet: Sheet, + window: Window? + ) { + todo() + } + + public func dismissSheet(_ sheet: Sheet, window: Window?) { + todo() + } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift new file mode 100644 index 0000000000..a68f74d097 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -0,0 +1,140 @@ +extension View { + /// presents a conditional modal overlay + /// onDismiss optional handler gets executed before + /// dismissing the sheet + public func sheet( + isPresented: Binding, onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> SheetContent + ) -> some View { + SheetModifier( + isPresented: isPresented, body: TupleView1(self), onDismiss: onDismiss, + sheetContent: content) + } +} + +struct SheetModifier: TypeSafeView { + typealias Children = SheetModifierViewChildren + + var isPresented: Binding + var body: TupleView1 + var onDismiss: (() -> Void)? + var sheetContent: () -> SheetContent + + var sheet: Any? + + func children( + backend: Backend, + snapshots: [ViewGraphSnapshotter.NodeSnapshot]?, + environment: EnvironmentValues + ) -> Children { + let bodyViewGraphNode = ViewGraphNode( + for: body.view0, + backend: backend, + environment: environment + ) + let bodyNode = AnyViewGraphNode(bodyViewGraphNode) + + let sheetViewGraphNode = ViewGraphNode( + for: sheetContent(), + backend: backend, + environment: environment + ) + let sheetContentNode = AnyViewGraphNode(sheetViewGraphNode) + + return SheetModifierViewChildren( + childNode: bodyNode, + sheetContentNode: sheetContentNode, + sheet: nil + ) + } + + func asWidget( + _ children: Children, + backend: Backend + ) -> Backend.Widget { + children.childNode.widget.into() + } + + func update( + _ widget: Backend.Widget, + children: Children, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + let childResult = children.childNode.update( + with: body.view0, + proposedSize: proposedSize, + environment: environment, + dryRun: dryRun + ) + + if isPresented.wrappedValue && children.sheet == nil { + let dryRunResult = children.sheetContentNode.update( + with: sheetContent(), + proposedSize: proposedSize, + environment: environment, + dryRun: true + ) + + let sheetSize = dryRunResult.size.idealSize + + let _ = children.sheetContentNode.update( + with: sheetContent(), + proposedSize: sheetSize, + environment: environment, + dryRun: false + ) + + let sheet = backend.createSheet() + + backend.updateSheet( + sheet, + content: children.sheetContentNode.widget.into(), + onDismiss: handleDismiss + ) + backend.showSheet( + sheet, + window: .some(environment.window! as! Backend.Window) + ) + children.sheet = sheet + } else if !isPresented.wrappedValue && children.sheet != nil { + backend.dismissSheet( + children.sheet as! Backend.Sheet, + window: .some(environment.window! as! Backend.Window) + ) + children.sheet = nil + } + return childResult + } + + func handleDismiss() { + onDismiss?() + isPresented.wrappedValue = false + } +} + +class SheetModifierViewChildren: ViewGraphNodeChildren { + var widgets: [AnyWidget] { + [childNode.widget] + } + + var erasedNodes: [ErasedViewGraphNode] { + [ErasedViewGraphNode(wrapping: childNode), ErasedViewGraphNode(wrapping: sheetContentNode)] + } + + var childNode: AnyViewGraphNode + var sheetContentNode: AnyViewGraphNode + var sheet: Any? + + init( + childNode: AnyViewGraphNode, + sheetContentNode: AnyViewGraphNode, + sheet: Any? + ) { + self.childNode = childNode + self.sheetContentNode = sheetContentNode + self.sheet = sheet + } +} From 19d772d9ace1f04b477381db97aeca81e2b6920c Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 3 Oct 2025 01:54:17 +0200 Subject: [PATCH 02/15] Added basic UIKit Sheet functionality and preparation for presentation modification --- .../WindowingExample/WindowingApp.swift | 32 +++++++------- Sources/AppKitBackend/AppKitBackend.swift | 4 ++ Sources/SwiftCrossUI/Backend/AppBackend.swift | 29 ++++++++++++ .../Values/PresentationDetent.swift | 16 +++++++ .../ViewGraph/PreferenceValues.swift | 23 +++++++++- .../Views/Modifiers/SheetModifier.swift | 13 ++++++ .../PresentationCornerRadiusModifier.swift | 19 ++++++++ .../Style/PresentationDetentsModifier.swift | 13 ++++++ Sources/UIKitBackend/UIKitBackend+Sheet.swift | 44 +++++++++++++++++++ 9 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 Sources/SwiftCrossUI/Values/PresentationDetent.swift create mode 100644 Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift create mode 100644 Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift create mode 100644 Sources/UIKitBackend/UIKitBackend+Sheet.swift diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 171d94bcd0..f79986f2fe 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -87,6 +87,7 @@ struct SheetDemo: View { .sheet(isPresented: $isShortTermSheetPresented) { Text("I'm only here for 5s") .padding(20) + .presentationCornerRadius(2) } } @@ -162,23 +163,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 } } diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index f351050e65..e7d3102df2 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1734,6 +1734,10 @@ public final class AppKitBackend: AppBackend { NSApplication.shared.stopModal() } } + + public func setPresentationCornerRadius(of sheet: NSCustomSheet, to radius: Int) { + print("setting Sheet Corner Radius is unavailable on macOS and will be ignored") + } } public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index c4f852d09b..7155efcd47 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -633,6 +633,27 @@ public protocol AppBackend: Sendable { /// Gets used by the SCUI sheet implementation to close a sheet. func dismissSheet(_ sheet: Sheet, window: Window?) + /// Sets the corner radius for a sheet presentation. + /// + /// This method is called when the sheet content has a `presentationCornerRadius` modifier + /// applied at its top level. The corner radius affects the sheet's presentation container, + /// not the content itself. + /// + /// - Parameters: + /// - sheet: The sheet to apply the corner radius to. + /// - radius: The corner radius in pixels. + func setPresentationCornerRadius(of sheet: Sheet, to radius: Int) + + /// Sets the available detents (heights) for a sheet presentation. + /// + /// This method is called when the sheet content has a `presentationDetents` modifier + /// applied at its top level. Detents allow users to resize the sheet to predefined heights. + /// + /// - Parameters: + /// - sheet: The sheet to apply the detents to. + /// - detents: An array of detents that the sheet can be resized to. + func setPresentationDetents(of sheet: Sheet, to detents: [PresentationDetent]) + /// Presents an 'Open file' dialog to the user for selecting files or /// folders. /// @@ -1215,4 +1236,12 @@ extension AppBackend { public func dismissSheet(_ sheet: Sheet, window: Window?) { todo() } + + public func setPresentationCornerRadius(of sheet: Sheet, to radius: Int) { + todo() + } + + public func setPresentationDetents(of sheet: Sheet, to detents: [PresentationDetent]) { + todo() + } } diff --git a/Sources/SwiftCrossUI/Values/PresentationDetent.swift b/Sources/SwiftCrossUI/Values/PresentationDetent.swift new file mode 100644 index 0000000000..59eb6228fb --- /dev/null +++ b/Sources/SwiftCrossUI/Values/PresentationDetent.swift @@ -0,0 +1,16 @@ +/// Represents the available detents (heights) for a sheet presentation. +public enum PresentationDetent: Sendable, Hashable { + /// A detent that represents a medium height sheet. + case medium + + /// A detent that represents a large (full-height) sheet. + case large + + /// A detent at a custom fractional height of the available space. + /// - Parameter fraction: A value between 0 and 1 representing the fraction of available height. + case fraction(Double) + + /// A detent at a specific fixed height in pixels. + /// - Parameter height: The height in pixels. + case height(Int) +} diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index d03e497a39..d49496787b 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -2,13 +2,27 @@ import Foundation public struct PreferenceValues: Sendable { public static let `default` = PreferenceValues( - onOpenURL: nil + onOpenURL: nil, + presentationDetents: nil, + presentationCornerRadius: nil ) public var onOpenURL: (@Sendable @MainActor (URL) -> Void)? - public init(onOpenURL: (@Sendable @MainActor (URL) -> Void)?) { + /// The available detents for a sheet presentation. Only applies to the top-level view in a sheet. + public var presentationDetents: [PresentationDetent]? + + /// The corner radius for a sheet presentation. Only applies to the top-level view in a sheet. + public var presentationCornerRadius: Int? + + public init( + onOpenURL: (@Sendable @MainActor (URL) -> Void)?, + presentationDetents: [PresentationDetent]? = nil, + presentationCornerRadius: Int? = nil + ) { self.onOpenURL = onOpenURL + self.presentationDetents = presentationDetents + self.presentationCornerRadius = presentationCornerRadius } public init(merging children: [PreferenceValues]) { @@ -21,5 +35,10 @@ public struct PreferenceValues: Sendable { } } } + + // For presentation modifiers, take the first (top-level) value only + // This ensures only the root view's presentation modifiers apply to the sheet + presentationDetents = children.first?.presentationDetents + presentationCornerRadius = children.first?.presentationCornerRadius } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index a68f74d097..7fda8c113d 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -78,6 +78,9 @@ struct SheetModifier: TypeSafeView { dryRun: true ) + // Extract preferences from the sheet content + let preferences = dryRunResult.preferences + let sheetSize = dryRunResult.size.idealSize let _ = children.sheetContentNode.update( @@ -94,6 +97,16 @@ struct SheetModifier: TypeSafeView { content: children.sheetContentNode.widget.into(), onDismiss: handleDismiss ) + + // Apply presentation preferences to the sheet + if let cornerRadius = preferences.presentationCornerRadius { + backend.setPresentationCornerRadius(of: sheet, to: cornerRadius) + } + + if let detents = preferences.presentationDetents { + backend.setPresentationDetents(of: sheet, to: detents) + } + backend.showSheet( sheet, window: .some(environment.window! as! Backend.Window) diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift new file mode 100644 index 0000000000..4bfe4937d2 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift @@ -0,0 +1,19 @@ +// +// PresentationCornerRadiusModifier.swift +// swift-cross-ui +// +// Created by Mia Koring on 03.10.25. +// + +extension View { + /// Sets the corner radius for a sheet presentation. + /// + /// This modifier only affects the sheet presentation itself when applied to the + /// top-level view within a sheet. It does not affect the content's corner radius. + /// + /// - Parameter radius: The corner radius in pixels. + /// - Returns: A view with the presentation corner radius preference set. + public func presentationCornerRadius(_ radius: Int) -> some View { + preference(key: \.presentationCornerRadius, value: radius) + } +} diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift new file mode 100644 index 0000000000..584b22ca33 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift @@ -0,0 +1,13 @@ +extension View { + /// Sets the available detents (heights) for a sheet presentation. + /// + /// This modifier only affects the sheet presentation itself when applied to the + /// top-level view within a sheet. It allows users to resize the sheet to different + /// predefined heights. + /// + /// - Parameter detents: A set of detents that the sheet can be resized to. + /// - Returns: A view with the presentation detents preference set. + public func presentationDetents(_ detents: Set) -> some View { + preference(key: \.presentationDetents, value: Array(detents)) + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift new file mode 100644 index 0000000000..188b49cdbf --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -0,0 +1,44 @@ +import SwiftCrossUI +import UIKit + +extension UIKitBackend { + public typealias Sheet = CustomSheet + + public func createSheet() -> CustomSheet { + let sheet = CustomSheet() + sheet.modalPresentationStyle = .formSheet + //sheet.transitioningDelegate = CustomSheetTransitioningDelegate() + + return sheet + } + + public func updateSheet(_ sheet: CustomSheet, content: Widget, onDismiss: @escaping () -> Void) + { + sheet.view = content.view + sheet.onDismiss = onDismiss + } + + public func showSheet(_ sheet: CustomSheet, window: UIWindow?) { + var topController = window?.rootViewController + while let presented = topController?.presentedViewController { + topController = presented + } + topController?.present(sheet, animated: true) + } + + public func dismissSheet(_ sheet: CustomSheet, window: UIWindow?) { + sheet.dismiss(animated: true) + } +} + +public final class CustomSheet: UIViewController { + var onDismiss: (() -> Void)? + + public override func viewDidLoad() { + super.viewDidLoad() + } + + public override func viewDidDisappear(_ animated: Bool) { + onDismiss?() + } +} From daca3e401af95fe8ce018be67ab8cb71a8357dee Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 3 Oct 2025 17:10:43 +0200 Subject: [PATCH 03/15] added presentationDetents and Radius to UIKitBackend --- .../WindowingExample/WindowingApp.swift | 4 +- Sources/AppKitBackend/AppKitBackend.swift | 4 -- Sources/SwiftCrossUI/Backend/AppBackend.swift | 16 ++++-- .../Values/PresentationDetent.swift | 6 ++- .../ViewGraph/PreferenceValues.swift | 4 +- .../PresentationCornerRadiusModifier.swift | 5 +- .../Style/PresentationDetentsModifier.swift | 4 ++ Sources/UIKitBackend/UIKitBackend+Sheet.swift | 54 ++++++++++++++++++- 8 files changed, 81 insertions(+), 16 deletions(-) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index f79986f2fe..eccb058d40 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -87,7 +87,8 @@ struct SheetDemo: View { .sheet(isPresented: $isShortTermSheetPresented) { Text("I'm only here for 5s") .padding(20) - .presentationCornerRadius(2) + .presentationDetents([.height(150), .medium, .large]) + .presentationCornerRadius(10) } } @@ -147,6 +148,7 @@ struct WindowingApp: App { Divider() SheetDemo() + .padding(.bottom, 20) } .padding(20) } diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index e7d3102df2..f351050e65 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1734,10 +1734,6 @@ public final class AppKitBackend: AppBackend { NSApplication.shared.stopModal() } } - - public func setPresentationCornerRadius(of sheet: NSCustomSheet, to radius: Int) { - print("setting Sheet Corner Radius is unavailable on macOS and will be ignored") - } } public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 7155efcd47..5cf88dbbd8 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -641,8 +641,8 @@ public protocol AppBackend: Sendable { /// /// - Parameters: /// - sheet: The sheet to apply the corner radius to. - /// - radius: The corner radius in pixels. - func setPresentationCornerRadius(of sheet: Sheet, to radius: Int) + /// - radius: The corner radius + func setPresentationCornerRadius(of sheet: Sheet, to radius: Double) /// Sets the available detents (heights) for a sheet presentation. /// @@ -778,6 +778,12 @@ extension AppBackend { Foundation.exit(1) } + private func ignored(_ function: String = #function) { + print( + "\(type(of: self)): \(function) is being ignored\nConsult at the documentation for further information." + ) + } + // MARK: System public func openExternalURL(_ url: URL) throws { @@ -1237,11 +1243,11 @@ extension AppBackend { todo() } - public func setPresentationCornerRadius(of sheet: Sheet, to radius: Int) { - todo() + public func setPresentationCornerRadius(of sheet: Sheet, to radius: Double) { + ignored() } public func setPresentationDetents(of sheet: Sheet, to detents: [PresentationDetent]) { - todo() + ignored() } } diff --git a/Sources/SwiftCrossUI/Values/PresentationDetent.swift b/Sources/SwiftCrossUI/Values/PresentationDetent.swift index 59eb6228fb..a8b24c5782 100644 --- a/Sources/SwiftCrossUI/Values/PresentationDetent.swift +++ b/Sources/SwiftCrossUI/Values/PresentationDetent.swift @@ -7,10 +7,12 @@ public enum PresentationDetent: Sendable, Hashable { case large /// A detent at a custom fractional height of the available space. + /// falling back to medium on iOS 15 /// - Parameter fraction: A value between 0 and 1 representing the fraction of available height. case fraction(Double) /// A detent at a specific fixed height in pixels. - /// - Parameter height: The height in pixels. - case height(Int) + /// falling back to medium on iOS 15 + /// - Parameter height: The height + case height(Double) } diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index d49496787b..d3d9c52075 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -13,12 +13,12 @@ public struct PreferenceValues: Sendable { public var presentationDetents: [PresentationDetent]? /// The corner radius for a sheet presentation. Only applies to the top-level view in a sheet. - public var presentationCornerRadius: Int? + public var presentationCornerRadius: Double? public init( onOpenURL: (@Sendable @MainActor (URL) -> Void)?, presentationDetents: [PresentationDetent]? = nil, - presentationCornerRadius: Int? = nil + presentationCornerRadius: Double? = nil ) { self.onOpenURL = onOpenURL self.presentationDetents = presentationDetents diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift index 4bfe4937d2..46a72075cc 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift @@ -11,9 +11,12 @@ extension View { /// This modifier only affects the sheet presentation itself when applied to the /// top-level view within a sheet. It does not affect the content's corner radius. /// + /// supported platforms: iOS (ignored on unsupported platforms) + /// ignored on: older than iOS 15 + /// /// - Parameter radius: The corner radius in pixels. /// - Returns: A view with the presentation corner radius preference set. - public func presentationCornerRadius(_ radius: Int) -> some View { + public func presentationCornerRadius(_ radius: Double) -> some View { preference(key: \.presentationCornerRadius, value: radius) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift index 584b22ca33..78afc5cd2d 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift @@ -5,6 +5,10 @@ extension View { /// top-level view within a sheet. It allows users to resize the sheet to different /// predefined heights. /// + /// supported platforms: iOS (ignored on unsupported platforms) + /// ignored on: older than iOS 15 + /// fraction and height fall back to medium on iOS 15 and work as you'd expect on >=16 + /// /// - Parameter detents: A set of detents that the sheet can be resized to. /// - Returns: A view with the presentation detents preference set. public func presentationDetents(_ detents: Set) -> some View { diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index 188b49cdbf..cb8853b05f 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -7,7 +7,6 @@ extension UIKitBackend { public func createSheet() -> CustomSheet { let sheet = CustomSheet() sheet.modalPresentationStyle = .formSheet - //sheet.transitioningDelegate = CustomSheetTransitioningDelegate() return sheet } @@ -29,6 +28,59 @@ extension UIKitBackend { public func dismissSheet(_ sheet: CustomSheet, window: UIWindow?) { sheet.dismiss(animated: true) } + + public func setPresentationDetents(of sheet: CustomSheet, to detents: [PresentationDetent]) { + if #available(iOS 15.0, *) { + if let sheetPresentation = sheet.sheetPresentationController { + sheetPresentation.detents = detents.map { + switch $0 { + case .medium: return .medium() + case .large: return .large() + case .fraction(let fraction): + if #available(iOS 16.0, *) { + return .custom( + identifier: .init("Fraction:\(fraction)"), + resolver: { context in + context.maximumDetentValue * fraction + }) + } else { + return .medium() + } + case .height(let height): + if #available(iOS 16.0, *) { + return .custom( + identifier: .init("Height:\(height)"), + resolver: { context in + height + }) + } else { + return .medium() + } + } + } + } + } else { + #if DEBUG + print( + "your current OS Version doesn't support variable sheet heights.\n Setting presentationDetents is only available from iOS 15.0" + ) + #endif + } + } + + public func setPresentationCornerRadius(of sheet: CustomSheet, to radius: Double) { + if #available(iOS 15.0, *) { + if let sheetController = sheet.sheetPresentationController { + sheetController.preferredCornerRadius = radius + } + } else { + #if DEBUG + print( + "your current OS Version doesn't support variable sheet corner radii.\n Setting them is only available from iOS 15.0" + ) + #endif + } + } } public final class CustomSheet: UIViewController { From d6a63f2d0d53e54633b4f4cd6e97bf4c746460d3 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 3 Oct 2025 19:05:52 +0200 Subject: [PATCH 04/15] improved sheet rendering --- .../Sources/WindowingExample/WindowingApp.swift | 3 +++ Sources/AppKitBackend/AppKitBackend.swift | 8 +++++++- Sources/SwiftCrossUI/Backend/AppBackend.swift | 6 +++++- .../Views/Modifiers/SheetModifier.swift | 13 ++++++------- Sources/UIKitBackend/UIKitBackend+Sheet.swift | 7 ++++++- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index eccb058d40..e478c0dbfa 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -83,6 +83,8 @@ struct SheetDemo: View { print("sheet dismissed") } content: { SheetBody() + .frame(maxWidth: 200, maxHeight: 100) + .presentationDetents([.height(150), .medium, .large]) } .sheet(isPresented: $isShortTermSheetPresented) { Text("I'm only here for 5s") @@ -105,6 +107,7 @@ struct SheetDemo: View { isPresented = true print("should get presented") } + Spacer() } } .sheet(isPresented: $isPresented) { diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index f351050e65..1d7fcb58af 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1736,7 +1736,13 @@ public final class AppKitBackend: AppBackend { } } -public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { +public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate, SheetImplementation { + public var size: SIMD2 { + 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 func dismiss() { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 5cf88dbbd8..e5412c982d 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -47,7 +47,7 @@ public protocol AppBackend: Sendable { associatedtype Menu associatedtype Alert associatedtype Path - associatedtype Sheet + associatedtype Sheet: SheetImplementation /// Creates an instance of the backend. init() @@ -771,6 +771,10 @@ extension AppBackend { } } +public protocol SheetImplementation { + var size: SIMD2 { get } +} + extension AppBackend { /// Used by placeholder implementations of backend methods. private func todo(_ function: String = #function) -> Never { diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index 7fda8c113d..2dad1d17ba 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -71,27 +71,26 @@ struct SheetModifier: TypeSafeView { ) if isPresented.wrappedValue && children.sheet == nil { + //let sheetSize = dryRunResult.size.idealSize + + let sheet = backend.createSheet() + let dryRunResult = children.sheetContentNode.update( with: sheetContent(), - proposedSize: proposedSize, + proposedSize: sheet.size, environment: environment, dryRun: true ) - // Extract preferences from the sheet content let preferences = dryRunResult.preferences - let sheetSize = dryRunResult.size.idealSize - let _ = children.sheetContentNode.update( with: sheetContent(), - proposedSize: sheetSize, + proposedSize: sheet.size, environment: environment, dryRun: false ) - let sheet = backend.createSheet() - backend.updateSheet( sheet, content: children.sheetContentNode.widget.into(), diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index cb8853b05f..3a3f8c4de4 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -83,7 +83,12 @@ extension UIKitBackend { } } -public final class CustomSheet: UIViewController { +public final class CustomSheet: UIViewController, SheetImplementation { + public var size: SIMD2 { + let size = view.frame.size + return SIMD2(x: Int(size.width), y: Int(size.height)) + } + var onDismiss: (() -> Void)? public override func viewDidLoad() { From 79837ed81dc0fc062089b56419baa5be9b5c10d2 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 3 Oct 2025 19:44:51 +0200 Subject: [PATCH 05/15] added presentationDragIndicatorVisibility modifier --- .../WindowingExample/WindowingApp.swift | 2 +- Sources/SwiftCrossUI/Backend/AppBackend.swift | 9 +++++ .../PresentationDragIndicatorVisibility.swift | 3 ++ .../ViewGraph/PreferenceValues.swift | 6 ++- .../Views/Modifiers/SheetModifier.swift | 9 ++++- .../PresentationCornerRadiusModifier.swift | 22 ----------- .../Style/PresentationDetentsModifier.swift | 17 --------- .../Style/PresentationModifiers.swift | 37 +++++++++++++++++++ Sources/UIKitBackend/UIKitBackend+Sheet.swift | 16 ++++++++ 9 files changed, 78 insertions(+), 43 deletions(-) create mode 100644 Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift delete mode 100644 Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift delete mode 100644 Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift create mode 100644 Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index e478c0dbfa..3d230d83b5 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -83,8 +83,8 @@ struct SheetDemo: View { print("sheet dismissed") } content: { SheetBody() - .frame(maxWidth: 200, maxHeight: 100) .presentationDetents([.height(150), .medium, .large]) + .presentationDragIndicatorVisibility(.visible) } .sheet(isPresented: $isShortTermSheetPresented) { Text("I'm only here for 5s") diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index e5412c982d..6f587700c7 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -654,6 +654,9 @@ public protocol AppBackend: Sendable { /// - detents: An array of detents that the sheet can be resized to. func setPresentationDetents(of sheet: Sheet, to detents: [PresentationDetent]) + func setPresentationDragIndicatorVisibility( + of sheet: Sheet, to visibility: PresentationDragIndicatorVisibility) + /// Presents an 'Open file' dialog to the user for selecting files or /// folders. /// @@ -1254,4 +1257,10 @@ extension AppBackend { public func setPresentationDetents(of sheet: Sheet, to detents: [PresentationDetent]) { ignored() } + + public func setPresentationDragIndicatorVisibility( + of sheet: Sheet, to visibility: PresentationDragIndicatorVisibility + ) { + ignored() + } } diff --git a/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift b/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift new file mode 100644 index 0000000000..60151bff6a --- /dev/null +++ b/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift @@ -0,0 +1,3 @@ +public enum PresentationDragIndicatorVisibility { + case hidden, visible +} diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index d3d9c52075..519b6b59d2 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -15,10 +15,13 @@ public struct PreferenceValues: Sendable { /// The corner radius for a sheet presentation. Only applies to the top-level view in a sheet. public var presentationCornerRadius: Double? + public var presentationDragIndicatorVisibility: PresentationDragIndicatorVisibility? + public init( onOpenURL: (@Sendable @MainActor (URL) -> Void)?, presentationDetents: [PresentationDetent]? = nil, - presentationCornerRadius: Double? = nil + presentationCornerRadius: Double? = nil, + presentationDragIndicatorVisibility: PresentationDragIndicatorVisibility? = nil ) { self.onOpenURL = onOpenURL self.presentationDetents = presentationDetents @@ -40,5 +43,6 @@ public struct PreferenceValues: Sendable { // This ensures only the root view's presentation modifiers apply to the sheet presentationDetents = children.first?.presentationDetents presentationCornerRadius = children.first?.presentationCornerRadius + presentationDragIndicatorVisibility = children.first?.presentationDragIndicatorVisibility } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index 2dad1d17ba..0e36ffb125 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -71,8 +71,6 @@ struct SheetModifier: TypeSafeView { ) if isPresented.wrappedValue && children.sheet == nil { - //let sheetSize = dryRunResult.size.idealSize - let sheet = backend.createSheet() let dryRunResult = children.sheetContentNode.update( @@ -106,6 +104,13 @@ struct SheetModifier: TypeSafeView { backend.setPresentationDetents(of: sheet, to: detents) } + if let presentationDragIndicatorVisibility = preferences + .presentationDragIndicatorVisibility + { + backend.setPresentationDragIndicatorVisibility( + of: sheet, to: presentationDragIndicatorVisibility) + } + backend.showSheet( sheet, window: .some(environment.window! as! Backend.Window) diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift deleted file mode 100644 index 46a72075cc..0000000000 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationCornerRadiusModifier.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// PresentationCornerRadiusModifier.swift -// swift-cross-ui -// -// Created by Mia Koring on 03.10.25. -// - -extension View { - /// Sets the corner radius for a sheet presentation. - /// - /// This modifier only affects the sheet presentation itself when applied to the - /// top-level view within a sheet. It does not affect the content's corner radius. - /// - /// supported platforms: iOS (ignored on unsupported platforms) - /// ignored on: older than iOS 15 - /// - /// - Parameter radius: The corner radius in pixels. - /// - Returns: A view with the presentation corner radius preference set. - public func presentationCornerRadius(_ radius: Double) -> some View { - preference(key: \.presentationCornerRadius, value: radius) - } -} diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift deleted file mode 100644 index 78afc5cd2d..0000000000 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationDetentsModifier.swift +++ /dev/null @@ -1,17 +0,0 @@ -extension View { - /// Sets the available detents (heights) for a sheet presentation. - /// - /// This modifier only affects the sheet presentation itself when applied to the - /// top-level view within a sheet. It allows users to resize the sheet to different - /// predefined heights. - /// - /// supported platforms: iOS (ignored on unsupported platforms) - /// ignored on: older than iOS 15 - /// fraction and height fall back to medium on iOS 15 and work as you'd expect on >=16 - /// - /// - Parameter detents: A set of detents that the sheet can be resized to. - /// - Returns: A view with the presentation detents preference set. - public func presentationDetents(_ detents: Set) -> some View { - preference(key: \.presentationDetents, value: Array(detents)) - } -} diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift new file mode 100644 index 0000000000..512095b0f7 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift @@ -0,0 +1,37 @@ +extension View { + /// Sets the available detents (heights) for a sheet presentation. + /// + /// This modifier only affects the sheet presentation itself when applied to the + /// top-level view within a sheet. It allows users to resize the sheet to different + /// predefined heights. + /// + /// supported platforms: iOS (ignored on unsupported platforms) + /// ignored on: older than iOS 15 + /// fraction and height fall back to medium on iOS 15 and work as you'd expect on >=16 + /// + /// - Parameter detents: A set of detents that the sheet can be resized to. + /// - Returns: A view with the presentation detents preference set. + public func presentationDetents(_ detents: Set) -> some View { + preference(key: \.presentationDetents, value: Array(detents)) + } + + /// Sets the corner radius for a sheet presentation. + /// + /// This modifier only affects the sheet presentation itself when applied to the + /// top-level view within a sheet. It does not affect the content's corner radius. + /// + /// supported platforms: iOS (ignored on unsupported platforms) + /// ignored on: older than iOS 15 + /// + /// - Parameter radius: The corner radius in pixels. + /// - Returns: A view with the presentation corner radius preference set. + public func presentationCornerRadius(_ radius: Double) -> some View { + preference(key: \.presentationCornerRadius, value: radius) + } + + public func presentationDragIndicatorVisibility( + _ visibility: PresentationDragIndicatorVisibility + ) -> some View { + preference(key: \.presentationDragIndicatorVisibility, value: visibility) + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index 3a3f8c4de4..a13de8e97b 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -81,6 +81,22 @@ extension UIKitBackend { #endif } } + + public func setPresentationDragIndicatorVisibility( + of sheet: Sheet, to visibility: PresentationDragIndicatorVisibility + ) { + if #available(iOS 15.0, *) { + if let sheetController = sheet.sheetPresentationController { + sheetController.prefersGrabberVisible = visibility == .visible ? true : false + } + } else { + #if DEBUG + print( + "Your current OS Version doesn't support setting sheet drag indicator visibility.\n Setting this is only available from iOS 15.0" + ) + #endif + } + } } public final class CustomSheet: UIViewController, SheetImplementation { From b1e436b33886c21db96374f27d572a13cdbd3cdf Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 3 Oct 2025 21:13:08 +0200 Subject: [PATCH 06/15] added presentationBackground --- .../WindowingExample/WindowingApp.swift | 19 +++++----- Sources/AppKitBackend/AppKitBackend.swift | 36 +++++++++++++++++-- Sources/SwiftCrossUI/Backend/AppBackend.swift | 34 +++++++++++++++--- .../ViewGraph/PreferenceValues.swift | 13 +++++-- .../Views/Modifiers/SheetModifier.swift | 4 +++ .../Style/PresentationModifiers.swift | 4 +++ Sources/UIKitBackend/UIKitBackend+Sheet.swift | 4 +++ 7 files changed, 96 insertions(+), 18 deletions(-) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 3d230d83b5..f878c3451d 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -85,12 +85,14 @@ struct SheetDemo: View { SheetBody() .presentationDetents([.height(150), .medium, .large]) .presentationDragIndicatorVisibility(.visible) + .presentationBackground(.blue) } .sheet(isPresented: $isShortTermSheetPresented) { Text("I'm only here for 5s") .padding(20) .presentationDetents([.height(150), .medium, .large]) .presentationCornerRadius(10) + .presentationBackground(.red) } } @@ -98,17 +100,14 @@ struct SheetDemo: View { @State var isPresented = false var body: some View { - ZStack { - Color.blue - VStack { - Text("Nice sheet content") - .padding(20) - Button("I want more sheet") { - isPresented = true - print("should get presented") - } - Spacer() + VStack { + Text("Nice sheet content") + .padding(20) + Button("I want more sheet") { + isPresented = true + print("should get presented") } + Spacer() } .sheet(isPresented: $isPresented) { print("nested sheet dismissed") diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 1d7fcb58af..ece14a9db3 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1708,8 +1708,8 @@ public final class AppKitBackend: AppBackend { ) { let contentSize = naturalSize(of: content) - let width = max(contentSize.x, 80) - let height = max(contentSize.y, 80) + let width = max(contentSize.x, 10) + let height = max(contentSize.y, 10) sheet.setContentSize(NSSize(width: width, height: height)) sheet.contentView = content @@ -1734,6 +1734,38 @@ public final class AppKitBackend: AppBackend { 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 final class NSCustomSheet: NSCustomWindow, NSWindowDelegate, SheetImplementation { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 6f587700c7..bc786d17f5 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -654,8 +654,28 @@ public protocol AppBackend: Sendable { /// - detents: An array of detents that the sheet can be resized to. func setPresentationDetents(of sheet: Sheet, to detents: [PresentationDetent]) + /// Sets the visibility for a sheet presentation. + /// + /// This method is called when the sheet content has a `presentationDragIndicatorVisibility` + /// modifier applied at its top level. + /// + /// - Parameters: + /// - sheet: The sheet to apply the detents to. + /// - visibility: visibility of the drag indicator (visible or hidden) func setPresentationDragIndicatorVisibility( - of sheet: Sheet, to visibility: PresentationDragIndicatorVisibility) + of sheet: Sheet, + to visibility: PresentationDragIndicatorVisibility + ) + + /// Sets the background color for a sheet presentation. + /// + /// This method is called when the sheet content has a `presentationBackground` + /// modifier applied at its top level. + /// + /// - Parameters: + /// - sheet: The sheet to apply the detents to. + /// - color: rgba background color + func setPresentationBackground(of sheet: Sheet, to color: Color) /// Presents an 'Open file' dialog to the user for selecting files or /// folders. @@ -786,9 +806,11 @@ extension AppBackend { } private func ignored(_ function: String = #function) { - print( - "\(type(of: self)): \(function) is being ignored\nConsult at the documentation for further information." - ) + #if DEBUG + print( + "\(type(of: self)): \(function) is being ignored\nConsult at the documentation for further information." + ) + #endif } // MARK: System @@ -1263,4 +1285,8 @@ extension AppBackend { ) { ignored() } + + func setPresentationBackground(of sheet: Sheet, to color: Color) { + todo() + } } diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index 519b6b59d2..bcc2eef6ca 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -4,7 +4,9 @@ public struct PreferenceValues: Sendable { public static let `default` = PreferenceValues( onOpenURL: nil, presentationDetents: nil, - presentationCornerRadius: nil + presentationCornerRadius: nil, + presentationDragIndicatorVisibility: nil, + presentationBackground: nil ) public var onOpenURL: (@Sendable @MainActor (URL) -> Void)? @@ -15,17 +17,23 @@ public struct PreferenceValues: Sendable { /// The corner radius for a sheet presentation. Only applies to the top-level view in a sheet. public var presentationCornerRadius: Double? + /// The drag indicator visibiity for a sheet presentation. Only applies to the top-level view in a sheet. public var presentationDragIndicatorVisibility: PresentationDragIndicatorVisibility? + public var presentationBackground: Color? + public init( onOpenURL: (@Sendable @MainActor (URL) -> Void)?, presentationDetents: [PresentationDetent]? = nil, presentationCornerRadius: Double? = nil, - presentationDragIndicatorVisibility: PresentationDragIndicatorVisibility? = nil + presentationDragIndicatorVisibility: PresentationDragIndicatorVisibility? = nil, + presentationBackground: Color? ) { self.onOpenURL = onOpenURL self.presentationDetents = presentationDetents self.presentationCornerRadius = presentationCornerRadius + self.presentationDragIndicatorVisibility = presentationDragIndicatorVisibility + self.presentationBackground = presentationBackground } public init(merging children: [PreferenceValues]) { @@ -44,5 +52,6 @@ public struct PreferenceValues: Sendable { presentationDetents = children.first?.presentationDetents presentationCornerRadius = children.first?.presentationCornerRadius presentationDragIndicatorVisibility = children.first?.presentationDragIndicatorVisibility + presentationBackground = children.first?.presentationBackground } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index 0e36ffb125..ac5b209902 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -111,6 +111,10 @@ struct SheetModifier: TypeSafeView { of: sheet, to: presentationDragIndicatorVisibility) } + if let presentationBackground = preferences.presentationBackground { + backend.setPresentationBackground(of: sheet, to: presentationBackground) + } + backend.showSheet( sheet, window: .some(environment.window! as! Backend.Window) diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift index 512095b0f7..47f8be56b6 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift @@ -34,4 +34,8 @@ extension View { ) -> some View { preference(key: \.presentationDragIndicatorVisibility, value: visibility) } + + public func presentationBackground(_ color: Color) -> some View { + preference(key: \.presentationBackground, value: color) + } } diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index a13de8e97b..65f1ff0947 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -97,6 +97,10 @@ extension UIKitBackend { #endif } } + + public func setPresentationBackground(of sheet: CustomSheet, to color: Color) { + sheet.view.backgroundColor = color.uiColor + } } public final class CustomSheet: UIViewController, SheetImplementation { From 3108f7734c171447630b4090b1276f208ed2d04e Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Fri, 3 Oct 2025 22:22:20 +0200 Subject: [PATCH 07/15] UIKit sheet parent dismissals now dismiss both all children and the parent sheet itself on AppKit its probably easier to let users handle this by just setting isPresented to false, as the implementation is quite different than on UIKit using windows instead of views. Maybe potential improvement later/in a different pr --- .../WindowingExample/WindowingApp.swift | 43 +++++++++++- .../Environment/Actions/DismissAction.swift | 70 +++++++++++++++++++ .../PresentationDragIndicatorVisibility.swift | 2 +- .../Views/Modifiers/SheetModifier.swift | 11 ++- Sources/UIKitBackend/UIKitBackend+Sheet.swift | 24 ++++++- 5 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index f878c3451d..a589167876 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -64,6 +64,7 @@ struct AlertDemo: View { } } +// kind of a stress test for the dismiss action struct SheetDemo: View { @State var isPresented = false @State var isShortTermSheetPresented = false @@ -85,7 +86,7 @@ struct SheetDemo: View { SheetBody() .presentationDetents([.height(150), .medium, .large]) .presentationDragIndicatorVisibility(.visible) - .presentationBackground(.blue) + .presentationBackground(.green) } .sheet(isPresented: $isShortTermSheetPresented) { Text("I'm only here for 5s") @@ -98,6 +99,7 @@ struct SheetDemo: View { struct SheetBody: View { @State var isPresented = false + @Environment(\.dismiss) var dismiss var body: some View { VStack { @@ -107,12 +109,51 @@ struct SheetDemo: View { isPresented = true print("should get presented") } + Button("Dismiss") { + dismiss() + } Spacer() } .sheet(isPresented: $isPresented) { print("nested sheet dismissed") } content: { + NestedSheetBody(dismissParent: { dismiss() }) + } + } + + 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() }) + } + 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() + } } } } diff --git a/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift b/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift new file mode 100644 index 0000000000..1258d67193 --- /dev/null +++ b/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift @@ -0,0 +1,70 @@ +/// An action that dismisses the current presentation context. +/// +/// Use the `dismiss` environment value to get an instance of this action, +/// then call it to dismiss the current sheet. +/// +/// Example usage: +/// ```swift +/// struct SheetContentView: View { +/// @Environment(\.dismiss) var dismiss +/// +/// var body: some View { +/// VStack { +/// Text("Sheet Content") +/// Button("Close") { +/// dismiss() +/// } +/// } +/// } +/// } +/// ``` +@MainActor +public struct DismissAction { + private let action: () -> Void + + internal init(action: @escaping () -> Void) { + self.action = action + } + + /// Dismisses the current presentation context. + public func callAsFunction() { + action() + } +} + +/// Environment key for the dismiss action. +private struct DismissActionKey: EnvironmentKey { + @MainActor + static var defaultValue: DismissAction { + DismissAction(action: { + #if DEBUG + print("warning: dismiss() called but no presentation context is available") + #endif + }) + } +} + +extension EnvironmentValues { + /// An action that dismisses the current presentation context. + /// + /// Use this environment value to get a dismiss action that can be called + /// to dismiss the current sheet, popover, or other presentation. + /// + /// Example: + /// ```swift + /// struct ContentView: View { + /// @Environment(\.dismiss) var dismiss + /// + /// var body: some View { + /// Button("Close") { + /// dismiss() + /// } + /// } + /// } + /// ``` + @MainActor + public var dismiss: DismissAction { + get { self[DismissActionKey.self] } + set { self[DismissActionKey.self] = newValue } + } +} diff --git a/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift b/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift index 60151bff6a..da2cd54978 100644 --- a/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift +++ b/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift @@ -1,3 +1,3 @@ -public enum PresentationDragIndicatorVisibility { +public enum PresentationDragIndicatorVisibility: Sendable { case hidden, visible } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index ac5b209902..b219654e79 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -73,10 +73,15 @@ struct SheetModifier: TypeSafeView { if isPresented.wrappedValue && children.sheet == nil { let sheet = backend.createSheet() + let dismissAction = DismissAction(action: { [isPresented] in + isPresented.wrappedValue = false + }) + let sheetEnvironment = environment.with(\.dismiss, dismissAction) + let dryRunResult = children.sheetContentNode.update( with: sheetContent(), proposedSize: sheet.size, - environment: environment, + environment: sheetEnvironment, dryRun: true ) @@ -85,7 +90,7 @@ struct SheetModifier: TypeSafeView { let _ = children.sheetContentNode.update( with: sheetContent(), proposedSize: sheet.size, - environment: environment, + environment: sheetEnvironment, dryRun: false ) @@ -95,7 +100,7 @@ struct SheetModifier: TypeSafeView { onDismiss: handleDismiss ) - // Apply presentation preferences to the sheet + // MARK: Sheet Presentation Preferences if let cornerRadius = preferences.presentationCornerRadius { backend.setPresentationCornerRadius(of: sheet, to: cornerRadius) } diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index 65f1ff0947..6b600e2c94 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -26,7 +26,15 @@ extension UIKitBackend { } public func dismissSheet(_ sheet: CustomSheet, window: UIWindow?) { - sheet.dismiss(animated: true) + // If this sheet has a presented view controller (nested sheet), dismiss it first + if let presentedVC = sheet.presentedViewController { + presentedVC.dismiss(animated: false) { [weak sheet] in + // After the nested sheet is dismissed, dismiss this sheet + sheet?.dismissProgrammatically() + } + } else { + sheet.dismissProgrammatically() + } } public func setPresentationDetents(of sheet: CustomSheet, to detents: [PresentationDetent]) { @@ -110,12 +118,24 @@ public final class CustomSheet: UIViewController, SheetImplementation { } var onDismiss: (() -> Void)? + private var isDismissedProgrammatically = false public override func viewDidLoad() { super.viewDidLoad() } + func dismissProgrammatically() { + isDismissedProgrammatically = true + dismiss(animated: true) + } + public override func viewDidDisappear(_ animated: Bool) { - onDismiss?() + super.viewDidDisappear(animated) + + // Only call onDismiss if the sheet was dismissed by user interaction (swipe down, tap outside) + // not when dismissed programmatically via the dismiss action + if !isDismissedProgrammatically { + onDismiss?() + } } } From 7df381c78723ce2a95118987dcb3200d5d576e7e Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Sat, 4 Oct 2025 14:38:07 +0200 Subject: [PATCH 08/15] added interactiveDismissDisabled modifier --- .../Sources/WindowingExample/WindowingApp.swift | 1 + Sources/AppKitBackend/AppKitBackend.swift | 10 +++++++++- Sources/SwiftCrossUI/Backend/AppBackend.swift | 16 ++++++++++++++++ .../ViewGraph/PreferenceValues.swift | 13 ++++++++++--- .../{Style => }/PresentationModifiers.swift | 4 ++++ .../Views/Modifiers/SheetModifier.swift | 4 ++++ Sources/UIKitBackend/UIKitBackend+Sheet.swift | 4 ++++ 7 files changed, 48 insertions(+), 4 deletions(-) rename Sources/SwiftCrossUI/Views/Modifiers/{Style => }/PresentationModifiers.swift (91%) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index a589167876..e621f5ff4e 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -133,6 +133,7 @@ struct SheetDemo: View { } .sheet(isPresented: $showNextChild) { DoubleNestedSheetBody(dismissParent: { dismiss() }) + .interactiveDismissDisabled() } Button("dismiss parent sheet") { dismissParent() diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index ece14a9db3..c28ca6fd01 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1766,6 +1766,10 @@ public final class AppKitBackend: AppBackend { sheet.contentView = container } } + + public func setInteractiveDismissDisabled(for sheet: NSCustomSheet, to disabled: Bool) { + sheet.interactiveDismissDisabled = disabled + } } public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate, SheetImplementation { @@ -1777,13 +1781,17 @@ public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate, SheetImpleme } public var onDismiss: (() -> Void)? + public var interactiveDismissDisabled: Bool = false + public func dismiss() { onDismiss?() self.contentViewController?.dismiss(self) } @objc override public func cancelOperation(_ sender: Any?) { - dismiss() + if !interactiveDismissDisabled { + dismiss() + } } } diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index bc786d17f5..9123ec9f49 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -677,6 +677,18 @@ public protocol AppBackend: Sendable { /// - color: rgba background color func setPresentationBackground(of sheet: Sheet, to color: Color) + /// Sets the interactive dismissablility of a sheet. + /// when disabled the sheet can only be closed programmatically, + /// not through users swiping, escape keys or similar. + /// + /// This method is called when the sheet content has a `interactiveDismissDisabled` + /// modifier applied at its top level. + /// + /// - Parameters: + /// - sheet: The sheet to apply the detents to. + /// - disabled: wether its disabled + func setInteractiveDismissDisabled(for sheet: Sheet, to disabled: Bool) + /// Presents an 'Open file' dialog to the user for selecting files or /// folders. /// @@ -1289,4 +1301,8 @@ extension AppBackend { func setPresentationBackground(of sheet: Sheet, to color: Color) { todo() } + + func setInteractiveDismissDisabled(for sheet: Sheet, to disabled: Bool) { + todo() + } } diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index bcc2eef6ca..df0e0f446f 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -6,7 +6,8 @@ public struct PreferenceValues: Sendable { presentationDetents: nil, presentationCornerRadius: nil, presentationDragIndicatorVisibility: nil, - presentationBackground: nil + presentationBackground: nil, + interactiveDismissDisabled: nil ) public var onOpenURL: (@Sendable @MainActor (URL) -> Void)? @@ -17,23 +18,28 @@ public struct PreferenceValues: Sendable { /// The corner radius for a sheet presentation. Only applies to the top-level view in a sheet. public var presentationCornerRadius: Double? - /// The drag indicator visibiity for a sheet presentation. Only applies to the top-level view in a sheet. + /// The drag indicator visibility for a sheet presentation. Only applies to the top-level view in a sheet. public var presentationDragIndicatorVisibility: PresentationDragIndicatorVisibility? + /// The backgroundcolor of a sheet. Only applies to the top-level view in a sheet public var presentationBackground: Color? + public var interactiveDismissDisabled: Bool? + public init( onOpenURL: (@Sendable @MainActor (URL) -> Void)?, presentationDetents: [PresentationDetent]? = nil, presentationCornerRadius: Double? = nil, presentationDragIndicatorVisibility: PresentationDragIndicatorVisibility? = nil, - presentationBackground: Color? + presentationBackground: Color? = nil, + interactiveDismissDisabled: Bool? = nil ) { self.onOpenURL = onOpenURL self.presentationDetents = presentationDetents self.presentationCornerRadius = presentationCornerRadius self.presentationDragIndicatorVisibility = presentationDragIndicatorVisibility self.presentationBackground = presentationBackground + self.interactiveDismissDisabled = interactiveDismissDisabled } public init(merging children: [PreferenceValues]) { @@ -53,5 +59,6 @@ public struct PreferenceValues: Sendable { presentationCornerRadius = children.first?.presentationCornerRadius presentationDragIndicatorVisibility = children.first?.presentationDragIndicatorVisibility presentationBackground = children.first?.presentationBackground + interactiveDismissDisabled = children.first?.interactiveDismissDisabled } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift similarity index 91% rename from Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift rename to Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift index 47f8be56b6..0a45edaaec 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/PresentationModifiers.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift @@ -38,4 +38,8 @@ extension View { public func presentationBackground(_ color: Color) -> some View { preference(key: \.presentationBackground, value: color) } + + public func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View { + preference(key: \.interactiveDismissDisabled, value: isDisabled) + } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index b219654e79..d2fd8ecb84 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -120,6 +120,10 @@ struct SheetModifier: TypeSafeView { backend.setPresentationBackground(of: sheet, to: presentationBackground) } + if let interactiveDismissDisabled = preferences.interactiveDismissDisabled { + backend.setInteractiveDismissDisabled(for: sheet, to: interactiveDismissDisabled) + } + backend.showSheet( sheet, window: .some(environment.window! as! Backend.Window) diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index 6b600e2c94..6cef72b4ac 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -109,6 +109,10 @@ extension UIKitBackend { public func setPresentationBackground(of sheet: CustomSheet, to color: Color) { sheet.view.backgroundColor = color.uiColor } + + public func setInteractiveDismissDisabled(for sheet: Sheet, to disabled: Bool) { + sheet.isModalInPresentation = disabled + } } public final class CustomSheet: UIViewController, SheetImplementation { From 8669cb6b03724fa94c95978d10341f49d255e40e Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Wed, 8 Oct 2025 16:13:09 +0200 Subject: [PATCH 09/15] renamed size Parameter of SheetImplementation Protocol to sheetSize --- Sources/AppKitBackend/AppKitBackend.swift | 2 +- Sources/SwiftCrossUI/Backend/AppBackend.swift | 2 +- Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift | 4 ++-- Sources/UIKitBackend/UIKitBackend+Sheet.swift | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index c28ca6fd01..af49464896 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1773,7 +1773,7 @@ public final class AppKitBackend: AppBackend { } public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate, SheetImplementation { - public var size: SIMD2 { + public var sheetSize: SIMD2 { guard let size = self.contentView?.frame.size else { return SIMD2(x: 0, y: 0) } diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 9123ec9f49..919f341562 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -807,7 +807,7 @@ extension AppBackend { } public protocol SheetImplementation { - var size: SIMD2 { get } + var sheetSize: SIMD2 { get } } extension AppBackend { diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index d2fd8ecb84..8e2e0bd710 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -80,7 +80,7 @@ struct SheetModifier: TypeSafeView { let dryRunResult = children.sheetContentNode.update( with: sheetContent(), - proposedSize: sheet.size, + proposedSize: sheet.sheetSize, environment: sheetEnvironment, dryRun: true ) @@ -89,7 +89,7 @@ struct SheetModifier: TypeSafeView { let _ = children.sheetContentNode.update( with: sheetContent(), - proposedSize: sheet.size, + proposedSize: sheet.sheetSize, environment: sheetEnvironment, dryRun: false ) diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index 6cef72b4ac..a0c265e5b9 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -116,7 +116,7 @@ extension UIKitBackend { } public final class CustomSheet: UIViewController, SheetImplementation { - public var size: SIMD2 { + public var sheetSize: SIMD2 { let size = view.frame.size return SIMD2(x: Int(size.width), y: Int(size.height)) } From ed300d2d0bbc9a7ed2d6967df81f5f40d62c1c56 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Wed, 8 Oct 2025 17:13:53 +0200 Subject: [PATCH 10/15] GtkBackend Sheets save --- Sources/GtkBackend/GtkBackend.swift | 110 ++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 5bc07226c1..d0a44f17ae 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -22,6 +22,7 @@ public final class GtkBackend: AppBackend { public typealias Widget = Gtk.Widget public typealias Menu = Gtk.PopoverMenu public typealias Alert = Gtk.MessageDialog + public typealias Sheet = Gtk.Window public final class Path { var path: SwiftCrossUI.Path? @@ -48,6 +49,45 @@ public final class GtkBackend: AppBackend { /// precreated window until it gets 'created' via `createWindow`. var windows: [Window] = [] + // Sheet management (close-request, programmatic dismiss, interactive lock) + private final class SheetContext { + var onDismiss: () -> Void + var isProgrammaticDismiss: Bool = false + var interactiveDismissDisabled: Bool = false + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } + } + + private var sheetContexts: [OpaquePointer: SheetContext] = [:] + private var connectedCloseHandlers: Set = [] + + // C thunk for GtkWindow::close-request + private static let closeRequestThunk: + @convention(c) ( + UnsafeMutableRawPointer?, UnsafeMutableRawPointer? + ) -> Int32 = { instance, userData in + // TRUE (1) = consume event (prevent native close) + guard let instance, let userData else { return 1 } + let backend = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let key = OpaquePointer(instance) + guard let ctx = backend.sheetContexts[key] else { return 1 } + + if ctx.interactiveDismissDisabled { return 1 } + + if ctx.isProgrammaticDismiss { + // Suppress onDismiss for programmatic closes + ctx.isProgrammaticDismiss = false + return 1 + } + + backend.runInMainThread { + ctx.onDismiss() + } + return 1 + } + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -1569,6 +1609,70 @@ public final class GtkBackend: AppBackend { return properties } + public func createSheet() -> Gtk.Window { + return Gtk.Window() + } + + public func updateSheet(_ sheet: Gtk.Window, content: Widget, onDismiss: @escaping () -> Void) { + sheet.setChild(content) + + // Track per-sheet context and hook close-request once + let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) + + if let ctx = sheetContexts[key] { + // Update onDismiss if sheet already tracked + ctx.onDismiss = onDismiss + } else { + // First-time setup: store context and connect signal + let ctx = SheetContext(onDismiss: onDismiss) + sheetContexts[key] = ctx + + if connectedCloseHandlers.insert(key).inserted { + let handler: GCallback = unsafeBitCast(Self.closeRequestThunk, to: GCallback.self) + g_signal_connect_data( + UnsafeMutableRawPointer(sheet.gobjectPointer), + "close-request", + handler, + Unmanaged.passUnretained(self).toOpaque(), + nil, + GConnectFlags(0) + ) + } + } + } + + public func showSheet(_ sheet: Gtk.Window, window: ApplicationWindow?) { + sheet.isModal = true + sheet.isDecorated = false // optional for a more sheet-like look + sheet.setTransient(for: window ?? windows[0]) + sheet.present() + } + + public func dismissSheet(_ sheet: Gtk.Window, window: ApplicationWindow?) { + let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) + if let ctx = sheetContexts[key] { + // Suppress onDismiss when closing programmatically + ctx.isProgrammaticDismiss = true + } + sheet.destroy() + sheetContexts.removeValue(forKey: key) + connectedCloseHandlers.remove(key) + } + + public func setPresentationBackground(of sheet: Gtk.Window, to color: SwiftCrossUI.Color) { + sheet.css.set(properties: [.backgroundColor(color.gtkColor)]) + } + + public func setInteractiveDismissDisabled(for sheet: Gtk.Window, to disabled: Bool) { + let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) + if let ctx = sheetContexts[key] { + ctx.interactiveDismissDisabled = disabled + } else { + let ctx = SheetContext(onDismiss: {}) + ctx.interactiveDismissDisabled = disabled + sheetContexts[key] = ctx + } + } } extension UnsafeMutablePointer { @@ -1581,3 +1685,9 @@ extension UnsafeMutablePointer { class CustomListBox: ListBox { var cachedSelection: Int? = nil } + +extension Gtk.Window: SheetImplementation { + public var sheetSize: SIMD2 { + return SIMD2(x: self.size.width, y: self.size.height) + } +} From a76ca7c179bee3fb3c663d302889dbbb53448339 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Thu, 9 Oct 2025 00:59:22 +0200 Subject: [PATCH 11/15] finished GtkBackendSheets --- .../WindowingExample/WindowingApp.swift | 1 + Package.resolved | 2 +- Sources/GtkBackend/GtkBackend.swift | 106 +++++++++++++----- 3 files changed, 81 insertions(+), 28 deletions(-) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index e621f5ff4e..d0a79afc83 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -118,6 +118,7 @@ struct SheetDemo: View { print("nested sheet dismissed") } content: { NestedSheetBody(dismissParent: { dismiss() }) + .presentationCornerRadius(35) } } diff --git a/Package.resolved b/Package.resolved index 18390df54a..13507586f2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "589b3dd67c6c4bf002ac0e661cdc5f048304c975897d3542f1623910c0b856d2", + "originHash" : "2ce783f3e8fad62599b6c6d22660ffc4e6abf55121ba292835278e9377b1f871", "pins" : [ { "identity" : "jpeg", diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index d0a44f17ae..28e411e36c 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -37,6 +37,7 @@ public final class GtkBackend: AppBackend { public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true public let deviceClass = DeviceClass.desktop + public let defaultSheetCornerRadius = 10 var gtkApp: Application @@ -73,21 +74,35 @@ public final class GtkBackend: AppBackend { let backend = Unmanaged.fromOpaque(userData).takeUnretainedValue() let key = OpaquePointer(instance) guard let ctx = backend.sheetContexts[key] else { return 1 } - + if ctx.interactiveDismissDisabled { return 1 } - + if ctx.isProgrammaticDismiss { // Suppress onDismiss for programmatic closes ctx.isProgrammaticDismiss = false return 1 } - + backend.runInMainThread { ctx.onDismiss() } return 1 } - + + // C-convention thunk for key-pressed + private let escapeKeyPressedThunk: @convention(c) ( + UnsafeMutableRawPointer?, guint, guint, GdkModifierType, gpointer? + ) -> gboolean = { controller, keyval, keycode, state, userData in + // TRUE (1) = consume event + if keyval == GDK_KEY_Escape { + guard let userData else { return 1 } + let box = Unmanaged Void>>.fromOpaque(userData).takeUnretainedValue() + box.value() + return 1 // consume + } + return 0 // let others handle + } + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -1616,34 +1631,53 @@ public final class GtkBackend: AppBackend { public func updateSheet(_ sheet: Gtk.Window, content: Widget, onDismiss: @escaping () -> Void) { sheet.setChild(content) - // Track per-sheet context and hook close-request once let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) - if let ctx = sheetContexts[key] { - // Update onDismiss if sheet already tracked - ctx.onDismiss = onDismiss - } else { - // First-time setup: store context and connect signal - let ctx = SheetContext(onDismiss: onDismiss) - sheetContexts[key] = ctx - - if connectedCloseHandlers.insert(key).inserted { - let handler: GCallback = unsafeBitCast(Self.closeRequestThunk, to: GCallback.self) - g_signal_connect_data( - UnsafeMutableRawPointer(sheet.gobjectPointer), - "close-request", - handler, - Unmanaged.passUnretained(self).toOpaque(), - nil, - GConnectFlags(0) - ) - } + //add a slight border to not be just a flat corner + sheet.css.set(property: .border(color: SwiftCrossUI.Color.gray.gtkColor, width: 1)) + + let ctx = getOrCreateSheetContext(for: sheet) + ctx.onDismiss = onDismiss + + sheet.css.set(property: .cornerRadius(defaultSheetCornerRadius)) + + if connectedCloseHandlers.insert(key).inserted { + let handler: GCallback = unsafeBitCast(Self.closeRequestThunk, to: GCallback.self) + g_signal_connect_data( + UnsafeMutableRawPointer(sheet.gobjectPointer), + "close-request", + handler, + Unmanaged.passUnretained(self).toOpaque(), + nil, + GConnectFlags(0) + ) + + let escapeHandler = gtk_event_controller_key_new() + gtk_event_controller_set_propagation_phase(escapeHandler, GTK_PHASE_BUBBLE) + g_signal_connect_data ( + UnsafeMutableRawPointer(escapeHandler), + "key-pressed", + unsafeBitCast(escapeKeyPressedThunk, to: GCallback.self), + Unmanaged.passRetained(ValueBox(value: { + if ctx.interactiveDismissDisabled { return } + self.runInMainThread { + ctx.onDismiss() + } + })).toOpaque(), + { data, _ in + if let data { + Unmanaged Void>>.fromOpaque(data).release() + } + }, + G_CONNECT_DEFAULT + ) + gtk_widget_add_controller(sheet.widgetPointer, escapeHandler) } } public func showSheet(_ sheet: Gtk.Window, window: ApplicationWindow?) { sheet.isModal = true - sheet.isDecorated = false // optional for a more sheet-like look + sheet.isDecorated = false sheet.setTransient(for: window ?? windows[0]) sheet.present() } @@ -1664,13 +1698,24 @@ public final class GtkBackend: AppBackend { } public func setInteractiveDismissDisabled(for sheet: Gtk.Window, to disabled: Bool) { + let ctx = getOrCreateSheetContext(for: sheet) + + ctx.interactiveDismissDisabled = disabled + } + + public func setPresentationCornerRadius(of sheet: Gtk.Window, to radius: Double) { + let radius = Int(radius) + sheet.css.set(property: .cornerRadius(radius)) + } + + private func getOrCreateSheetContext(for sheet: Gtk.Window) -> SheetContext { let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) if let ctx = sheetContexts[key] { - ctx.interactiveDismissDisabled = disabled + return ctx } else { let ctx = SheetContext(onDismiss: {}) - ctx.interactiveDismissDisabled = disabled sheetContexts[key] = ctx + return ctx } } } @@ -1691,3 +1736,10 @@ extension Gtk.Window: SheetImplementation { return SIMD2(x: self.size.width, y: self.size.height) } } + +final class ValueBox { + let value: T + init(value: T) { + self.value = value + } +} From 941131e8c00bc9efd6931975e3201d0b03c28209 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Thu, 9 Oct 2025 01:14:41 +0200 Subject: [PATCH 12/15] documentation improvements --- Sources/GtkBackend/GtkBackend.swift | 6 ++-- .../Modifiers/PresentationModifiers.swift | 32 ++++++++++++++++--- .../Views/Modifiers/SheetModifier.swift | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 28e411e36c..30f4ace11f 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -78,7 +78,6 @@ public final class GtkBackend: AppBackend { if ctx.interactiveDismissDisabled { return 1 } if ctx.isProgrammaticDismiss { - // Suppress onDismiss for programmatic closes ctx.isProgrammaticDismiss = false return 1 } @@ -98,9 +97,9 @@ public final class GtkBackend: AppBackend { guard let userData else { return 1 } let box = Unmanaged Void>>.fromOpaque(userData).takeUnretainedValue() box.value() - return 1 // consume + return 1 } - return 0 // let others handle + return 0 } // A separate initializer to satisfy ``AppBackend``'s requirements. @@ -1685,7 +1684,6 @@ public final class GtkBackend: AppBackend { public func dismissSheet(_ sheet: Gtk.Window, window: ApplicationWindow?) { let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) if let ctx = sheetContexts[key] { - // Suppress onDismiss when closing programmatically ctx.isProgrammaticDismiss = true } sheet.destroy() diff --git a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift index 0a45edaaec..f2ffb0b99a 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift @@ -20,25 +20,47 @@ extension View { /// This modifier only affects the sheet presentation itself when applied to the /// top-level view within a sheet. It does not affect the content's corner radius. /// - /// supported platforms: iOS (ignored on unsupported platforms) - /// ignored on: older than iOS 15 + /// supported platforms: iOS 15+, Gtk4 (ignored on unsupported platforms) /// /// - Parameter radius: The corner radius in pixels. /// - Returns: A view with the presentation corner radius preference set. public func presentationCornerRadius(_ radius: Double) -> some View { preference(key: \.presentationCornerRadius, value: radius) } - + + /// Sets the visibility of a sheet's drag indicator. + /// + /// This modifier only affects the sheet presentation itself when applied to the + /// top-level view within a sheet. + /// + /// supported platforms: iOS 15+ (ignored on unsupported platforms) + /// + /// - Parameter visibiliy: visible or hidden + /// - Returns: A view with the presentation corner radius preference set. public func presentationDragIndicatorVisibility( _ visibility: PresentationDragIndicatorVisibility ) -> some View { preference(key: \.presentationDragIndicatorVisibility, value: visibility) } - + + /// Sets the background of a sheet. + /// + /// This modifier only affects the sheet presentation itself when applied to the + /// top-level view within a sheet. + /// + /// - Parameter color: the background color + /// - Returns: A view with the presentation corner radius preference set. public func presentationBackground(_ color: Color) -> some View { preference(key: \.presentationBackground, value: color) } - + + /// Sets wether the user should be able to dismiss the sheet themself. + /// + /// This modifier only affects the sheet presentation itself when applied to the + /// top-level view within a sheet. + /// + /// - Parameter isDisabled: is it disabled + /// - Returns: A view with the presentation corner radius preference set. public func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View { preference(key: \.interactiveDismissDisabled, value: isDisabled) } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index 8e2e0bd710..f0e94b8d3e 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -1,5 +1,5 @@ extension View { - /// presents a conditional modal overlay + /// Presents a conditional modal overlay /// onDismiss optional handler gets executed before /// dismissing the sheet public func sheet( From 9d6542e94bbaefea1c4d04e1ad589503b868134c Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Thu, 9 Oct 2025 10:52:50 +0200 Subject: [PATCH 13/15] Should fix UIKitBackend compile issue with visionOS and AppBackend Conformance of WinUIBackend and Gtk3Backend --- Sources/Gtk3Backend/Gtk3Backend.swift | 7 ++ Sources/GtkBackend/GtkBackend.swift | 59 ++++++++-------- Sources/SwiftCrossUI/Backend/AppBackend.swift | 4 +- .../Modifiers/PresentationModifiers.swift | 6 +- Sources/UIKitBackend/UIKitBackend+Sheet.swift | 70 ++++++++++--------- Sources/WinUIBackend/WinUIBackend.swift | 9 ++- 6 files changed, 89 insertions(+), 66 deletions(-) diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index ab504ef045..30c98409d7 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -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? @@ -1516,3 +1517,9 @@ struct Gtk3Error: LocalizedError { "gerror: code=\(code), domain=\(domain), message=\(message)" } } + +extension Gtk3.Window: SheetImplementation { + public var sheetSize: SIMD2 { + SIMD2(x: size.width, y: size.height) + } +} diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 30f4ace11f..f3818d620e 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -74,34 +74,35 @@ public final class GtkBackend: AppBackend { let backend = Unmanaged.fromOpaque(userData).takeUnretainedValue() let key = OpaquePointer(instance) guard let ctx = backend.sheetContexts[key] else { return 1 } - + if ctx.interactiveDismissDisabled { return 1 } - + if ctx.isProgrammaticDismiss { ctx.isProgrammaticDismiss = false return 1 } - + backend.runInMainThread { ctx.onDismiss() } return 1 } - + // C-convention thunk for key-pressed - private let escapeKeyPressedThunk: @convention(c) ( - UnsafeMutableRawPointer?, guint, guint, GdkModifierType, gpointer? - ) -> gboolean = { controller, keyval, keycode, state, userData in - // TRUE (1) = consume event - if keyval == GDK_KEY_Escape { - guard let userData else { return 1 } - let box = Unmanaged Void>>.fromOpaque(userData).takeUnretainedValue() - box.value() - return 1 + private let escapeKeyPressedThunk: + @convention(c) ( + UnsafeMutableRawPointer?, guint, guint, GdkModifierType, gpointer? + ) -> gboolean = { controller, keyval, keycode, state, userData in + // TRUE (1) = consume event + if keyval == GDK_KEY_Escape { + guard let userData else { return 1 } + let box = Unmanaged Void>>.fromOpaque(userData).takeUnretainedValue() + box.value() + return 1 + } + return 0 } - return 0 - } - + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -1637,7 +1638,7 @@ public final class GtkBackend: AppBackend { let ctx = getOrCreateSheetContext(for: sheet) ctx.onDismiss = onDismiss - + sheet.css.set(property: .cornerRadius(defaultSheetCornerRadius)) if connectedCloseHandlers.insert(key).inserted { @@ -1650,19 +1651,21 @@ public final class GtkBackend: AppBackend { nil, GConnectFlags(0) ) - + let escapeHandler = gtk_event_controller_key_new() gtk_event_controller_set_propagation_phase(escapeHandler, GTK_PHASE_BUBBLE) - g_signal_connect_data ( + g_signal_connect_data( UnsafeMutableRawPointer(escapeHandler), "key-pressed", unsafeBitCast(escapeKeyPressedThunk, to: GCallback.self), - Unmanaged.passRetained(ValueBox(value: { - if ctx.interactiveDismissDisabled { return } - self.runInMainThread { - ctx.onDismiss() - } - })).toOpaque(), + Unmanaged.passRetained( + ValueBox(value: { + if ctx.interactiveDismissDisabled { return } + self.runInMainThread { + ctx.onDismiss() + } + }) + ).toOpaque(), { data, _ in if let data { Unmanaged Void>>.fromOpaque(data).release() @@ -1697,15 +1700,15 @@ public final class GtkBackend: AppBackend { public func setInteractiveDismissDisabled(for sheet: Gtk.Window, to disabled: Bool) { let ctx = getOrCreateSheetContext(for: sheet) - + ctx.interactiveDismissDisabled = disabled } - + public func setPresentationCornerRadius(of sheet: Gtk.Window, to radius: Double) { let radius = Int(radius) sheet.css.set(property: .cornerRadius(radius)) } - + private func getOrCreateSheetContext(for sheet: Gtk.Window) -> SheetContext { let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) if let ctx = sheetContexts[key] { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 919f341562..3cddf71532 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -1298,11 +1298,11 @@ extension AppBackend { ignored() } - func setPresentationBackground(of sheet: Sheet, to color: Color) { + public func setPresentationBackground(of sheet: Sheet, to color: Color) { todo() } - func setInteractiveDismissDisabled(for sheet: Sheet, to disabled: Bool) { + public func setInteractiveDismissDisabled(for sheet: Sheet, to disabled: Bool) { todo() } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift index f2ffb0b99a..4967dda994 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift @@ -27,7 +27,7 @@ extension View { public func presentationCornerRadius(_ radius: Double) -> some View { preference(key: \.presentationCornerRadius, value: radius) } - + /// Sets the visibility of a sheet's drag indicator. /// /// This modifier only affects the sheet presentation itself when applied to the @@ -42,7 +42,7 @@ extension View { ) -> some View { preference(key: \.presentationDragIndicatorVisibility, value: visibility) } - + /// Sets the background of a sheet. /// /// This modifier only affects the sheet presentation itself when applied to the @@ -53,7 +53,7 @@ extension View { public func presentationBackground(_ color: Color) -> some View { preference(key: \.presentationBackground, value: color) } - + /// Sets wether the user should be able to dismiss the sheet themself. /// /// This modifier only affects the sheet presentation itself when applied to the diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index a0c265e5b9..ab57ab0b8e 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -39,34 +39,36 @@ extension UIKitBackend { public func setPresentationDetents(of sheet: CustomSheet, to detents: [PresentationDetent]) { if #available(iOS 15.0, *) { - if let sheetPresentation = sheet.sheetPresentationController { - sheetPresentation.detents = detents.map { - switch $0 { - case .medium: return .medium() - case .large: return .large() - case .fraction(let fraction): - if #available(iOS 16.0, *) { - return .custom( - identifier: .init("Fraction:\(fraction)"), - resolver: { context in - context.maximumDetentValue * fraction - }) - } else { - return .medium() - } - case .height(let height): - if #available(iOS 16.0, *) { - return .custom( - identifier: .init("Height:\(height)"), - resolver: { context in - height - }) - } else { - return .medium() - } + #if !os(visionOS) + if let sheetPresentation = sheet.sheetPresentationController { + sheetPresentation.detents = detents.map { + switch $0 { + case .medium: return .medium() + case .large: return .large() + case .fraction(let fraction): + if #available(iOS 16.0, *) { + return .custom( + identifier: .init("Fraction:\(fraction)"), + resolver: { context in + context.maximumDetentValue * fraction + }) + } else { + return .medium() + } + case .height(let height): + if #available(iOS 16.0, *) { + return .custom( + identifier: .init("Height:\(height)"), + resolver: { context in + height + }) + } else { + return .medium() + } + } } } - } + #endif } else { #if DEBUG print( @@ -78,9 +80,11 @@ extension UIKitBackend { public func setPresentationCornerRadius(of sheet: CustomSheet, to radius: Double) { if #available(iOS 15.0, *) { - if let sheetController = sheet.sheetPresentationController { - sheetController.preferredCornerRadius = radius - } + #if !os(visionOS) + if let sheetController = sheet.sheetPresentationController { + sheetController.preferredCornerRadius = radius + } + #endif } else { #if DEBUG print( @@ -94,9 +98,11 @@ extension UIKitBackend { of sheet: Sheet, to visibility: PresentationDragIndicatorVisibility ) { if #available(iOS 15.0, *) { - if let sheetController = sheet.sheetPresentationController { - sheetController.prefersGrabberVisible = visibility == .visible ? true : false - } + #if !os(visionOS) + if let sheetController = sheet.sheetPresentationController { + sheetController.prefersGrabberVisible = visibility == .visible ? true : false + } + #endif } else { #if DEBUG print( diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index a54a1a8625..d65f00934b 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -34,6 +34,7 @@ public final class WinUIBackend: AppBackend { public typealias Menu = Void public typealias Alert = WinUI.ContentDialog public typealias Path = GeometryGroupHolder + public typealias Sheet = CustomWindow //only for be protocol conform. doesn't currently support it public let defaultTableRowContentHeight = 20 public let defaultTableCellVerticalPadding = 4 @@ -1869,7 +1870,7 @@ class SwiftIInitializeWithWindow: WindowsFoundation.IUnknown { } } -public class CustomWindow: WinUI.Window { +public class CustomWindow: WinUI.Window, SheetImplementation { /// Hardcoded menu bar height from MenuBar_themeresources.xaml in the /// microsoft-ui-xaml repository. static let menuBarHeight = 0 @@ -1879,6 +1880,12 @@ public class CustomWindow: WinUI.Window { var grid: WinUI.Grid var cachedAppWindow: WinAppSDK.AppWindow! + //only for AppBackend conformance, no support yet + var sheetSize: SIMD2 { + let size = self.cachedAppWindow.size + return SIMD2(x: size.width, y: size.height) + } + var scaleFactor: Double { // I'm leaving this code here for future travellers. Be warned that this always // seems to return 100% even if the scale factor is set to 125% in settings. From 602ab542e5a197750f1a5cb593a99c0b97a476b1 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Thu, 9 Oct 2025 11:02:00 +0200 Subject: [PATCH 14/15] maybe ease gh actions gtk compile fix? --- Sources/GtkBackend/GtkBackend.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index f3818d620e..f89d9a7b28 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -1671,7 +1671,7 @@ public final class GtkBackend: AppBackend { Unmanaged Void>>.fromOpaque(data).release() } }, - G_CONNECT_DEFAULT + .init(0) ) gtk_widget_add_controller(sheet.widgetPointer, escapeHandler) } From af6da885d00cd1ede5acc4fe46876ba068a94c51 Mon Sep 17 00:00:00 2001 From: Mia Koring Date: Thu, 9 Oct 2025 11:29:49 +0200 Subject: [PATCH 15/15] fixed winUI AppBackend Conformance --- Sources/WinUIBackend/WinUIBackend.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index d65f00934b..9e724bea69 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -1881,9 +1881,9 @@ public class CustomWindow: WinUI.Window, SheetImplementation { var cachedAppWindow: WinAppSDK.AppWindow! //only for AppBackend conformance, no support yet - var sheetSize: SIMD2 { + public var sheetSize: SIMD2 { let size = self.cachedAppWindow.size - return SIMD2(x: size.width, y: size.height) + return SIMD2(x: Int(size.width), y: Int(size.height)) } var scaleFactor: Double {