From e2c5db49fe6236eca0f9fbbef9d10ddc04092af4 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Mon, 19 Aug 2024 22:38:19 +0800 Subject: [PATCH 01/15] Common --- Package.swift | 17 ++ Package@swift-6.0.swift | 17 ++ .../AppKitNavigation/AppKitAnimation.swift | 102 ++++++++++ .../Internal/AssociatedKeys.swift | 36 ++++ .../Internal/AssumeIsolated.swift | 35 ++++ .../Internal/ErrorMechanism.swift | 20 ++ .../AppKitNavigation/Internal/Exports.swift | 3 + .../Internal/ToOptionalUnit.swift | 12 ++ Sources/AppKitNavigation/Observe.swift | 181 ++++++++++++++++++ Sources/AppKitNavigation/UIBinding.swift | 15 ++ Sources/AppKitNavigation/UITransaction.swift | 53 +++++ Sources/AppKitNavigationShim/include/shim.h | 25 +++ Sources/AppKitNavigationShim/shim.m | 152 +++++++++++++++ 13 files changed, 668 insertions(+) create mode 100644 Sources/AppKitNavigation/AppKitAnimation.swift create mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift create mode 100644 Sources/AppKitNavigation/Internal/AssumeIsolated.swift create mode 100644 Sources/AppKitNavigation/Internal/ErrorMechanism.swift create mode 100644 Sources/AppKitNavigation/Internal/Exports.swift create mode 100644 Sources/AppKitNavigation/Internal/ToOptionalUnit.swift create mode 100644 Sources/AppKitNavigation/Observe.swift create mode 100644 Sources/AppKitNavigation/UIBinding.swift create mode 100644 Sources/AppKitNavigation/UITransaction.swift create mode 100644 Sources/AppKitNavigationShim/include/shim.h create mode 100644 Sources/AppKitNavigationShim/shim.m diff --git a/Package.swift b/Package.swift index 46c1d7edb..fc20ea17d 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,10 @@ let package = Package( name: "UIKitNavigation", targets: ["UIKitNavigation"] ), + .library( + name: "AppKitNavigation", + targets: ["AppKitNavigation"] + ), ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -31,6 +35,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"), ], targets: [ .target( @@ -73,6 +78,18 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), + ] + ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 9ed432ff6..a97be3d08 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -23,6 +23,10 @@ let package = Package( name: "UIKitNavigation", targets: ["UIKitNavigation"] ), + .library( + name: "AppKitNavigation", + targets: ["AppKitNavigation"] + ), ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -31,6 +35,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"), ], targets: [ .target( @@ -73,6 +78,18 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), + ] + ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift new file mode 100644 index 000000000..0d8230c8f --- /dev/null +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -0,0 +1,102 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +#if canImport(SwiftUI) +import SwiftUI +#endif + +import SwiftNavigation + +/// Executes a closure with the specified animation and returns the result. +/// +/// - Parameters: +/// - animation: An animation, set in the ``UITransaction/appKit`` property of the thread's +/// current transaction. +/// - body: A closure to execute. +/// - completion: A completion to run when the animation is complete. +/// - Returns: The result of executing the closure with the specified animation. +@MainActor +public func withAppKitAnimation( + _ animation: AppKitAnimation? = .default, + _ body: () throws -> Result, + completion: (@Sendable (Bool?) -> Void)? = nil +) rethrows -> Result { + var transaction = UITransaction() + transaction.appKit.animation = animation + if let completion { + transaction.appKit.addAnimationCompletion(completion) + } + return try withUITransaction(transaction, body) +} + +/// The way a view changes over time to create a smooth visual transition from one state to +/// another. +public struct AppKitAnimation: Hashable, Sendable { + fileprivate let framework: Framework + + @MainActor + func perform( + _ body: () throws -> Result, + completion: ((Bool?) -> Void)? = nil + ) rethrows -> Result { + switch framework { + case let .swiftUI(animation): + _ = animation + fatalError() + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.duration = animation.duration + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) + } + + return try result!._rethrowGet() + } + } + + fileprivate enum Framework: Hashable, Sendable { + case appKit(AppKit) + case swiftUI(Animation) + + fileprivate struct AppKit: Hashable, Sendable { + fileprivate var duration: TimeInterval + + func hash(into hasher: inout Hasher) { + hasher.combine(duration) + } + } + } +} + +extension AppKitAnimation { + /// Performs am animation using a timing curve corresponding to the motion of a physical spring. + /// + /// A value description of + /// `UIView.animate(withDuration:delay:dampingRatio:velocity:options:animations:completion:)` + /// that can be used with ``withAppKitAnimation(_:_:completion:)``. + /// + /// - Parameters: + /// - duration: The total duration of the animations, measured in seconds. If you specify a + /// negative value or `0`, the changes are made without animating them. + /// - Returns: An animation using a timing curve corresponding to the motion of a physical + /// spring. + public static func animate( + withDuration duration: TimeInterval = 0.25 + ) -> Self { + Self( + framework: .appKit( + Framework.AppKit( + duration: duration + ) + ) + ) + } + + /// A default animation instance. + public static var `default`: Self { + return .animate() + } +} +#endif diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift new file mode 100644 index 000000000..1df9f50ed --- /dev/null +++ b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift @@ -0,0 +1,36 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +struct AssociatedKeys { + var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + + mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { + let key = AnyHashableMetatype(type) + if let associatedKey = keys[key] { + return associatedKey + } else { + let associatedKey = malloc(1)! + keys[key] = associatedKey + return associatedKey + } + } +} + +struct AnyHashableMetatype: Hashable { + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } + + let base: Any.Type + + init(_ base: Any.Type) { + self.base = base + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift new file mode 100644 index 000000000..93f1c4009 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift @@ -0,0 +1,35 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +extension MainActor { + // NB: This functionality was not back-deployed in Swift 5.9 + static func _assumeIsolated( + _ operation: @MainActor () throws -> T, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows -> T { + #if swift(<5.10) + typealias YesActor = @MainActor () throws -> T + typealias NoActor = () throws -> T + + guard Thread.isMainThread else { + fatalError( + "Incorrect actor executor assumption; Expected same executor as \(self).", + file: file, + line: line + ) + } + + return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in + let rawFn = unsafeBitCast(fn, to: NoActor.self) + return try rawFn() + } + #else + return try assumeIsolated(operation, file: file, line: line) + #endif + } +} + + +#endif diff --git a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift new file mode 100644 index 000000000..1ec4c47b0 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift @@ -0,0 +1,20 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +@rethrows +protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output +} + +extension _ErrorMechanism { + func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + + func _rethrowGet() rethrows -> Output { + return try get() + } +} + +extension Result: _ErrorMechanism {} +#endif diff --git a/Sources/AppKitNavigation/Internal/Exports.swift b/Sources/AppKitNavigation/Internal/Exports.swift new file mode 100644 index 000000000..554225adc --- /dev/null +++ b/Sources/AppKitNavigation/Internal/Exports.swift @@ -0,0 +1,3 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +@_exported import SwiftNavigation +#endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift new file mode 100644 index 000000000..a11cfaaf1 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift @@ -0,0 +1,12 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +extension Bool { + struct Unit: Hashable, Identifiable { + var id: Unit { self } + } + + var toOptionalUnit: Unit? { + get { self ? Unit() : nil } + set { self = newValue != nil } + } +} +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift new file mode 100644 index 000000000..803287449 --- /dev/null +++ b/Sources/AppKitNavigation/Observe.swift @@ -0,0 +1,181 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +@_spi(Internals) import SwiftNavigation +import AppKit + +@MainActor +extension NSObject { + /// Observe access to properties of an observable (or perceptible) object. + /// + /// This tool allows you to set up an observation loop so that you can access fields from an + /// observable model in order to populate your view, and also automatically track changes to + /// any accessed fields so that the view is always up-to-date. + /// + /// It is most useful when dealing with non-SwiftUI views, such as AppKit views and controller. + /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all + /// the view elements: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let countLabel = NSTextField(labelWithString: "") + /// let incrementButton = NSButton { [weak self] _ in + /// self?.model.incrementButtonTapped() + /// } + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.stringValue = "\(model.count)" + /// } + /// } + /// ``` + /// + /// This closure is immediately called, allowing you to set the initial state of your UI + /// components from the feature's state. And if the `count` property in the feature's state is + /// ever mutated, this trailing closure will be called again, allowing us to update the view + /// again. + /// + /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your + /// view, such as `viewDidLoad` for `NSViewController`. This works even if you have many UI + /// components to update: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.isHidden = model.isObservingCount + /// if !countLabel.isHidden { + /// countLabel.stringValue = "\(model.count)" + /// } + /// factLabel.stringValue = model.fact + /// } + /// } + /// ``` + /// + /// This does mean that you may execute the line `factLabel.text = model.fact` even when + /// something unrelated changes, such as `store.model`, but that is typically OK for simple + /// properties of UI components. It is not a performance problem to repeatedly set the `text` of + /// a label or the `isHidden` of a button. + /// + /// However, if there is heavy work you need to perform when state changes, then it is best to + /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or + /// collection view when a collection changes: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// dataSource = model.items + /// tableView.reloadData() + /// } + /// } + /// ``` + /// + /// ## Cancellation + /// + /// The method returns an ``ObservationToken`` that can be used to cancel observation. For + /// example, if you only want to observe while a view controller is visible, you can start + /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: + /// + /// ```swift + /// var observation: ObservationToken? + /// + /// func viewWillAppear() { + /// super.viewWillAppear() + /// observation = observe { [weak self] in + /// // ... + /// } + /// } + /// func viewWillDisappear() { + /// super.viewWillDisappear() + /// observation?.cancel() + /// } + /// ``` + /// + /// - Parameter apply: A closure that contains properties to track and is invoked when the value + /// of a property changes. + /// - Returns: A cancellation token. + @discardableResult + public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { + observe { _ in apply() } + } + + /// Observe access to properties of an observable (or perceptible) object. + /// + /// A version of ``observe(_:)`` that is passed the current transaction. + /// + /// - Parameter apply: A closure that contains properties to track and is invoked when the value + /// of a property changes. + /// - Returns: A cancellation token. + @discardableResult + public func observe( + _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void + ) -> ObservationToken { + let token = SwiftNavigation.observe { transaction in + MainActor._assumeIsolated { + withUITransaction(transaction) { + if transaction.appKit.disablesAnimations { + NSView.performWithoutAnimation { apply(transaction) } + for completion in transaction.appKit.animationCompletions { + completion(true) + } + } else if let animation = transaction.appKit.animation { + return animation.perform( + { apply(transaction) }, + completion: transaction.appKit.animationCompletions.isEmpty + ? nil + : { + for completion in transaction.appKit.animationCompletions { + completion($0) + } + } + ) + } else { + apply(transaction) + for completion in transaction.appKit.animationCompletions { + completion(true) + } + } + } + } + } task: { transaction, work in + DispatchQueue.main.async { + withUITransaction(transaction, work) + } + } + tokens.append(token) + return token + } + + fileprivate var tokens: [Any] { + get { + objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] + } + set { + objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private static let tokensKey = malloc(1)! +} + +extension NSView { + fileprivate static func performWithoutAnimation(_ block: () -> Void) { + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = false + block() + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift new file mode 100644 index 000000000..ea3499dec --- /dev/null +++ b/Sources/AppKitNavigation/UIBinding.swift @@ -0,0 +1,15 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftNavigation + +extension UIBinding { + /// Specifies an animation to perform when the binding value changes. + /// + /// - Parameter animation: An animation sequence performed when the binding value changes. + /// - Returns: A new binding. + public func animation(_ animation: AppKitAnimation? = .default) -> Self { + var binding = self + binding.transaction.appKit.animation = animation + return binding + } +} +#endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift new file mode 100644 index 000000000..b4f9535f4 --- /dev/null +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -0,0 +1,53 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import SwiftNavigation + +extension UITransaction { + /// Creates a transaction and assigns its animation property. + /// + /// - Parameter animation: The animation to perform when the current state changes. + public init(animation: AppKitAnimation? = nil) { + self.init() + appKit.animation = animation + } + + /// AppKit-specific data associated with the current state change. + public var appKit: AppKit { + get { self[AppKitKey.self] } + set { self[AppKitKey.self] = newValue } + } + + private enum AppKitKey: UITransactionKey { + static let defaultValue = AppKit() + } + + /// AppKit-specific data associated with a ``UITransaction``. + public struct AppKit: Sendable { + /// The animation, if any, associated with the current state change. + public var animation: AppKitAnimation? + + /// A Boolean value that indicates whether views should disable animations. + public var disablesAnimations = false + + var animationCompletions: [@Sendable (Bool?) -> Void] = [] + + /// Adds a completion to run when the animations created with this transaction are all + /// complete. + /// + /// The completion callback will always be fired exactly one time. + public mutating func addAnimationCompletion( + _ completion: @escaping @Sendable (Bool?) -> Void + ) { + animationCompletions.append(completion) + } + } +} + +private enum AnimationCompletionsKey: UITransactionKey { + static let defaultValue: [@Sendable (Bool?) -> Void] = [] +} + +private enum DisablesAnimationsKey: UITransactionKey { + static let defaultValue = false +} +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100644 index 000000000..e1844275f --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,25 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface NSViewController (AppKitNavigation) + +@property BOOL hasViewAppeared; +@property (nullable) void (^ onDismiss)(); +@property NSArray *onViewAppear; + +@end + +@interface NSSavePanel (AppKitNavigation) +@property (nullable) void (^ AppKitNavigation_onFinalURL)(NSURL *_Nullable); +@property (nullable) void (^ AppKitNavigation_onFinalURLs)(NSArray *); +@end + + +NS_ASSUME_NONNULL_END +#endif +#endif /* if __has_include() */ diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m new file mode 100644 index 000000000..21f5f9c08 --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,152 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import ObjectiveC; +@import AppKit; +#import "shim.h" + +@interface AppKitNavigationShim : NSObject + +@end + +@implementation AppKitNavigationShim + +// NB: We must use Objective-C here to eagerly swizzle view controller code that is responsible +// for state-driven presentation and dismissal of child features. + ++ (void)load { + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(viewDidAppear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURL:")), + class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURL:)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURLs:")), + class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURLs:)) + ); +} + +@end + +@implementation NSSavePanel (AppKitNavigation) + +- (void)setAppKitNavigation_onFinalURLs:(void (^)(NSArray * _Nonnull))AppKitNavigation_onFinalURLs { + objc_setAssociatedObject(self, @selector(AppKitNavigation_onFinalURLs), AppKitNavigation_onFinalURLs, OBJC_ASSOCIATION_COPY); +} + +- (void (^)(NSArray * _Nonnull))AppKitNavigation_onFinalURLs { + return objc_getAssociatedObject(self, @selector(AppKitNavigation_onFinalURLs)); +} + +- (void)setAppKitNavigation_onFinalURL:(void (^)(NSURL * _Nullable))AppKitNavigation_onFinalURL { + objc_setAssociatedObject(self, @selector(AppKitNavigation_onFinalURL), AppKitNavigation_onFinalURL, OBJC_ASSOCIATION_COPY); +} + +- (void (^)(NSURL * _Nullable))AppKitNavigation_onFinalURL { + return objc_getAssociatedObject(self, @selector(AppKitNavigation_onFinalURL)); +} + +- (void)AppKitNavigation_setFinalURL:(nullable NSURL *)url { + [self AppKitNavigation_setFinalURL:url]; + if (self.AppKitNavigation_onFinalURL) { + self.AppKitNavigation_onFinalURL(url); + } +} + +- (void)AppKitNavigation_setFinalURLs:(NSArray *)urls { + [self AppKitNavigation_setFinalURLs:urls]; + if (self.AppKitNavigation_onFinalURLs) { + self.AppKitNavigation_onFinalURLs(urls); + } +} + +@end + +static void *hasViewAppearedKey = &hasViewAppearedKey; +static void *onDismissKey = &onDismissKey; +static void *onViewAppearKey = &onViewAppearKey; + +@implementation NSViewController (AppKitNavigation) + +- (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; + + if (self.hasViewAppeared) { + return; + } + + self.hasViewAppeared = YES; + + for (void (^work)() in self.onViewAppear) { + work(); + } + + self.onViewAppear = @[]; +} + +- (void)setBeingDismissed:(BOOL)beingDismissed { + objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); +} + +- (BOOL)isBeingDismissed { + return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; +} + +- (void)AppKitNavigation_viewDidDisappear { + [self AppKitNavigation_viewDidDisappear]; + + if ((self.isBeingDismissed) && self.onDismiss != NULL) { + self.onDismiss(); + self.onDismiss = nil; + [self setBeingDismissed:NO]; + } +} + +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} + +- (BOOL)hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} + +- (void)setHasViewAppeared:(BOOL)hasViewAppeared { + objc_setAssociatedObject( + self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + ); +} + +- (void (^)())onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} + +- (void)setOnDismiss:(void (^)())onDismiss { + objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (NSMutableArray *)onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); + + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +@end +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ From 0c61b636b616e3fa186c7f42627add5cf1e8d0be Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 20 Aug 2024 20:39:14 +0800 Subject: [PATCH 02/15] Remove unused code --- Package.swift | 6 - Package@swift-6.0.swift | 6 - .../AppKitNavigation/AppKitAnimation.swift | 22 ---- .../Internal/AssociatedKeys.swift | 36 ------ .../Internal/ToOptionalUnit.swift | 12 -- Sources/AppKitNavigation/Observe.swift | 108 ------------------ Sources/AppKitNavigation/UIBinding.swift | 4 - Sources/AppKitNavigation/UITransaction.swift | 11 -- .../Internal/AssumeIsolated.swift | 7 +- .../Internal/ErrorMechanism.swift | 9 +- .../Internal/ToOptionalUnit.swift | 12 ++ .../Internal/AssumeIsolated.swift | 30 ----- .../Internal/ErrorMechanism.swift | 20 ---- .../Internal/ToOptionalUnit.swift | 12 -- 14 files changed, 17 insertions(+), 278 deletions(-) delete mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift delete mode 100644 Sources/AppKitNavigation/Internal/ToOptionalUnit.swift rename Sources/{AppKitNavigation => SwiftNavigation}/Internal/AssumeIsolated.swift (88%) rename Sources/{AppKitNavigation => SwiftNavigation}/Internal/ErrorMechanism.swift (55%) create mode 100644 Sources/SwiftNavigation/Internal/ToOptionalUnit.swift delete mode 100644 Sources/UIKitNavigation/Internal/AssumeIsolated.swift delete mode 100644 Sources/UIKitNavigation/Internal/ErrorMechanism.swift delete mode 100644 Sources/UIKitNavigation/Internal/ToOptionalUnit.swift diff --git a/Package.swift b/Package.swift index fc20ea17d..3ca03ba03 100644 --- a/Package.swift +++ b/Package.swift @@ -35,7 +35,6 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), - .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"), ], targets: [ .target( @@ -82,14 +81,9 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), - .target( - name: "AppKitNavigationShim" - ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index a97be3d08..7d316472d 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -35,7 +35,6 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), - .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"), ], targets: [ .target( @@ -82,14 +81,9 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), - .target( - name: "AppKitNavigationShim" - ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 0d8230c8f..e2c8199a0 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -7,14 +7,6 @@ import SwiftUI import SwiftNavigation -/// Executes a closure with the specified animation and returns the result. -/// -/// - Parameters: -/// - animation: An animation, set in the ``UITransaction/appKit`` property of the thread's -/// current transaction. -/// - body: A closure to execute. -/// - completion: A completion to run when the animation is complete. -/// - Returns: The result of executing the closure with the specified animation. @MainActor public func withAppKitAnimation( _ animation: AppKitAnimation? = .default, @@ -29,8 +21,6 @@ public func withAppKitAnimation( return try withUITransaction(transaction, body) } -/// The way a view changes over time to create a smooth visual transition from one state to -/// another. public struct AppKitAnimation: Hashable, Sendable { fileprivate let framework: Framework @@ -71,17 +61,6 @@ public struct AppKitAnimation: Hashable, Sendable { } extension AppKitAnimation { - /// Performs am animation using a timing curve corresponding to the motion of a physical spring. - /// - /// A value description of - /// `UIView.animate(withDuration:delay:dampingRatio:velocity:options:animations:completion:)` - /// that can be used with ``withAppKitAnimation(_:_:completion:)``. - /// - /// - Parameters: - /// - duration: The total duration of the animations, measured in seconds. If you specify a - /// negative value or `0`, the changes are made without animating them. - /// - Returns: An animation using a timing curve corresponding to the motion of a physical - /// spring. public static func animate( withDuration duration: TimeInterval = 0.25 ) -> Self { @@ -94,7 +73,6 @@ extension AppKitAnimation { ) } - /// A default animation instance. public static var `default`: Self { return .animate() } diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift deleted file mode 100644 index 1df9f50ed..000000000 --- a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift +++ /dev/null @@ -1,36 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -struct AssociatedKeys { - var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] - - mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { - let key = AnyHashableMetatype(type) - if let associatedKey = keys[key] { - return associatedKey - } else { - let associatedKey = malloc(1)! - keys[key] = associatedKey - return associatedKey - } - } -} - -struct AnyHashableMetatype: Hashable { - static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { - return lhs.base == rhs.base - } - - let base: Any.Type - - init(_ base: Any.Type) { - self.base = base - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(base)) - } -} - -#endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift deleted file mode 100644 index a11cfaaf1..000000000 --- a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift +++ /dev/null @@ -1,12 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -extension Bool { - struct Unit: Hashable, Identifiable { - var id: Unit { self } - } - - var toOptionalUnit: Unit? { - get { self ? Unit() : nil } - set { self = newValue != nil } - } -} -#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index 803287449..6e334545c 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -4,119 +4,11 @@ import AppKit @MainActor extension NSObject { - /// Observe access to properties of an observable (or perceptible) object. - /// - /// This tool allows you to set up an observation loop so that you can access fields from an - /// observable model in order to populate your view, and also automatically track changes to - /// any accessed fields so that the view is always up-to-date. - /// - /// It is most useful when dealing with non-SwiftUI views, such as AppKit views and controller. - /// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all - /// the view elements: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// let countLabel = NSTextField(labelWithString: "") - /// let incrementButton = NSButton { [weak self] _ in - /// self?.model.incrementButtonTapped() - /// } - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// countLabel.stringValue = "\(model.count)" - /// } - /// } - /// ``` - /// - /// This closure is immediately called, allowing you to set the initial state of your UI - /// components from the feature's state. And if the `count` property in the feature's state is - /// ever mutated, this trailing closure will be called again, allowing us to update the view - /// again. - /// - /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your - /// view, such as `viewDidLoad` for `NSViewController`. This works even if you have many UI - /// components to update: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// countLabel.isHidden = model.isObservingCount - /// if !countLabel.isHidden { - /// countLabel.stringValue = "\(model.count)" - /// } - /// factLabel.stringValue = model.fact - /// } - /// } - /// ``` - /// - /// This does mean that you may execute the line `factLabel.text = model.fact` even when - /// something unrelated changes, such as `store.model`, but that is typically OK for simple - /// properties of UI components. It is not a performance problem to repeatedly set the `text` of - /// a label or the `isHidden` of a button. - /// - /// However, if there is heavy work you need to perform when state changes, then it is best to - /// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or - /// collection view when a collection changes: - /// - /// ```swift - /// override func viewDidLoad() { - /// super.viewDidLoad() - /// - /// observe { [weak self] in - /// guard let self - /// else { return } - /// - /// dataSource = model.items - /// tableView.reloadData() - /// } - /// } - /// ``` - /// - /// ## Cancellation - /// - /// The method returns an ``ObservationToken`` that can be used to cancel observation. For - /// example, if you only want to observe while a view controller is visible, you can start - /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: - /// - /// ```swift - /// var observation: ObservationToken? - /// - /// func viewWillAppear() { - /// super.viewWillAppear() - /// observation = observe { [weak self] in - /// // ... - /// } - /// } - /// func viewWillDisappear() { - /// super.viewWillDisappear() - /// observation?.cancel() - /// } - /// ``` - /// - /// - Parameter apply: A closure that contains properties to track and is invoked when the value - /// of a property changes. - /// - Returns: A cancellation token. @discardableResult public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { observe { _ in apply() } } - /// Observe access to properties of an observable (or perceptible) object. - /// - /// A version of ``observe(_:)`` that is passed the current transaction. - /// - /// - Parameter apply: A closure that contains properties to track and is invoked when the value - /// of a property changes. - /// - Returns: A cancellation token. @discardableResult public func observe( _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift index ea3499dec..e69009dfe 100644 --- a/Sources/AppKitNavigation/UIBinding.swift +++ b/Sources/AppKitNavigation/UIBinding.swift @@ -2,10 +2,6 @@ import SwiftNavigation extension UIBinding { - /// Specifies an animation to perform when the binding value changes. - /// - /// - Parameter animation: An animation sequence performed when the binding value changes. - /// - Returns: A new binding. public func animation(_ animation: AppKitAnimation? = .default) -> Self { var binding = self binding.transaction.appKit.animation = animation diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift index b4f9535f4..01b798797 100644 --- a/Sources/AppKitNavigation/UITransaction.swift +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -3,15 +3,11 @@ import SwiftNavigation extension UITransaction { - /// Creates a transaction and assigns its animation property. - /// - /// - Parameter animation: The animation to perform when the current state changes. public init(animation: AppKitAnimation? = nil) { self.init() appKit.animation = animation } - /// AppKit-specific data associated with the current state change. public var appKit: AppKit { get { self[AppKitKey.self] } set { self[AppKitKey.self] = newValue } @@ -21,20 +17,13 @@ extension UITransaction { static let defaultValue = AppKit() } - /// AppKit-specific data associated with a ``UITransaction``. public struct AppKit: Sendable { - /// The animation, if any, associated with the current state change. public var animation: AppKitAnimation? - /// A Boolean value that indicates whether views should disable animations. public var disablesAnimations = false var animationCompletions: [@Sendable (Bool?) -> Void] = [] - /// Adds a completion to run when the animations created with this transaction are all - /// complete. - /// - /// The completion callback will always be fired exactly one time. public mutating func addAnimationCompletion( _ completion: @escaping @Sendable (Bool?) -> Void ) { diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/SwiftNavigation/Internal/AssumeIsolated.swift similarity index 88% rename from Sources/AppKitNavigation/Internal/AssumeIsolated.swift rename to Sources/SwiftNavigation/Internal/AssumeIsolated.swift index 93f1c4009..97054027e 100644 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ b/Sources/SwiftNavigation/Internal/AssumeIsolated.swift @@ -1,10 +1,8 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - import Foundation extension MainActor { // NB: This functionality was not back-deployed in Swift 5.9 - static func _assumeIsolated( + package static func _assumeIsolated( _ operation: @MainActor () throws -> T, file: StaticString = #fileID, line: UInt = #line @@ -30,6 +28,3 @@ extension MainActor { #endif } } - - -#endif diff --git a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/SwiftNavigation/Internal/ErrorMechanism.swift similarity index 55% rename from Sources/AppKitNavigation/Internal/ErrorMechanism.swift rename to Sources/SwiftNavigation/Internal/ErrorMechanism.swift index 1ec4c47b0..36cbdd7bc 100644 --- a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift +++ b/Sources/SwiftNavigation/Internal/ErrorMechanism.swift @@ -1,20 +1,19 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) @rethrows -protocol _ErrorMechanism { +package protocol _ErrorMechanism { associatedtype Output func get() throws -> Output } extension _ErrorMechanism { - func _rethrowError() rethrows -> Never { + package func _rethrowError() rethrows -> Never { _ = try _rethrowGet() fatalError() } - func _rethrowGet() rethrows -> Output { + package func _rethrowGet() rethrows -> Output { return try get() } } extension Result: _ErrorMechanism {} -#endif + diff --git a/Sources/SwiftNavigation/Internal/ToOptionalUnit.swift b/Sources/SwiftNavigation/Internal/ToOptionalUnit.swift new file mode 100644 index 000000000..46fa0d119 --- /dev/null +++ b/Sources/SwiftNavigation/Internal/ToOptionalUnit.swift @@ -0,0 +1,12 @@ +extension Bool { + package struct Unit: Hashable, Identifiable { + package var id: Unit { self } + + package init() {} + } + + package var toOptionalUnit: Unit? { + get { self ? Unit() : nil } + set { self = newValue != nil } + } +} diff --git a/Sources/UIKitNavigation/Internal/AssumeIsolated.swift b/Sources/UIKitNavigation/Internal/AssumeIsolated.swift deleted file mode 100644 index fea107e3a..000000000 --- a/Sources/UIKitNavigation/Internal/AssumeIsolated.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -extension MainActor { - // NB: This functionality was not back-deployed in Swift 5.9 - static func _assumeIsolated( - _ operation: @MainActor () throws -> T, - file: StaticString = #fileID, - line: UInt = #line - ) rethrows -> T { - #if swift(<5.10) - typealias YesActor = @MainActor () throws -> T - typealias NoActor = () throws -> T - - guard Thread.isMainThread else { - fatalError( - "Incorrect actor executor assumption; Expected same executor as \(self).", - file: file, - line: line - ) - } - - return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in - let rawFn = unsafeBitCast(fn, to: NoActor.self) - return try rawFn() - } - #else - return try assumeIsolated(operation, file: file, line: line) - #endif - } -} diff --git a/Sources/UIKitNavigation/Internal/ErrorMechanism.swift b/Sources/UIKitNavigation/Internal/ErrorMechanism.swift deleted file mode 100644 index 18644daeb..000000000 --- a/Sources/UIKitNavigation/Internal/ErrorMechanism.swift +++ /dev/null @@ -1,20 +0,0 @@ -#if canImport(UIKit) - @rethrows - protocol _ErrorMechanism { - associatedtype Output - func get() throws -> Output - } - - extension _ErrorMechanism { - func _rethrowError() rethrows -> Never { - _ = try _rethrowGet() - fatalError() - } - - func _rethrowGet() rethrows -> Output { - return try get() - } - } - - extension Result: _ErrorMechanism {} -#endif diff --git a/Sources/UIKitNavigation/Internal/ToOptionalUnit.swift b/Sources/UIKitNavigation/Internal/ToOptionalUnit.swift deleted file mode 100644 index ed30e639d..000000000 --- a/Sources/UIKitNavigation/Internal/ToOptionalUnit.swift +++ /dev/null @@ -1,12 +0,0 @@ -#if canImport(UIKit) - extension Bool { - struct Unit: Hashable, Identifiable { - var id: Unit { self } - } - - var toOptionalUnit: Unit? { - get { self ? Unit() : nil } - set { self = newValue != nil } - } - } -#endif From c2bdb0dde8a19d4cc337cc2002a1945d282ade93 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:38:40 +0800 Subject: [PATCH 03/15] Remove unused code --- Sources/AppKitNavigationShim/include/shim.h | 25 ---- Sources/AppKitNavigationShim/shim.m | 152 -------------------- 2 files changed, 177 deletions(-) delete mode 100644 Sources/AppKitNavigationShim/include/shim.h delete mode 100644 Sources/AppKitNavigationShim/shim.m diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h deleted file mode 100644 index e1844275f..000000000 --- a/Sources/AppKitNavigationShim/include/shim.h +++ /dev/null @@ -1,25 +0,0 @@ -#if __has_include() -#include - -#if __has_include() && !TARGET_OS_MACCATALYST -@import AppKit; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSViewController (AppKitNavigation) - -@property BOOL hasViewAppeared; -@property (nullable) void (^ onDismiss)(); -@property NSArray *onViewAppear; - -@end - -@interface NSSavePanel (AppKitNavigation) -@property (nullable) void (^ AppKitNavigation_onFinalURL)(NSURL *_Nullable); -@property (nullable) void (^ AppKitNavigation_onFinalURLs)(NSArray *); -@end - - -NS_ASSUME_NONNULL_END -#endif -#endif /* if __has_include() */ diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m deleted file mode 100644 index 21f5f9c08..000000000 --- a/Sources/AppKitNavigationShim/shim.m +++ /dev/null @@ -1,152 +0,0 @@ -#if __has_include() -#include - -#if __has_include() && !TARGET_OS_MACCATALYST -@import ObjectiveC; -@import AppKit; -#import "shim.h" - -@interface AppKitNavigationShim : NSObject - -@end - -@implementation AppKitNavigationShim - -// NB: We must use Objective-C here to eagerly swizzle view controller code that is responsible -// for state-driven presentation and dismissal of child features. - -+ (void)load { - method_exchangeImplementations( - class_getInstanceMethod(NSViewController.class, @selector(viewDidAppear)), - class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) - ); - method_exchangeImplementations( - class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), - class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) - ); - method_exchangeImplementations( - class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), - class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) - ); - method_exchangeImplementations( - class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURL:")), - class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURL:)) - ); - method_exchangeImplementations( - class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURLs:")), - class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURLs:)) - ); -} - -@end - -@implementation NSSavePanel (AppKitNavigation) - -- (void)setAppKitNavigation_onFinalURLs:(void (^)(NSArray * _Nonnull))AppKitNavigation_onFinalURLs { - objc_setAssociatedObject(self, @selector(AppKitNavigation_onFinalURLs), AppKitNavigation_onFinalURLs, OBJC_ASSOCIATION_COPY); -} - -- (void (^)(NSArray * _Nonnull))AppKitNavigation_onFinalURLs { - return objc_getAssociatedObject(self, @selector(AppKitNavigation_onFinalURLs)); -} - -- (void)setAppKitNavigation_onFinalURL:(void (^)(NSURL * _Nullable))AppKitNavigation_onFinalURL { - objc_setAssociatedObject(self, @selector(AppKitNavigation_onFinalURL), AppKitNavigation_onFinalURL, OBJC_ASSOCIATION_COPY); -} - -- (void (^)(NSURL * _Nullable))AppKitNavigation_onFinalURL { - return objc_getAssociatedObject(self, @selector(AppKitNavigation_onFinalURL)); -} - -- (void)AppKitNavigation_setFinalURL:(nullable NSURL *)url { - [self AppKitNavigation_setFinalURL:url]; - if (self.AppKitNavigation_onFinalURL) { - self.AppKitNavigation_onFinalURL(url); - } -} - -- (void)AppKitNavigation_setFinalURLs:(NSArray *)urls { - [self AppKitNavigation_setFinalURLs:urls]; - if (self.AppKitNavigation_onFinalURLs) { - self.AppKitNavigation_onFinalURLs(urls); - } -} - -@end - -static void *hasViewAppearedKey = &hasViewAppearedKey; -static void *onDismissKey = &onDismissKey; -static void *onViewAppearKey = &onViewAppearKey; - -@implementation NSViewController (AppKitNavigation) - -- (void)AppKitNavigation_viewDidAppear { - [self AppKitNavigation_viewDidAppear]; - - if (self.hasViewAppeared) { - return; - } - - self.hasViewAppeared = YES; - - for (void (^work)() in self.onViewAppear) { - work(); - } - - self.onViewAppear = @[]; -} - -- (void)setBeingDismissed:(BOOL)beingDismissed { - objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); -} - -- (BOOL)isBeingDismissed { - return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; -} - -- (void)AppKitNavigation_viewDidDisappear { - [self AppKitNavigation_viewDidDisappear]; - - if ((self.isBeingDismissed) && self.onDismiss != NULL) { - self.onDismiss(); - self.onDismiss = nil; - [self setBeingDismissed:NO]; - } -} - -- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { - [self AppKitNavigation_dismissViewController:sender]; - [self setBeingDismissed:YES]; -} - -- (BOOL)hasViewAppeared { - return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; -} - -- (void)setHasViewAppeared:(BOOL)hasViewAppeared { - objc_setAssociatedObject( - self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC - ); -} - -- (void (^)())onDismiss { - return objc_getAssociatedObject(self, onDismissKey); -} - -- (void)setOnDismiss:(void (^)())onDismiss { - objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); -} - -- (NSMutableArray *)onViewAppear { - id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); - - return onViewAppear == nil ? @[] : onViewAppear; -} - -- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { - objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); -} - -@end -#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ -#endif /* if __has_include() */ From 8e5e4e610303017e0707fbd8791a4c5896683b7d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 22 Aug 2024 16:32:18 -0700 Subject: [PATCH 04/15] Integrate custom transaction --- Sources/AppKitNavigation/Observe.swift | 73 ------------------- Sources/AppKitNavigation/UITransaction.swift | 77 ++++++++++++++------ 2 files changed, 55 insertions(+), 95 deletions(-) delete mode 100644 Sources/AppKitNavigation/Observe.swift diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift deleted file mode 100644 index 6e334545c..000000000 --- a/Sources/AppKitNavigation/Observe.swift +++ /dev/null @@ -1,73 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -@_spi(Internals) import SwiftNavigation -import AppKit - -@MainActor -extension NSObject { - @discardableResult - public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { - observe { _ in apply() } - } - - @discardableResult - public func observe( - _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void - ) -> ObservationToken { - let token = SwiftNavigation.observe { transaction in - MainActor._assumeIsolated { - withUITransaction(transaction) { - if transaction.appKit.disablesAnimations { - NSView.performWithoutAnimation { apply(transaction) } - for completion in transaction.appKit.animationCompletions { - completion(true) - } - } else if let animation = transaction.appKit.animation { - return animation.perform( - { apply(transaction) }, - completion: transaction.appKit.animationCompletions.isEmpty - ? nil - : { - for completion in transaction.appKit.animationCompletions { - completion($0) - } - } - ) - } else { - apply(transaction) - for completion in transaction.appKit.animationCompletions { - completion(true) - } - } - } - } - } task: { transaction, work in - DispatchQueue.main.async { - withUITransaction(transaction, work) - } - } - tokens.append(token) - return token - } - - fileprivate var tokens: [Any] { - get { - objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? [] - } - set { - objc_setAssociatedObject(self, Self.tokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - private static let tokensKey = malloc(1)! -} - -extension NSView { - fileprivate static func performWithoutAnimation(_ block: () -> Void) { - NSAnimationContext.runAnimationGroup { context in - context.allowsImplicitAnimation = false - block() - } - } -} - -#endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift index 01b798797..99559dbc4 100644 --- a/Sources/AppKitNavigation/UITransaction.swift +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -1,42 +1,75 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) + import AppKit + import SwiftNavigation -import SwiftNavigation - -extension UITransaction { + extension UITransaction { public init(animation: AppKitAnimation? = nil) { - self.init() - appKit.animation = animation + self.init() + appKit.animation = animation } public var appKit: AppKit { - get { self[AppKitKey.self] } - set { self[AppKitKey.self] = newValue } + get { self[AppKitKey.self] } + set { self[AppKitKey.self] = newValue } } - private enum AppKitKey: UITransactionKey { - static let defaultValue = AppKit() + private enum AppKitKey: _UICustomTransactionKey { + static let defaultValue = AppKit() + + static func perform( + value: AppKit, + operation: @Sendable () -> Void + ) { + MainActor._assumeIsolated { + if value.disablesAnimations { + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = false + operation() + } + for completion in value.animationCompletions { + completion(true) + } + } else if let animation = value.animation { + return animation.perform( + { operation() }, + completion: value.animationCompletions.isEmpty + ? nil + : { + for completion in value.animationCompletions { + completion($0) + } + } + ) + } else { + operation() + for completion in value.animationCompletions { + completion(true) + } + } + } + } } public struct AppKit: Sendable { - public var animation: AppKitAnimation? + public var animation: AppKitAnimation? - public var disablesAnimations = false + public var disablesAnimations = false - var animationCompletions: [@Sendable (Bool?) -> Void] = [] + var animationCompletions: [@Sendable (Bool?) -> Void] = [] - public mutating func addAnimationCompletion( - _ completion: @escaping @Sendable (Bool?) -> Void - ) { - animationCompletions.append(completion) - } + public mutating func addAnimationCompletion( + _ completion: @escaping @Sendable (Bool?) -> Void + ) { + animationCompletions.append(completion) + } } -} + } -private enum AnimationCompletionsKey: UITransactionKey { + private enum AnimationCompletionsKey: UITransactionKey { static let defaultValue: [@Sendable (Bool?) -> Void] = [] -} + } -private enum DisablesAnimationsKey: UITransactionKey { + private enum DisablesAnimationsKey: UITransactionKey { static let defaultValue = false -} + } #endif From 2b91081b304a729a5c532e43ade481b0a14264ce Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 22 Aug 2024 16:43:34 -0700 Subject: [PATCH 05/15] address fatal error --- .../AppKitNavigation/AppKitAnimation.swift | 101 ++++++++++-------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index e2c8199a0..346478c85 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -1,80 +1,95 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -import AppKit + import AppKit -#if canImport(SwiftUI) -import SwiftUI -#endif + #if canImport(SwiftUI) + import SwiftUI + #endif -import SwiftNavigation + import SwiftNavigation -@MainActor -public func withAppKitAnimation( + @MainActor + public func withAppKitAnimation( _ animation: AppKitAnimation? = .default, _ body: () throws -> Result, completion: (@Sendable (Bool?) -> Void)? = nil -) rethrows -> Result { + ) rethrows -> Result { var transaction = UITransaction() transaction.appKit.animation = animation if let completion { - transaction.appKit.addAnimationCompletion(completion) + transaction.appKit.addAnimationCompletion(completion) } return try withUITransaction(transaction, body) -} + } -public struct AppKitAnimation: Hashable, Sendable { + public struct AppKitAnimation: Hashable, Sendable { fileprivate let framework: Framework @MainActor func perform( - _ body: () throws -> Result, - completion: ((Bool?) -> Void)? = nil + _ body: () throws -> Result, + completion: ((Bool?) -> Void)? = nil ) rethrows -> Result { - switch framework { - case let .swiftUI(animation): - _ = animation - fatalError() - case let .appKit(animation): - var result: Swift.Result? - NSAnimationContext.runAnimationGroup { context in - context.duration = animation.duration - result = Swift.Result(catching: body) - } completionHandler: { - completion?(true) + switch framework { + case let .swiftUI(animation): + var result: Swift.Result? + #if swift(>=6) + if #available(macOS 15, *) { + NSAnimationContext.animate(animation) { + result = Swift.Result(catching: body) + } completion: { + completion?(true) } - return try result!._rethrowGet() + } + #endif + _ = animation + fatalError() + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.duration = animation.duration + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) } + return try result!._rethrowGet() + } } fileprivate enum Framework: Hashable, Sendable { - case appKit(AppKit) - case swiftUI(Animation) + case appKit(AppKit) + case swiftUI(Animation) - fileprivate struct AppKit: Hashable, Sendable { - fileprivate var duration: TimeInterval + fileprivate struct AppKit: Hashable, Sendable { + fileprivate var duration: TimeInterval - func hash(into hasher: inout Hasher) { - hasher.combine(duration) - } + func hash(into hasher: inout Hasher) { + hasher.combine(duration) } + } + } + } + + extension AppKitAnimation { + @available(macOS 15, *) + public init(_ animation: Animation) { + self.init(framework: .swiftUI(animation)) } -} -extension AppKitAnimation { public static func animate( - withDuration duration: TimeInterval = 0.25 + withDuration duration: TimeInterval = 0.25 ) -> Self { - Self( - framework: .appKit( - Framework.AppKit( - duration: duration - ) - ) + Self( + framework: .appKit( + Framework.AppKit( + duration: duration + ) ) + ) } public static var `default`: Self { - return .animate() + return .animate() } -} + } #endif From cad947be25bb3b9bede965c2a20089abded9d69f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 23 Aug 2024 08:16:19 -0700 Subject: [PATCH 06/15] Round out animation --- .../AppKitNavigation/AppKitAnimation.swift | 56 ++++++++++++++----- .../SwiftNavigationTests/LifetimeTests.swift | 2 +- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 346478c85..2dab48e14 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -30,6 +30,18 @@ completion: ((Bool?) -> Void)? = nil ) rethrows -> Result { switch framework { + case let .appKit(animation): + var result: Swift.Result? + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = true + context.duration = animation.duration + context.timingFunction = animation.timingFunction + result = Swift.Result(catching: body) + } completionHandler: { + completion?(true) + } + return try result!._rethrowGet() + case let .swiftUI(animation): var result: Swift.Result? #if swift(>=6) @@ -44,15 +56,6 @@ #endif _ = animation fatalError() - case let .appKit(animation): - var result: Swift.Result? - NSAnimationContext.runAnimationGroup { context in - context.duration = animation.duration - result = Swift.Result(catching: body) - } completionHandler: { - completion?(true) - } - return try result!._rethrowGet() } } @@ -60,8 +63,9 @@ case appKit(AppKit) case swiftUI(Animation) - fileprivate struct AppKit: Hashable, Sendable { + fileprivate struct AppKit: Hashable, @unchecked Sendable { fileprivate var duration: TimeInterval + fileprivate var timingFunction: CAMediaTimingFunction? func hash(into hasher: inout Hasher) { hasher.combine(duration) @@ -77,19 +81,45 @@ } public static func animate( - withDuration duration: TimeInterval = 0.25 + duration: TimeInterval = 0.25, + timingFunction: CAMediaTimingFunction? = nil ) -> Self { Self( framework: .appKit( Framework.AppKit( - duration: duration + duration: duration, + timingFunction: timingFunction ) ) ) } public static var `default`: Self { - return .animate() + .animate() + } + + public static var linear: Self { .linear(duration: 0.25) } + + public static func linear(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .linear)) + } + + public static var easeIn: Self { .easeIn(duration: 0.25) } + + public static func easeIn(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeIn)) + } + + public static var easeOut: Self { .easeOut(duration: 0.25) } + + public static func easeOut(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeOut)) + } + + public static var easeInOut: Self { .easeInOut(duration: 0.25) } + + public static func easeInOut(duration: TimeInterval) -> Self { + .animate(duration: duration, timingFunction: CAMediaTimingFunction(name: .easeInEaseOut)) } } #endif diff --git a/Tests/SwiftNavigationTests/LifetimeTests.swift b/Tests/SwiftNavigationTests/LifetimeTests.swift index 66569ba62..6c68de463 100644 --- a/Tests/SwiftNavigationTests/LifetimeTests.swift +++ b/Tests/SwiftNavigationTests/LifetimeTests.swift @@ -29,7 +29,7 @@ @Perceptible @MainActor - class Model { + private class Model { var count = 0 } #endif From e863c54496957d76fb1dbae5a355fb3632aa9364 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:57:16 -0400 Subject: [PATCH 07/15] Update Package.swift --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8a8b00238..f3b38e853 100644 --- a/Package.swift +++ b/Package.swift @@ -83,7 +83,6 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] ), .testTarget( From 00ed18a322665a08ca85a47955d23e841a840022 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Fri, 23 Aug 2024 14:57:32 -0400 Subject: [PATCH 08/15] Update Package@swift-6.0.swift --- Package@swift-6.0.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 9f37971b8..2edac05b6 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -83,7 +83,6 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] ), .testTarget( From df6eda79c2932762f2e01e668efac142975e88cd Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 25 Aug 2024 01:16:25 +0800 Subject: [PATCH 09/15] Support AppKit Navigation --- Package.swift | 4 + Package@swift-6.0.swift | 4 + .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../Internal/AssociatedKeys.swift | 36 ++++ .../AppKitNavigation/Navigation/Modal.swift | 200 ++++++++++++++++++ .../Navigation/ModalContent.swift | 35 +++ .../Navigation/ModalSessionContent.swift | 22 ++ .../Navigation/ModalWindowsObserver.swift | 33 +++ .../Navigation/NavigationContent.swift | 38 ++++ .../Navigation/NavigationObserver.swift | 88 ++++++++ .../Navigation/Presentation.swift | 175 +++++++++++++++ .../Navigation/PresentationContent.swift | 41 ++++ .../AppKitNavigation/Navigation/Sheet.swift | 132 ++++++++++++ .../Navigation/SheetContent.swift | 60 ++++++ Sources/AppKitNavigationShim/include/shim.h | 19 ++ Sources/AppKitNavigationShim/shim.m | 110 ++++++++++ 16 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift create mode 100644 Sources/AppKitNavigation/Navigation/Modal.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalSessionContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/Presentation.swift create mode 100644 Sources/AppKitNavigation/Navigation/PresentationContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/Sheet.swift create mode 100644 Sources/AppKitNavigation/Navigation/SheetContent.swift create mode 100644 Sources/AppKitNavigationShim/include/shim.h create mode 100644 Sources/AppKitNavigationShim/shim.m diff --git a/Package.swift b/Package.swift index f3b38e853..0210bad71 100644 --- a/Package.swift +++ b/Package.swift @@ -83,8 +83,12 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", + "AppKitNavigationShim", ] ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 2edac05b6..1859da950 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -83,8 +83,12 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", + "AppKitNavigationShim", ] ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 2dab48e14..b0e8a2cd8 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -43,8 +43,8 @@ return try result!._rethrowGet() case let .swiftUI(animation): - var result: Swift.Result? #if swift(>=6) + var result: Swift.Result? if #available(macOS 15, *) { NSAnimationContext.animate(animation) { result = Swift.Result(catching: body) diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift new file mode 100644 index 000000000..1df9f50ed --- /dev/null +++ b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift @@ -0,0 +1,36 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +struct AssociatedKeys { + var keys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + + mutating func key(of type: T.Type) -> UnsafeMutableRawPointer { + let key = AnyHashableMetatype(type) + if let associatedKey = keys[key] { + return associatedKey + } else { + let associatedKey = malloc(1)! + keys[key] = associatedKey + return associatedKey + } + } +} + +struct AnyHashableMetatype: Hashable { + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } + + let base: Any.Type + + init(_ base: Any.Type) { + self.base = base + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift new file mode 100644 index 000000000..14681eda9 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -0,0 +1,200 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +private var modalObserverKeys = AssociatedKeys() + +private typealias ModalObserver = NavigationObserver + +@MainActor +extension NSObject { + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modal(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + modalSession(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modal(item: item, id: id) { $item in + content($item) + } beginModal: { modalContent, _ in + if NSApplication.shared.modalWindow != nil { + NSApplication.shared.stopModal() + onDismiss?() + DispatchQueue.main.async { + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.appKitNavigationRunModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil + } + + } else { + DispatchQueue.main.async { + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.appKitNavigationRunModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil + } + } + } endModal: { _, _ in + NSApplication.shared.stopModal() + onDismiss?() + } + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + modal(item: item, id: id) { $item in + content($item) + } beginModal: { modalContent, _ in + if let modaledWindow = NSApplication.shared.modalWindow, let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modaledWindow] { + NSApplication.shared.endModalSession(modalSession) + modaledWindow.window.close() + onDismiss?() + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) + } + + } else { + DispatchQueue.main.async { + let modalSession = modalContent.appKitNavigationBeginModalSession() + ModalWindowsObserver.shared.observeWindow(modalContent.window, modalSession: modalSession) + } + } + } endModal: { modalContent, _ in + if let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modalContent.window] { + NSApplication.shared.endModalSession(modalSession) + modalContent.window.close() + onDismiss?() + } + } + } + + private func modal( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + beginModal: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + endModal: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let modalObserver: ModalObserver = modalObserver() + return modalObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginModal, + end: endModal + ) + } + + private func modalObserver() -> ModalObserver { + if let observer = objc_getAssociatedObject(self, modalObserverKeys.key(of: Content.self)) as? ModalObserver { + return observer + } else { + let observer = ModalObserver(owner: self) + objc_setAssociatedObject(self, modalObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer + } + } +} + +extension Navigated where Content: ModalContent { + func clearup() { + NSApplication.shared.stopModal() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift new file mode 100644 index 000000000..00772647f --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -0,0 +1,35 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol ModalContent: NavigationContent { + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +extension NSWindow: ModalContent { + public var window: NSWindow { self } + + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + __appKitNavigationRunModal() + } + + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} + +extension NSSavePanel { + override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} + +extension NSAlert: ModalContent { + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift new file mode 100644 index 000000000..9132638de --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -0,0 +1,22 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol ModalSessionContent: ModalContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession +} + +extension NSWindow: ModalSessionContent { + + public func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + __appKitNavigationBeginModalSession() + } + + @objc func __appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + let modalSession = NSApplication.shared.beginModalSession(for: self) + return modalSession + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift new file mode 100644 index 000000000..bc63b3344 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift @@ -0,0 +1,33 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +class ModalWindowsObserver: NSObject { + static let shared = ModalWindowsObserver() + + var windowsCancellable: [NSWindow: AnyCancellable] = [:] + + var modalSessionByWindow: [NSWindow: NSApplication.ModalSession] = [:] + + func observeWindow(_ window: NSWindow, modalSession: NSApplication.ModalSession? = nil) { + if let modalSession { + modalSessionByWindow[window] = modalSession + } + windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { [weak self] _ in + guard let self else { return } + if let modalSession = modalSessionByWindow[window] { + NSApplication.shared.endModalSession(modalSession) + } else if NSApplication.shared.modalWindow === window { + NSApplication.shared.stopModal() + } + modalSessionByWindow.removeValue(forKey: window) + windowsCancellable[window]?.cancel() + windowsCancellable.removeValue(forKey: window) + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift new file mode 100644 index 000000000..762032cf0 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -0,0 +1,38 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +@MainActor +public protocol NavigationContent: AnyObject { + var onBeginNavigation: (() -> Void)? { set get } + var onEndNavigation: (() -> Void)? { set get } +} + +@MainActor +private var onBeginNavigationKeys = AssociatedKeys() + +@MainActor +private var onEndNavigationKeys = AssociatedKeys() + +extension NavigationContent { + public var onBeginNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self)) as? () -> Void + } + } + + public var onEndNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, onEndNavigationKeys.key(of: Self.self)) as? () -> Void + } + } +} + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift new file mode 100644 index 000000000..488cf6b4b --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -0,0 +1,88 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation +import SwiftNavigation + +@MainActor +class NavigationObserver: NSObject { + private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] + + unowned let owner: Owner + + init(owner: Owner) { + self.owner = owner + super.init() + } + + func observe( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> Content, + begin: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void, + end: @escaping ( + _ content: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let navigated = navigatedByID[key] { + guard let navigationID = navigated.id, + navigationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let content = content(unwrappedItem) + let onEndNavigation = { [navigationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + navigationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + content.onEndNavigation = onEndNavigation + + self.navigatedByID[key] = Navigated(content, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + begin(content, transaction) + } + } + commitWork(work) + } else if let navigated = navigatedByID[key] { + if let content = navigated.content { + end(content, transaction) + } + self.navigatedByID[key] = nil + } + } + } + + func commitWork(_ work: @escaping () -> Void) { + work() + } +} + +@MainActor +class Navigated { + weak var content: Content? + let id: AnyHashable? + func clearup() {} + deinit { + MainActor._assumeIsolated { + clearup() + } + } + + required init(_ content: Content, id: AnyHashable?) { + self.content = content + self.id = id + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift new file mode 100644 index 000000000..5b2c0662c --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -0,0 +1,175 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftNavigation +import AppKit +import AppKitNavigationShim + +@MainActor +private var presentationObserverKeys = AssociatedKeys() + +class PresentationObserver: NavigationObserver { + override func commitWork(_ work: @escaping () -> Void) { + if owner.hasViewAppeared { + work() + } else { + owner.onViewAppear.append(work) + } + } +} + +extension NSViewController { + @discardableResult + public func present( + isPresented: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func present( + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + present(item: item, id: id, style: style, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + destination(item: item, id: id) { $item in + content($item) + } present: { [weak self] child, transaction in + guard let self else { return } + if let presentedViewController = presentedViewControllers?.first { + self.dismiss(presentedViewController) + onDismiss?() + child.presented(from: self, style: style) + } else { + child.presented(from: self, style: style) + } + } dismiss: { [weak self] child, transaction in + guard let self else { return } + child.dismiss(from: self) + onDismiss?() + } + } + + @discardableResult + public func destination( + isPresented: UIBinding, + content: @escaping () -> Content, + present: @escaping (Content, UITransaction) -> Void, + dismiss: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) + } + + @discardableResult + public func destination( + item: UIBinding, + content: @escaping (UIBinding) -> Content, + present: @escaping (Content, UITransaction) -> Void, + dismiss: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe( + item: item, + id: { _ in nil }, + content: content, + begin: present, + end: dismiss + ) + } + + @discardableResult + public func destination( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + present: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + } + + private func presentationObserver() -> PresentationObserver { + if let observer = objc_getAssociatedObject(self, presentationObserverKeys.key(of: Content.self)) as? PresentationObserver { + return observer + } else { + let observer = PresentationObserver(owner: self) + objc_setAssociatedObject(self, presentationObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer + } + } +} + +extension NavigationContent where Self: NSViewController { + var _onEndNavigation: (() -> Void)? { + set { + onDismiss = newValue + } + get { + onDismiss + } + } +} + +extension Navigated where Content: NSViewController { + func clearup() { + content?.dismiss(nil) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift new file mode 100644 index 000000000..eba08ad60 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol PresentationContent: NavigationContent { + func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) + func dismiss(from presentingViewController: NSViewController) +} + +extension NSViewController: PresentationContent { + public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + presentingViewController.present(self, for: style) + } + + public func dismiss(from presentingViewController: NSViewController) { + presentingViewController.dismiss(self) + } + + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) + } + + private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case let .popover(rect, view, preferredEdge, behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case let .custom(animator): + present(viewControllerToPresent, animator: animator) + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift new file mode 100644 index 000000000..d7bc4fcae --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -0,0 +1,132 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +private typealias SheetObserver = NavigationObserver + +@MainActor +private var sheetObserverKeys = AssociatedKeys() + +extension SheetContent { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + sheet(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + sheet(item: item, id: id) { $item in + content($item) + } beginSheet: { [weak self] child, _ in + guard let self else { return } + if let attachedSheetWindow = currentWindow?.attachedSheet { + self.endSheet(for: attachedSheetWindow) + onDismiss?() + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndNavigation?() + child.onEndNavigation = nil + } + } else { + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndNavigation?() + child.onEndNavigation = nil + } + } + } endSheet: { [weak self] content, _ in + self?.endSheet(for: content) + onDismiss?() + } + } + + private func sheet( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> Content, + beginSheet: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: Content, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let sheetObserver: SheetObserver = sheetObserver() + return sheetObserver.observe( + item: item, + id: { $0[keyPath: id] }, + content: content, + begin: beginSheet, + end: endSheet + ) + } + + private func sheetObserver() -> SheetObserver { + if let observer = objc_getAssociatedObject(self, sheetObserverKeys.key(of: Content.self)) as? SheetObserver { + return observer + } else { + let observer = SheetObserver(owner: self) + objc_setAssociatedObject(self, sheetObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer + } + } +} + +extension NSWindow { + func endSheeted() { + guard sheetParent != nil else { + return + } + sheetParent?.endSheet(self) + } +} + +extension Navigated where Content: SheetContent { + func clearup() { + content?.currentWindow?.endSheeted() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift new file mode 100644 index 000000000..eb06919cd --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -0,0 +1,60 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol SheetContent: NavigationContent { + var currentWindow: NSWindow? { get } + func beginSheet(for content: SheetContent) async + func endSheet(for content: SheetContent) +} + +extension SheetContent { + public func beginSheet(for content: any SheetContent) async { + guard let sheetedWindow = content.currentWindow else { return } + await currentWindow?.beginSheet(sheetedWindow) + } + + public func endSheet(for content: any SheetContent) { + guard let sheetedWindow = content.currentWindow else { return } + currentWindow?.endSheet(sheetedWindow) + } +} + +extension NSWindow: SheetContent { + public var currentWindow: NSWindow? { self } +} + +extension NSWindowController: SheetContent { + public var currentWindow: NSWindow? { window } +} + +extension NSViewController: SheetContent { + public var currentWindow: NSWindow? { view.window } +} + +extension NSAlert: SheetContent { + public var currentWindow: NSWindow? { window } + + public func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } +} + +extension NSSavePanel { + public func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } +} + +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100644 index 000000000..5a4e070ea --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,19 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface NSViewController (AppKitNavigation) + +@property BOOL hasViewAppeared; +@property (nullable) void (^ onDismiss)(); +@property NSArray *onViewAppear; + +@end + +NS_ASSUME_NONNULL_END +#endif +#endif /* if __has_include() */ diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m new file mode 100644 index 000000000..14e82c72d --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,110 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import ObjectiveC; +@import AppKit; +#import "shim.h" + +@interface AppKitNavigationShim : NSObject + +@end + +@implementation AppKitNavigationShim + +// NB: We must use Objective-C here to eagerly swizzle view controller code that is responsible +// for state-driven presentation and dismissal of child features. + ++ (void)load { + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(viewDidAppear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) + ); + method_exchangeImplementations( + class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), + class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) + ); +} + +@end + +static void *hasViewAppearedKey = &hasViewAppearedKey; +static void *onDismissKey = &onDismissKey; +static void *onViewAppearKey = &onViewAppearKey; + +@implementation NSViewController (AppKitNavigation) + +- (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; + + if (self.hasViewAppeared) { + return; + } + + self.hasViewAppeared = YES; + + for (void (^work)() in self.onViewAppear) { + work(); + } + + self.onViewAppear = @[]; +} + +- (void)setBeingDismissed:(BOOL)beingDismissed { + objc_setAssociatedObject(self, @selector(isBeingDismissed), @(beingDismissed), OBJC_ASSOCIATION_COPY); +} + +- (BOOL)isBeingDismissed { + return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; +} + +- (void)AppKitNavigation_viewDidDisappear { + [self AppKitNavigation_viewDidDisappear]; + + if ((self.isBeingDismissed) && self.onDismiss != NULL) { + self.onDismiss(); + self.onDismiss = nil; + [self setBeingDismissed:NO]; + } +} + +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} + +- (BOOL)hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} + +- (void)setHasViewAppeared:(BOOL)hasViewAppeared { + objc_setAssociatedObject( + self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + ); +} + +- (void (^)())onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} + +- (void)setOnDismiss:(void (^)())onDismiss { + objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (NSMutableArray *)onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); + + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +@end +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ From 2228ad119c9339d2219dc0c8aa6f153757534b6b Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 28 Aug 2024 00:59:52 +0800 Subject: [PATCH 10/15] Update Sources/AppKitNavigationShim/include/shim.h Co-authored-by: Stephen Celis --- Sources/AppKitNavigationShim/include/shim.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h index 5a4e070ea..a19cbd8f5 100644 --- a/Sources/AppKitNavigationShim/include/shim.h +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -8,9 +8,9 @@ NS_ASSUME_NONNULL_BEGIN @interface NSViewController (AppKitNavigation) -@property BOOL hasViewAppeared; -@property (nullable) void (^ onDismiss)(); -@property NSArray *onViewAppear; +@property BOOL _AppKitNavigation_hasViewAppeared; +@property (nullable) void (^ _AppKitNavigation_onDismiss)(); +@property NSArray *_AppKitNavigation_onViewAppear; @end From 47ac7b3df1f0cdb2d47c518d6a599de4c762e283 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 28 Aug 2024 01:00:02 +0800 Subject: [PATCH 11/15] Update Sources/AppKitNavigation/Navigation/Sheet.swift Co-authored-by: Stephen Celis --- Sources/AppKitNavigation/Navigation/Sheet.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index d7bc4fcae..6ec8b0d80 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -114,18 +114,10 @@ extension SheetContent { } } -extension NSWindow { - func endSheeted() { - guard sheetParent != nil else { - return - } - sheetParent?.endSheet(self) - } -} - extension Navigated where Content: SheetContent { func clearup() { - content?.currentWindow?.endSheeted() + guard let window = content?.currentWindow else { return } + window.sheetParent?.endSheet(window) } } From 6111e32c433b5940486320e0bf69aa254ed31776 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 28 Aug 2024 01:00:20 +0800 Subject: [PATCH 12/15] Update Sources/AppKitNavigation/Navigation/ModalSessionContent.swift Co-authored-by: Stephen Celis --- .../AppKitNavigation/Navigation/ModalSessionContent.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift index 9132638de..cc50c0afd 100644 --- a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -10,12 +10,7 @@ public protocol ModalSessionContent: ModalContent { extension NSWindow: ModalSessionContent { public func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { - __appKitNavigationBeginModalSession() - } - - @objc func __appKitNavigationBeginModalSession() -> NSApplication.ModalSession { - let modalSession = NSApplication.shared.beginModalSession(for: self) - return modalSession + NSApplication.shared.beginModalSession(for: self) } } From 3832d5118ed814210669cd382be52323e6d82913 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:32:43 +0800 Subject: [PATCH 13/15] Fixes building errors --- .../Navigation/Presentation.swift | 8 ++--- Sources/AppKitNavigationShim/shim.m | 30 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 5b2c0662c..e5f0197e7 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -8,10 +8,10 @@ private var presentationObserverKeys = AssociatedKeys() class PresentationObserver: NavigationObserver { override func commitWork(_ work: @escaping () -> Void) { - if owner.hasViewAppeared { + if owner._AppKitNavigation_hasViewAppeared { work() } else { - owner.onViewAppear.append(work) + owner._AppKitNavigation_onViewAppear.append(work) } } } @@ -158,10 +158,10 @@ extension NSViewController { extension NavigationContent where Self: NSViewController { var _onEndNavigation: (() -> Void)? { set { - onDismiss = newValue + _AppKitNavigation_onDismiss = newValue } get { - onDismiss + _AppKitNavigation_onDismiss } } } diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m index 14e82c72d..9ebf4859b 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -41,17 +41,17 @@ @implementation NSViewController (AppKitNavigation) - (void)AppKitNavigation_viewDidAppear { [self AppKitNavigation_viewDidAppear]; - if (self.hasViewAppeared) { + if (self._AppKitNavigation_hasViewAppeared) { return; } - self.hasViewAppeared = YES; + self._AppKitNavigation_hasViewAppeared = YES; - for (void (^work)() in self.onViewAppear) { + for (void (^work)() in self._AppKitNavigation_onViewAppear) { work(); } - self.onViewAppear = @[]; + self._AppKitNavigation_onViewAppear = @[]; } - (void)setBeingDismissed:(BOOL)beingDismissed { @@ -65,9 +65,9 @@ - (BOOL)isBeingDismissed { - (void)AppKitNavigation_viewDidDisappear { [self AppKitNavigation_viewDidDisappear]; - if ((self.isBeingDismissed) && self.onDismiss != NULL) { - self.onDismiss(); - self.onDismiss = nil; + if ((self.isBeingDismissed) && self._AppKitNavigation_onDismiss != NULL) { + self._AppKitNavigation_onDismiss(); + self._AppKitNavigation_onDismiss = nil; [self setBeingDismissed:NO]; } } @@ -77,31 +77,31 @@ - (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { [self setBeingDismissed:YES]; } -- (BOOL)hasViewAppeared { +- (BOOL)_AppKitNavigation_hasViewAppeared { return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; } -- (void)setHasViewAppeared:(BOOL)hasViewAppeared { +- (void)set_AppKitNavigation_hasViewAppeared:(BOOL)_AppKitNavigation_hasViewAppeared { objc_setAssociatedObject( - self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + self, hasViewAppearedKey, @(_AppKitNavigation_hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC ); } -- (void (^)())onDismiss { +- (void (^)())_AppKitNavigation_onDismiss { return objc_getAssociatedObject(self, onDismissKey); } -- (void)setOnDismiss:(void (^)())onDismiss { - objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +- (void)set_AppKitNavigation_onDismiss:(void (^)())_AppKitNavigation_onDismiss { + objc_setAssociatedObject(self, onDismissKey, [_AppKitNavigation_onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); } -- (NSMutableArray *)onViewAppear { +- (NSMutableArray *)_AppKitNavigation_onViewAppear { id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); return onViewAppear == nil ? @[] : onViewAppear; } -- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { +- (void)set_AppKitNavigation_onViewAppear:(NSMutableArray *)onViewAppear { objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); } From 2479dbaa7e4e8042ee3429c0984cb1c97cba531a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 11:40:01 +0800 Subject: [PATCH 14/15] Translate some protocol to internal --- .../AppKitNavigation/Navigation/Modal.swift | 153 ++++++++++++++---- .../Navigation/ModalContent.swift | 10 +- .../Navigation/ModalSessionContent.swift | 5 +- .../Navigation/NavigationContent.swift | 6 +- .../Navigation/Presentation.swift | 63 ++++---- .../Navigation/PresentationContent.swift | 6 +- .../AppKitNavigation/Navigation/Sheet.swift | 22 +-- .../Navigation/SheetContent.swift | 22 +-- 8 files changed, 185 insertions(+), 102 deletions(-) diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 14681eda9..79075a67b 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -9,94 +9,139 @@ private typealias ModalObserver = NavigationObserver( + public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + content: @escaping () -> NSWindow ) -> ObserveToken { modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } - @discardableResult - public func modalSession( - isModaled: UIBinding, + public func modal( + item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } + @_disfavoredOverload @discardableResult - public func modal( + public func modal( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (UIBinding) -> NSWindow ) -> ObserveToken { modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } @discardableResult - public func modalSession( + public func modal( item: UIBinding, + id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + modal(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } } - @_disfavoredOverload @discardableResult - public func modal( + public func modal( item: UIBinding, + id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping (UIBinding) -> NSWindow ) -> ObserveToken { - modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: item, id: id, onDismiss: onDismiss, content: content) } + // MARK: - Modal - NSSavePanel - @_disfavoredOverload @discardableResult - public func modalSession( - item: UIBinding, + public func modal( + isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping () -> NSSavePanel + ) -> ObserveToken { + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + // MARK: - Modal - NSAlert + + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSAlert + ) -> ObserveToken { + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + // MARK: - Modal Session - NSWindow + + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + // MARK: - Private Modal + + @discardableResult + private func _modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content ) -> ObserveToken { - modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } - + @discardableResult - public func modal( + private func _modal( item: UIBinding, - id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - modal(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + private func _modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } @discardableResult - public func modalSession( + private func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - modalSession(item: item, id: id, onDismiss: onDismiss) { + _modal(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) } } @discardableResult - public func modal( + private func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - modal(item: item, id: id) { $item in + _modal(item: item, id: id) { $item in content($item) } beginModal: { modalContent, _ in if NSApplication.shared.modalWindow != nil { @@ -122,15 +167,55 @@ extension NSObject { onDismiss?() } } + // MARK: - Modal Session + @discardableResult + private func _modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + private func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + private func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + private func _modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } @discardableResult - public func modalSession( + private func _modalSession( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - modal(item: item, id: id) { $item in + _modal(item: item, id: id) { $item in content($item) } beginModal: { modalContent, _ in if let modaledWindow = NSApplication.shared.modalWindow, let modalSession = ModalWindowsObserver.shared.modalSessionByWindow[modaledWindow] { @@ -157,7 +242,7 @@ extension NSObject { } } - private func modal( + private func _modal( item: UIBinding, id: KeyPath, content: @escaping (UIBinding) -> Content, diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index 00772647f..676b8857b 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -3,18 +3,18 @@ import AppKit @MainActor -public protocol ModalContent: NavigationContent { +protocol ModalContent: NavigationContent { @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse var window: NSWindow { get } } extension NSWindow: ModalContent { - public var window: NSWindow { self } + var window: NSWindow { self } - public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + func appKitNavigationRunModal() -> NSApplication.ModalResponse { __appKitNavigationRunModal() } - + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { NSApplication.shared.runModal(for: self) } @@ -27,7 +27,7 @@ extension NSSavePanel { } extension NSAlert: ModalContent { - public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + func appKitNavigationRunModal() -> NSApplication.ModalResponse { runModal() } } diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift index cc50c0afd..a897018c9 100644 --- a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -3,13 +3,12 @@ import AppKit @MainActor -public protocol ModalSessionContent: ModalContent { +protocol ModalSessionContent: ModalContent { func appKitNavigationBeginModalSession() -> NSApplication.ModalSession } extension NSWindow: ModalSessionContent { - - public func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { NSApplication.shared.beginModalSession(for: self) } } diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index 762032cf0..2e35321c1 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -3,7 +3,7 @@ import Foundation @MainActor -public protocol NavigationContent: AnyObject { +protocol NavigationContent: AnyObject { var onBeginNavigation: (() -> Void)? { set get } var onEndNavigation: (() -> Void)? { set get } } @@ -15,7 +15,7 @@ private var onBeginNavigationKeys = AssociatedKeys() private var onEndNavigationKeys = AssociatedKeys() extension NavigationContent { - public var onBeginNavigation: (() -> Void)? { + var onBeginNavigation: (() -> Void)? { set { objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } @@ -24,7 +24,7 @@ extension NavigationContent { } } - public var onEndNavigation: (() -> Void)? { + var onEndNavigation: (() -> Void)? { set { objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index e5f0197e7..a9e28256f 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -3,10 +3,7 @@ import SwiftNavigation import AppKit import AppKitNavigationShim -@MainActor -private var presentationObserverKeys = AssociatedKeys() - -class PresentationObserver: NavigationObserver { +class PresentationObserver: NavigationObserver { override func commitWork(_ work: @escaping () -> Void) { if owner._AppKitNavigation_hasViewAppeared { work() @@ -18,43 +15,43 @@ class PresentationObserver: NavigationObserver( + public func present( isPresented: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + content: @escaping () -> NSViewController ) -> ObserveToken { present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (Item) -> NSViewController ) -> ObserveToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping (UIBinding) -> NSViewController ) -> ObserveToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content + content: @escaping (Item) -> NSViewController ) -> ObserveToken { present(item: item, id: id, style: style, onDismiss: onDismiss) { content($0.wrappedValue) @@ -63,12 +60,12 @@ extension NSViewController { @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content + content: @escaping (UIBinding) -> NSViewController ) -> ObserveToken { destination(item: item, id: id) { $item in content($item) @@ -89,12 +86,12 @@ extension NSViewController { } @discardableResult - public func destination( + public func destination( isPresented: UIBinding, - content: @escaping () -> Content, - present: @escaping (Content, UITransaction) -> Void, + content: @escaping () -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, dismiss: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void ) -> ObserveToken { @@ -107,16 +104,16 @@ extension NSViewController { } @discardableResult - public func destination( + public func destination( item: UIBinding, - content: @escaping (UIBinding) -> Content, - present: @escaping (Content, UITransaction) -> Void, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, dismiss: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void ) -> ObserveToken { - let presentationObserver: PresentationObserver = presentationObserver() + let presentationObserver: PresentationObserver = presentationObserver() return presentationObserver.observe( item: item, id: { _ in nil }, @@ -127,29 +124,31 @@ extension NSViewController { } @discardableResult - public func destination( + public func destination( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> Content, + content: @escaping (UIBinding) -> NSViewController, present: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void, dismiss: @escaping ( - _ child: Content, + _ child: NSViewController, _ transaction: UITransaction ) -> Void ) -> ObserveToken { - let presentationObserver: PresentationObserver = presentationObserver() + let presentationObserver: PresentationObserver = presentationObserver() return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) } - private func presentationObserver() -> PresentationObserver { - if let observer = objc_getAssociatedObject(self, presentationObserverKeys.key(of: Content.self)) as? PresentationObserver { + private static var presentationObserverKey = malloc(1)! + + private func presentationObserver() -> PresentationObserver { + if let observer = objc_getAssociatedObject(self, Self.presentationObserverKey) as? PresentationObserver { return observer } else { - let observer = PresentationObserver(owner: self) - objc_setAssociatedObject(self, presentationObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + let observer = PresentationObserver(owner: self) + objc_setAssociatedObject(self, Self.presentationObserverKey, observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) return observer } } diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift index eba08ad60..93de778e5 100644 --- a/Sources/AppKitNavigation/Navigation/PresentationContent.swift +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -3,17 +3,17 @@ import AppKit @MainActor -public protocol PresentationContent: NavigationContent { +protocol PresentationContent: NavigationContent { func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) func dismiss(from presentingViewController: NSViewController) } extension NSViewController: PresentationContent { - public func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + func presented(from presentingViewController: NSViewController, style: TransitionStyle) { presentingViewController.present(self, for: style) } - public func dismiss(from presentingViewController: NSViewController) { + func dismiss(from presentingViewController: NSViewController) { presentingViewController.dismiss(self) } diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 6ec8b0d80..ee0cda49a 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -9,53 +9,53 @@ private var sheetObserverKeys = AssociatedKeys() extension SheetContent { @discardableResult - public func sheet( + private func _sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content ) -> ObserveToken { - sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } @discardableResult - public func sheet( + private func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @_disfavoredOverload @discardableResult - public func sheet( + private func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @discardableResult - public func sheet( + private func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content ) -> ObserveToken { - sheet(item: item, id: id, onDismiss: onDismiss) { + _sheet(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) } } @discardableResult - public func sheet( + private func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content ) -> ObserveToken { - sheet(item: item, id: id) { $item in + _sheet(item: item, id: id) { $item in content($item) } beginSheet: { [weak self] child, _ in guard let self else { return } @@ -80,7 +80,7 @@ extension SheetContent { } } - private func sheet( + private func _sheet( item: UIBinding, id: KeyPath, content: @escaping (UIBinding) -> Content, diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index eb06919cd..25d27c466 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -3,56 +3,56 @@ import AppKit @MainActor -public protocol SheetContent: NavigationContent { +protocol SheetContent: NavigationContent { var currentWindow: NSWindow? { get } func beginSheet(for content: SheetContent) async func endSheet(for content: SheetContent) } extension SheetContent { - public func beginSheet(for content: any SheetContent) async { + func beginSheet(for content: any SheetContent) async { guard let sheetedWindow = content.currentWindow else { return } await currentWindow?.beginSheet(sheetedWindow) } - public func endSheet(for content: any SheetContent) { + func endSheet(for content: any SheetContent) { guard let sheetedWindow = content.currentWindow else { return } currentWindow?.endSheet(sheetedWindow) } } extension NSWindow: SheetContent { - public var currentWindow: NSWindow? { self } + var currentWindow: NSWindow? { self } } extension NSWindowController: SheetContent { - public var currentWindow: NSWindow? { window } + var currentWindow: NSWindow? { window } } extension NSViewController: SheetContent { - public var currentWindow: NSWindow? { view.window } + var currentWindow: NSWindow? { view.window } } extension NSAlert: SheetContent { - public var currentWindow: NSWindow? { window } + var currentWindow: NSWindow? { window } - public func beginSheet(for content: any SheetContent) async { + func beginSheet(for content: any SheetContent) async { guard let parentWindow = content.currentWindow else { return } await beginSheetModal(for: parentWindow) } - public func endSheet(for content: any SheetContent) { + func endSheet(for content: any SheetContent) { content.currentWindow?.endSheet(window) } } extension NSSavePanel { - public func beginSheet(for content: any SheetContent) async { + func beginSheet(for content: any SheetContent) async { guard let parentWindow = content.currentWindow else { return } await beginSheetModal(for: parentWindow) } - public func endSheet(for content: any SheetContent) { + func endSheet(for content: any SheetContent) { content.currentWindow?.endSheet(window) } } From 863c8d2d6c37462a012b78ad654fc6c6c9d274c5 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:23:03 +0800 Subject: [PATCH 15/15] Completion --- .../AppKitNavigation/Navigation/Modal.swift | 189 +++++++++++++++--- .../Navigation/Presentation.swift | 26 ++- .../Navigation/PresentationContent.swift | 41 ---- .../AppKitNavigation/Navigation/Sheet.swift | 161 ++++++++++++++- .../Navigation/SheetContent.swift | 8 - 5 files changed, 339 insertions(+), 86 deletions(-) delete mode 100644 Sources/AppKitNavigation/Navigation/PresentationContent.swift diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 79075a67b..0d053d6f0 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -7,25 +7,79 @@ private var modalObserverKeys = AssociatedKeys() private typealias ModalObserver = NavigationObserver +// MARK: - Modal Session - NSWindow @MainActor extension NSObject { - // MARK: - Modal - NSWindow + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _modalSession(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modalSession(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +// MARK: - Modal - NSWindow + +@MainActor +extension NSObject { @discardableResult public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> NSWindow ) -> ObserveToken { - modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) } + @discardableResult public func modal( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: item, onDismiss: onDismiss, content: content) } @_disfavoredOverload @@ -35,7 +89,7 @@ extension NSObject { onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> NSWindow ) -> ObserveToken { - modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + _modal(item: item, onDismiss: onDismiss, content: content) } @discardableResult @@ -45,9 +99,7 @@ extension NSObject { onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> NSWindow ) -> ObserveToken { - modal(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } + _modal(item: item, id: id, onDismiss: onDismiss, content: content) } @discardableResult @@ -59,43 +111,120 @@ extension NSObject { ) -> ObserveToken { _modal(item: item, id: id, onDismiss: onDismiss, content: content) } - // MARK: - Modal - NSSavePanel +} +// MARK: - Modal - NSAlert + +@MainActor +extension NSObject { @discardableResult public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSSavePanel + content: @escaping () -> NSAlert ) -> ObserveToken { - _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) } - // MARK: - Modal - NSAlert + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +// MARK: - Modal - NSSavePanel + +@MainActor +extension NSObject { @discardableResult public func modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSAlert + content: @escaping () -> NSSavePanel ) -> ObserveToken { - _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) } - // MARK: - Modal Session - NSWindow + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + @_disfavoredOverload @discardableResult - public func modalSession( - isModaled: UIBinding, + public func modal( + item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSWindow + content: @escaping (UIBinding) -> NSSavePanel ) -> ObserveToken { - _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) } - // MARK: - Private Modal + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +// MARK: - Private Modal +@MainActor +extension NSObject { @discardableResult - private func _modal( + fileprivate func _modal( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content @@ -104,7 +233,7 @@ extension NSObject { } @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content @@ -114,7 +243,7 @@ extension NSObject { @_disfavoredOverload @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content @@ -123,7 +252,7 @@ extension NSObject { } @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -135,7 +264,7 @@ extension NSObject { } @discardableResult - private func _modal( + fileprivate func _modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -167,9 +296,11 @@ extension NSObject { onDismiss?() } } + // MARK: - Modal Session + @discardableResult - private func _modalSession( + fileprivate func _modalSession( isModaled: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content @@ -178,7 +309,7 @@ extension NSObject { } @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content @@ -188,7 +319,7 @@ extension NSObject { @_disfavoredOverload @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content @@ -197,7 +328,7 @@ extension NSObject { } @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -209,7 +340,7 @@ extension NSObject { } @discardableResult - private func _modalSession( + fileprivate func _modalSession( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index a9e28256f..fbd5f4677 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -74,13 +74,13 @@ extension NSViewController { if let presentedViewController = presentedViewControllers?.first { self.dismiss(presentedViewController) onDismiss?() - child.presented(from: self, style: style) + self.present(child, for: style) } else { - child.presented(from: self, style: style) + self.present(child, for: style) } } dismiss: { [weak self] child, transaction in guard let self else { return } - child.dismiss(from: self) + self.dismiss(child) onDismiss?() } } @@ -152,6 +152,26 @@ extension NSViewController { return observer } } + + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) + } + + private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case let .popover(rect, view, preferredEdge, behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case let .custom(animator): + present(viewControllerToPresent, animator: animator) + } + } } extension NavigationContent where Self: NSViewController { diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift deleted file mode 100644 index 93de778e5..000000000 --- a/Sources/AppKitNavigation/Navigation/PresentationContent.swift +++ /dev/null @@ -1,41 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -protocol PresentationContent: NavigationContent { - func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) - func dismiss(from presentingViewController: NSViewController) -} - -extension NSViewController: PresentationContent { - func presented(from presentingViewController: NSViewController, style: TransitionStyle) { - presentingViewController.present(self, for: style) - } - - func dismiss(from presentingViewController: NSViewController) { - presentingViewController.dismiss(self) - } - - public enum TransitionStyle { - case sheet - case modalWindow - case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) - } - - private func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { - switch style { - case .sheet: - presentAsSheet(viewControllerToPresent) - case .modalWindow: - presentAsModalWindow(viewControllerToPresent) - case let .popover(rect, view, preferredEdge, behavior): - present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) - case let .custom(animator): - present(viewControllerToPresent, animator: animator) - } - } -} - -#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index ee0cda49a..ba9cbd184 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -7,9 +7,160 @@ private typealias SheetObserver, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +extension NSWindow { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSAlert + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSAlert + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + +extension NSWindow { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSSavePanel + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + + extension SheetContent { @discardableResult - private func _sheet( + fileprivate func _sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content @@ -18,7 +169,7 @@ extension SheetContent { } @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content @@ -28,7 +179,7 @@ extension SheetContent { @_disfavoredOverload @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, content: @escaping (UIBinding) -> Content @@ -37,7 +188,7 @@ extension SheetContent { } @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, @@ -49,7 +200,7 @@ extension SheetContent { } @discardableResult - private func _sheet( + fileprivate func _sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 25d27c466..3da396258 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -25,14 +25,6 @@ extension NSWindow: SheetContent { var currentWindow: NSWindow? { self } } -extension NSWindowController: SheetContent { - var currentWindow: NSWindow? { window } -} - -extension NSViewController: SheetContent { - var currentWindow: NSWindow? { view.window } -} - extension NSAlert: SheetContent { var currentWindow: NSWindow? { window }