diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index c17417b053..a5b1a98bf9 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) + 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..3aea6efaa6 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,105 @@ 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, + onDismiss: @escaping () -> Void + ) { + sheet.onDismiss = onDismiss + } + + public func sizeOf(_ 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, 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) { + window.endSheet(sheet) + } + + 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 { + 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/Gtk/Widgets/Window.swift b/Sources/Gtk/Widgets/Window.swift index 059cb65743..364d3edfc9 100644 --- a/Sources/Gtk/Widgets/Window.swift +++ b/Sources/Gtk/Widgets/Window.swift @@ -82,5 +82,77 @@ 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)?) { + if let data = escapeKeyHandlerData { + Unmanaged Void>>.fromOpaque(data).release() + escapeKeyHandlerData = nil + } + + if let oldController = escapeKeyEventController { + gtk_widget_remove_controller(widgetPointer, oldController) + escapeKeyEventController = nil + } + + escapeKeyPressed = handler + + guard handler != nil else { return } + + let keyEventController = gtk_event_controller_key_new() + gtk_event_controller_set_propagation_phase(keyEventController, GTK_PHASE_BUBBLE) + + let thunk: + @convention(c) ( + UnsafeMutableRawPointer?, guint, guint, GdkModifierType, gpointer? + ) -> gboolean = { _, keyval, _, _, userData in + if keyval == GDK_KEY_Escape { + guard let userData else { return 1 } + let box = Unmanaged Void>>.fromOpaque(userData) + .takeUnretainedValue() + box.value() + return 1 + } + return 0 + } + + let boxedHandler = Unmanaged.passRetained( + ValueBox(value: handler!) + ).toOpaque() + + g_signal_connect_data( + UnsafeMutableRawPointer(keyEventController), + "key-pressed", + unsafeBitCast(thunk, to: GCallback.self), + boxedHandler, + { data, _ in + if let data { + Unmanaged Void>>.fromOpaque(data).release() + } + }, + .init(0) + ) + + gtk_widget_add_controller(widgetPointer, keyEventController) + escapeKeyEventController = keyEventController + escapeKeyHandlerData = boxedHandler + } + + private var escapeKeyEventController: OpaquePointer? + private var escapeKeyHandlerData: UnsafeMutableRawPointer? + + public var onCloseRequest: ((Window) -> Int32)? + 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..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..27c303d3d6 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,96 @@ 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, onDismiss: @escaping () -> Void) { + 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 { + sheet.onCloseRequest = { [weak self] _ in + if ctx.interactiveDismissDisabled { return 1 } + + if ctx.isProgrammaticDismiss { + ctx.isProgrammaticDismiss = false + return 1 + } + + self?.runInMainThread { + ctx.onDismiss() + } + return 1 + } + + sheet.setEscapeKeyPressedHandler { + print("escapeKeyPressed") + if ctx.interactiveDismissDisabled { return } + self.runInMainThread { + ctx.onDismiss() + } + } + + } + } + + 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 sizeOf(_ 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/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 31a412cf2f..2eb27efb05 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,94 @@ 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, + 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, + window: Window? + ) + + /// Dismisses a sheet programmatically. + /// Gets used by the ``View/sheet`` modifier to close a sheet. + func dismissSheet(_ sheet: Sheet, window: Window) + + /// Get the dimensions of a sheet + func sizeOf(_ sheet: Sheet) -> SIMD2 + + /// Sets the corner radius for a sheet presentation. + /// + /// This method is called when the sheet content has a `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 +816,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 +1259,54 @@ extension AppBackend { ) { todo() } + + public func createSheet(content: Widget) -> Sheet { + todo() + } + + public func updateSheet( + _ sheet: Sheet, + onDismiss: @escaping () -> Void + ) { + todo() + } + + public func sizeOf( + sheet: Sheet + ) -> SIMD2 { + 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: 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/Values/PresentationDetent.swift b/Sources/SwiftCrossUI/Values/PresentationDetent.swift new file mode 100644 index 0000000000..f25ba037a0 --- /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 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..507a9984f8 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. Applies to enclosing sheets. + 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..7d577d006c --- /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 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 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. + /// It does not affect the content's corner radius. + /// + /// - Supported platforms: iOS 15+, Gtk4 (ignored on unsupported platforms) + /// + /// - Parameter radius: The corner radius in points. + /// - 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. + /// + /// - Supported platforms: iOS 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..62dbc9f80f --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -0,0 +1,171 @@ +extension View { + /// Presents a conditional modal overlay. `onDismiss` gets invoked when the sheet is dismissed. + 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 + }) + let sheetEnvironment = environment.with(\.dismiss, dismissAction) + + let result = children.sheetContentNode!.update( + with: sheetContent(), + proposedSize: backend.sizeOf(sheet), + environment: sheetEnvironment, + dryRun: false + ) + + backend.updateSheet( + sheet, + 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, + 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: environment.window! as! Backend.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..72733d0936 --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -0,0 +1,149 @@ +import SwiftCrossUI +import UIKit + +extension UIKitBackend { + public typealias Sheet = CustomSheet + + public func createSheet(content: Widget) -> CustomSheet { + let sheet = CustomSheet() + sheet.modalPresentationStyle = .formSheet + sheet.view = content.view + return sheet + } + + public func updateSheet(_ sheet: CustomSheet, onDismiss: @escaping () -> Void) { + 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 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, *) { + #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. 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. Setting them is only available from iOS 15.0" + ) + #endif + } + } + + public func setPresentationDragIndicatorVisibility( + of sheet: Sheet, to visibility: Visibility + ) { + 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. 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 { + 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..cf462ec95a 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