diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index c17417b053..34ba1e68e0 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -64,6 +64,112 @@ struct AlertDemo: View { } } +// A demo displaying SwiftCrossUI's `View.sheet` modifier. +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() + .onAppear { + print("deepest nested sheet appeared") + } + .onDisappear { + print("deepest nested sheet disappeared") + } + } + Button("dismiss parent sheet") { + dismissParent() + } + Button("dismiss") { + dismiss() + } + .onDisappear { + print("nested sheet disappeared") + } + } + } + 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 +198,11 @@ struct WindowingApp: App { Divider() AlertDemo() + + Divider() + + SheetDemo() + .padding(.bottom, 20) } .padding(20) } @@ -108,23 +219,24 @@ struct WindowingApp: App { } } } - - WindowGroup("Secondary window") { - #hotReloadable { - Text("This a secondary window!") - .padding(10) + #if !os(iOS) && !os(tvOS) + 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 f0f3fa4817..914a0cd574 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 @@ -1689,6 +1690,122 @@ public final class AppKitBackend: AppBackend { let request = URLRequest(url: url) webView.load(request) } + + public func createSheet(content: NSView) -> NSCustomSheet { + // Initialize with a default contentRect, similar to `createWindow` + let sheet = NSCustomSheet( + contentRect: NSRect( + x: 0, + y: 0, + width: 400, // Default width + height: 400 // Default height + ), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true + ) + sheet.contentView = content + + return sheet + } + + public func updateSheet( + _ sheet: NSCustomSheet, + size: SIMD2, + onDismiss: @escaping () -> Void + ) { + sheet.contentView?.frame.size = .init(width: size.x, height: size.y) + sheet.onDismiss = onDismiss + } + + public func size(ofSheet sheet: NSCustomSheet) -> SIMD2 { + guard let size = sheet.contentView?.frame.size else { + return SIMD2(x: 0, y: 0) + } + return SIMD2(x: Int(size.width), y: Int(size.height)) + } + + public func showSheet(_ sheet: NSCustomSheet, sheetParent: Any) { + // Critical sheets stack. beginSheet only shows a nested sheet + // after its parent gets dismissed. + let window = sheetParent as! NSCustomWindow + window.beginSheet(sheet) + window.managedAttachedSheet = sheet + } + + public func dismissSheet(_ sheet: NSCustomSheet, sheetParent: Any) { + let window = sheetParent as! NSCustomWindow + + if let nestedSheet = sheet.managedAttachedSheet { + dismissSheet(nestedSheet, sheetParent: sheet) + } + + defer { window.managedAttachedSheet = nil } + + window.endSheet(sheet) + } + + public func setPresentationBackground(of sheet: NSCustomSheet, to color: Color) { + if let backgroundView = sheet.backgroundView { + backgroundView.layer?.backgroundColor = color.nsColor.cgColor + return + } + + let backgroundView = NSView() + backgroundView.wantsLayer = true + backgroundView.layer?.backgroundColor = color.nsColor.cgColor + + sheet.backgroundView = backgroundView + + 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 { + public var onDismiss: (() -> Void)? + + public var interactiveDismissDisabled: Bool = false + + public var backgroundView: NSView? + + public func dismiss() { + onDismiss?() + self.contentViewController?.dismiss(self) + } + + @objc override public func cancelOperation(_ sender: Any?) { + if !interactiveDismissDisabled { + dismiss() + } + } } final class NSCustomTapGestureTarget: NSView { @@ -2111,6 +2228,8 @@ public class NSCustomWindow: NSWindow { var customDelegate = Delegate() var persistentUndoManager = UndoManager() + var managedAttachedSheet: NSCustomSheet? + /// Allows the backing scale factor to be overridden. Useful for keeping /// UI tests consistent across devices. /// diff --git a/Sources/Gtk/Generated/EventControllerKey.swift b/Sources/Gtk/Generated/EventControllerKey.swift new file mode 100644 index 0000000000..99ffe0301a --- /dev/null +++ b/Sources/Gtk/Generated/EventControllerKey.swift @@ -0,0 +1,77 @@ +import CGtk + +/// Provides access to key events. +open class EventControllerKey: EventController { + /// Creates a new event controller that will handle key events. + public convenience init() { + self.init( + gtk_event_controller_key_new() + ) + } + + public override func registerSignals() { + super.registerSignals() + + addSignal(name: "im-update") { [weak self] () in + guard let self = self else { return } + self.imUpdate?(self) + } + + let handler1: + @convention(c) ( + UnsafeMutableRawPointer, UInt, UInt, GdkModifierType, UnsafeMutableRawPointer + ) -> Void = + { _, value1, value2, value3, data in + SignalBox3.run(data, value1, value2, value3) + } + + addSignal(name: "key-pressed", handler: gCallback(handler1)) { + [weak self] (param0: UInt, param1: UInt, param2: GdkModifierType) in + guard let self = self else { return } + self.keyPressed?(self, param0, param1, param2) + } + + let handler2: + @convention(c) ( + UnsafeMutableRawPointer, UInt, UInt, GdkModifierType, UnsafeMutableRawPointer + ) -> Void = + { _, value1, value2, value3, data in + SignalBox3.run(data, value1, value2, value3) + } + + addSignal(name: "key-released", handler: gCallback(handler2)) { + [weak self] (param0: UInt, param1: UInt, param2: GdkModifierType) in + guard let self = self else { return } + self.keyReleased?(self, param0, param1, param2) + } + + let handler3: + @convention(c) (UnsafeMutableRawPointer, GdkModifierType, UnsafeMutableRawPointer) -> + Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "modifiers", handler: gCallback(handler3)) { + [weak self] (param0: GdkModifierType) in + guard let self = self else { return } + self.modifiers?(self, param0) + } + } + + /// Emitted whenever the input method context filters away + /// a keypress and prevents the @controller receiving it. + /// + /// See [method@Gtk.EventControllerKey.set_im_context] and + /// [method@Gtk.IMContext.filter_keypress]. + public var imUpdate: ((EventControllerKey) -> Void)? + + /// Emitted whenever a key is pressed. + public var keyPressed: ((EventControllerKey, UInt, UInt, GdkModifierType) -> Void)? + + /// Emitted whenever a key is released. + public var keyReleased: ((EventControllerKey, UInt, UInt, GdkModifierType) -> Void)? + + /// Emitted whenever the state of modifier keys and pointer buttons change. + public var modifiers: ((EventControllerKey, GdkModifierType) -> Void)? +} diff --git a/Sources/Gtk/Widgets/Window.swift b/Sources/Gtk/Widgets/Window.swift index 059cb65743..5776d7158a 100644 --- a/Sources/Gtk/Widgets/Window.swift +++ b/Sources/Gtk/Widgets/Window.swift @@ -7,6 +7,8 @@ import CGtk open class Window: Widget { public var child: Widget? + public var managedAttachedSheet: Window? + public convenience init() { self.init(gtk_window_new()) } @@ -82,5 +84,36 @@ open class Window: Widget { public func present() { gtk_window_present(castedPointer()) + + addSignal(name: "close-request") { [weak self] () in + guard let self = self else { return } + self.onCloseRequest?(self) + } + } + + public func setEscapeKeyPressedHandler(to handler: (() -> Void)?) { + escapeKeyPressed = handler + + guard escapeKeyEventController == nil else { return } + + let keyEventController = EventControllerKey() + keyEventController.keyPressed = { [weak self] _, keyval, _, _ in + guard keyval == GDK_KEY_Escape else { return } + self?.escapeKeyPressed?() + } + escapeKeyEventController = keyEventController + addEventController(keyEventController) + } + + private var escapeKeyEventController: EventControllerKey? + + public var onCloseRequest: ((Window) -> Void)? + public var escapeKeyPressed: (() -> Void)? +} + +final class ValueBox { + let value: T + init(value: T) { + self.value = value } } diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index ab504ef045..a8e412896b 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? diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 418014c1c0..01db816a97 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,20 @@ 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 = [] + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -1569,6 +1585,114 @@ public final class GtkBackend: AppBackend { return properties } + + public func createSheet(content: Widget) -> Gtk.Window { + let sheet = Gtk.Window() + sheet.setChild(content) + + return sheet + } + + public func updateSheet( + _ sheet: Gtk.Window, + size: SIMD2, + onDismiss: @escaping () -> Void + ) { + let key: OpaquePointer = OpaquePointer(sheet.widgetPointer) + + sheet.size = Size(width: size.x, height: size.y) + + // 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 { + sheet.onCloseRequest = { [weak self] _ in + if ctx.interactiveDismissDisabled { return } + + if ctx.isProgrammaticDismiss { + ctx.isProgrammaticDismiss = false + return + } + + self?.runInMainThread { + ctx.onDismiss() + } + return + } + + sheet.setEscapeKeyPressedHandler { + if ctx.interactiveDismissDisabled { return } + self.runInMainThread { + ctx.onDismiss() + } + } + + } + } + + public func showSheet(_ sheet: Gtk.Window, sheetParent: Any) { + let window = sheetParent as! Gtk.Window + sheet.isModal = true + sheet.isDecorated = false + sheet.setTransient(for: window) + sheet.present() + window.managedAttachedSheet = sheet + } + + public func dismissSheet(_ sheet: Gtk.Window, sheetParent: Any) { + let window = sheetParent as! Gtk.Window + + if let nestedSheet = window.managedAttachedSheet { + dismissSheet(nestedSheet, sheetParent: sheet) + } + + defer { window.managedAttachedSheet = nil } + + 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 size(ofSheet sheet: Gtk.Window) -> SIMD2 { + return SIMD2(x: sheet.size.width, y: sheet.size.height) + } + + 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 { diff --git a/Sources/GtkCodeGen/GtkCodeGen.swift b/Sources/GtkCodeGen/GtkCodeGen.swift index 921f604305..a1f18b01c2 100644 --- a/Sources/GtkCodeGen/GtkCodeGen.swift +++ b/Sources/GtkCodeGen/GtkCodeGen.swift @@ -68,6 +68,7 @@ struct GtkCodeGen { "Gdk.GLContext": "OpaquePointer", "Gdk.Paintable": "OpaquePointer", "Gdk.Clipboard": "OpaquePointer", + "Gdk.ModifierType": "GdkModifierType", ] static let interfaces: [String] = [ @@ -115,6 +116,7 @@ struct GtkCodeGen { let gtk3AllowListedClasses = ["MenuShell", "EventBox"] let gtk4AllowListedClasses = [ "Picture", "DropDown", "Popover", "ListBox", "EventControllerMotion", + "EventControllerKey", ] for class_ in gir.namespace.classes { guard diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 31a412cf2f..31cf8dc700 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,95 @@ 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 provided code on dismiss and + /// prevent users from interacting with the parent window until dimissed. + func createSheet(content: Widget) -> Sheet + + /// Updates the content and appearance of a sheet. + func updateSheet( + _ sheet: Sheet, + size: SIMD2, + 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. + /// `onDismiss` only gets called once the sheet has been closed. + /// + /// 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, + sheetParent: Any + ) + + /// Dismisses a sheet programmatically. + /// Gets used by the ``View/sheet`` modifier to close a sheet. + func dismissSheet(_ sheet: Sheet, sheetParent: Any) + + /// Get the dimensions of a sheet + func size(ofSheet sheet: Sheet) -> SIMD2 + + /// Sets the corner radius for a sheet presentation. + /// + /// This method is called when the sheet content has the `presentationCornerRadius` + /// preference key set. 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` + /// preference key set. 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` + /// preference key set. + /// + /// - Parameters: + /// - sheet: The sheet to apply the drag indicator visibility to. + /// - visibility: visibility of the drag indicator (visible or hidden) + func setPresentationDragIndicatorVisibility( + of sheet: Sheet, + to visibility: Visibility + ) + + /// Sets the background color for a sheet presentation. + /// + /// This method is called when the sheet content has a `presentationBackground` + /// preference key set. + /// + /// - Parameters: + /// - sheet: The sheet to apply the background to. + /// - color: Background color for the sheet + func setPresentationBackground(of sheet: Sheet, to color: Color) + + /// Sets the interactive dismissibility 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` + /// preference key set. + /// + /// - Parameters: + /// - sheet: The sheet to apply the interactive dismissability to. + /// - disabled: Whether interactive dismissing is disabled. + func setInteractiveDismissDisabled(for sheet: Sheet, to disabled: Bool) + /// Presents an 'Open file' dialog to the user for selecting files or /// folders. /// @@ -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,55 @@ extension AppBackend { ) { todo() } + + public func createSheet(content: Widget) -> Sheet { + todo() + } + + public func updateSheet( + _ sheet: Sheet, + size: SIMD2, + onDismiss: @escaping () -> Void + ) { + todo() + } + + public func size( + ofSheet sheet: Sheet + ) -> SIMD2 { + todo() + } + + public func showSheet( + _ sheet: Sheet, + sheetParent: Any + ) { + todo() + } + + public func dismissSheet(_ sheet: Sheet, sheetParent: Any) { + 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: Visibility + ) { + 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/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index ffb03d5d94..4431383260 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -128,6 +128,11 @@ public struct EnvironmentValues { /// The backend in use. Mustn't change throughout the app's lifecycle. let backend: any AppBackend + /// The backend's representation of the class that owns the current sheet, + /// if any. This is a very internal detail that should never get + /// exposed to users. + package var sheetParent: Any? + /// Presents an 'Open file' dialog fit for selecting a single file. Some /// backends only allow selecting either files or directories but not both /// in a single dialog. Returns `nil` if the user cancels the operation. diff --git a/Sources/SwiftCrossUI/Values/PresentationDetent.swift b/Sources/SwiftCrossUI/Values/PresentationDetent.swift new file mode 100644 index 0000000000..7aedeb66b1 --- /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. + /// Falls 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 points. + /// Falls back to medium on iOS 15. + /// - Parameter height: The height + case height(Double) +} diff --git a/Sources/SwiftCrossUI/Values/Visibility.swift b/Sources/SwiftCrossUI/Values/Visibility.swift new file mode 100644 index 0000000000..88a5aa2c35 --- /dev/null +++ b/Sources/SwiftCrossUI/Values/Visibility.swift @@ -0,0 +1,3 @@ +public enum Visibility: Sendable { + case automatic, hidden, visible +} diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index d03e497a39..588cc12624 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -2,13 +2,45 @@ 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. Applies to enclosing sheets. + public var presentationDetents: [PresentationDetent]? + + /// The corner radius for a sheet presentation. Applies to enclosing sheets. + public var presentationCornerRadius: Double? + + /// The drag indicator visibility for a sheet presentation. Applies to enclosing sheets. + public var presentationDragIndicatorVisibility: Visibility? + + /// The backgroundcolor of a sheet. + public var presentationBackground: Color? + + /// Controls whether the user can interactively dismiss enclosing sheets. Applies to enclosing sheets. + public var interactiveDismissDisabled: Bool? + + public init( + onOpenURL: (@Sendable @MainActor (URL) -> Void)?, + presentationDetents: [PresentationDetent]? = nil, + presentationCornerRadius: Double? = nil, + presentationDragIndicatorVisibility: Visibility? = 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 +53,15 @@ public struct PreferenceValues: Sendable { } } } + + // For presentation modifiers, take the outer-most value (using child ordering to break ties). + presentationDetents = children.compactMap { $0.presentationDetents }.first + presentationCornerRadius = children.compactMap { $0.presentationCornerRadius }.first + presentationDragIndicatorVisibility = + children.compactMap { + $0.presentationDragIndicatorVisibility + }.first + presentationBackground = children.compactMap { $0.presentationBackground }.first + interactiveDismissDisabled = children.compactMap { $0.interactiveDismissDisabled }.first } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift new file mode 100644 index 0000000000..a0d05ff7e8 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift @@ -0,0 +1,62 @@ +extension View { + /// Sets the available detents (heights) for a sheet presentation. + /// + /// This modifier only affects the sheet presentation itself. + /// It allows users to resize the sheet to different predefined heights. + /// + /// - Supported platforms: iOS & Mac Catalyst 15+ (ignored on unsupported platforms) + /// - `.fraction` and `.height` fall back to `.medium` on iOS 15 and earlier + /// + /// - Parameter detents: A set of detents that the sheet can be resized to. + /// - Returns: A view with the presentationDetents 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. + /// It does not affect the content's corner radius. + /// + /// - Supported platforms: iOS & Mac Catalyst 15+, Gtk4 (ignored on unsupported platforms) + /// + /// - Parameter radius: The corner radius in points. + /// - Returns: A view with the presentationCornerRadius 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. + /// + /// - Supported platforms: iOS & Mac Catalyst 15+ (ignored on unsupported platforms) + /// + /// - Parameter visibility: visible or hidden + /// - Returns: A view with the presentationDragIndicatorVisibility preference set. + public func presentationDragIndicatorVisibility( + _ visibility: Visibility + ) -> some View { + preference(key: \.presentationDragIndicatorVisibility, value: visibility) + } + + /// Sets the background of a sheet. + /// + /// This modifier only affects the sheet presentation itself. + /// + /// - Parameter color: the background color + /// - Returns: A view with the presentationBackground preference set. + public func presentationBackground(_ color: Color) -> some View { + preference(key: \.presentationBackground, value: color) + } + + /// Sets whether the user should be able to dismiss the sheet themself. + /// + /// This modifier only affects the sheet presentation itself. + /// + /// - Parameter isDisabled: Whether interactive dismissal is disabled + /// - Returns: A view with the interactiveDismissDisabled 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..2637e6b395 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -0,0 +1,178 @@ +extension View { + /// Presents a conditional modal overlay. `onDismiss` gets invoked when the sheet is dismissed. + /// + /// Internal UIViewController.modalPresentationStyle falls back to .overFullScreen (non-opaque) on tvOS. + 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) + + return SheetModifierViewChildren( + childNode: bodyNode, + sheetContentNode: nil, + 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 sheetViewGraphNode = ViewGraphNode( + for: sheetContent(), + backend: backend, + environment: environment + ) + let sheetContentNode = AnyViewGraphNode(sheetViewGraphNode) + children.sheetContentNode = sheetContentNode + + let sheet = backend.createSheet( + content: children.sheetContentNode!.widget.into() + ) + + let dismissAction = DismissAction(action: { [isPresented] in + isPresented.wrappedValue = false + }) + + var sheetEnvironment = + environment + .with(\.dismiss, dismissAction) + .with(\.sheetParent, sheet) + + let result = children.sheetContentNode!.update( + with: sheetContent(), + proposedSize: SIMD2(x: 10_000, y: 0), + environment: sheetEnvironment, + dryRun: false + ) + + backend.updateSheet( + sheet, + size: result.size.size, + onDismiss: { handleDismiss(children: children) } + ) + + let preferences = result.preferences + + // 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, + sheetParent: (environment.sheetParent ?? environment.window)! + ) + children.sheet = sheet + } else if !isPresented.wrappedValue && children.sheet != nil { + backend.dismissSheet( + children.sheet as! Backend.Sheet, + sheetParent: (environment.sheetParent ?? environment.window)! + ) + children.sheet = nil + children.sheetContentNode = nil + } + return childResult + } + + func handleDismiss(children: Children) { + onDismiss?() + isPresented.wrappedValue = false + } +} + +class SheetModifierViewChildren: ViewGraphNodeChildren { + var widgets: [AnyWidget] { + [childNode.widget] + } + + var erasedNodes: [ErasedViewGraphNode] { + var nodes: [ErasedViewGraphNode] = [ErasedViewGraphNode(wrapping: childNode)] + if let sheetContentNode = sheetContentNode { + nodes.append(ErasedViewGraphNode(wrapping: sheetContentNode)) + } + return nodes + } + + 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..b171df2404 --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -0,0 +1,163 @@ +import SwiftCrossUI +import UIKit + +extension UIKitBackend { + public typealias Sheet = CustomSheet + + public func createSheet(content: Widget) -> CustomSheet { + let sheet = CustomSheet() + #if !os(tvOS) + sheet.modalPresentationStyle = .formSheet + #else + sheet.modalPresentationStyle = .overFullScreen + #endif + sheet.view = content.view + return sheet + } + + public func updateSheet( + _ sheet: CustomSheet, + size: SIMD2, + onDismiss: @escaping () -> Void + ) { + sheet.view.frame.size = CGSize(width: size.x, height: size.y) + sheet.onDismiss = onDismiss + } + + public func showSheet( + _ sheet: CustomSheet, + sheetParent: Any + ) { + var topController: UIViewController? = nil + if let window = sheetParent as? UIWindow { + topController = window.rootViewController + } else { + topController = sheetParent as? UIViewController + } + topController?.present(sheet, animated: true) + } + + public func dismissSheet(_ sheet: CustomSheet, sheetParent: Any) { + // 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 sizeOf(_ sheet: CustomSheet) -> SIMD2 { + let size = sheet.view.frame.size + return SIMD2(x: Int(size.width), y: Int(size.height)) + } + + public func setPresentationDetents(of sheet: CustomSheet, to detents: [PresentationDetent]) { + if #available(iOS 15.0, macCatalyst 15.0, *) { + #if !os(tvOS) && !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. Setting presentationDetents is only available from iOS 15.0. tvOS and visionOS are not supported." + ) + #endif + } + } + + public func setPresentationCornerRadius(of sheet: CustomSheet, to radius: Double) { + if #available(iOS 15.0, *) { + #if !os(tvOS) && !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. Setting them is only available from iOS 15.0. tvOS and visionOS are not supported." + ) + #endif + } + } + + public func setPresentationDragIndicatorVisibility( + of sheet: Sheet, to visibility: Visibility + ) { + if #available(iOS 15.0, *) { + #if !os(tvOS) && !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. Setting this is only available from iOS 15.0. tvOS and visionOS are not supported." + ) + #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 { + 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 882285118a..948012ee16 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 protocol conformance. WinUI doesn't currently support it. public let defaultTableRowContentHeight = 20 public let defaultTableCellVerticalPadding = 4