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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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(