diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index c17417b053..d0a79afc83 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -64,6 +64,103 @@ struct AlertDemo: View { } } +// kind of a stress test for the dismiss action +struct SheetDemo: View { + @State var isPresented = false + @State var isShortTermSheetPresented = false + + var body: some View { + Button("Open Sheet") { + isPresented = true + } + Button("Show Sheet for 5s") { + isShortTermSheetPresented = true + Task { + try? await Task.sleep(nanoseconds: 1_000_000_000 * 5) + isShortTermSheetPresented = false + } + } + .sheet(isPresented: $isPresented) { + print("sheet dismissed") + } content: { + SheetBody() + .presentationDetents([.height(150), .medium, .large]) + .presentationDragIndicatorVisibility(.visible) + .presentationBackground(.green) + } + .sheet(isPresented: $isShortTermSheetPresented) { + Text("I'm only here for 5s") + .padding(20) + .presentationDetents([.height(150), .medium, .large]) + .presentationCornerRadius(10) + .presentationBackground(.red) + } + } + + struct SheetBody: View { + @State var isPresented = false + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack { + Text("Nice sheet content") + .padding(20) + Button("I want more sheet") { + isPresented = true + print("should get presented") + } + Button("Dismiss") { + dismiss() + } + Spacer() + } + .sheet(isPresented: $isPresented) { + print("nested sheet dismissed") + } content: { + NestedSheetBody(dismissParent: { dismiss() }) + .presentationCornerRadius(35) + } + } + + struct NestedSheetBody: View { + @Environment(\.dismiss) var dismiss + var dismissParent: () -> Void + @State var showNextChild = false + + var body: some View { + Text("I'm nested. Its claustrophobic in here.") + Button("New Child Sheet") { + showNextChild = true + } + .sheet(isPresented: $showNextChild) { + DoubleNestedSheetBody(dismissParent: { dismiss() }) + .interactiveDismissDisabled() + } + Button("dismiss parent sheet") { + dismissParent() + } + Button("dismiss") { + dismiss() + } + } + } + struct DoubleNestedSheetBody: View { + @Environment(\.dismiss) var dismiss + var dismissParent: () -> Void + + var body: some View { + Text("I'm nested. Its claustrophobic in here.") + Button("dismiss parent sheet") { + dismissParent() + } + Button("dismiss") { + dismiss() + } + } + } + } +} + @main @HotReloadable struct WindowingApp: App { @@ -92,6 +189,11 @@ struct WindowingApp: App { Divider() AlertDemo() + + Divider() + + SheetDemo() + .padding(.bottom, 20) } .padding(20) } @@ -108,23 +210,24 @@ struct WindowingApp: App { } } } - - WindowGroup("Secondary window") { - #hotReloadable { - Text("This a secondary window!") - .padding(10) + #if !os(iOS) + WindowGroup("Secondary window") { + #hotReloadable { + Text("This a secondary window!") + .padding(10) + } } - } - .defaultSize(width: 200, height: 200) - .windowResizability(.contentMinSize) + .defaultSize(width: 200, height: 200) + .windowResizability(.contentMinSize) - WindowGroup("Tertiary window") { - #hotReloadable { - Text("This a tertiary window!") - .padding(10) + WindowGroup("Tertiary window") { + #hotReloadable { + Text("This a tertiary window!") + .padding(10) + } } - } - .defaultSize(width: 200, height: 200) - .windowResizability(.contentMinSize) + .defaultSize(width: 200, height: 200) + .windowResizability(.contentMinSize) + #endif } } 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/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 4f23de39d1..af49464896 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,113 @@ public final class AppKitBackend: AppBackend { let request = URLRequest(url: url) webView.load(request) } + + public func createSheet() -> NSCustomSheet { + // Initialize with a default contentRect, similar to window creation (lines 58-68) + let sheet = NSCustomSheet( + contentRect: NSRect( + x: 0, + y: 0, + width: 400, // Default width + height: 300 // Default height + ), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true + ) + return sheet + } + + public func updateSheet( + _ sheet: NSCustomSheet, content: NSView, onDismiss: @escaping () -> Void + ) { + let contentSize = naturalSize(of: content) + + let width = max(contentSize.x, 10) + let height = max(contentSize.y, 10) + sheet.setContentSize(NSSize(width: width, height: height)) + + sheet.contentView = content + sheet.onDismiss = onDismiss + } + + public func showSheet(_ sheet: NSCustomSheet, window: NSCustomWindow?) { + guard let window else { + print("warning: Cannot show sheet without a parent window") + return + } + // critical sheets stack + // beginSheet only shows a nested + // sheet after its parent gets dismissed + window.beginCriticalSheet(sheet) + } + + public func dismissSheet(_ sheet: NSCustomSheet, window: NSCustomWindow?) { + if let window { + window.endSheet(sheet) + } else { + NSApplication.shared.stopModal() + } + } + + public func setPresentationBackground(of sheet: NSCustomSheet, to color: Color) { + let backgroundView = NSView() + backgroundView.wantsLayer = true + backgroundView.layer?.backgroundColor = color.nsColor.cgColor + + if let existingContentView = sheet.contentView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(backgroundView) + backgroundView.translatesAutoresizingMaskIntoConstraints = false + backgroundView.leadingAnchor.constraint(equalTo: container.leadingAnchor).isActive = + true + backgroundView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true + backgroundView.trailingAnchor.constraint(equalTo: container.trailingAnchor).isActive = + true + backgroundView.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true + + container.addSubview(existingContentView) + existingContentView.translatesAutoresizingMaskIntoConstraints = false + existingContentView.leadingAnchor.constraint(equalTo: container.leadingAnchor) + .isActive = true + existingContentView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true + existingContentView.trailingAnchor.constraint(equalTo: container.trailingAnchor) + .isActive = true + existingContentView.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = + true + + sheet.contentView = container + } + } + + public func setInteractiveDismissDisabled(for sheet: NSCustomSheet, to disabled: Bool) { + sheet.interactiveDismissDisabled = disabled + } +} + +public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate, SheetImplementation { + public var sheetSize: SIMD2 { + guard let size = self.contentView?.frame.size else { + return SIMD2(x: 0, y: 0) + } + return SIMD2(x: Int(size.width), y: Int(size.height)) + } + public var onDismiss: (() -> Void)? + + public var interactiveDismissDisabled: Bool = false + + public func dismiss() { + onDismiss?() + self.contentViewController?.dismiss(self) + } + + @objc override public func cancelOperation(_ sender: Any?) { + if !interactiveDismissDisabled { + dismiss() + } + } } final class NSCustomTapGestureTarget: NSView { 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 5bc07226c1..f89d9a7b28 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? @@ -36,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 @@ -48,6 +50,59 @@ 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 { + 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 + } + return 0 + } + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -1569,6 +1624,101 @@ 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) + + let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) + + //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() + } + }, + .init(0) + ) + gtk_widget_add_controller(sheet.widgetPointer, escapeHandler) + } + } + + public func showSheet(_ sheet: Gtk.Window, window: ApplicationWindow?) { + sheet.isModal = true + sheet.isDecorated = false + 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] { + 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 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] { + return ctx + } else { + let ctx = SheetContext(onDismiss: {}) + sheetContexts[key] = ctx + return ctx + } + } } extension UnsafeMutablePointer { @@ -1581,3 +1731,16 @@ 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) + } +} + +final class ValueBox { + let value: T + init(value: T) { + self.value = value + } +} diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 31a412cf2f..3cddf71532 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: SheetImplementation /// Creates an instance of the backend. init() @@ -603,6 +604,91 @@ 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?) + + /// 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 + func setPresentationCornerRadius(of sheet: Sheet, to radius: Double) + + /// 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]) + + /// 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 + ) + + /// 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) + + /// 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. /// @@ -720,6 +806,10 @@ extension AppBackend { } } +public protocol SheetImplementation { + var sheetSize: SIMD2 { get } +} + extension AppBackend { /// Used by placeholder implementations of backend methods. private func todo(_ function: String = #function) -> Never { @@ -727,6 +817,14 @@ extension AppBackend { Foundation.exit(1) } + private func ignored(_ function: String = #function) { + #if DEBUG + print( + "\(type(of: self)): \(function) is being ignored\nConsult at the documentation for further information." + ) + #endif + } + // MARK: System public func openExternalURL(_ url: URL) throws { @@ -1162,4 +1260,49 @@ 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() + } + + public func setPresentationCornerRadius(of sheet: Sheet, to radius: Double) { + ignored() + } + + public func setPresentationDetents(of sheet: Sheet, to detents: [PresentationDetent]) { + ignored() + } + + public func setPresentationDragIndicatorVisibility( + of sheet: Sheet, to visibility: PresentationDragIndicatorVisibility + ) { + ignored() + } + + public func setPresentationBackground(of sheet: Sheet, to color: Color) { + todo() + } + + public func setInteractiveDismissDisabled(for sheet: Sheet, to disabled: Bool) { + todo() + } } 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/PresentationDetent.swift b/Sources/SwiftCrossUI/Values/PresentationDetent.swift new file mode 100644 index 0000000000..a8b24c5782 --- /dev/null +++ b/Sources/SwiftCrossUI/Values/PresentationDetent.swift @@ -0,0 +1,18 @@ +/// 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. + /// 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. + /// falling back to medium on iOS 15 + /// - Parameter height: The height + case height(Double) +} diff --git a/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift b/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift new file mode 100644 index 0000000000..da2cd54978 --- /dev/null +++ b/Sources/SwiftCrossUI/Values/PresentationDragIndicatorVisibility.swift @@ -0,0 +1,3 @@ +public enum PresentationDragIndicatorVisibility: Sendable { + case hidden, visible +} diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index d03e497a39..df0e0f446f 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -2,13 +2,44 @@ import Foundation public struct PreferenceValues: Sendable { public static let `default` = PreferenceValues( - onOpenURL: nil + onOpenURL: nil, + presentationDetents: nil, + presentationCornerRadius: nil, + presentationDragIndicatorVisibility: nil, + presentationBackground: nil, + interactiveDismissDisabled: 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: Double? + + /// 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? = 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]) { @@ -21,5 +52,13 @@ 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 + presentationDragIndicatorVisibility = children.first?.presentationDragIndicatorVisibility + presentationBackground = children.first?.presentationBackground + interactiveDismissDisabled = children.first?.interactiveDismissDisabled } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift new file mode 100644 index 0000000000..4967dda994 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift @@ -0,0 +1,67 @@ +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 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 new file mode 100644 index 0000000000..f0e94b8d3e --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -0,0 +1,170 @@ +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 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.sheetSize, + environment: sheetEnvironment, + dryRun: true + ) + + let preferences = dryRunResult.preferences + + let _ = children.sheetContentNode.update( + with: sheetContent(), + proposedSize: sheet.sheetSize, + environment: sheetEnvironment, + dryRun: false + ) + + backend.updateSheet( + sheet, + content: children.sheetContentNode.widget.into(), + onDismiss: handleDismiss + ) + + // MARK: Sheet Presentation Preferences + if let cornerRadius = preferences.presentationCornerRadius { + backend.setPresentationCornerRadius(of: sheet, to: cornerRadius) + } + + if let detents = preferences.presentationDetents { + backend.setPresentationDetents(of: sheet, to: detents) + } + + if let presentationDragIndicatorVisibility = preferences + .presentationDragIndicatorVisibility + { + backend.setPresentationDragIndicatorVisibility( + of: sheet, to: presentationDragIndicatorVisibility) + } + + if let presentationBackground = preferences.presentationBackground { + 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) + ) + 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 + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift new file mode 100644 index 0000000000..ab57ab0b8e --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -0,0 +1,151 @@ +import SwiftCrossUI +import UIKit + +extension UIKitBackend { + public typealias Sheet = CustomSheet + + public func createSheet() -> CustomSheet { + let sheet = CustomSheet() + sheet.modalPresentationStyle = .formSheet + + 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?) { + // 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]) { + if #available(iOS 15.0, *) { + #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( + "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 !os(visionOS) + if let sheetController = sheet.sheetPresentationController { + sheetController.preferredCornerRadius = radius + } + #endif + } 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 func setPresentationDragIndicatorVisibility( + of sheet: Sheet, to visibility: PresentationDragIndicatorVisibility + ) { + if #available(iOS 15.0, *) { + #if !os(visionOS) + if let sheetController = sheet.sheetPresentationController { + sheetController.prefersGrabberVisible = visibility == .visible ? true : false + } + #endif + } 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 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 { + public var sheetSize: SIMD2 { + let size = view.frame.size + return SIMD2(x: Int(size.width), y: Int(size.height)) + } + + 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) { + 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?() + } + } +} diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index a54a1a8625..9e724bea69 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 + public var sheetSize: SIMD2 { + let size = self.cachedAppWindow.size + return SIMD2(x: Int(size.width), y: Int(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.