From 37e7adbef1bde09de869686b18a9eeee26fc81d3 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:09:48 +0800 Subject: [PATCH 01/34] Initial commit --- Package.swift | 24 + .../Bindings/UIColorWell.swift | 30 ++ .../AppKitNavigation/Bindings/UIControl.swift | 107 ++++ .../Bindings/UIDatePicker.swift | 30 ++ .../Bindings/UIPageControl.swift | 28 ++ .../Bindings/UISegmentedControl.swift | 81 +++ .../AppKitNavigation/Bindings/UISlider.swift | 29 ++ .../AppKitNavigation/Bindings/UIStepper.swift | 29 ++ .../AppKitNavigation/Bindings/UISwitch.swift | 32 ++ .../Bindings/UITabBarController.swift | 65 +++ .../Bindings/UITextField.swift | 311 ++++++++++++ .../Extensions/UIColorWell.md | 8 + .../Extensions/UIControlProtocol.md | 8 + .../Extensions/UIDatePicker.md | 8 + .../Extensions/UIKitAnimation.md | 35 ++ .../Extensions/UIPageControl.md | 8 + .../Documentation.docc/Extensions/UISlider.md | 8 + .../Extensions/UIStepper.md | 8 + .../Documentation.docc/Extensions/UISwitch.md | 8 + .../Extensions/UITextField.md | 22 + .../Extensions/UIViewController.md | 26 + .../Documentation.docc/Extensions/observe.md | 7 + .../Documentation.docc/UIKitNavigation.md | 165 +++++++ .../Internal/AssumeIsolated.swift | 30 ++ .../Internal/ErrorMechanism.swift | 20 + .../AppKitNavigation/Internal/Exports.swift | 3 + .../Internal/ToOptionalUnit.swift | 12 + .../Navigation/Presentation.swift | 463 ++++++++++++++++++ .../Navigation/UIAlertController.swift | 87 ++++ Sources/AppKitNavigation/Observe.swift | 188 +++++++ .../SwiftUI/Representable.swift | 24 + Sources/AppKitNavigation/UIBinding.swift | 15 + Sources/AppKitNavigation/UIKitAnimation.swift | 144 ++++++ Sources/AppKitNavigation/UITransaction.swift | 53 ++ Sources/AppKitNavigationShim/include/shim.h | 19 + Sources/AppKitNavigationShim/shim.m | 106 ++++ 36 files changed, 2241 insertions(+) create mode 100644 Sources/AppKitNavigation/Bindings/UIColorWell.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIDatePicker.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIPageControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/UISegmentedControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/UISlider.swift create mode 100644 Sources/AppKitNavigation/Bindings/UIStepper.swift create mode 100644 Sources/AppKitNavigation/Bindings/UISwitch.swift create mode 100644 Sources/AppKitNavigation/Bindings/UITabBarController.swift create mode 100644 Sources/AppKitNavigation/Bindings/UITextField.swift create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIColorWell.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIControlProtocol.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIDatePicker.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIKitAnimation.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIPageControl.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UISlider.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIStepper.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UISwitch.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UITextField.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/UIViewController.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/Extensions/observe.md create mode 100644 Sources/AppKitNavigation/Documentation.docc/UIKitNavigation.md 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/Navigation/Presentation.swift create mode 100644 Sources/AppKitNavigation/Navigation/UIAlertController.swift create mode 100644 Sources/AppKitNavigation/Observe.swift create mode 100644 Sources/AppKitNavigation/SwiftUI/Representable.swift create mode 100644 Sources/AppKitNavigation/UIBinding.swift create mode 100644 Sources/AppKitNavigation/UIKitAnimation.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 46c1d7edbe..3bc91c28ee 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"), @@ -62,10 +66,18 @@ let package = Package( "SwiftUINavigation" ] ), + .target( + name: "CocoaNavigation", + dependencies: [ + "SwiftNavigation", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ] + ), .target( name: "UIKitNavigation", dependencies: [ "SwiftNavigation", + "CocoaNavigation", "UIKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] @@ -73,6 +85,18 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "CocoaNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ] + ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/Bindings/UIColorWell.swift b/Sources/AppKitNavigation/Bindings/UIColorWell.swift new file mode 100644 index 0000000000..96c0a12f16 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIColorWell.swift @@ -0,0 +1,30 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UIColorWell { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - selectedColor: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(frame: CGRect = .zero, selectedColor: UIBinding) { + self.init(frame: frame) + bind(selectedColor: selectedColor) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter selectedColor: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(selectedColor: UIBinding) -> ObservationToken { + bind(selectedColor, to: \.selectedColor, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIControl.swift b/Sources/AppKitNavigation/Bindings/UIControl.swift new file mode 100644 index 0000000000..2854c166d0 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIControl.swift @@ -0,0 +1,107 @@ +#if canImport(UIKit) && !os(watchOS) + import ConcurrencyExtras + @_spi(Internals) import SwiftNavigation + import UIKit + + /// A protocol used to extend `UIControl`. + @MainActor + public protocol UIControlProtocol: UIControl {} + + extension UIControl: UIControlProtocol {} + + @available(iOS 14, tvOS 14, *) + extension UIControlProtocol { + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath, + for event: UIControl.Event + ) -> ObservationToken { + bind(binding, to: keyPath, for: event) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - set: A closure that is called when the binding's value changes with a weakly-captured + /// control, a new value that can be used to configure the control, and a transaction, which + /// can be used to determine how and if the change should be animated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: KeyPath, + for event: UIControl.Event, + set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObservationToken { + unbind(keyPath) + let action = UIAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + addAction(action, for: event) + let isSetting = LockIsolated(false) + let token = observe { [weak self] transaction in + guard let self else { return } + isSetting.withValue { $0 = true } + defer { isSetting.withValue { $0 = false } } + set( + self, + binding.wrappedValue, + transaction.uiKit.animation == nil && !transaction.uiKit.disablesAnimations + ? binding.transaction + : transaction + ) + } + // NB: This key path must only be accessed on the main actor + @UncheckedSendable var uncheckedKeyPath = keyPath + let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in + guard isSetting.withValue({ !$0 }) else { return } + MainActor._assumeIsolated { + binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + } + } + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.removeAction(action, for: .allEvents) + } + token.cancel() + observation.invalidate() + } + observationTokens[keyPath] = observationToken + return observationToken + } + + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } + + var observationTokens: [AnyKeyPath: ObservationToken] { + get { + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] + ?? [:] + } + set { + objc_setAssociatedObject( + self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + } + + @MainActor + private let observationTokensKey = malloc(1)! +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIDatePicker.swift b/Sources/AppKitNavigation/Bindings/UIDatePicker.swift new file mode 100644 index 0000000000..b4c02a007f --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIDatePicker.swift @@ -0,0 +1,30 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UIDatePicker { + /// Creates a new date picker with the specified frame and registers the binding against the + /// selected date. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected date, and write to when the selected + /// date changes. + public convenience init(frame: CGRect = .zero, date: UIBinding) { + self.init(frame: frame) + bind(date: date) + } + + /// Establishes a two-way connection between a binding and the date picker's selected date. + /// + /// - Parameter date: The binding to read from for the selected date, and write to when the + /// selected date changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(date: UIBinding) -> ObservationToken { + bind(date, to: \.date, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIPageControl.swift b/Sources/AppKitNavigation/Bindings/UIPageControl.swift new file mode 100644 index 0000000000..626798fe36 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIPageControl.swift @@ -0,0 +1,28 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKit + + @available(iOS 14, tvOS 14, *) + extension UIPageControl { + /// Creates a new page control with the specified frame and registers the binding against the + /// current page. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - currentPage: The binding to read from for the current page, and write to when the + /// current page changes. + public convenience init(frame: CGRect = .zero, currentPage: UIBinding) { + self.init(frame: frame) + bind(currentPage: currentPage) + } + + /// Establishes a two-way connection between a binding and the page control's current page. + /// + /// - Parameter currentPage: The binding to read from for the current page, and write to when + /// the current page changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(currentPage: UIBinding) -> ObservationToken { + bind(currentPage, to: \.currentPage, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift b/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift new file mode 100644 index 0000000000..5c2c9a86a6 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift @@ -0,0 +1,81 @@ +#if canImport(UIKit) && !os(watchOS) + import IssueReporting + import UIKit + + @available(iOS 14, tvOS 14, *) + extension UISegmentedControl { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - selectedSegment: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init( + frame: CGRect = .zero, selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.init(frame: frame) + bind( + selectedSegment: selectedSegment, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter selectedSegment: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind( + selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> ObservationToken { + let fileID = HashableStaticString(rawValue: fileID) + let filePath = HashableStaticString(rawValue: filePath) + return bind( + selectedSegment[fileID: fileID, filePath: filePath, line: line, column: column], + to: \.selectedSegmentIndex, + for: .valueChanged + ) + } + } + + extension RawRepresentable { + fileprivate subscript( + fileID fileID: HashableStaticString, + filePath filePath: HashableStaticString, + line line: UInt, + column column: UInt + ) -> Int { + get { rawValue } + set { + guard let rawRepresentable = Self(rawValue: newValue) + else { + reportIssue( + """ + Raw-representable 'UIBinding<\(Self.self)>' attempted to write an invalid raw value \ + ('\(newValue)'). + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) + return + } + self = rawRepresentable + } + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UISlider.swift b/Sources/AppKitNavigation/Bindings/UISlider.swift new file mode 100644 index 0000000000..f2fd9a2f61 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UISlider.swift @@ -0,0 +1,29 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UISlider { + /// Creates a new slider with the specified frame and registers the binding against the value. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - value: The binding to read from for the current value, and write to when the value + /// changes. + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + bind(value: value) + } + + /// Establishes a two-way connection between a binding and the slider's current value. + /// + /// - Parameter value: The binding to read from for the current value, and write to when the + /// value changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(value: UIBinding) -> ObservationToken { + bind(value, to: \.value, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIStepper.swift b/Sources/AppKitNavigation/Bindings/UIStepper.swift new file mode 100644 index 0000000000..d3e664e9d2 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UIStepper.swift @@ -0,0 +1,29 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UIStepper { + /// Creates a new stepper with the specified frame and registers the binding against the value. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - value: The binding to read from for the current value, and write to when the value + /// changes. + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + bind(value: value) + } + + /// Establishes a two-way connection between a binding and the stepper's current value. + /// + /// - Parameter value: The binding to read from for the current value, and write to when the + /// value changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(value: UIBinding) -> ObservationToken { + bind(value, to: \.value, for: .valueChanged) + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UISwitch.swift b/Sources/AppKitNavigation/Bindings/UISwitch.swift new file mode 100644 index 0000000000..c8e4ab0ff3 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UISwitch.swift @@ -0,0 +1,32 @@ +#if canImport(UIKit) && !os(tvOS) && !os(watchOS) + import UIKit + + @available(iOS 14, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension UISwitch { + /// Creates a new switch with the specified frame and registers the binding against whether or + /// not the switch is on. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - isOn: The binding to read from for the current state, and write to when the state + /// changes. + public convenience init(frame: CGRect = .zero, isOn: UIBinding) { + self.init(frame: frame) + bind(isOn: isOn) + } + + /// Establishes a two-way connection between a binding and the switch's current state. + /// + /// - Parameter isOn: The binding to read from for the current state, and write to when the + /// state changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(isOn: UIBinding) -> ObservationToken { + bind(isOn, to: \.isOn, for: .valueChanged) { control, isOn, transaction in + control.setOn(isOn, animated: !transaction.uiKit.disablesAnimations) + } + } + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UITabBarController.swift b/Sources/AppKitNavigation/Bindings/UITabBarController.swift new file mode 100644 index 0000000000..7b9e6351a1 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UITabBarController.swift @@ -0,0 +1,65 @@ +#if swift(>=6) && canImport(UIKit) && !os(tvOS) && !os(watchOS) + import IssueReporting + import UIKit + + @available(iOS 18, tvOS 18, visionOS 2, *) + extension UITabBarController { + @discardableResult + public func bind( + selectedTab: UIBinding, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> ObservationToken { + let token = observe { [weak self] in + guard let self else { return } + guard let identifier = selectedTab.wrappedValue else { + self.selectedTab = nil + return + } + guard let tab = tabs.first(where: { $0.identifier == identifier }) + else { + reportIssue( + """ + Tab bar controller binding attempted to write an invalid identifier ('\(identifier)'). + + Valid identifiers: \(tabs.map(\.identifier)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + self.selectedTab = nil + return + } + self.selectedTab = tab + } + let observation = observe(\.selectedTab) { controller, _ in + MainActor.assumeIsolated { + selectedTab.wrappedValue = controller.selectedTab?.identifier + } + } + let observationToken = ObservationToken { + token.cancel() + observation.invalidate() + } + self.observationToken = observationToken + return observationToken + } + + private var observationToken: ObservationToken? { + get { + objc_getAssociatedObject(self, Self.observationTokenKey) as? ObservationToken + } + set { + objc_setAssociatedObject( + self, Self.observationTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static let observationTokenKey = malloc(1)! + } +#endif diff --git a/Sources/AppKitNavigation/Bindings/UITextField.swift b/Sources/AppKitNavigation/Bindings/UITextField.swift new file mode 100644 index 0000000000..6214680fe9 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/UITextField.swift @@ -0,0 +1,311 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKit + + @available(iOS 14, tvOS 14, *) + extension UITextField { + /// Creates a new text field with the specified frame and registers the binding against its + /// text. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - text: The binding to read from for the current text, and write to when the text + /// changes. + public convenience init(frame: CGRect = .zero, text: UIBinding) { + self.init(frame: frame) + bind(text: text) + } + + /// Creates a new text field with the specified frame and registers the binding against its + /// text. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - attributedText: The binding to read from for the current text, and write to when the + /// attributed text changes. + public convenience init(frame: CGRect = .zero, attributedText: UIBinding) { + self.init(frame: frame) + bind(attributedText: attributedText) + } + + /// Establishes a two-way connection between a binding and the text field's current text. + /// + /// - Parameter text: The binding to read from for the current text, and write to when the text + /// changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(text: UIBinding) -> ObservationToken { + bind(UIBinding(text), to: \.text, for: .editingChanged) + } + + /// Establishes a two-way connection between a binding and the text field's current text. + /// + /// - Parameter attributedText: The binding to read from for the current text, and write to when + /// the attributed text changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(attributedText: UIBinding) -> ObservationToken { + bind(UIBinding(attributedText), to: \.attributedText, for: .editingChanged) + } + + /// Establishes a two-way connection between a binding and the text field's current selection. + /// + /// - Parameter selection: The binding to read from for the current selection, and write to when + /// the selected text range changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(selection: UIBinding) -> ObservationToken { + let editingChangedAction = UIAction { [weak self] _ in + guard let self else { return } + selection.wrappedValue = self.textSelection + } + addAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) + let editingDidEndAction = UIAction { _ in selection.wrappedValue = nil } + addAction(editingDidEndAction, for: .editingDidEnd) + let token = observe { [weak self] in + guard let self else { return } + textSelection = selection.wrappedValue + } + let observation = observe(\.selectedTextRange) { control, _ in + MainActor._assumeIsolated { + selection.wrappedValue = control.textSelection + } + } + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.removeAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) + self?.removeAction(editingDidEndAction, for: .editingDidEnd) + } + token.cancel() + observation.invalidate() + } + observationTokens[\UITextField.selectedTextRange] = observationToken + return observationToken + } + + fileprivate var textSelection: UITextSelection? { + get { + guard + let textRange = selectedTextRange, + let text + else { + return nil + } + let lowerBound = + text.index( + text.startIndex, + offsetBy: offset(from: beginningOfDocument, to: textRange.start), + limitedBy: text.endIndex + ) ?? text.endIndex + let upperBound = + text.index( + text.startIndex, + offsetBy: offset(from: beginningOfDocument, to: textRange.end), + limitedBy: text.endIndex + ) ?? text.endIndex + return UITextSelection(range: lowerBound..( + focus: UIBinding, equals value: Value + ) -> ObservationToken { + self.focusToken?.cancel() + let editingDidBeginAction = UIAction { _ in focus.wrappedValue = value } + let editingDidEndAction = UIAction { _ in + guard focus.wrappedValue == value else { return } + focus.wrappedValue = nil + } + addAction(editingDidBeginAction, for: .editingDidBegin) + addAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) + let innerToken = observe { [weak self] in + guard let self else { return } + switch (focus.wrappedValue, isFirstResponder) { + case (value, false): + becomeFirstResponder() + case (nil, true): + resignFirstResponder() + default: + break + } + } + let outerToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.removeAction(editingDidBeginAction, for: .editingDidBegin) + self?.removeAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) + } + innerToken.cancel() + } + self.focusToken = outerToken + return outerToken + } + + /// Binds this text field's focus state to the given Boolean state value. + /// + /// Use this method to cause the text field to receive focus whenever the the `condition` value + /// is `true`. You can use this method to observe the focus state of a text field, or + /// programmatically set and remove focus from the text field. + /// + /// In the following example, a single `UITextField` accepts a user's desired `username`. The + /// text field binds its focus state to the Boolean value `usernameFieldIsFocused`. A "Submit" + /// button's action verifies whether the name is available. If the name is unavailable, the + /// button sets `usernameFieldIsFocused` to `true`, which causes focus to return to the text + /// field, so the user can enter a different name. + /// + /// ```swift + /// final class LoginViewController: UIViewController { + /// @UIBindable private var username = "" + /// @UIBindable private var usernameFieldIsFocused = false + /// + /// // ... + /// + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let textField = UITextField(text: $username) + /// textField.focus($usernameFieldIsFocused) + /// + /// let submitButton = UIButton( + /// style: .system, + /// primaryAction: UIAction { [weak self] _ in + /// guard let self else { return } + /// if !isUserNameAvailable(username: username) { + /// usernameFieldIsFocused = true + /// } + /// } + /// ) + /// submitButton.setTitle("Sign In", for: .normal) + /// + /// // ... + /// } + /// } + /// ``` + /// + /// To control focus by matching a value, use the ``UIKit/UITextField/bind(focus:equals:)`` + /// method instead. + /// + /// - Parameter condition: The focus state to bind. When focus moves to the text field, the + /// binding sets the bound value to `true`. If a caller sets the value to `true` + /// programmatically, then focus moves to the text field. When focus leaves the text field, + /// the binding sets the value to `false`. If a caller sets the value to `false`, UIKit + /// automatically dismisses focus. + /// - Returns: A cancel token. + @discardableResult + public func bind(focus condition: UIBinding) -> ObservationToken { + bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) + } + + private var focusToken: ObservationToken? { + get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObservationToken } + set { + objc_setAssociatedObject( + self, Self.focusTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static let focusTokenKey = malloc(1)! + } + + /// Represents a selection of text. + /// + /// Like SwiftUI's `TextSelection`, but for UIKit. + public struct UITextSelection: Hashable, Sendable { + public var range: Range + + public init(range: Range) { + self.range = range + } + public init(insertionPoint: String.Index) { + self.range = insertionPoint..( + _ 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/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift new file mode 100644 index 0000000000..d527e73ca7 --- /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 0000000000..6f94b2acad --- /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 0000000000..bbe60acbac --- /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/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift new file mode 100644 index 0000000000..6952a998b6 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -0,0 +1,463 @@ +#if canImport(UIKit) && !os(watchOS) + import IssueReporting + @_spi(Internals) import SwiftNavigation + import UIKit + import UIKitNavigationShim + + extension UIViewController { + /// Presents a view controller modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the view + /// controller. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func present( + isPresented: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> UIViewController + ) -> ObservationToken { + present(item: isPresented.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func present( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + present(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + present(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + present(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + + /// Presents a view controller modally using the given item as a data source for its content. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + destination(item: item, id: id) { $item in + content($item) + } present: { [weak self] child, transaction in + guard let self else { return } + if presentedViewController != nil { + self.dismiss(animated: !transaction.uiKit.disablesAnimations) { + onDismiss?() + self.present(child, animated: !transaction.uiKit.disablesAnimations) + } + } else { + self.present(child, animated: !transaction.uiKit.disablesAnimations) + } + } dismiss: { [weak self] _, transaction in + self?.dismiss(animated: !transaction.uiKit.disablesAnimations) { + onDismiss?() + } + } + } + + /// Pushes a view controller onto the receiver's stack when a binding to a Boolean value you + /// provide is true. + /// + /// Like SwiftUI's `navigationDestination(isPresented:)` view modifier, but for UIKit. + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to push the view + /// controller. + /// - content: A closure that returns the view controller to display onto the receiver's + /// stack. + @discardableResult + public func navigationDestination( + isPresented: UIBinding, + content: @escaping () -> UIViewController + ) -> ObservationToken { + navigationDestination(item: isPresented.toOptionalUnit) { _ in content() } + } + + /// Pushes a view controller onto the receiver's stack using the given item as a data source for + /// its content. + /// + /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. + /// - content: A closure that returns the view controller to display onto the receiver's + /// stack. + @discardableResult + public func navigationDestination( + item: UIBinding, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + navigationDestination(item: item) { + content($0.wrappedValue) + } + } + + /// Pushes a view controller onto the receiver's stack using the given item as a data source for + /// its content. + /// + /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. + /// - content: A closure that returns the view controller to display onto the receiver's + /// stack. + @_disfavoredOverload + @discardableResult + public func navigationDestination( + item: UIBinding, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + destination(item: item) { $item in + content($item) + } present: { [weak self] child, transaction in + guard + let navigationController = self?.navigationController ?? self as? UINavigationController + else { + reportIssue( + """ + Can't present navigation item: "navigationController" is "nil". + """ + ) + return + } + navigationController.pushViewController( + child, animated: !transaction.uiKit.disablesAnimations + ) + } dismiss: { [weak self] child, transaction in + guard + let navigationController = self?.navigationController ?? self as? UINavigationController + else { + reportIssue( + """ + Can't dismiss navigation item: "navigationController" is "nil". + """ + ) + return + } + navigationController.popFromViewController( + child, animated: !transaction.uiKit.disablesAnimations + ) + } + } + + /// Presents a view controller when a binding to a Boolean value you provide is true. + /// + /// This helper powers ``present(isPresented:onDismiss:content:)`` and + /// ``UIKit/UINavigationController/pushViewController(isPresented:content:)`` and can be used to + /// define custom transitions. + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the view + /// controller. + /// - content: A closure that returns the view controller to display. + /// - present: The closure to execute when presenting the view controller. + /// - dismiss: The closure to execute when dismissing the view controller. + @discardableResult + public func destination( + isPresented: UIBinding, + content: @escaping () -> UIViewController, + present: @escaping (UIViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) + } + + /// Presents a view controller using the given item as a data source for its content. + /// + /// This helper powers ``navigationDestination(item:content:)-367r6`` and can be used to define + /// custom transitions. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. + /// - content: A closure that returns the view controller to display. + /// - present: The closure to execute when presenting the view controller. + /// - dismiss: The closure to execute when dismissing the view controller. + @discardableResult + public func destination( + item: UIBinding, + content: @escaping (UIBinding) -> UIViewController, + present: @escaping (UIViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + destination( + item: item, + id: { _ in nil }, + content: content, + present: present, + dismiss: dismiss + ) + } + + /// Presents a view controller using the given item as a data source for its content. + /// + /// This helper powers ``present(item:onDismiss:content:)-34iup`` and can be used to define + /// custom transitions. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - content: A closure that returns the view controller to display. + /// - present: The closure to execute when presenting the view controller. + /// - dismiss: The closure to execute when dismissing the view controller. + @discardableResult + public func destination( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> UIViewController, + present: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + destination( + item: item, + id: { $0[keyPath: id] }, + content: content, + present: present, + dismiss: dismiss + ) + } + + private func destination( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> UIViewController, + present: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: UIViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = presentedByID[key] { + guard let presentationID = presented.presentationID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let childController = content(unwrappedItem) + let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) + { + item.wrappedValue = nil + } + } + childController.onDismiss = onDismiss + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + childController.traitOverrides.dismiss = UIDismissAction { _ in + onDismiss() + } + } + self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + present(childController, transaction) + } + } + if hasViewAppeared { + work() + } else { + onViewAppear.append(work) + } + } else if let presented = presentedByID[key] { + if let controller = presented.controller { + dismiss(controller, transaction) + } + self.presentedByID[key] = nil + } + } + } + + fileprivate var presentedByID: [UIBindingIdentifier: Presented] { + get { + (objc_getAssociatedObject(self, Self.presentedKey) + as? [UIBindingIdentifier: Presented]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static let presentedKey = malloc(1)! + } + + extension UINavigationController { + @available( + *, deprecated, + message: """ + Use 'self.navigationDestination(isPresented:)' instead of 'self.navigationController?.pushViewController(isPresented:)'. + """ + ) + @discardableResult + public func pushViewController( + isPresented: UIBinding, + content: @escaping () -> UIViewController + ) -> ObservationToken { + navigationDestination(isPresented: isPresented, content: content) + } + + @available( + *, deprecated, + message: """ + Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. + """ + ) + @discardableResult + public func pushViewController( + item: UIBinding, + content: @escaping (Item) -> UIViewController + ) -> ObservationToken { + navigationDestination(item: item, content: content) + } + + @available( + *, deprecated, + message: """ + Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. + """ + ) + @_disfavoredOverload + @discardableResult + public func pushViewController( + item: UIBinding, + content: @escaping (UIBinding) -> UIViewController + ) -> ObservationToken { + navigationDestination(item: item, content: content) + } + } + + @MainActor + private class Presented { + weak var controller: UIViewController? + let presentationID: AnyHashable? + deinit { + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + self.controller?.dismiss(animated: false) + } + } + init(_ controller: UIViewController, id presentationID: AnyHashable? = nil) { + self.controller = controller + self.presentationID = presentationID + } + } +#endif diff --git a/Sources/AppKitNavigation/Navigation/UIAlertController.swift b/Sources/AppKitNavigation/Navigation/UIAlertController.swift new file mode 100644 index 0000000000..0151edcdf4 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/UIAlertController.swift @@ -0,0 +1,87 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKit + + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS, unavailable) + extension UIAlertController { + /// Creates and returns a view controller for displaying an alert using a data description. + /// + /// - Parameters: + /// - state: A data description of the alert. + /// - handler: A closure that is invoked with an action held in `state`. + public convenience init( + state: AlertState, + handler: @escaping (_ action: Action?) -> Void + ) { + self.init( + title: String(state: state.title), + message: state.message.map { String(state: $0) }, + preferredStyle: .alert + ) + for button in state.buttons { + addAction(UIAlertAction(button, action: handler)) + } + } + + /// Creates and returns a view controller for displaying an action sheet using a data + /// description. + /// + /// - Parameters: + /// - state: A data description of the alert. + /// - handler: A closure that is invoked with an action held in `state`. + public convenience init( + state: ConfirmationDialogState, + handler: @escaping (_ action: Action?) -> Void + ) { + self.init( + title: state.titleVisibility == .visible ? String(state: state.title) : nil, + message: state.message.map { String(state: $0) }, + preferredStyle: .actionSheet + ) + for button in state.buttons { + addAction(UIAlertAction(button, action: handler)) + } + } + } + + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS, unavailable) + extension UIAlertAction.Style { + public init(_ role: ButtonStateRole) { + switch role { + case .cancel: + self = .cancel + case .destructive: + self = .destructive + } + } + } + + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS, unavailable) + extension UIAlertAction { + public convenience init( + _ button: ButtonState, + action handler: @escaping (_ action: Action?) -> Void + ) { + self.init( + title: String(state: button.label), + style: button.role.map(UIAlertAction.Style.init) ?? .default + ) { _ in + button.withAction(handler) + } + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } + } + } + } +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift new file mode 100644 index 0000000000..d7a6d934a7 --- /dev/null +++ b/Sources/AppKitNavigation/Observe.swift @@ -0,0 +1,188 @@ +#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 UIKit 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 = UILabel() + /// let incrementButton = UIButton(primaryAction: UIAction { [weak self] _ in + /// self?.model.incrementButtonTapped() + /// }) + /// + /// observe { [weak self] in + /// guard let self + /// else { return } + /// + /// countLabel.text = "\(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 `UIViewController`. 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.text = "\(model.count)" + /// } + /// factLabel.text = 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 os(watchOS) + apply(transaction) + #else + 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) + } + } + #endif + } + } + } 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 { + static func performWithoutAnimation(_ block: () -> Void) { + if NSAnimationContext.current.allowsImplicitAnimation { + NSAnimationContext.current.allowsImplicitAnimation = false + block() + NSAnimationContext.current.allowsImplicitAnimation = true + } else { + block() + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/SwiftUI/Representable.swift b/Sources/AppKitNavigation/SwiftUI/Representable.swift new file mode 100644 index 0000000000..29ac8b2301 --- /dev/null +++ b/Sources/AppKitNavigation/SwiftUI/Representable.swift @@ -0,0 +1,24 @@ +#if canImport(SwiftUI) && canImport(AppKit) && !targetEnvironment(macCatalyst) + import SwiftUI + import AppKit + + public struct NSViewControllerRepresenting< + NSViewControllerType: NSViewController + >: NSViewControllerRepresentable { + private let base: NSViewControllerType + public init(_ base: () -> NSViewControllerType) { + self.base = base() + } + public func makeNSViewController(context _: Context) -> NSViewControllerType { base } + public func updateNSViewController(_: NSViewControllerType, context _: Context) {} + } + + public struct NSViewRepresenting: NSViewRepresentable { + private let base: NSViewType + public init(_ base: () -> NSViewType) { + self.base = base() + } + public func makeNSView(context _: Context) -> NSViewType { base } + public func updateNSView(_: NSViewType, context _: Context) {} + } +#endif diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift new file mode 100644 index 0000000000..4a66220b17 --- /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/UIKitAnimation.swift b/Sources/AppKitNavigation/UIKitAnimation.swift new file mode 100644 index 0000000000..7e5106edc5 --- /dev/null +++ b/Sources/AppKitNavigation/UIKitAnimation.swift @@ -0,0 +1,144 @@ +#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): + #if swift(>=6) + if #available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) { + var result: Swift.Result? + UIView.animate( + with: animation, + changes: { result = Swift.Result(catching: body) }, + completion: completion.map { completion in { 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) + + 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. + /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. + /// Specify a value of `0` to begin the animations immediately. + /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent + /// state. + /// + /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a + /// damping ratio closer to zero to increase oscillation. + /// - velocity: The initial spring velocity. For smooth start to the animation, match this + /// value to the view's velocity as it was prior to attachment. + /// + /// A value of `1` corresponds to the total animation distance traversed in one second. For + /// example, if the total animation distance is 200 points and you want the start of the + /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. + /// - options: A mask of options indicating how you want to perform the animations. For a list + /// of valid constants, see `UIView.AnimationOptions`. + /// - 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 + ) + ) + ) + } + + + /// Animates changes using the specified SwiftUI animation. + /// + /// - Parameter animation: The animation to use for the changes. + @available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) + public init(_ animation: Animation) { + self.init(framework: .swiftUI(animation)) + } + + /// A default animation instance. + public static var `default`: Self { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return .animate() + } else { + return .animate(withDuration: 0.35) + } + } + } +#endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift new file mode 100644 index 0000000000..88ba674b30 --- /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() + self.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 0000000000..1d2054f3ca --- /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 diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m new file mode 100644 index 0000000000..a2fb9479a5 --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,106 @@ +#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 +#endif From 4678f0adba6576a56467f0136a34b9d14cda985a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 14 Aug 2024 01:33:33 +0800 Subject: [PATCH 02/34] Updates --- Package.swift | 9 - .../AppKitNavigation/AppKitAnimation.swift | 121 +++++ .../Bindings/NSColorPanel.swift | 41 ++ .../Bindings/NSColorWell.swift | 27 + .../AppKitNavigation/Bindings/NSControl.swift | 17 + ...{UIDatePicker.swift => NSDatePicker.swift} | 17 +- .../Bindings/NSFontManager.swift | 74 +++ .../Bindings/NSMenuItem.swift | 17 + .../Bindings/NSOpenPanel.swift | 5 + .../Bindings/NSPathControl.swift | 29 ++ .../Bindings/NSSavePanel.swift | 5 + .../Bindings/NSSegmentedControl.swift | 79 +++ .../{UISlider.swift => NSSlider.swift} | 17 +- .../{UIStepper.swift => NSStepper.swift} | 17 +- .../{UISwitch.swift => NSSwitch.swift} | 26 +- .../Bindings/NSTargetAction.swift | 127 +++++ .../{UITextField.swift => NSTextField.swift} | 260 +++++----- .../Bindings/NSToolbarItem.swift | 17 + .../Bindings/UIColorWell.swift | 30 -- .../AppKitNavigation/Bindings/UIControl.swift | 107 ---- .../Bindings/UIPageControl.swift | 28 -- .../Bindings/UISegmentedControl.swift | 81 --- .../Bindings/UITabBarController.swift | 65 --- .../Navigation/Presentation.swift | 475 +++++++----------- .../Navigation/UIAlertController.swift | 168 +++---- Sources/AppKitNavigation/UIKitAnimation.swift | 144 ------ 26 files changed, 999 insertions(+), 1004 deletions(-) create mode 100644 Sources/AppKitNavigation/AppKitAnimation.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSColorPanel.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSColorWell.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSControl.swift rename Sources/AppKitNavigation/Bindings/{UIDatePicker.swift => NSDatePicker.swift} (73%) create mode 100644 Sources/AppKitNavigation/Bindings/NSFontManager.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSMenuItem.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSOpenPanel.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSPathControl.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSSavePanel.swift create mode 100644 Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift rename Sources/AppKitNavigation/Bindings/{UISlider.swift => NSSlider.swift} (72%) rename Sources/AppKitNavigation/Bindings/{UIStepper.swift => NSStepper.swift} (72%) rename Sources/AppKitNavigation/Bindings/{UISwitch.swift => NSSwitch.swift} (66%) create mode 100644 Sources/AppKitNavigation/Bindings/NSTargetAction.swift rename Sources/AppKitNavigation/Bindings/{UITextField.swift => NSTextField.swift} (58%) create mode 100644 Sources/AppKitNavigation/Bindings/NSToolbarItem.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UIColorWell.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UIControl.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UIPageControl.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UISegmentedControl.swift delete mode 100644 Sources/AppKitNavigation/Bindings/UITabBarController.swift delete mode 100644 Sources/AppKitNavigation/UIKitAnimation.swift diff --git a/Package.swift b/Package.swift index 3bc91c28ee..480e3861b8 100644 --- a/Package.swift +++ b/Package.swift @@ -66,18 +66,10 @@ let package = Package( "SwiftUINavigation" ] ), - .target( - name: "CocoaNavigation", - dependencies: [ - "SwiftNavigation", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - ] - ), .target( name: "UIKitNavigation", dependencies: [ "SwiftNavigation", - "CocoaNavigation", "UIKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] @@ -89,7 +81,6 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", - "CocoaNavigation", "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ] diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift new file mode 100644 index 0000000000..75e40b8f86 --- /dev/null +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -0,0 +1,121 @@ +#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. + /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. + /// Specify a value of `0` to begin the animations immediately. + /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent + /// state. + /// + /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a + /// damping ratio closer to zero to increase oscillation. + /// - velocity: The initial spring velocity. For smooth start to the animation, match this + /// value to the view's velocity as it was prior to attachment. + /// + /// A value of `1` corresponds to the total animation distance traversed in one second. For + /// example, if the total animation distance is 200 points and you want the start of the + /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. + /// - options: A mask of options indicating how you want to perform the animations. For a list + /// of valid constants, see `UIView.AnimationOptions`. + /// - 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 { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return .animate() + } else { + return .animate(withDuration: 0.35) + } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift new file mode 100644 index 0000000000..e266d8bc8a --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSColorPanel: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { setTarget(newValue) } + get { value(forKeyPath: "target") as? AnyObject } + } + + public var appkitNavigationAction: Selector? { + set { setAction(newValue) } + get { value(forKeyPath: "action") as? Selector } + } +} + +extension NSColorPanel { + /// Creates a new color panel and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - color: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(color: UIBinding) { + self.init() + bind(color: color) + } + + /// Establishes a two-way connection between a binding and the color panel's selected color. + /// + /// - Parameter color: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(color: UIBinding) -> ObservationToken { + bind(color, to: \.color) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorWell.swift b/Sources/AppKitNavigation/Bindings/NSColorWell.swift new file mode 100644 index 0000000000..8dda1c8a1c --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSColorWell.swift @@ -0,0 +1,27 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSColorWell { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - color: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(frame: CGRect = .zero, color: UIBinding) { + self.init(frame: frame) + bind(color: color) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter color: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(color: UIBinding) -> ObservationToken { + bind(color, to: \.color) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift new file mode 100644 index 0000000000..de03e4bd48 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSControl: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/UIDatePicker.swift b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift similarity index 73% rename from Sources/AppKitNavigation/Bindings/UIDatePicker.swift rename to Sources/AppKitNavigation/Bindings/NSDatePicker.swift index b4c02a007f..793dcc4d8f 100644 --- a/Sources/AppKitNavigation/Bindings/UIDatePicker.swift +++ b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UIDatePicker { +extension NSDatePicker { /// Creates a new date picker with the specified frame and registers the binding against the /// selected date. /// @@ -13,8 +10,8 @@ /// - date: The binding to read from for the selected date, and write to when the selected /// date changes. public convenience init(frame: CGRect = .zero, date: UIBinding) { - self.init(frame: frame) - bind(date: date) + self.init(frame: frame) + bind(date: date) } /// Establishes a two-way connection between a binding and the date picker's selected date. @@ -24,7 +21,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(date: UIBinding) -> ObservationToken { - bind(date, to: \.date, for: .valueChanged) + bind(date, to: \.dateValue) } - } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift new file mode 100644 index 0000000000..373b40f34d --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -0,0 +1,74 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { + public var appkitNavigationTarget: AnyObject? { + set { appkitNavigationDelegate.target = newValue } + get { appkitNavigationDelegate.target } + } + + public var appkitNavigationAction: Selector? { + set { appkitNavigationDelegate.action = newValue } + get { appkitNavigationDelegate.action } + } + + private var appkitNavigationDelegate: Delegate { + set { + objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let delegate = objc_getAssociatedObject(self, #function) as? Delegate { + return delegate + } else { + let delegate = Delegate() + self.target = delegate + self.appkitNavigationDelegate = delegate + return delegate + } + } + } + + private class Delegate: NSObject, NSFontChanging { + var target: AnyObject? + var action: Selector? + + func changeFont(_ sender: NSFontManager?) { + if let action { + NSApplication.shared.sendAction(action, to: target, from: sender) + } + } + } +} + +@MainActor +extension NSFontManager { + /// Creates a new date picker with the specified frame and registers the binding against the + /// selected date. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected date, and write to when the selected + /// date changes. + public convenience init(font: UIBinding) { + self.init() + bind(font: font) + } + + /// Establishes a two-way connection between a binding and the date picker's selected date. + /// + /// - Parameter date: The binding to read from for the selected date, and write to when the + /// selected date changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(font: UIBinding) -> ObservationToken { + bind(font, to: \._selectedFont) + } + + private var _selectedFont: NSFont { + set { setSelectedFont(newValue, isMultiple: false) } + get { convert(.systemFont(ofSize: 0)) } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift new file mode 100644 index 0000000000..25d7dd5c0a --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSMenuItem: NSTargetActionProtocol, @unchecked Sendable { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift new file mode 100644 index 0000000000..893f31d20e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift @@ -0,0 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSPathControl.swift b/Sources/AppKitNavigation/Bindings/NSPathControl.swift new file mode 100644 index 0000000000..78a0bf0f0c --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSPathControl.swift @@ -0,0 +1,29 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSPathControl { + /// Creates a new path control with the specified frame and registers the binding against the + /// selected url. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected url, and write to when the selected + /// url changes. + public convenience init(frame: CGRect = .zero, date: UIBinding) { + self.init(frame: frame) + bind(url: date) + } + + /// Establishes a two-way connection between a binding and the path control's selected url. + /// + /// - Parameter url: The binding to read from for the selected url, and write to when the + /// selected url changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(url: UIBinding) -> ObservationToken { + bind(url, to: \.url) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSavePanel.swift b/Sources/AppKitNavigation/Bindings/NSSavePanel.swift new file mode 100644 index 0000000000..893f31d20e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSavePanel.swift @@ -0,0 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift new file mode 100644 index 0000000000..fa32c08dd5 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift @@ -0,0 +1,79 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import IssueReporting +import AppKit + +extension NSSegmentedControl { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - selectedSegment: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init( + frame: CGRect = .zero, selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.init(frame: frame) + bind( + selectedSegment: selectedSegment, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter selectedSegment: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind( + selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> ObservationToken { + let fileID = HashableStaticString(rawValue: fileID) + let filePath = HashableStaticString(rawValue: filePath) + return bind( + selectedSegment[fileID: fileID, filePath: filePath, line: line, column: column], + to: \.selectedSegment + ) + } +} + +extension RawRepresentable { + fileprivate subscript( + fileID fileID: HashableStaticString, + filePath filePath: HashableStaticString, + line line: UInt, + column column: UInt + ) -> Int { + get { rawValue } + set { + guard let rawRepresentable = Self(rawValue: newValue) + else { + reportIssue( + """ + Raw-representable 'UIBinding<\(Self.self)>' attempted to write an invalid raw value \ + ('\(newValue)'). + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) + return + } + self = rawRepresentable + } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/UISlider.swift b/Sources/AppKitNavigation/Bindings/NSSlider.swift similarity index 72% rename from Sources/AppKitNavigation/Bindings/UISlider.swift rename to Sources/AppKitNavigation/Bindings/NSSlider.swift index f2fd9a2f61..50778248a1 100644 --- a/Sources/AppKitNavigation/Bindings/UISlider.swift +++ b/Sources/AppKitNavigation/Bindings/NSSlider.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UISlider { +extension NSSlider { /// Creates a new slider with the specified frame and registers the binding against the value. /// /// - Parameters: @@ -12,8 +9,8 @@ /// - value: The binding to read from for the current value, and write to when the value /// changes. public convenience init(frame: CGRect = .zero, value: UIBinding) { - self.init(frame: frame) - bind(value: value) + self.init(frame: frame) + bind(value: value) } /// Establishes a two-way connection between a binding and the slider's current value. @@ -23,7 +20,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(value: UIBinding) -> ObservationToken { - bind(value, to: \.value, for: .valueChanged) + bind(value, to: \.floatValue) } - } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/UIStepper.swift b/Sources/AppKitNavigation/Bindings/NSStepper.swift similarity index 72% rename from Sources/AppKitNavigation/Bindings/UIStepper.swift rename to Sources/AppKitNavigation/Bindings/NSStepper.swift index d3e664e9d2..a2fb9bbf43 100644 --- a/Sources/AppKitNavigation/Bindings/UIStepper.swift +++ b/Sources/AppKitNavigation/Bindings/NSStepper.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UIStepper { +extension NSStepper { /// Creates a new stepper with the specified frame and registers the binding against the value. /// /// - Parameters: @@ -12,8 +9,8 @@ /// - value: The binding to read from for the current value, and write to when the value /// changes. public convenience init(frame: CGRect = .zero, value: UIBinding) { - self.init(frame: frame) - bind(value: value) + self.init(frame: frame) + bind(value: value) } /// Establishes a two-way connection between a binding and the stepper's current value. @@ -23,7 +20,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(value: UIBinding) -> ObservationToken { - bind(value, to: \.value, for: .valueChanged) + bind(value, to: \.doubleValue) } - } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/UISwitch.swift b/Sources/AppKitNavigation/Bindings/NSSwitch.swift similarity index 66% rename from Sources/AppKitNavigation/Bindings/UISwitch.swift rename to Sources/AppKitNavigation/Bindings/NSSwitch.swift index c8e4ab0ff3..21b752be18 100644 --- a/Sources/AppKitNavigation/Bindings/UISwitch.swift +++ b/Sources/AppKitNavigation/Bindings/NSSwitch.swift @@ -1,10 +1,7 @@ -#if canImport(UIKit) && !os(tvOS) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit - @available(iOS 14, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - extension UISwitch { +extension NSSwitch { /// Creates a new switch with the specified frame and registers the binding against whether or /// not the switch is on. /// @@ -13,8 +10,8 @@ /// - isOn: The binding to read from for the current state, and write to when the state /// changes. public convenience init(frame: CGRect = .zero, isOn: UIBinding) { - self.init(frame: frame) - bind(isOn: isOn) + self.init(frame: frame) + bind(isOn: isOn) } /// Establishes a two-way connection between a binding and the switch's current state. @@ -24,9 +21,14 @@ /// - Returns: A cancel token. @discardableResult public func bind(isOn: UIBinding) -> ObservationToken { - bind(isOn, to: \.isOn, for: .valueChanged) { control, isOn, transaction in - control.setOn(isOn, animated: !transaction.uiKit.disablesAnimations) - } + bind(isOn, to: \.boolValue) { control, isOn, transaction in + control.boolValue = isOn + } } - } + + var boolValue: Bool { + set { state = newValue ? .on : .off } + get { state == .on } + } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift new file mode 100644 index 0000000000..305ef278ed --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -0,0 +1,127 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import ConcurrencyExtras +@_spi(Internals) import SwiftNavigation +import AppKit + +/// A protocol used to extend `NSControl, NSMenuItem...`. +@MainActor +public protocol NSTargetActionProtocol: NSObject, Sendable { + var appkitNavigationTarget: AnyObject? { set get } + var appkitNavigationAction: Selector? { set get } +} + +private class NSControlActionHandler: NSObject { + let action: (NSControl) -> Void + + init(action: @escaping (NSControl) -> Void) { + self.action = action + } + + @objc func invokeAction(_ sender: NSControl) { + action(sender) + } +} + +extension NSTargetActionProtocol { + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath + ) -> ObservationToken { + bind(binding, to: keyPath) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + private var actionHandler: NSControlActionHandler? { + set { objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + get { objc_getAssociatedObject(self, #function) as? NSControlActionHandler } + } + + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - set: A closure that is called when the binding's value changes with a weakly-captured + /// control, a new value that can be used to configure the control, and a transaction, which + /// can be used to determine how and if the change should be animated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: KeyPath, + set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObservationToken { + unbind(keyPath) + let actionHandler = NSControlActionHandler { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + self.actionHandler = actionHandler + appkitNavigationTarget = actionHandler + appkitNavigationAction = #selector(NSControlActionHandler.invokeAction(_:)) + + let isSetting = LockIsolated(false) + let token = observe { [weak self] transaction in + guard let self else { return } + isSetting.withValue { $0 = true } + defer { isSetting.withValue { $0 = false } } + set( + self, + binding.wrappedValue, + transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations + ? binding.transaction + : transaction + ) + } + // NB: This key path must only be accessed on the main actor + @UncheckedSendable var uncheckedKeyPath = keyPath + let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in + guard isSetting.withValue({ !$0 }) else { return } + MainActor._assumeIsolated { + binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + } + } + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.appkitNavigationTarget = nil + self?.appkitNavigationAction = nil + self?.actionHandler = nil + } + token.cancel() + observation.invalidate() + } + observationTokens[keyPath] = observationToken + return observationToken + } + + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } + + var observationTokens: [AnyKeyPath: ObservationToken] { + get { + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] + ?? [:] + } + set { + objc_setAssociatedObject( + self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} + +@MainActor +private let observationTokensKey = malloc(1)! +#endif diff --git a/Sources/AppKitNavigation/Bindings/UITextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift similarity index 58% rename from Sources/AppKitNavigation/Bindings/UITextField.swift rename to Sources/AppKitNavigation/Bindings/NSTextField.swift index 6214680fe9..e5c4370993 100644 --- a/Sources/AppKitNavigation/Bindings/UITextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -1,8 +1,8 @@ -#if canImport(UIKit) && !os(watchOS) - import UIKit +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import SwiftNavigation - @available(iOS 14, tvOS 14, *) - extension UITextField { +extension NSTextField: NSTextViewDelegate { /// Creates a new text field with the specified frame and registers the binding against its /// text. /// @@ -11,8 +11,8 @@ /// - text: The binding to read from for the current text, and write to when the text /// changes. public convenience init(frame: CGRect = .zero, text: UIBinding) { - self.init(frame: frame) - bind(text: text) + self.init(frame: frame) + bind(text: text) } /// Creates a new text field with the specified frame and registers the binding against its @@ -23,8 +23,8 @@ /// - attributedText: The binding to read from for the current text, and write to when the /// attributed text changes. public convenience init(frame: CGRect = .zero, attributedText: UIBinding) { - self.init(frame: frame) - bind(attributedText: attributedText) + self.init(frame: frame) + bind(attributedText: attributedText) } /// Establishes a two-way connection between a binding and the text field's current text. @@ -34,7 +34,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(text: UIBinding) -> ObservationToken { - bind(UIBinding(text), to: \.text, for: .editingChanged) + bind(text, to: \.stringValue) } /// Establishes a two-way connection between a binding and the text field's current text. @@ -44,7 +44,7 @@ /// - Returns: A cancel token. @discardableResult public func bind(attributedText: UIBinding) -> ObservationToken { - bind(UIBinding(attributedText), to: \.attributedText, for: .editingChanged) + bind(attributedText, to: \.attributedStringValue) } /// Establishes a two-way connection between a binding and the text field's current selection. @@ -53,79 +53,101 @@ /// the selected text range changes. /// - Returns: A cancel token. @discardableResult - public func bind(selection: UIBinding) -> ObservationToken { - let editingChangedAction = UIAction { [weak self] _ in - guard let self else { return } - selection.wrappedValue = self.textSelection - } - addAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) - let editingDidEndAction = UIAction { _ in selection.wrappedValue = nil } - addAction(editingDidEndAction, for: .editingDidEnd) - let token = observe { [weak self] in - guard let self else { return } - textSelection = selection.wrappedValue - } - let observation = observe(\.selectedTextRange) { control, _ in - MainActor._assumeIsolated { - selection.wrappedValue = control.textSelection + public func bind(selection: UIBinding) -> ObservationToken { + let editingChangedAction = NotificationCenter.default.publisher(for: NSTextField.textDidChangeNotification, object: self) + .sink { [weak self] _ in + guard let self else { return } + selection.wrappedValue = self.textSelection + } + let editingDidEndAction = NotificationCenter.default.publisher(for: NSTextField.textDidEndEditingNotification, object: self).sink { _ in selection.wrappedValue = nil } + let token = observe { [weak self] in + guard let self else { return } + textSelection = selection.wrappedValue } - } - let observationToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - self?.removeAction(editingChangedAction, for: [.editingChanged, .editingDidBegin]) - self?.removeAction(editingDidEndAction, for: .editingDidEnd) + textSelectionObserver = TextSelectionObserver { control in + MainActor._assumeIsolated { + selection.wrappedValue = control.textSelection + } + } + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + editingChangedAction.cancel() + editingDidEndAction.cancel() + } + token.cancel() + self?.textSelectionObserver = nil + } + observationTokens[\NSTextField.selectedRange] = observationToken + return observationToken + } + + fileprivate var selectedRange: NSRange? { + set { + currentEditor()?.selectedRange = newValue ?? .init(location: 0, length: 0) + } + + get { + currentEditor()?.selectedRange } - token.cancel() - observation.invalidate() - } - observationTokens[\UITextField.selectedTextRange] = observationToken - return observationToken } - fileprivate var textSelection: UITextSelection? { - get { - guard - let textRange = selectedTextRange, - let text - else { - return nil + fileprivate class TextSelectionObserver: NSObject { + let observer: (NSTextField) -> Void + + init(observer: @escaping (NSTextField) -> Void) { + self.observer = observer + } + } + + fileprivate var textSelectionObserver: TextSelectionObserver? { + set { + objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, #function) as? TextSelectionObserver } - let lowerBound = - text.index( - text.startIndex, - offsetBy: offset(from: beginningOfDocument, to: textRange.start), - limitedBy: text.endIndex - ) ?? text.endIndex - let upperBound = - text.index( - text.startIndex, - offsetBy: offset(from: beginningOfDocument, to: textRange.end), - limitedBy: text.endIndex - ) ?? text.endIndex - return UITextSelection(range: lowerBound..( - focus: UIBinding, equals value: Value + focus: UIBinding, equals value: Value ) -> ObservationToken { - self.focusToken?.cancel() - let editingDidBeginAction = UIAction { _ in focus.wrappedValue = value } - let editingDidEndAction = UIAction { _ in - guard focus.wrappedValue == value else { return } - focus.wrappedValue = nil - } - addAction(editingDidBeginAction, for: .editingDidBegin) - addAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) - let innerToken = observe { [weak self] in - guard let self else { return } - switch (focus.wrappedValue, isFirstResponder) { - case (value, false): - becomeFirstResponder() - case (nil, true): - resignFirstResponder() - default: - break + focusToken?.cancel() + let editingDidBeginAction = NotificationCenter.default.publisher(for: NSTextField.textDidBeginEditingNotification, object: self).sink { _ in + focus.wrappedValue = value + } + + let editingDidEndAction = NotificationCenter.default.publisher(for: NSTextField.textDidEndEditingNotification, object: self).sink { _ in + guard focus.wrappedValue == value else { return } + focus.wrappedValue = nil } - } - let outerToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - self?.removeAction(editingDidBeginAction, for: .editingDidBegin) - self?.removeAction(editingDidEndAction, for: [.editingDidEnd, .editingDidEndOnExit]) + + let innerToken = observe { [weak self] in + guard let self else { return } + switch (focus.wrappedValue, currentEditor() != nil) { + case (value, false): + becomeFirstResponder() + case (nil, true): + window?.makeFirstResponder(nil) + default: + break + } + } + let outerToken = ObservationToken { + editingDidBeginAction.cancel() + editingDidEndAction.cancel() + innerToken.cancel() } - innerToken.cancel() - } - self.focusToken = outerToken - return outerToken + focusToken = outerToken + return outerToken } /// Binds this text field's focus state to the given Boolean state value. @@ -277,35 +299,37 @@ /// - Returns: A cancel token. @discardableResult public func bind(focus condition: UIBinding) -> ObservationToken { - bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) + bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) } private var focusToken: ObservationToken? { - get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObservationToken } - set { - objc_setAssociatedObject( - self, Self.focusTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } + get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObservationToken } + set { + objc_setAssociatedObject( + self, Self.focusTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } } private static let focusTokenKey = malloc(1)! - } +} - /// Represents a selection of text. - /// - /// Like SwiftUI's `TextSelection`, but for UIKit. - public struct UITextSelection: Hashable, Sendable { +/// Represents a selection of text. +/// +/// Like SwiftUI's `TextSelection`, but for UIKit. +public struct AppKitTextSelection: Hashable, Sendable { public var range: Range public init(range: Range) { - self.range = range + self.range = range } + public init(insertionPoint: String.Index) { - self.range = insertionPoint..) { - self.init(frame: frame) - bind(selectedColor: selectedColor) - } - - /// Establishes a two-way connection between a binding and the color well's selected color. - /// - /// - Parameter selectedColor: The binding to read from for the selected color, and write to - /// when the selected color changes. - /// - Returns: A cancel token. - @discardableResult - public func bind(selectedColor: UIBinding) -> ObservationToken { - bind(selectedColor, to: \.selectedColor, for: .valueChanged) - } - } -#endif diff --git a/Sources/AppKitNavigation/Bindings/UIControl.swift b/Sources/AppKitNavigation/Bindings/UIControl.swift deleted file mode 100644 index 2854c166d0..0000000000 --- a/Sources/AppKitNavigation/Bindings/UIControl.swift +++ /dev/null @@ -1,107 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) - import ConcurrencyExtras - @_spi(Internals) import SwiftNavigation - import UIKit - - /// A protocol used to extend `UIControl`. - @MainActor - public protocol UIControlProtocol: UIControl {} - - extension UIControl: UIControlProtocol {} - - @available(iOS 14, tvOS 14, *) - extension UIControlProtocol { - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: ReferenceWritableKeyPath, - for event: UIControl.Event - ) -> ObservationToken { - bind(binding, to: keyPath, for: event) { control, newValue, _ in - control[keyPath: keyPath] = newValue - } - } - - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - set: A closure that is called when the binding's value changes with a weakly-captured - /// control, a new value that can be used to configure the control, and a transaction, which - /// can be used to determine how and if the change should be animated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: KeyPath, - for event: UIControl.Event, - set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObservationToken { - unbind(keyPath) - let action = UIAction { [weak self] _ in - guard let self else { return } - binding.wrappedValue = self[keyPath: keyPath] - } - addAction(action, for: event) - let isSetting = LockIsolated(false) - let token = observe { [weak self] transaction in - guard let self else { return } - isSetting.withValue { $0 = true } - defer { isSetting.withValue { $0 = false } } - set( - self, - binding.wrappedValue, - transaction.uiKit.animation == nil && !transaction.uiKit.disablesAnimations - ? binding.transaction - : transaction - ) - } - // NB: This key path must only be accessed on the main actor - @UncheckedSendable var uncheckedKeyPath = keyPath - let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in - guard isSetting.withValue({ !$0 }) else { return } - MainActor._assumeIsolated { - binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] - } - } - let observationToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - self?.removeAction(action, for: .allEvents) - } - token.cancel() - observation.invalidate() - } - observationTokens[keyPath] = observationToken - return observationToken - } - - public func unbind(_ keyPath: KeyPath) { - observationTokens[keyPath]?.cancel() - observationTokens[keyPath] = nil - } - - var observationTokens: [AnyKeyPath: ObservationToken] { - get { - objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] - ?? [:] - } - set { - objc_setAssociatedObject( - self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - } - - @MainActor - private let observationTokensKey = malloc(1)! -#endif diff --git a/Sources/AppKitNavigation/Bindings/UIPageControl.swift b/Sources/AppKitNavigation/Bindings/UIPageControl.swift deleted file mode 100644 index 626798fe36..0000000000 --- a/Sources/AppKitNavigation/Bindings/UIPageControl.swift +++ /dev/null @@ -1,28 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) - import UIKit - - @available(iOS 14, tvOS 14, *) - extension UIPageControl { - /// Creates a new page control with the specified frame and registers the binding against the - /// current page. - /// - /// - Parameters: - /// - frame: The frame rectangle for the view, measured in points. - /// - currentPage: The binding to read from for the current page, and write to when the - /// current page changes. - public convenience init(frame: CGRect = .zero, currentPage: UIBinding) { - self.init(frame: frame) - bind(currentPage: currentPage) - } - - /// Establishes a two-way connection between a binding and the page control's current page. - /// - /// - Parameter currentPage: The binding to read from for the current page, and write to when - /// the current page changes. - /// - Returns: A cancel token. - @discardableResult - public func bind(currentPage: UIBinding) -> ObservationToken { - bind(currentPage, to: \.currentPage, for: .valueChanged) - } - } -#endif diff --git a/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift b/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift deleted file mode 100644 index 5c2c9a86a6..0000000000 --- a/Sources/AppKitNavigation/Bindings/UISegmentedControl.swift +++ /dev/null @@ -1,81 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) - import IssueReporting - import UIKit - - @available(iOS 14, tvOS 14, *) - extension UISegmentedControl { - /// Creates a new color well with the specified frame and registers the binding against the - /// selected color. - /// - /// - Parameters: - /// - frame: The frame rectangle for the view, measured in points. - /// - selectedSegment: The binding to read from for the selected color, and write to when the - /// selected color is changes. - public convenience init( - frame: CGRect = .zero, selectedSegment: UIBinding>, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - self.init(frame: frame) - bind( - selectedSegment: selectedSegment, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - } - - /// Establishes a two-way connection between a binding and the color well's selected color. - /// - /// - Parameter selectedSegment: The binding to read from for the selected color, and write to - /// when the selected color changes. - /// - Returns: A cancel token. - @discardableResult - public func bind( - selectedSegment: UIBinding>, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> ObservationToken { - let fileID = HashableStaticString(rawValue: fileID) - let filePath = HashableStaticString(rawValue: filePath) - return bind( - selectedSegment[fileID: fileID, filePath: filePath, line: line, column: column], - to: \.selectedSegmentIndex, - for: .valueChanged - ) - } - } - - extension RawRepresentable { - fileprivate subscript( - fileID fileID: HashableStaticString, - filePath filePath: HashableStaticString, - line line: UInt, - column column: UInt - ) -> Int { - get { rawValue } - set { - guard let rawRepresentable = Self(rawValue: newValue) - else { - reportIssue( - """ - Raw-representable 'UIBinding<\(Self.self)>' attempted to write an invalid raw value \ - ('\(newValue)'). - """, - fileID: fileID.rawValue, - filePath: filePath.rawValue, - line: line, - column: column - ) - return - } - self = rawRepresentable - } - } - } -#endif diff --git a/Sources/AppKitNavigation/Bindings/UITabBarController.swift b/Sources/AppKitNavigation/Bindings/UITabBarController.swift deleted file mode 100644 index 7b9e6351a1..0000000000 --- a/Sources/AppKitNavigation/Bindings/UITabBarController.swift +++ /dev/null @@ -1,65 +0,0 @@ -#if swift(>=6) && canImport(UIKit) && !os(tvOS) && !os(watchOS) - import IssueReporting - import UIKit - - @available(iOS 18, tvOS 18, visionOS 2, *) - extension UITabBarController { - @discardableResult - public func bind( - selectedTab: UIBinding, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) -> ObservationToken { - let token = observe { [weak self] in - guard let self else { return } - guard let identifier = selectedTab.wrappedValue else { - self.selectedTab = nil - return - } - guard let tab = tabs.first(where: { $0.identifier == identifier }) - else { - reportIssue( - """ - Tab bar controller binding attempted to write an invalid identifier ('\(identifier)'). - - Valid identifiers: \(tabs.map(\.identifier)) - """, - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - self.selectedTab = nil - return - } - self.selectedTab = tab - } - let observation = observe(\.selectedTab) { controller, _ in - MainActor.assumeIsolated { - selectedTab.wrappedValue = controller.selectedTab?.identifier - } - } - let observationToken = ObservationToken { - token.cancel() - observation.invalidate() - } - self.observationToken = observationToken - return observationToken - } - - private var observationToken: ObservationToken? { - get { - objc_getAssociatedObject(self, Self.observationTokenKey) as? ObservationToken - } - set { - objc_setAssociatedObject( - self, Self.observationTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - private static let observationTokenKey = malloc(1)! - } -#endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 6952a998b6..bc9c377ea6 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -1,10 +1,10 @@ -#if canImport(UIKit) && !os(watchOS) - import IssueReporting - @_spi(Internals) import SwiftNavigation - import UIKit - import UIKitNavigationShim +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import IssueReporting +@_spi(Internals) import SwiftNavigation +import AppKit +import AppKitNavigationShim - extension UIViewController { +extension NSViewController { /// Presents a view controller modally when a binding to a Boolean value you provide is true. /// /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. @@ -17,11 +17,12 @@ /// controller's content. @discardableResult public func present( - isPresented: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> UIViewController + isPresented: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController ) -> ObservationToken { - present(item: isPresented.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } /// Presents a view controller modally using the given item as a data source for its content. @@ -39,11 +40,12 @@ /// controller's content. @discardableResult public func present( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> UIViewController + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController ) -> ObservationToken { - present(item: item, id: \.id, onDismiss: onDismiss, content: content) + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } /// Presents a view controller modally using the given item as a data source for its content. @@ -62,11 +64,12 @@ @_disfavoredOverload @discardableResult public func present( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> UIViewController + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController ) -> ObservationToken { - present(item: item, id: \.id, onDismiss: onDismiss, content: content) + present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } /// Presents a view controller modally using the given item as a data source for its content. @@ -85,14 +88,15 @@ /// controller's content. @discardableResult public func present( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> UIViewController + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController ) -> ObservationToken { - present(item: item, id: id, onDismiss: onDismiss) { - content($0.wrappedValue) - } + present(item: item, id: id, style: style, onDismiss: onDismiss) { + content($0.wrappedValue) + } } /// Presents a view controller modally using the given item as a data source for its content. @@ -112,117 +116,49 @@ @_disfavoredOverload @discardableResult public func present( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> UIViewController + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController ) -> ObservationToken { - destination(item: item, id: id) { $item in - content($item) - } present: { [weak self] child, transaction in - guard let self else { return } - if presentedViewController != nil { - self.dismiss(animated: !transaction.uiKit.disablesAnimations) { + destination(item: item, id: id) { $item in + content($item) + } present: { [weak self] child, transaction in + guard let self else { return } + if presentedViewControllers != nil { + self.dismiss(nil) + onDismiss?() + self.present(child, for: style) + + } else { + self.present(child, for: style) + } + } dismiss: { [weak self] _, transaction in + self?.dismiss(nil) onDismiss?() - self.present(child, animated: !transaction.uiKit.disablesAnimations) - } - } else { - self.present(child, animated: !transaction.uiKit.disablesAnimations) } - } dismiss: { [weak self] _, transaction in - self?.dismiss(animated: !transaction.uiKit.disablesAnimations) { - onDismiss?() - } - } - } - - /// Pushes a view controller onto the receiver's stack when a binding to a Boolean value you - /// provide is true. - /// - /// Like SwiftUI's `navigationDestination(isPresented:)` view modifier, but for UIKit. - /// - /// - Parameters: - /// - isPresented: A binding to a Boolean value that determines whether to push the view - /// controller. - /// - content: A closure that returns the view controller to display onto the receiver's - /// stack. - @discardableResult - public func navigationDestination( - isPresented: UIBinding, - content: @escaping () -> UIViewController - ) -> ObservationToken { - navigationDestination(item: isPresented.toOptionalUnit) { _ in content() } } - /// Pushes a view controller onto the receiver's stack using the given item as a data source for - /// its content. - /// - /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the view controller. When `item` is - /// non-`nil`, the item's content is passed to the `content` closure. You display this - /// content in a view controller that you create that is displayed to the user. - /// - content: A closure that returns the view controller to display onto the receiver's - /// stack. - @discardableResult - public func navigationDestination( - item: UIBinding, - content: @escaping (Item) -> UIViewController - ) -> ObservationToken { - navigationDestination(item: item) { - content($0.wrappedValue) - } + + public enum TransitionStyle { + case sheet + case modalWindow + case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) + case custom(NSViewControllerPresentationAnimator) } - /// Pushes a view controller onto the receiver's stack using the given item as a data source for - /// its content. - /// - /// Like SwiftUI's `navigationDestination(item:)` view modifier, but for UIKit. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the view controller. When `item` is - /// non-`nil`, the item's content is passed to the `content` closure. You display this - /// content in a view controller that you create that is displayed to the user. - /// - content: A closure that returns the view controller to display onto the receiver's - /// stack. - @_disfavoredOverload - @discardableResult - public func navigationDestination( - item: UIBinding, - content: @escaping (UIBinding) -> UIViewController - ) -> ObservationToken { - destination(item: item) { $item in - content($item) - } present: { [weak self] child, transaction in - guard - let navigationController = self?.navigationController ?? self as? UINavigationController - else { - reportIssue( - """ - Can't present navigation item: "navigationController" is "nil". - """ - ) - return + fileprivate func present(_ viewControllerToPresent: NSViewController, for style: TransitionStyle) { + switch style { + case .sheet: + presentAsSheet(viewControllerToPresent) + case .modalWindow: + presentAsModalWindow(viewControllerToPresent) + case .popover(let rect, let view, let preferredEdge, let behavior): + present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) + case .custom(let animator): + present(viewControllerToPresent, animator: animator) } - navigationController.pushViewController( - child, animated: !transaction.uiKit.disablesAnimations - ) - } dismiss: { [weak self] child, transaction in - guard - let navigationController = self?.navigationController ?? self as? UINavigationController - else { - reportIssue( - """ - Can't dismiss navigation item: "navigationController" is "nil". - """ - ) - return - } - navigationController.popFromViewController( - child, animated: !transaction.uiKit.disablesAnimations - ) - } } /// Presents a view controller when a binding to a Boolean value you provide is true. @@ -239,20 +175,20 @@ /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult public func destination( - isPresented: UIBinding, - content: @escaping () -> UIViewController, - present: @escaping (UIViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + isPresented: UIBinding, + content: @escaping () -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - destination( - item: isPresented.toOptionalUnit, - content: { _ in content() }, - present: present, - dismiss: dismiss - ) + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) } /// Presents a view controller using the given item as a data source for its content. @@ -269,21 +205,21 @@ /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult public func destination( - item: UIBinding, - content: @escaping (UIBinding) -> UIViewController, - present: @escaping (UIViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + item: UIBinding, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - destination( - item: item, - id: { _ in nil }, - content: content, - present: present, - dismiss: dismiss - ) + destination( + item: item, + id: { _ in nil }, + content: content, + present: present, + dismiss: dismiss + ) } /// Presents a view controller using the given item as a data source for its content. @@ -303,161 +239,112 @@ /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult public func destination( - item: UIBinding, - id: KeyPath, - content: @escaping (UIBinding) -> UIViewController, - present: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - destination( - item: item, - id: { $0[keyPath: id] }, - content: content, - present: present, - dismiss: dismiss - ) + destination( + item: item, + id: { $0[keyPath: id] }, + content: content, + present: present, + dismiss: dismiss + ) } private func destination( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> UIViewController, - present: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: UIViewController, - _ transaction: UITransaction - ) -> Void + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = presentedByID[key] { - guard let presentationID = presented.presentationID, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let childController = content(unwrappedItem) - let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) - { - item.wrappedValue = nil + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = presentedByID[key] { + guard let presentationID = presented.presentationID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let childController = content(unwrappedItem) + let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + childController.onDismiss = onDismiss + + self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + present(childController, transaction) + } + } + if hasViewAppeared { + work() + } else { + onViewAppear.append(work) + } + } else if let presented = presentedByID[key] { + if let controller = presented.controller { + dismiss(controller, transaction) + } + self.presentedByID[key] = nil } - } - childController.onDismiss = onDismiss - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - childController.traitOverrides.dismiss = UIDismissAction { _ in - onDismiss() - } - } - self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - present(childController, transaction) - } - } - if hasViewAppeared { - work() - } else { - onViewAppear.append(work) - } - } else if let presented = presentedByID[key] { - if let controller = presented.controller { - dismiss(controller, transaction) - } - self.presentedByID[key] = nil } - } } fileprivate var presentedByID: [UIBindingIdentifier: Presented] { - get { - (objc_getAssociatedObject(self, Self.presentedKey) - as? [UIBindingIdentifier: Presented]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } + get { + (objc_getAssociatedObject(self, Self.presentedKey) + as? [UIBindingIdentifier: Presented]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } } private static let presentedKey = malloc(1)! - } +} - extension UINavigationController { - @available( - *, deprecated, - message: """ - Use 'self.navigationDestination(isPresented:)' instead of 'self.navigationController?.pushViewController(isPresented:)'. - """ - ) - @discardableResult - public func pushViewController( - isPresented: UIBinding, - content: @escaping () -> UIViewController - ) -> ObservationToken { - navigationDestination(isPresented: isPresented, content: content) - } - - @available( - *, deprecated, - message: """ - Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. - """ - ) - @discardableResult - public func pushViewController( - item: UIBinding, - content: @escaping (Item) -> UIViewController - ) -> ObservationToken { - navigationDestination(item: item, content: content) - } - - @available( - *, deprecated, - message: """ - Use 'self.navigationDestination(item:)' instead of 'self.navigationController?.pushViewController(item:)'. - """ - ) - @_disfavoredOverload - @discardableResult - public func pushViewController( - item: UIBinding, - content: @escaping (UIBinding) -> UIViewController - ) -> ObservationToken { - navigationDestination(item: item, content: content) - } - } - - @MainActor - private class Presented { - weak var controller: UIViewController? +@MainActor +private class Presented { + weak var controller: NSViewController? let presentationID: AnyHashable? deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - self.controller?.dismiss(animated: false) - } + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + self.controller?.dismiss(nil) + } } - init(_ controller: UIViewController, id presentationID: AnyHashable? = nil) { - self.controller = controller - self.presentationID = presentationID + + init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { + self.controller = controller + self.presentationID = presentationID } - } +} #endif diff --git a/Sources/AppKitNavigation/Navigation/UIAlertController.swift b/Sources/AppKitNavigation/Navigation/UIAlertController.swift index 0151edcdf4..ba24c57eed 100644 --- a/Sources/AppKitNavigation/Navigation/UIAlertController.swift +++ b/Sources/AppKitNavigation/Navigation/UIAlertController.swift @@ -1,87 +1,83 @@ -#if canImport(UIKit) && !os(watchOS) - import UIKit - - @available(iOS 13, *) - @available(macCatalyst 13, *) - @available(macOS, unavailable) - @available(tvOS 13, *) - @available(watchOS, unavailable) - extension UIAlertController { - /// Creates and returns a view controller for displaying an alert using a data description. - /// - /// - Parameters: - /// - state: A data description of the alert. - /// - handler: A closure that is invoked with an action held in `state`. - public convenience init( - state: AlertState, - handler: @escaping (_ action: Action?) -> Void - ) { - self.init( - title: String(state: state.title), - message: state.message.map { String(state: $0) }, - preferredStyle: .alert - ) - for button in state.buttons { - addAction(UIAlertAction(button, action: handler)) - } - } - - /// Creates and returns a view controller for displaying an action sheet using a data - /// description. - /// - /// - Parameters: - /// - state: A data description of the alert. - /// - handler: A closure that is invoked with an action held in `state`. - public convenience init( - state: ConfirmationDialogState, - handler: @escaping (_ action: Action?) -> Void - ) { - self.init( - title: state.titleVisibility == .visible ? String(state: state.title) : nil, - message: state.message.map { String(state: $0) }, - preferredStyle: .actionSheet - ) - for button in state.buttons { - addAction(UIAlertAction(button, action: handler)) - } - } - } - - @available(iOS 13, *) - @available(macCatalyst 13, *) - @available(macOS, unavailable) - @available(tvOS 13, *) - @available(watchOS, unavailable) - extension UIAlertAction.Style { - public init(_ role: ButtonStateRole) { - switch role { - case .cancel: - self = .cancel - case .destructive: - self = .destructive - } - } - } - - @available(iOS 13, *) - @available(macCatalyst 13, *) - @available(macOS, unavailable) - @available(tvOS 13, *) - @available(watchOS, unavailable) - extension UIAlertAction { - public convenience init( - _ button: ButtonState, - action handler: @escaping (_ action: Action?) -> Void - ) { - self.init( - title: String(state: button.label), - style: button.role.map(UIAlertAction.Style.init) ?? .default - ) { _ in - button.withAction(handler) - } - if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { - self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } - } - } - } +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +// import AppKit +// +// +// extension NSAlert { +// /// Creates and returns a view controller for displaying an alert using a data description. +// /// +// /// - Parameters: +// /// - state: A data description of the alert. +// /// - handler: A closure that is invoked with an action held in `state`. +// public convenience init( +// state: AlertState, +// handler: @escaping (_ action: Action?) -> Void +// ) { +// self.init( +// title: String(state: state.title), +// message: state.message.map { String(state: $0) }, +// preferredStyle: .alert +// ) +// for button in state.buttons { +// addAction(UIAlertAction(button, action: handler)) +// } +// } +// +// /// Creates and returns a view controller for displaying an action sheet using a data +// /// description. +// /// +// /// - Parameters: +// /// - state: A data description of the alert. +// /// - handler: A closure that is invoked with an action held in `state`. +// public convenience init( +// state: ConfirmationDialogState, +// handler: @escaping (_ action: Action?) -> Void +// ) { +// self.init( +// title: state.titleVisibility == .visible ? String(state: state.title) : nil, +// message: state.message.map { String(state: $0) }, +// preferredStyle: .actionSheet +// ) +// for button in state.buttons { +// addAction(UIAlertAction(button, action: handler)) +// } +// } +// } +// +// @available(iOS 13, *) +// @available(macCatalyst 13, *) +// @available(macOS, unavailable) +// @available(tvOS 13, *) +// @available(watchOS, unavailable) +// extension UIAlertAction.Style { +// public init(_ role: ButtonStateRole) { +// switch role { +// case .cancel: +// self = .cancel +// case .destructive: +// self = .destructive +// } +// } +// } +// +// @available(iOS 13, *) +// @available(macCatalyst 13, *) +// @available(macOS, unavailable) +// @available(tvOS 13, *) +// @available(watchOS, unavailable) +// extension UIAlertAction { +// public convenience init( +// _ button: ButtonState, +// action handler: @escaping (_ action: Action?) -> Void +// ) { +// self.init( +// title: String(state: button.label), +// style: button.role.map(UIAlertAction.Style.init) ?? .default +// ) { _ in +// button.withAction(handler) +// } +// if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { +// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } +// } +// } +// } #endif diff --git a/Sources/AppKitNavigation/UIKitAnimation.swift b/Sources/AppKitNavigation/UIKitAnimation.swift deleted file mode 100644 index 7e5106edc5..0000000000 --- a/Sources/AppKitNavigation/UIKitAnimation.swift +++ /dev/null @@ -1,144 +0,0 @@ -#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): - #if swift(>=6) - if #available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) { - var result: Swift.Result? - UIView.animate( - with: animation, - changes: { result = Swift.Result(catching: body) }, - completion: completion.map { completion in { 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) - - 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. - /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. - /// Specify a value of `0` to begin the animations immediately. - /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent - /// state. - /// - /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a - /// damping ratio closer to zero to increase oscillation. - /// - velocity: The initial spring velocity. For smooth start to the animation, match this - /// value to the view's velocity as it was prior to attachment. - /// - /// A value of `1` corresponds to the total animation distance traversed in one second. For - /// example, if the total animation distance is 200 points and you want the start of the - /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. - /// - options: A mask of options indicating how you want to perform the animations. For a list - /// of valid constants, see `UIView.AnimationOptions`. - /// - 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 - ) - ) - ) - } - - - /// Animates changes using the specified SwiftUI animation. - /// - /// - Parameter animation: The animation to use for the changes. - @available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) - public init(_ animation: Animation) { - self.init(framework: .swiftUI(animation)) - } - - /// A default animation instance. - public static var `default`: Self { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - return .animate() - } else { - return .animate(withDuration: 0.35) - } - } - } -#endif From 4611a2a737d6570a3db3c34fa34021baaf1a7cb0 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:14:31 +0800 Subject: [PATCH 03/34] Updates --- ...Kit+MinimalObservationViewController.swift | 78 +++++++++++ .../NSControlBindingsViewController.swift | 126 ++++++++++++++++++ Examples/CaseStudies/Internal/CaseStudy.swift | 24 +++- .../CaseStudies/Internal/DetentsHelper.swift | 2 + Examples/CaseStudies/RootView.swift | 2 + .../CaseStudies/SwiftUI/EnumNavigation.swift | 4 +- .../SwiftUI/OptionalNavigation.swift | 4 +- .../UIKit/AnimationsViewController.swift | 2 + .../BasicsNavigationViewController.swift | 2 + .../ConciseEnumNavigationViewController.swift | 2 + .../UIKit/EnumControlsViewController.swift | 2 + .../ErasedNavigationStackController.swift | 2 + .../UIKit/FocusViewController.swift | 2 + .../MinimalObservationViewController.swift | 2 + .../StaticNavigationStackController.swift | 2 + .../UIControlBindingsViewController.swift | 2 + .../CaseStudies/UIKit/UIKitCaseStudies.swift | 2 + .../WiFiFeature/ConnectToNetworkFeature.swift | 2 + .../WiFiFeature/NetworkDetailFeature.swift | 2 + .../WiFiFeature/WiFiSettingsFeature.swift | 2 + .../CaseStudiesTests/Internal/SetUp.swift | 2 + .../NavigationPathTests.swift | 2 + .../NavigationStackTests.swift | 2 + .../CaseStudiesTests/PresentationTests.swift | 2 + .../RuntimeWarningTests.swift | 2 + Examples/Examples.xcodeproj/project.pbxproj | 33 +++++ .../Bindings/NSFontManager.swift | 6 +- .../AppKitNavigation/Bindings/NSSwitch.swift | 2 +- .../Bindings/NSTargetAction.swift | 27 +++- .../Bindings/NSTextField.swift | 16 ++- .../Internal/AssumeIsolated.swift | 48 +++---- .../Internal/ErrorMechanism.swift | 18 +-- .../AppKitNavigation/Internal/Exports.swift | 2 +- .../Internal/ToOptionalUnit.swift | 10 +- .../AppKitNavigation/Navigation/NSAlert.swift | 48 +++++++ .../Navigation/UIAlertController.swift | 83 ------------ Sources/AppKitNavigation/Observe.swift | 7 +- 37 files changed, 423 insertions(+), 153 deletions(-) create mode 100644 Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift create mode 100644 Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift create mode 100644 Sources/AppKitNavigation/Navigation/NSAlert.swift delete mode 100644 Sources/AppKitNavigation/Navigation/UIAlertController.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift new file mode 100644 index 0000000000..9fcc665639 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift @@ -0,0 +1,78 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import AppKitNavigation +import ConcurrencyExtras + +class MinimalObservationViewController: NSViewController, AppKitCaseStudy { + let caseStudyTitle = "Minimal observation" + let readMe = """ + This case study demonstrates how to use the 'observe' tool from the library in order to \ + minimally observe changes to an @Observable model. + + To see this, tap the "Increment" button to see that the view re-renders each time you count \ + up. Then, hide the counter and increment again to see that the view does not re-render, even \ + though the count is changing. This shows that only the state accessed inside the trailing \ + closure of 'observe' causes re-renders. + """ + @UIBindable var model = Model() + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + + let countLabel = NSTextField(labelWithString: "") + let incrementButton = NSButton(title: "Increment", target: self, action: #selector(incrementButtonAction(_:))) + let isCountHiddenSwitch = NSSwitch(isOn: $model.isCountHidden) + let isCountHiddenLabel = NSTextField(labelWithString: "Is count hidden?") + let viewRenderLabel = NSTextField(labelWithString: "") + let stack = NSStackView(views: [ + countLabel, + incrementButton, + isCountHiddenLabel, + isCountHiddenSwitch, + viewRenderLabel, + ]) + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + let viewRenderCount = LockIsolated(0) + observe { [weak self] in + guard let self else { return } + viewRenderCount.withValue { $0 += 1 } + + if !model.isCountHidden { + // NB: We do not access 'model.count' when the count is hidden, and therefore its mutations + // will not cause a re-render of the view. + countLabel.stringValue = model.count.description + } + countLabel.isHidden = model.isCountHidden + viewRenderLabel.stringValue = "# of view renders: \(viewRenderCount.value)" + } + } + + @objc func incrementButtonAction(_ sender: NSButton) { + model.count += 1 + } + + @Observable + class Model { + var count = 0 + var isCountHidden = false + } +} + +#Preview { + MinimalObservationViewController() +} + +#endif diff --git a/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift b/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift new file mode 100644 index 0000000000..fbecc6031d --- /dev/null +++ b/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift @@ -0,0 +1,126 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import AppKitNavigation + +class XiblessViewController: NSViewController { + lazy var contentView = View() + + override func loadView() { + view = contentView + } +} + +class NSControlBindingsViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "NSControl bindings" + let readMe = """ + This demonstrates how to use the library's `@UIBinding` and `@UIBindable` property wrappers \ + to bind an observable model to various `NSControl`s. For the most part it works exactly \ + as it does in SwiftUI. Just use `$model` to construct an object that can derive bindings, \ + and then use simple dot-syntax to decide which field you want to derive a binding for. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + contentView.boxType = .custom + contentView.borderWidth = 0 + let colorWell = NSColorWell(color: $model.color) + let datePicker = NSDatePicker(date: $model.date) + let segmentControl = NSSegmentedControl(labels: Model.Segment.allCases.map { "\($0)" }, trackingMode: .selectOne, target: nil, action: nil) + segmentControl.bind(selectedSegment: $model.segment) + let slider = NSSlider(value: $model.sliderValue) + let stepper = NSStepper(value: $model.stepperValue) + let `switch` = NSSwitch(isOn: $model.isOn) + let textField = NSTextField(text: $model.text) + textField.bind(focus: $model.focus, equals: .text) + textField.bind(selection: $model.textSelection) + textField.bezelStyle = .roundedBezel + let label = NSTextField(labelWithString: "") + label.maximumNumberOfLines = 0 + let stack = NSStackView(views: [ + colorWell, + datePicker, + segmentControl, + slider, + stepper, + `switch`, + textField, + label, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.contentView?.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + colorWell.heightAnchor.constraint(equalToConstant: 50), + ]) + + observe { [weak self] in + guard let self else { return } + + contentView.fillColor = model.color + + let textSelection = model.textSelection.map { + self.model.text.range(for: $0.range) + } + + label.stringValue = """ + MyModel( + color: \(model.color.description), + date: \(model.date), + focus: \(model.focus.map(String.init(describing:)) ?? "nil"), + isOn: \(model.isOn), + segment: \(model.segment), + sliderValue: \(model.sliderValue), + stepperValue: \(model.stepperValue), + text: \(model.text.debugDescription), + textSelection: \(textSelection.map(String.init(describing:)) ?? "nil") + ) + """ + } + } + + @MainActor + @Observable + final class Model: HashableObject { + var color: NSColor = .windowBackgroundColor + var date = Date() + var focus: Focus? + var isOn = false + var segment = Segment.columnA + var sliderValue: Float = 0.5 + var stepperValue: Double = 5 + var text = "Blob" + var textSelection: AppKitTextSelection? + + enum Focus: Hashable { + case attributedText + case text + } + + enum Segment: Int, CaseIterable { + case columnA + case columnB + } + } +} + +extension String { + fileprivate func range(for range: Range) -> Range { + distance( + from: startIndex, to: range.lowerBound + ) ..< distance(from: startIndex, to: range.upperBound) + } +} + + +#Preview(traits: .fixedLayout(width: 500, height: 800)) { + NSControlBindingsViewController() +} + + +#endif diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index 2f2a60cffe..9f7a8d1167 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -9,7 +9,18 @@ protocol CaseStudy { var isPresentedInSheet: Bool { get } } protocol SwiftUICaseStudy: CaseStudy, View {} +#if canImport(UIKit) && !os(watchOS) protocol UIKitCaseStudy: CaseStudy, UIViewController {} +extension UIKitCaseStudy { + var usesOwnLayout: Bool { true } +} +#endif +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +protocol AppKitCaseStudy: CaseStudy, NSViewController {} +extension AppKitCaseStudy { + var usesOwnLayout: Bool { true } +} +#endif extension CaseStudy { var caseStudyNavigationTitle: String { caseStudyTitle } @@ -18,9 +29,7 @@ extension CaseStudy { extension SwiftUICaseStudy { var usesOwnLayout: Bool { false } } -extension UIKitCaseStudy { - var usesOwnLayout: Bool { true } -} + @resultBuilder @MainActor @@ -31,10 +40,12 @@ enum CaseStudyViewBuilder { static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { SwiftUICaseStudyButton(caseStudy: caseStudy) } +#if canImport(UIKit) && !os(watchOS) @ViewBuilder static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { UIKitCaseStudyButton(caseStudy: caseStudy) } + #endif static func buildPartialBlock(first: some View) -> some View { first } @@ -69,7 +80,7 @@ struct SwiftUICaseStudyButton: View { } } } - +#if canImport(UIKit) && !os(watchOS) struct UIKitCaseStudyButton: View { let caseStudy: C @State var isPresented = false @@ -116,7 +127,7 @@ extension UINavigationController { return self } } - +#endif struct CaseStudyModifier: ViewModifier { let caseStudy: C @State var isAboutPresented = false @@ -209,7 +220,7 @@ private struct DemoCaseStudy: SwiftUICaseStudy { Text("Hello!") } } - +#if canImport(UIKit) && !os(watchOS) private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { let caseStudyTitle = "Demo Case Study" let readMe = """ @@ -218,3 +229,4 @@ private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { Enjoy! """ } +#endif diff --git a/Examples/CaseStudies/Internal/DetentsHelper.swift b/Examples/CaseStudies/Internal/DetentsHelper.swift index 6f9ed41449..a782d5c297 100644 --- a/Examples/CaseStudies/Internal/DetentsHelper.swift +++ b/Examples/CaseStudies/Internal/DetentsHelper.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit extension UIViewController { @@ -10,3 +11,4 @@ extension UIViewController { } } } +#endif diff --git a/Examples/CaseStudies/RootView.swift b/Examples/CaseStudies/RootView.swift index 81287338db..d552ddf930 100644 --- a/Examples/CaseStudies/RootView.swift +++ b/Examples/CaseStudies/RootView.swift @@ -8,9 +8,11 @@ struct RootView: View { NavigationLink("SwiftUI") { SwiftUICaseStudiesView() } +#if canImport(UIKit) && !os(watchOS) NavigationLink("UIKit") { UIKitCaseStudiesView() } +#endif } .navigationTitle("Case studies") } diff --git a/Examples/CaseStudies/SwiftUI/EnumNavigation.swift b/Examples/CaseStudies/SwiftUI/EnumNavigation.swift index c3769345df..9e5750bee4 100644 --- a/Examples/CaseStudies/SwiftUI/EnumNavigation.swift +++ b/Examples/CaseStudies/SwiftUI/EnumNavigation.swift @@ -70,7 +70,7 @@ struct EnumNavigation: SwiftUICaseStudy { .navigationTitle("Sheet with payload") .presentationDetents([.medium]) } - +#if canImport(UIKit) && !os(watchOS) Button("Cover is presented: \(destination.is(\.fullScreenCover) ? "✅" : "❌")") { destination = .fullScreenCover(.random(in: 1...1_000)) } @@ -92,7 +92,7 @@ struct EnumNavigation: SwiftUICaseStudy { } } } - + #endif Button("Popover is presented: \(destination.is(\.popover) ? "✅" : "❌")") { destination = .popover(.random(in: 1...1_000)) } diff --git a/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift b/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift index 1d6b0160ac..2378d76283 100644 --- a/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift +++ b/Examples/CaseStudies/SwiftUI/OptionalNavigation.swift @@ -57,7 +57,7 @@ struct OptionalNavigation: SwiftUICaseStudy { .navigationTitle("Sheet") .presentationDetents([.medium]) } - +#if canImport(UIKit) && !os(watchOS) Button("Cover is presented: \(fullScreenCover != nil ? "✅" : "❌")") { fullScreenCover = .random(in: 1...1_000) } @@ -79,7 +79,7 @@ struct OptionalNavigation: SwiftUICaseStudy { } } } - + #endif Button("Popover is presented: \(popover != nil ? "✅" : "❌")") { popover = .random(in: 1...1_000) } diff --git a/Examples/CaseStudies/UIKit/AnimationsViewController.swift b/Examples/CaseStudies/UIKit/AnimationsViewController.swift index d06f6c9116..664a35855c 100644 --- a/Examples/CaseStudies/UIKit/AnimationsViewController.swift +++ b/Examples/CaseStudies/UIKit/AnimationsViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -89,3 +90,4 @@ class AnimationsViewController: UIViewController, UIKitCaseStudy { #Preview { AnimationsViewController() } +#endif diff --git a/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift b/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift index 6df4ef23ad..85433562c2 100644 --- a/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/UIKit/BasicsNavigationViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -119,3 +120,4 @@ class BasicsNavigationViewController: UIViewController, UIKitCaseStudy { rootViewController: BasicsNavigationViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift index 6b022f528d..c66bdb9d31 100644 --- a/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift +++ b/Examples/CaseStudies/UIKit/ConciseEnumNavigationViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -134,3 +135,4 @@ class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { rootViewController: BasicsNavigationViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/EnumControlsViewController.swift b/Examples/CaseStudies/UIKit/EnumControlsViewController.swift index 0348a3e8c4..26919741aa 100644 --- a/Examples/CaseStudies/UIKit/EnumControlsViewController.swift +++ b/Examples/CaseStudies/UIKit/EnumControlsViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit import UIKitNavigation @@ -111,3 +112,4 @@ extension Int { set { self = Int(newValue) } } } +#endif diff --git a/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift b/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift index c77aae3e3d..d7bb43b6d8 100644 --- a/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift +++ b/Examples/CaseStudies/UIKit/ErasedNavigationStackController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -258,3 +259,4 @@ private class BoolFeatureViewController: UIViewController { #Preview { ErasedNavigationStackController(model: ErasedNavigationStackController.Model()) } +#endif diff --git a/Examples/CaseStudies/UIKit/FocusViewController.swift b/Examples/CaseStudies/UIKit/FocusViewController.swift index 8e6d4cb59d..20243756d6 100644 --- a/Examples/CaseStudies/UIKit/FocusViewController.swift +++ b/Examples/CaseStudies/UIKit/FocusViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -130,3 +131,4 @@ class FocusViewController: UIViewController, UIKitCaseStudy { #Preview { FocusViewController() } +#endif diff --git a/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift b/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift index 887eebf62e..99655a4a7e 100644 --- a/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift +++ b/Examples/CaseStudies/UIKit/MinimalObservationViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import ConcurrencyExtras import UIKit import UIKitNavigation @@ -76,3 +77,4 @@ class MinimalObservationViewController: UIViewController, UIKitCaseStudy { rootViewController: MinimalObservationViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift b/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift index 54128d9a25..ed5d72b896 100644 --- a/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift +++ b/Examples/CaseStudies/UIKit/StaticNavigationStackController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKit import UIKitNavigation @@ -172,3 +173,4 @@ private class FeatureViewController: UIViewController { #Preview { StaticNavigationStackController() } +#endif diff --git a/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift b/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift index f9a2fe907c..9995dd4c0b 100644 --- a/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift +++ b/Examples/CaseStudies/UIKit/UIControlBindingsViewController.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit import UIKitNavigation @@ -127,3 +128,4 @@ extension String { rootViewController: UIControlBindingsViewController() ) } +#endif diff --git a/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift b/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift index f4b47e72a1..6751d3439e 100644 --- a/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift +++ b/Examples/CaseStudies/UIKit/UIKitCaseStudies.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import SwiftUINavigation @@ -38,3 +39,4 @@ struct UIKitCaseStudiesView: View { UIKitCaseStudiesView() } } +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift index fee0ef6472..fe305a2748 100644 --- a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift +++ b/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKitNavigation import XCTestDynamicOverlay @@ -109,3 +110,4 @@ final class ConnectToNetworkViewController: UIViewController { ) } } +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift index 3255e01d04..124b64124c 100644 --- a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift +++ b/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKitNavigation import XCTestDynamicOverlay @@ -97,3 +98,4 @@ final class NetworkDetailViewController: UIViewController { ) } } +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift index 3fb9c068c7..a025d7855e 100644 --- a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift +++ b/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import SwiftUI import UIKitNavigation @@ -309,3 +310,4 @@ extension NSDiffableDataSourceSnapshot< } } } +#endif diff --git a/Examples/CaseStudiesTests/Internal/SetUp.swift b/Examples/CaseStudiesTests/Internal/SetUp.swift index 8eb7a5a9fc..432aacbfb1 100644 --- a/Examples/CaseStudiesTests/Internal/SetUp.swift +++ b/Examples/CaseStudiesTests/Internal/SetUp.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKit import XCTest @@ -16,3 +17,4 @@ extension XCTestCase { try await Task.sleep(for: .milliseconds(100)) } } +#endif diff --git a/Examples/CaseStudiesTests/NavigationPathTests.swift b/Examples/CaseStudiesTests/NavigationPathTests.swift index f9604841a4..6014c6ce09 100644 --- a/Examples/CaseStudiesTests/NavigationPathTests.swift +++ b/Examples/CaseStudiesTests/NavigationPathTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) @_spi(Internals) import SwiftNavigation import UIKitNavigation import XCTest @@ -644,3 +645,4 @@ private struct User: Hashable, Codable { static let mangledTypeName = "21UIKitCaseStudiesTests014NavigationPathD0C10$10685e7e0yXZ10$10685e7ecyXZ4UserV" } +#endif diff --git a/Examples/CaseStudiesTests/NavigationStackTests.swift b/Examples/CaseStudiesTests/NavigationStackTests.swift index e504e94c33..d4a9c05884 100644 --- a/Examples/CaseStudiesTests/NavigationStackTests.swift +++ b/Examples/CaseStudiesTests/NavigationStackTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import XCTest @@ -316,3 +317,4 @@ private final class ChildViewController: UIViewController { } } } +#endif diff --git a/Examples/CaseStudiesTests/PresentationTests.swift b/Examples/CaseStudiesTests/PresentationTests.swift index d318c35552..ff453ba76a 100644 --- a/Examples/CaseStudiesTests/PresentationTests.swift +++ b/Examples/CaseStudiesTests/PresentationTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import XCTest @@ -496,3 +497,4 @@ private class BasicViewController: UIViewController { } } } +#endif diff --git a/Examples/CaseStudiesTests/RuntimeWarningTests.swift b/Examples/CaseStudiesTests/RuntimeWarningTests.swift index 0730982b3c..7d80bb2bad 100644 --- a/Examples/CaseStudiesTests/RuntimeWarningTests.swift +++ b/Examples/CaseStudiesTests/RuntimeWarningTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import XCTest @@ -142,3 +143,4 @@ final class RuntimeWarningTests: XCTestCase { try await setUp(controller: vc) } } +#endif diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 46455e484b..9a7069ae89 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -52,6 +52,9 @@ CADCA3662C1CE8BE00DE645F /* CaseStudy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CADCA3652C1CE8BE00DE645F /* CaseStudy.swift */; }; DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */; }; DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC86E8702C208D8A003C0EC9 /* Text+Template.swift */; }; + E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */; }; + E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; + E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -110,6 +113,8 @@ DC5EB9D32C0525FA0034B757 /* NavigationStackTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationStackTests.swift; sourceTree = ""; }; DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedBindings.swift; sourceTree = ""; }; DC86E8702C208D8A003C0EC9 /* Text+Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Template.swift"; sourceTree = ""; }; + E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+MinimalObservationViewController.swift"; sourceTree = ""; }; + E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSControlBindingsViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -130,6 +135,7 @@ CA48F2FA2C49645100BE2C3C /* SwiftUINavigation in Frameworks */, CA48F3052C49650F00BE2C3C /* Tagged in Frameworks */, CA48F2FC2C49645100BE2C3C /* UIKitNavigation in Frameworks */, + E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -194,6 +200,7 @@ CA9D70492C1F642A003B672A /* Internal */, CADCA3642C1CE80B00DE645F /* SwiftUI */, CA9D704A2C1F6431003B672A /* UIKit */, + E9836D902C6C9E3000FC6EFD /* AppKit */, ); path = CaseStudies; sourceTree = ""; @@ -274,6 +281,15 @@ path = Internal; sourceTree = ""; }; + E9836D902C6C9E3000FC6EFD /* AppKit */ = { + isa = PBXGroup; + children = ( + E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */, + E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */, + ); + path = AppKit; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -316,6 +332,7 @@ CA48F3012C49650100BE2C3C /* IdentifiedCollections */, CA48F3042C49650F00BE2C3C /* Tagged */, CA48F3072C49651700BE2C3C /* ConcurrencyExtras */, + E9836D932C6CA07300FC6EFD /* AppKitNavigation */, ); productName = CaseStudies; productReference = CA473804272F0D330012CAC3 /* CaseStudies.app */; @@ -432,6 +449,7 @@ buildActionMask = 2147483647; files = ( CABE9FC1272F2C0000AFC150 /* EnumNavigation.swift in Sources */, + E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */, DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */, CA9D70502C1FB431003B672A /* StaticNavigationStackController.swift in Sources */, CA473837272F0D860012CAC3 /* FactClient.swift in Sources */, @@ -441,6 +459,7 @@ CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */, CA473834272F0D860012CAC3 /* RootView.swift in Sources */, CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */, + E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */, CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */, CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */, CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */, @@ -683,6 +702,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -713,6 +734,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudies; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -725,6 +748,7 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -734,6 +758,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -748,6 +774,7 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -757,6 +784,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -868,6 +897,10 @@ package = CA48F3062C49651700BE2C3C /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */; productName = ConcurrencyExtras; }; + E9836D932C6CA07300FC6EFD /* AppKitNavigation */ = { + isa = XCSwiftPackageProductDependency; + productName = AppKitNavigation; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CA47378C272F08EF0012CAC3 /* Project object */; diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 373b40f34d..85a5cd29d2 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -13,12 +13,14 @@ extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { get { appkitNavigationDelegate.action } } + private static let appkitNavigationDelegateKey = malloc(1)! + private var appkitNavigationDelegate: Delegate { set { - objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - if let delegate = objc_getAssociatedObject(self, #function) as? Delegate { + if let delegate = objc_getAssociatedObject(self, Self.appkitNavigationDelegateKey) as? Delegate { return delegate } else { let delegate = Delegate() diff --git a/Sources/AppKitNavigation/Bindings/NSSwitch.swift b/Sources/AppKitNavigation/Bindings/NSSwitch.swift index 21b752be18..47f4eead06 100644 --- a/Sources/AppKitNavigation/Bindings/NSSwitch.swift +++ b/Sources/AppKitNavigation/Bindings/NSSwitch.swift @@ -26,7 +26,7 @@ extension NSSwitch { } } - var boolValue: Bool { + @objc var boolValue: Bool { set { state = newValue ? .on : .off } get { state == .on } } diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 305ef278ed..295ff6b670 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -10,18 +10,28 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { var appkitNavigationAction: Selector? { set get } } -private class NSControlActionHandler: NSObject { +@MainActor +internal class NSTargetActionHandler: NSObject { let action: (NSControl) -> Void + var originTarget: AnyObject? + + var originAction: Selector? + init(action: @escaping (NSControl) -> Void) { self.action = action } @objc func invokeAction(_ sender: NSControl) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) + } action(sender) + } } + extension NSTargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. /// @@ -40,9 +50,11 @@ extension NSTargetActionProtocol { } } - private var actionHandler: NSControlActionHandler? { - set { objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - get { objc_getAssociatedObject(self, #function) as? NSControlActionHandler } + + + internal var actionHandler: NSTargetActionHandler? { + set { objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + get { objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler } } /// Establishes a two-way connection between a source of truth and a property of this control. @@ -62,13 +74,13 @@ extension NSTargetActionProtocol { set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void ) -> ObservationToken { unbind(keyPath) - let actionHandler = NSControlActionHandler { [weak self] _ in + let actionHandler = NSTargetActionHandler { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } self.actionHandler = actionHandler appkitNavigationTarget = actionHandler - appkitNavigationAction = #selector(NSControlActionHandler.invokeAction(_:)) + appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) let isSetting = LockIsolated(false) let token = observe { [weak self] transaction in @@ -124,4 +136,7 @@ extension NSTargetActionProtocol { @MainActor private let observationTokensKey = malloc(1)! +@MainActor +private let actionHandlerKey = malloc(1)! + #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index e5c4370993..64d1c0de7a 100644 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -69,11 +69,12 @@ extension NSTextField: NSTextViewDelegate { selection.wrappedValue = control.textSelection } } + let observationToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - editingChangedAction.cancel() - editingDidEndAction.cancel() - } +// MainActor._assumeIsolated { + editingChangedAction.cancel() + editingDidEndAction.cancel() +// } token.cancel() self?.textSelectionObserver = nil } @@ -99,12 +100,13 @@ extension NSTextField: NSTextViewDelegate { } } - fileprivate var textSelectionObserver: TextSelectionObserver? { + private static let textSelectionObserverKey = malloc(1)! + private var textSelectionObserver: TextSelectionObserver? { set { - objc_setAssociatedObject(self, #function, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(self, Self.textSelectionObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - objc_getAssociatedObject(self, #function) as? TextSelectionObserver + objc_getAssociatedObject(self, Self.textSelectionObserverKey) as? TextSelectionObserver } } diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift index fea107e3ae..9a7465a99f 100644 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift @@ -1,30 +1,30 @@ 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 + // 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 - ) - } + 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 - } + 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/AppKitNavigation/Internal/ErrorMechanism.swift b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift index d527e73ca7..1ec4c47b0a 100644 --- a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift +++ b/Sources/AppKitNavigation/Internal/ErrorMechanism.swift @@ -1,20 +1,20 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @rethrows - protocol _ErrorMechanism { +@rethrows +protocol _ErrorMechanism { associatedtype Output func get() throws -> Output - } +} - extension _ErrorMechanism { +extension _ErrorMechanism { func _rethrowError() rethrows -> Never { - _ = try _rethrowGet() - fatalError() + _ = try _rethrowGet() + fatalError() } func _rethrowGet() rethrows -> Output { - return try get() + return try get() } - } +} - extension Result: _ErrorMechanism {} +extension Result: _ErrorMechanism {} #endif diff --git a/Sources/AppKitNavigation/Internal/Exports.swift b/Sources/AppKitNavigation/Internal/Exports.swift index 6f94b2acad..554225adc1 100644 --- a/Sources/AppKitNavigation/Internal/Exports.swift +++ b/Sources/AppKitNavigation/Internal/Exports.swift @@ -1,3 +1,3 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @_exported import SwiftNavigation +@_exported import SwiftNavigation #endif diff --git a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift index bbe60acbac..a11cfaaf1d 100644 --- a/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift +++ b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift @@ -1,12 +1,12 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - extension Bool { +extension Bool { struct Unit: Hashable, Identifiable { - var id: Unit { self } + var id: Unit { self } } var toOptionalUnit: Unit? { - get { self ? Unit() : nil } - set { self = newValue != nil } + get { self ? Unit() : nil } + set { self = newValue != nil } } - } +} #endif diff --git a/Sources/AppKitNavigation/Navigation/NSAlert.swift b/Sources/AppKitNavigation/Navigation/NSAlert.swift new file mode 100644 index 0000000000..09229b6e57 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NSAlert.swift @@ -0,0 +1,48 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSAlert { + /// Creates and returns a view controller for displaying an alert using a data description. + /// + /// - Parameters: + /// - state: A data description of the alert. + /// - handler: A closure that is invoked with an action held in `state`. + public convenience init( + state: AlertState, + handler: @escaping (_ action: Action?) -> Void + ) { + self.init() + self.messageText = String(state: state.title) + state.message.map { self.informativeText = String(state: $0) } + + for button in state.buttons { + addButton(button, action: handler) + } + } +} + +extension NSAlert { + public func addButton( + _ buttonState: ButtonState, + action handler: @escaping (_ action: Action?) -> Void + ) { + let button = addButton(withTitle: String(state: buttonState.label)) + + let actionHandler = NSTargetActionHandler { _ in + buttonState.withAction(handler) + } + actionHandler.originTarget = button.target + actionHandler.originAction = button.action + button.target = actionHandler + button.action = #selector(NSTargetActionHandler.invokeAction(_:)) + button.actionHandler = actionHandler + if buttonState.role == .destructive, #available(macOS 11.0, *) { + button.hasDestructiveAction = true + } + if buttonState.role == .cancel { + button.keyEquivalent = "\u{1b}" + } +// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } + } +} +#endif diff --git a/Sources/AppKitNavigation/Navigation/UIAlertController.swift b/Sources/AppKitNavigation/Navigation/UIAlertController.swift deleted file mode 100644 index ba24c57eed..0000000000 --- a/Sources/AppKitNavigation/Navigation/UIAlertController.swift +++ /dev/null @@ -1,83 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -// import AppKit -// -// -// extension NSAlert { -// /// Creates and returns a view controller for displaying an alert using a data description. -// /// -// /// - Parameters: -// /// - state: A data description of the alert. -// /// - handler: A closure that is invoked with an action held in `state`. -// public convenience init( -// state: AlertState, -// handler: @escaping (_ action: Action?) -> Void -// ) { -// self.init( -// title: String(state: state.title), -// message: state.message.map { String(state: $0) }, -// preferredStyle: .alert -// ) -// for button in state.buttons { -// addAction(UIAlertAction(button, action: handler)) -// } -// } -// -// /// Creates and returns a view controller for displaying an action sheet using a data -// /// description. -// /// -// /// - Parameters: -// /// - state: A data description of the alert. -// /// - handler: A closure that is invoked with an action held in `state`. -// public convenience init( -// state: ConfirmationDialogState, -// handler: @escaping (_ action: Action?) -> Void -// ) { -// self.init( -// title: state.titleVisibility == .visible ? String(state: state.title) : nil, -// message: state.message.map { String(state: $0) }, -// preferredStyle: .actionSheet -// ) -// for button in state.buttons { -// addAction(UIAlertAction(button, action: handler)) -// } -// } -// } -// -// @available(iOS 13, *) -// @available(macCatalyst 13, *) -// @available(macOS, unavailable) -// @available(tvOS 13, *) -// @available(watchOS, unavailable) -// extension UIAlertAction.Style { -// public init(_ role: ButtonStateRole) { -// switch role { -// case .cancel: -// self = .cancel -// case .destructive: -// self = .destructive -// } -// } -// } -// -// @available(iOS 13, *) -// @available(macCatalyst 13, *) -// @available(macOS, unavailable) -// @available(tvOS 13, *) -// @available(watchOS, unavailable) -// extension UIAlertAction { -// public convenience init( -// _ button: ButtonState, -// action handler: @escaping (_ action: Action?) -> Void -// ) { -// self.init( -// title: String(state: button.label), -// style: button.role.map(UIAlertAction.Style.init) ?? .default -// ) { _ in -// button.withAction(handler) -// } -// if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { -// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } -// } -// } -// } -#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index d7a6d934a7..59e3fcbb23 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -175,11 +175,8 @@ extension NSView { static func performWithoutAnimation(_ block: () -> Void) { - if NSAnimationContext.current.allowsImplicitAnimation { - NSAnimationContext.current.allowsImplicitAnimation = false - block() - NSAnimationContext.current.allowsImplicitAnimation = true - } else { + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = false block() } } From 06f09afa8c225407f41d6919cddb7c5ad9c48c8b Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Thu, 15 Aug 2024 01:10:56 +0800 Subject: [PATCH 04/34] Updates --- ...ppKit+BasicsNavigationViewController.swift | 93 +++++ ...+ConciseEnumNavigationViewController.swift | 138 +++++++ .../AppKit+EnumControlsViewController.swift | 117 ++++++ .../AppKit/AppKit+FocusViewController.swift | 131 +++++++ ...Kit+MinimalObservationViewController.swift | 6 +- ...Kit+NSControlBindingsViewController.swift} | 0 .../AppKit/AppKitCaseStudies.swift | 42 +++ Examples/CaseStudies/Internal/CaseStudy.swift | 336 ++++++++++-------- Examples/CaseStudies/RootView.swift | 37 +- Examples/Examples.xcodeproj/project.pbxproj | 28 +- .../AppKitNavigation/Bindings/NSControl.swift | 10 + .../Bindings/NSFontManager.swift | 2 +- .../Bindings/NSTargetAction.swift | 51 ++- .../AppKitNavigation/Navigation/NSAlert.swift | 8 +- .../Navigation/Presentation.swift | 12 +- .../AppKitNavigation/Navigation/Sheet.swift | 228 ++++++++++++ Sources/AppKitNavigation/Observe.swift | 96 ++--- 17 files changed, 1084 insertions(+), 251 deletions(-) create mode 100644 Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift create mode 100644 Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift create mode 100644 Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift create mode 100644 Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift rename Examples/CaseStudies/AppKit/{NSControlBindingsViewController.swift => AppKit+NSControlBindingsViewController.swift} (100%) create mode 100644 Examples/CaseStudies/AppKit/AppKitCaseStudies.swift create mode 100644 Sources/AppKitNavigation/Navigation/Sheet.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift new file mode 100644 index 0000000000..eeede2e017 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -0,0 +1,93 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftUI +import AppKit +import AppKitNavigation + +class BasicsNavigationViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "Basics" + let readMe = """ + This case study demonstrates how to perform every major form of navigation in UIKit (alerts, \ + sheets, drill-downs) by driving navigation off of optional and boolean state. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let showAlertButton = NSButton { [weak self] _ in + self?.model.alert = "Hello!" + } + + let showSheetButton = NSButton { [weak self] _ in + self?.model.sheet = .random(in: 1 ... 1_000) + } + + let showSheetFromBooleanButton = NSButton { [weak self] _ in + self?.model.isSheetPresented = true + } + + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" + + } + +// present(item: $model.alert, id: \.self) { message in +// let alert = UIAlertController( +// title: "This is an alert", +// message: message, +// preferredStyle: .alert +// ) +// alert.addAction(UIAlertAction(title: "OK", style: .default)) +// return alert +// } + sheet(item: $model.sheet, id: \.self) { count in + NSAlert(error: CocoaError.error(.coderInvalidValue)) + } +// present(item: $model.sheet, id: \.self, style: .sheet) { count in +//// let vc = NSHostingController( +//// rootView: Form { Text(count.description) } +//// ) +// let vc = XiblessViewController() +// vc.preferredContentSize = .init(width: 300, height: 200) +// return vc +// } + present(isPresented: $model.isSheetPresented, style: .sheet) { + let vc = NSHostingController( + rootView: Form { Text("Hello!") } + ) + return vc + } + } + + @Observable + class Model { + var alert: String? + var isSheetPresented = false + var sheet: Int? + } +} + +#Preview { + BasicsNavigationViewController() +} +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift new file mode 100644 index 0000000000..af08f37dfe --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift @@ -0,0 +1,138 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +//import SwiftUI +//import UIKit +//import UIKitNavigation +// +//class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { +// let caseStudyNavigationTitle = "Enum navigation" +// let caseStudyTitle = "Concise enum navigation" +// let readMe = """ +// This case study demonstrates how to navigate to multiple destinations from a single optional \ +// enum. +// +// This allows you to be very concise with your domain modeling by having a single enum \ +// describe all the possible destinations you can navigate to. In the case of this demo, we have \ +// four cases in the enum, which means there are exactly 5 possible states, including the case \ +// where none are active. +// +// If you were to instead model this domain with 4 optionals (or booleans), then you would have \ +// 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ +// because you can never be sure of exactly what is presented at a given time. +// """ +// @UIBindable var model = Model() +// +// override func viewDidLoad() { +// super.viewDidLoad() +// view.backgroundColor = .systemBackground +// +// let showAlertButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .alert("Hello!") +// }) +// let showSheetButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .sheet(.random(in: 1...1_000)) +// }) +// let showSheetFromBooleanButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .sheetWithoutPayload +// }) +// let drillDownButton = UIButton( +// type: .system, +// primaryAction: UIAction { [weak self] _ in +// self?.model.destination = .drillDown(.random(in: 1...1_000)) +// }) +// +// let stack = UIStackView(arrangedSubviews: [ +// showAlertButton, +// showSheetButton, +// drillDownButton, +// showSheetFromBooleanButton, +// ]) +// stack.axis = .vertical +// stack.spacing = 12 +// stack.translatesAutoresizingMaskIntoConstraints = false +// +// view.addSubview(stack) +// NSLayoutConstraint.activate([ +// stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), +// stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// ]) +// +// observe { [weak self] in +// guard let self else { return } +// +// showAlertButton.setTitle( +// "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")", +// for: .normal +// ) +// showSheetButton.setTitle( +// "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")", +// for: .normal +// ) +// showSheetFromBooleanButton.setTitle( +// "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")", +// for: .normal +// ) +// drillDownButton.setTitle( +// "Drill-down is presented: \(model.destination?.drillDown != nil ? "✅" : "❌")", +// for: .normal +// ) +// } +// +// present(item: $model.destination.alert, id: \.self) { message in +// let alert = UIAlertController( +// title: "This is an alert", +// message: message, +// preferredStyle: .alert +// ) +// alert.addAction(UIAlertAction(title: "OK", style: .default)) +// return alert +// } +// present(item: $model.destination.sheet, id: \.self) { count in +// let vc = UIHostingController( +// rootView: Form { Text(count.description) } +// ) +// vc.mediumDetents() +// return vc +// } +// present(isPresented: UIBinding($model.destination.sheetWithoutPayload)) { +// let vc = UIHostingController( +// rootView: Form { Text("Hello!") } +// ) +// vc.mediumDetents() +// return vc +// } +// navigationDestination(item: $model.destination.drillDown) { count in +// UIHostingController( +// rootView: Form { +// Text(count.description) +// } +// ) +// } +// } +// +// @Observable +// class Model { +// var destination: Destination? +// @CasePathable +// @dynamicMemberLookup +// enum Destination { +// case alert(String) +// case drillDown(Int) +// case sheet(Int) +// case sheetWithoutPayload +// } +// } +//} +// +//#Preview { +// UINavigationController( +// rootViewController: BasicsNavigationViewController() +// ) +//} +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift new file mode 100644 index 0000000000..f6aada2f04 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift @@ -0,0 +1,117 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation + +class EnumControlsViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyNavigationTitle = "Enum controls" + let caseStudyTitle = "Concise enum controls" + let readMe = """ + This case study demonstrates how to drive form controls from bindings to enum state. In this \ + example, a single `Status` enum holds two cases: + + • An integer quantity for when an item is in stock, which can drive a stepper. + • A Boolean for whether an item is on back order when it is _not_ in stock, which can drive a \ + switch. + + This library provides tools to chain deeper into a binding's case by applying the \ + `@CasePathable` macro. + """ + + @CasePathable + enum Status { + case inStock(quantity: Int) + case outOfStock(isOnBackOrder: Bool) + } + + @UIBinding var status: Status = .inStock(quantity: 100) + + override func viewDidLoad() { + super.viewDidLoad() + + let quantityLabel = NSTextField(labelWithString: "") + let quantityStepper = NSStepper() + quantityStepper.maxValue = .infinity + let quantityStack = NSStackView(views: [ + quantityLabel, + quantityStepper, + ]) + let outOfStockButton = NSButton { [weak self] _ in + self?.status = .outOfStock(isOnBackOrder: false) + } + outOfStockButton.title = "Out of stock" + let inStockStack = NSStackView(views: [ + quantityStack, + outOfStockButton, + ]) + inStockStack.orientation = .vertical + + let isOnBackOrderLabel = NSTextField(labelWithString: "Is on back order?") + let isOnBackOrderSwitch = NSSwitch() + let isOnBackOrderStack = NSStackView(views: [ + isOnBackOrderLabel, + isOnBackOrderSwitch, + ]) + let backInStockButton = NSButton { [weak self] _ in + self?.status = .inStock(quantity: 100) + } + + backInStockButton.title = "Back in stock!" + let outOfStockStack = NSStackView(views: [ + isOnBackOrderStack, + backInStockButton, + ]) + outOfStockStack.orientation = .vertical + + let stack = NSStackView(views: [ + inStockStack, + outOfStockStack, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + inStockStack.isHidden = !status.is(\.inStock) + outOfStockStack.isHidden = !status.is(\.outOfStock) + + switch status { + case .inStock: + if let quantity = $status.inStock { + quantityLabel.stringValue = "Quantity: \(quantity.wrappedValue)" + quantityStepper.bind(value: quantity.asDouble) + } + + case .outOfStock: + if let isOnBackOrder = $status.outOfStock { + isOnBackOrderSwitch.bind(isOn: isOnBackOrder) + } + } + } + } +} + +@available(macOS 14.0, *) +#Preview { + EnumControlsViewController() +} + + +extension Int { + fileprivate var asDouble: Double { + get { Double(self) } + set { self = Int(newValue) } + } +} + +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift new file mode 100644 index 0000000000..9008fdf714 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift @@ -0,0 +1,131 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation + +class FocusViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "Focus" + let readMe = """ + This case study demonstrates how to handle `UITextField` focus in a state-driven manner. The \ + focus in the UI is kept in sync with the focus value held in an observable model so that \ + changes in one are immediately reflected in the other. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let bioTextField = NSTextField(text: $model.bio) + bioTextField.bind(focus: $model.focus, equals: .bio) + bioTextField.bezelStyle = .roundedBezel + bioTextField.placeholderString = "Bio" + let emailTextField = NSTextField(text: $model.email) + emailTextField.bind(focus: $model.focus, equals: .email) + emailTextField.bezelStyle = .roundedBezel + emailTextField.placeholderString = "Email" + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bind(focus: $model.focus, equals: .password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.placeholderString = "Password" + let usernameTextField = NSTextField(text: $model.username) + usernameTextField.bind(focus: $model.focus, equals: .username) + usernameTextField.bezelStyle = .roundedBezel + usernameTextField.placeholderString = "Username" + + let currentFocusLabel = NSTextField(labelWithString: "") + + let focusBioButton = NSButton { [weak self] _ in + self?.model.focus = .bio + } + + focusBioButton.title = "Focus bio" + let focusEmailButton = NSButton { [weak self] _ in + self?.model.focus = .email + } + + focusEmailButton.title = "Focus email" + let focusPasswordButton = NSButton { [weak self] _ in + self?.model.focus = .password + } + + focusPasswordButton.title = "Focus password" + let focusUsernameButton = NSButton { [weak self] _ in + self?.model.focus = .username + } + + focusUsernameButton.title = "Focus username" + let resignFirstResponder = NSButton { [weak self] _ in + self?.view.window?.makeFirstResponder(nil) + } + + resignFirstResponder.title = "Resign first responder" + + let stack = NSStackView(views: [ + usernameTextField, + emailTextField, + passwordTextField, + bioTextField, + currentFocusLabel, + focusUsernameButton, + focusEmailButton, + focusPasswordButton, + focusBioButton, + resignFirstResponder, + ]) + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.leadingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), + stack.trailingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + ]) + + observe { [weak self] in + guard let self else { return } + + currentFocusLabel.stringValue = "Current focus: \(model.focus?.rawValue ?? "none")" + bioTextField.backgroundColor = nil + emailTextField.backgroundColor = nil + passwordTextField.backgroundColor = nil + usernameTextField.backgroundColor = nil + switch model.focus { + case .bio: + bioTextField.backgroundColor = .lightGray + case .email: + emailTextField.backgroundColor = .lightGray + case .password: + passwordTextField.backgroundColor = .lightGray + case .username: + usernameTextField.backgroundColor = .lightGray + case .none: + break + } + } + } + + @Observable + class Model { + var bio = "" + var email = "" + var focus: Focus? + var password = "" + var username = "" + enum Focus: String { + case bio + case email + case password + case username + } + } +} + +#Preview { + FocusViewController() +} + +#endif diff --git a/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift index 9fcc665639..f9b51ffca1 100644 --- a/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift @@ -3,7 +3,7 @@ import AppKit import AppKitNavigation import ConcurrencyExtras -class MinimalObservationViewController: NSViewController, AppKitCaseStudy { +class MinimalObservationViewController: XiblessViewController, AppKitCaseStudy { let caseStudyTitle = "Minimal observation" let readMe = """ This case study demonstrates how to use the 'observe' tool from the library in order to \ @@ -15,10 +15,6 @@ class MinimalObservationViewController: NSViewController, AppKitCaseStudy { closure of 'observe' causes re-renders. """ @UIBindable var model = Model() - - override func loadView() { - view = NSView() - } override func viewDidLoad() { super.viewDidLoad() diff --git a/Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift similarity index 100% rename from Examples/CaseStudies/AppKit/NSControlBindingsViewController.swift rename to Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift diff --git a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift new file mode 100644 index 0000000000..acdf6cb0e2 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift @@ -0,0 +1,42 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftUI +import SwiftUINavigation + +struct AppKitCaseStudiesView: View { + var body: some View { + List { + CaseStudyGroupView("Observation") { + MinimalObservationViewController() +// AnimationsViewController() + } + CaseStudyGroupView("Bindings") { + NSControlBindingsViewController() + EnumControlsViewController() + FocusViewController() + } + CaseStudyGroupView("Optional navigation") { + BasicsNavigationViewController() + // TODO: Alert/dialog state +// ConciseEnumNavigationViewController() + } +// CaseStudyGroupView("Stack navigation") { +// StaticNavigationStackController() +// ErasedNavigationStackController(model: ErasedNavigationStackController.Model()) +// // TODO: state restoration +// } +// CaseStudyGroupView("Advanced") { +// // TODO: Deep link +// // TODO: Dismissal (show off from VCs and views) +// WiFiSettingsViewController(model: WiFiSettingsModel(foundNetworks: .mocks)) +// } + } + .navigationTitle("AppKit") + } +} + +#Preview { + NavigationStack { + AppKitCaseStudiesView() + } +} +#endif diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index 9f7a8d1167..88730a9b21 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -1,18 +1,20 @@ import SwiftUI +import AppKitNavigation import UIKitNavigation protocol CaseStudy { - var readMe: String { get } - var caseStudyTitle: String { get } - var caseStudyNavigationTitle: String { get } - var usesOwnLayout: Bool { get } - var isPresentedInSheet: Bool { get } + var readMe: String { get } + var caseStudyTitle: String { get } + var caseStudyNavigationTitle: String { get } + var usesOwnLayout: Bool { get } + var isPresentedInSheet: Bool { get } } + protocol SwiftUICaseStudy: CaseStudy, View {} #if canImport(UIKit) && !os(watchOS) protocol UIKitCaseStudy: CaseStudy, UIViewController {} extension UIKitCaseStudy { - var usesOwnLayout: Bool { true } + var usesOwnLayout: Bool { true } } #endif #if canImport(AppKit) && !targetEnvironment(macCatalyst) @@ -23,207 +25,249 @@ extension AppKitCaseStudy { #endif extension CaseStudy { - var caseStudyNavigationTitle: String { caseStudyTitle } - var isPresentedInSheet: Bool { false } + var caseStudyNavigationTitle: String { caseStudyTitle } + var isPresentedInSheet: Bool { false } } + extension SwiftUICaseStudy { - var usesOwnLayout: Bool { false } + var usesOwnLayout: Bool { false } } - @resultBuilder @MainActor enum CaseStudyViewBuilder { - @ViewBuilder - static func buildBlock() -> some View {} - @ViewBuilder - static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { - SwiftUICaseStudyButton(caseStudy: caseStudy) - } -#if canImport(UIKit) && !os(watchOS) - @ViewBuilder - static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { - UIKitCaseStudyButton(caseStudy: caseStudy) - } + @ViewBuilder + static func buildBlock() -> some View {} + @ViewBuilder + static func buildExpression(_ caseStudy: some SwiftUICaseStudy) -> some View { + SwiftUICaseStudyButton(caseStudy: caseStudy) + } + + #if canImport(UIKit) && !os(watchOS) + @ViewBuilder + static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { + UIKitCaseStudyButton(caseStudy: caseStudy) + } + #endif + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + @ViewBuilder + static func buildExpression(_ caseStudy: some AppKitCaseStudy) -> some View { + AppKitCaseStudyButton(caseStudy: caseStudy) + } #endif - static func buildPartialBlock(first: some View) -> some View { - first - } - @ViewBuilder - static func buildPartialBlock(accumulated: some View, next: some View) -> some View { - accumulated - next - } + static func buildPartialBlock(first: some View) -> some View { + first + } + + @ViewBuilder + static func buildPartialBlock(accumulated: some View, next: some View) -> some View { + accumulated + next + } } struct SwiftUICaseStudyButton: View { - let caseStudy: C - @State var isPresented = false - var body: some View { - if caseStudy.isPresentedInSheet { - Button(caseStudy.caseStudyTitle) { - isPresented = true - } - .sheet(isPresented: $isPresented) { - CaseStudyView { - caseStudy + let caseStudy: C + @State var isPresented = false + var body: some View { + if caseStudy.isPresentedInSheet { + Button(caseStudy.caseStudyTitle) { + isPresented = true + } + .sheet(isPresented: $isPresented) { + CaseStudyView { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } else { + NavigationLink(caseStudy.caseStudyTitle) { + CaseStudyView { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } - } else { - NavigationLink(caseStudy.caseStudyTitle) { - CaseStudyView { - caseStudy + } +} + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +struct AppKitCaseStudyButton: View { + let caseStudy: C + @State var isPresented = false + var body: some View { +// if caseStudy.isPresentedInSheet { +// Button(caseStudy.caseStudyTitle) { +// isPresented = true +// } +// .sheet(isPresented: $isPresented) { +// NSViewControllerRepresenting { +// ((caseStudy as? UINavigationController) +// ?? UINavigationController(rootViewController: caseStudy)) +// .setUp(caseStudy: caseStudy) +// } +// .modifier(CaseStudyModifier(caseStudy: caseStudy)) +// } +// } else { + NavigationLink(caseStudy.caseStudyTitle) { + NSViewControllerRepresenting { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } +// } } - } } + +#endif + #if canImport(UIKit) && !os(watchOS) struct UIKitCaseStudyButton: View { - let caseStudy: C - @State var isPresented = false - var body: some View { - if caseStudy.isPresentedInSheet { - Button(caseStudy.caseStudyTitle) { - isPresented = true - } - .sheet(isPresented: $isPresented) { - UIViewControllerRepresenting { - ((caseStudy as? UINavigationController) - ?? UINavigationController(rootViewController: caseStudy)) - .setUp(caseStudy: caseStudy) - } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } - } else { - NavigationLink(caseStudy.caseStudyTitle) { - UIViewControllerRepresenting { - caseStudy + let caseStudy: C + @State var isPresented = false + var body: some View { + if caseStudy.isPresentedInSheet { + Button(caseStudy.caseStudyTitle) { + isPresented = true + } + .sheet(isPresented: $isPresented) { + UIViewControllerRepresenting { + ((caseStudy as? UINavigationController) + ?? UINavigationController(rootViewController: caseStudy)) + .setUp(caseStudy: caseStudy) + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } + } else { + NavigationLink(caseStudy.caseStudyTitle) { + UIViewControllerRepresenting { + caseStudy + } + .modifier(CaseStudyModifier(caseStudy: caseStudy)) + } } - .modifier(CaseStudyModifier(caseStudy: caseStudy)) - } } - } } extension UINavigationController { - func setUp(caseStudy: some CaseStudy) -> Self { - self.viewControllers[0].title = caseStudy.caseStudyNavigationTitle - self.viewControllers[0].navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "About", - primaryAction: UIAction { [weak self] _ in - self?.present( - UIHostingController( - rootView: Form { - Text(template: caseStudy.readMe) + func setUp(caseStudy: some CaseStudy) -> Self { + viewControllers[0].title = caseStudy.caseStudyNavigationTitle + viewControllers[0].navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "About", + primaryAction: UIAction { [weak self] _ in + self?.present( + UIHostingController( + rootView: Form { + Text(template: caseStudy.readMe) + } + .presentationDetents([.medium]) + ), + animated: true + ) } - .presentationDetents([.medium]) - ), - animated: true ) - }) - return self - } + return self + } } #endif struct CaseStudyModifier: ViewModifier { - let caseStudy: C - @State var isAboutPresented = false - func body(content: Content) -> some View { - content - .navigationTitle(caseStudy.caseStudyNavigationTitle) - .toolbar { - ToolbarItem { - Button("About") { isAboutPresented = true } - } - } - .sheet(isPresented: $isAboutPresented) { - Form { - Text(template: caseStudy.readMe) - } - .presentationDetents([.medium]) - } - } + let caseStudy: C + @State var isAboutPresented = false + func body(content: Content) -> some View { + content + .navigationTitle(caseStudy.caseStudyNavigationTitle) + .toolbar { + ToolbarItem { + Button("About") { isAboutPresented = true } + } + } + .sheet(isPresented: $isAboutPresented) { + Form { + Text(template: caseStudy.readMe) + } + .presentationDetents([.medium]) + } + } } struct CaseStudyView: View { - @ViewBuilder let caseStudy: C - @State var isAboutPresented = false - var body: some View { - if caseStudy.usesOwnLayout { - VStack { - caseStudy - } - } else { - Form { - caseStudy - } + @ViewBuilder let caseStudy: C + @State var isAboutPresented = false + var body: some View { + if caseStudy.usesOwnLayout { + VStack { + caseStudy + } + } else { + Form { + caseStudy + } + } } - } } struct CaseStudyGroupView: View { - @CaseStudyViewBuilder let content: Content - @ViewBuilder let title: Title + @CaseStudyViewBuilder let content: Content + @ViewBuilder let title: Title - var body: some View { - Section { - content - } header: { - title + var body: some View { + Section { + content + } header: { + title + } } - } } extension CaseStudyGroupView where Title == Text { - init(_ title: String, @CaseStudyViewBuilder content: () -> Content) { - self.init(content: content) { Text(title) } - } + init(_ title: String, @CaseStudyViewBuilder content: () -> Content) { + self.init(content: content) { Text(title) } + } } extension SwiftUICaseStudy { - fileprivate func navigationLink() -> some View { - NavigationLink(caseStudyTitle) { - self + fileprivate func navigationLink() -> some View { + NavigationLink(caseStudyTitle) { + self + } } - } } #Preview("SwiftUI case study") { - NavigationStack { - CaseStudyView { - DemoCaseStudy() + NavigationStack { + CaseStudyView { + DemoCaseStudy() + } } - } } #Preview("SwiftUI case study group") { - NavigationStack { - Form { - CaseStudyGroupView("Group") { - DemoCaseStudy() - } + NavigationStack { + Form { + CaseStudyGroupView("Group") { + DemoCaseStudy() + } + } } - } } private struct DemoCaseStudy: SwiftUICaseStudy { - let caseStudyTitle = "Demo Case Study" - let readMe = """ + let caseStudyTitle = "Demo Case Study" + let readMe = """ Hello! This is a demo case study. Enjoy! """ - var body: some View { - Text("Hello!") - } + var body: some View { + Text("Hello!") + } } + #if canImport(UIKit) && !os(watchOS) private class DemoCaseStudyController: UIViewController, UIKitCaseStudy { - let caseStudyTitle = "Demo Case Study" - let readMe = """ + let caseStudyTitle = "Demo Case Study" + let readMe = """ Hello! This is a demo case study. Enjoy! diff --git a/Examples/CaseStudies/RootView.swift b/Examples/CaseStudies/RootView.swift index d552ddf930..cb466a2879 100644 --- a/Examples/CaseStudies/RootView.swift +++ b/Examples/CaseStudies/RootView.swift @@ -2,25 +2,30 @@ import SwiftUI import SwiftUINavigation struct RootView: View { - var body: some View { - NavigationStack { - List { - NavigationLink("SwiftUI") { - SwiftUICaseStudiesView() + var body: some View { + NavigationStack { + List { + NavigationLink("SwiftUI") { + SwiftUICaseStudiesView() + } + #if canImport(UIKit) && !os(watchOS) + NavigationLink("UIKit") { + UIKitCaseStudiesView() + } + #endif + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + NavigationLink("AppKit") { + AppKitCaseStudiesView() + } + #endif + } + .navigationTitle("Case studies") } -#if canImport(UIKit) && !os(watchOS) - NavigationLink("UIKit") { - UIKitCaseStudiesView() - } -#endif - } - .navigationTitle("Case studies") } - } } struct RootView_Previews: PreviewProvider { - static var previews: some View { - RootView() - } + static var previews: some View { + RootView() + } } diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 9a7069ae89..f3552b0cfa 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -54,7 +54,12 @@ DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC86E8702C208D8A003C0EC9 /* Text+Template.swift */; }; E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */; }; E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; - E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */; }; + E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */; }; + E9CD5B762C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */; }; + E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */; }; + E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */; }; + E9CD5B7C2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B7A2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift */; }; + E9CD5B7E2C6D14DC00CE7947 /* AppKitCaseStudies.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B7D2C6D14DC00CE7947 /* AppKitCaseStudies.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -114,7 +119,12 @@ DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedBindings.swift; sourceTree = ""; }; DC86E8702C208D8A003C0EC9 /* Text+Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Template.swift"; sourceTree = ""; }; E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+MinimalObservationViewController.swift"; sourceTree = ""; }; - E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSControlBindingsViewController.swift; sourceTree = ""; }; + E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+NSControlBindingsViewController.swift"; sourceTree = ""; }; + E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+EnumControlsViewController.swift"; sourceTree = ""; }; + E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+FocusViewController.swift"; sourceTree = ""; }; + E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppKit+BasicsNavigationViewController.swift"; sourceTree = ""; }; + E9CD5B7A2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppKit+ConciseEnumNavigationViewController.swift"; sourceTree = ""; }; + E9CD5B7D2C6D14DC00CE7947 /* AppKitCaseStudies.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppKitCaseStudies.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -284,8 +294,13 @@ E9836D902C6C9E3000FC6EFD /* AppKit */ = { isa = PBXGroup; children = ( + E9CD5B7D2C6D14DC00CE7947 /* AppKitCaseStudies.swift */, E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */, - E9836D952C6CADBB00FC6EFD /* NSControlBindingsViewController.swift */, + E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */, + E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */, + E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */, + E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */, + E9CD5B7A2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift */, ); path = AppKit; sourceTree = ""; @@ -449,17 +464,20 @@ buildActionMask = 2147483647; files = ( CABE9FC1272F2C0000AFC150 /* EnumNavigation.swift in Sources */, + E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */, E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */, + E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */, DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */, CA9D70502C1FB431003B672A /* StaticNavigationStackController.swift in Sources */, CA473837272F0D860012CAC3 /* FactClient.swift in Sources */, + E9CD5B7C2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift in Sources */, CA9D704E2C1FAFAD003B672A /* ConciseEnumNavigationViewController.swift in Sources */, CA9D70582C2088B7003B672A /* MinimalObservationViewController.swift in Sources */, CA9D70562C2087DC003B672A /* EnumControls.swift in Sources */, CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */, CA473834272F0D860012CAC3 /* RootView.swift in Sources */, CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */, - E9836D962C6CADBB00FC6EFD /* NSControlBindingsViewController.swift in Sources */, + E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */, CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */, CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */, CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */, @@ -473,8 +491,10 @@ CA601BAE2C234613006B0C5F /* AnimationsViewController.swift in Sources */, CADCA3662C1CE8BE00DE645F /* CaseStudy.swift in Sources */, CA49D9542C20D4DF00E6C5BB /* ErasedNavigationStackController.swift in Sources */, + E9CD5B762C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift in Sources */, CA9D70542C1FB4FA003B672A /* BasicsNavigationViewController.swift in Sources */, DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */, + E9CD5B7E2C6D14DC00CE7947 /* AppKitCaseStudies.swift in Sources */, CA473838272F0D860012CAC3 /* OptionalNavigation.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index de03e4bd48..d1ee6d68b3 100644 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -14,4 +14,14 @@ extension NSControl: NSTargetActionProtocol { } } +extension NSControl { + public convenience init(action: @escaping (Self) -> Void) { + self.init(frame: .zero) + createActionHandlerIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } +} + #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 85a5cd29d2..0525f42582 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -67,7 +67,7 @@ extension NSFontManager { bind(font, to: \._selectedFont) } - private var _selectedFont: NSFont { + @objc private var _selectedFont: NSFont { set { setSelectedFont(newValue, isMultiple: false) } get { convert(.systemFont(ofSize: 0)) } } diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 295ff6b670..3412960d4d 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -12,25 +12,24 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { @MainActor internal class NSTargetActionHandler: NSObject { - let action: (NSControl) -> Void + typealias Action = (Any?) -> Void + var actions: [Action] = [] var originTarget: AnyObject? - + var originAction: Selector? - - init(action: @escaping (NSControl) -> Void) { - self.action = action - } - @objc func invokeAction(_ sender: NSControl) { + @objc func invokeAction(_ sender: Any?) { if let originTarget, let originAction { NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) } - action(sender) - + actions.forEach { $0(sender) } } -} + func addAction(_ action: @escaping Action) { + actions.append(action) + } +} extension NSTargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. @@ -50,11 +49,27 @@ extension NSTargetActionProtocol { } } - - internal var actionHandler: NSTargetActionHandler? { - set { objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - get { objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler } + set { + objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler + } + } + + internal func createActionHandlerIfNeeded() -> NSTargetActionHandler { + if let actionHandler { + return actionHandler + } else { + let actionHandler = NSTargetActionHandler() + actionHandler.originTarget = appkitNavigationTarget + actionHandler.originAction = appkitNavigationAction + self.actionHandler = actionHandler + appkitNavigationTarget = actionHandler + appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) + return actionHandler + } } /// Establishes a two-way connection between a source of truth and a property of this control. @@ -74,13 +89,11 @@ extension NSTargetActionProtocol { set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void ) -> ObservationToken { unbind(keyPath) - let actionHandler = NSTargetActionHandler { [weak self] _ in + let actionHandler = createActionHandlerIfNeeded() + actionHandler.addAction { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } - self.actionHandler = actionHandler - appkitNavigationTarget = actionHandler - appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) let isSetting = LockIsolated(false) let token = observe { [weak self] transaction in @@ -134,6 +147,8 @@ extension NSTargetActionProtocol { } } + + @MainActor private let observationTokensKey = malloc(1)! @MainActor diff --git a/Sources/AppKitNavigation/Navigation/NSAlert.swift b/Sources/AppKitNavigation/Navigation/NSAlert.swift index 09229b6e57..ed7dbe924b 100644 --- a/Sources/AppKitNavigation/Navigation/NSAlert.swift +++ b/Sources/AppKitNavigation/Navigation/NSAlert.swift @@ -28,14 +28,10 @@ extension NSAlert { ) { let button = addButton(withTitle: String(state: buttonState.label)) - let actionHandler = NSTargetActionHandler { _ in + button.createActionHandlerIfNeeded().addAction { _ in buttonState.withAction(handler) } - actionHandler.originTarget = button.target - actionHandler.originAction = button.action - button.target = actionHandler - button.action = #selector(NSTargetActionHandler.invokeAction(_:)) - button.actionHandler = actionHandler + if buttonState.role == .destructive, #available(macOS 11.0, *) { button.hasDestructiveAction = true } diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index bc9c377ea6..50e4bf93c3 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -128,9 +128,8 @@ extension NSViewController { guard let self else { return } if presentedViewControllers != nil { self.dismiss(nil) - onDismiss?() - self.present(child, for: style) - + onDismiss?() + self.present(child, for: style) } else { self.present(child, for: style) } @@ -140,7 +139,6 @@ extension NSViewController { } } - public enum TransitionStyle { case sheet case modalWindow @@ -154,9 +152,9 @@ extension NSViewController { presentAsSheet(viewControllerToPresent) case .modalWindow: presentAsModalWindow(viewControllerToPresent) - case .popover(let rect, let view, let preferredEdge, let behavior): + case let .popover(rect, view, preferredEdge, behavior): present(viewControllerToPresent, asPopoverRelativeTo: rect, of: view, preferredEdge: preferredEdge, behavior: behavior) - case .custom(let animator): + case let .custom(animator): present(viewControllerToPresent, animator: animator) } } @@ -292,7 +290,7 @@ extension NSViewController { } } childController.onDismiss = onDismiss - + self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) let work = { withUITransaction(transaction) { diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift new file mode 100644 index 0000000000..199de83e28 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -0,0 +1,228 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol WindowProviderAdapter: NSObject { + var currentWindow: NSWindow? { get } + func beginSheet(for provider: WindowProviderAdapter) async + func endSheet(for provider: WindowProviderAdapter) +} + +extension WindowProviderAdapter { + public func beginSheet(for provider: any WindowProviderAdapter) async { + if let sheetedWindow = provider.currentWindow { + await currentWindow?.beginSheet(sheetedWindow) + } + } + + public func endSheet(for provider: any WindowProviderAdapter) { + if let sheetedWindow = provider.currentWindow { + currentWindow?.endSheet(sheetedWindow) + } + } +} + +extension NSWindow: WindowProviderAdapter { + public var currentWindow: NSWindow? { self } +} + +extension NSWindowController: WindowProviderAdapter { + public var currentWindow: NSWindow? { window } +} + +extension NSViewController: WindowProviderAdapter { + public var currentWindow: NSWindow? { view.window } +} + +extension NSAlert: WindowProviderAdapter { + public var currentWindow: NSWindow? { window } + + public func beginSheet(for provider: any WindowProviderAdapter) async { + guard let parentWindow = provider.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for provider: any WindowProviderAdapter) { + provider.currentWindow?.endSheet(window) + } +} + +extension WindowProviderAdapter { + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> WindowProviderAdapter + ) -> ObservationToken { + sheet(item: item, id: id) { $item in + content($item) + } beginSheet: { [weak self] child, transaction 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.onEndSheet?.invoke() + child.onEndSheet = nil + } + } else { + Task { @MainActor in + await self.beginSheet(for: child) + child.onEndSheet?.invoke() + child.onEndSheet = nil + } + } + } endSheet: { [weak self] provider, transaction in + self?.endSheet(for: provider) + onDismiss?() + } + } + + private func sheet( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> WindowProviderAdapter, + beginSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + sheet( + item: item, + id: { $0[keyPath: id] }, + content: content, + beginSheet: beginSheet, + endSheet: endSheet + ) + } + + private func sheet( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> WindowProviderAdapter, + beginSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: WindowProviderAdapter, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = sheetedByID[key] { + guard let presentationID = presented.sheetID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let childController = content(unwrappedItem) + let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + childController.onEndSheet = onEndSheet + + self.sheetedByID[key] = Sheeted(childController, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + beginSheet(childController, transaction) + } + } +// if hasViewAppeared { + work() +// } else { +// onViewAppear.append(work) +// } + } else if let presented = sheetedByID[key] { + if let controller = presented.provider { + endSheet(controller, transaction) + } + self.sheetedByID[key] = nil + } + } + } + + private var sheetedByID: [UIBindingIdentifier: Sheeted] { + get { + (objc_getAssociatedObject(self, sheetedKey) + as? [UIBindingIdentifier: Sheeted]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, sheetedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private var onEndSheet: ClosureHolder? { + set { + objc_setAssociatedObject( + self, onEndSheetKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + get { + objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder + } + } + +// func modal() {} +} + +private class ClosureHolder: NSObject { + let closure: () -> Void + init(closure: @escaping () -> Void) { + self.closure = closure + } + func invoke() { + closure() + } +} + +private let onEndSheetKey = malloc(1)! +private let sheetedKey = malloc(1)! + +@MainActor +private class Sheeted { + weak var provider: WindowProviderAdapter? + let sheetID: AnyHashable? + deinit { + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + self.provider?.currentWindow?.endSheeted() + } + } + + init(_ provider: WindowProviderAdapter? = nil, id: AnyHashable?) { + self.provider = provider + self.sheetID = id + } +} + +extension NSWindow { + func endSheeted() { + guard sheetParent != nil else { + return + } + sheetParent?.endSheet(self) + } +} + + +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index 59e3fcbb23..23190e08bf 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -1,9 +1,9 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @_spi(Internals) import SwiftNavigation - import AppKit +@_spi(Internals) import SwiftNavigation +import AppKit - @MainActor - extension NSObject { +@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 @@ -107,7 +107,7 @@ /// - Returns: A cancellation token. @discardableResult public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObservationToken { - observe { _ in apply() } + observe { _ in apply() } } /// Observe access to properties of an observable (or perceptible) object. @@ -119,59 +119,59 @@ /// - Returns: A cancellation token. @discardableResult public func observe( - _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void + _ apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void ) -> ObservationToken { - let token = SwiftNavigation.observe { transaction in - MainActor._assumeIsolated { - withUITransaction(transaction) { - #if os(watchOS) - apply(transaction) - #else - 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) - } + let token = SwiftNavigation.observe { transaction in + MainActor._assumeIsolated { + withUITransaction(transaction) { + #if os(watchOS) + apply(transaction) + #else + 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) + } } - ) - } else { - apply(transaction) - for completion in transaction.appKit.animationCompletions { - completion(true) + #endif } - } - #endif - } - } - } task: { transaction, work in - DispatchQueue.main.async { - withUITransaction(transaction, work) + } + } task: { transaction, work in + DispatchQueue.main.async { + withUITransaction(transaction, work) + } } - } - tokens.append(token) - return token + 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) - } + 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 { static func performWithoutAnimation(_ block: () -> Void) { From 590bb2bf6ad6fc190110e4f8ea2da4c45bc6e175 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Thu, 15 Aug 2024 19:14:25 +0800 Subject: [PATCH 05/34] WIP --- ...ppKit+BasicsNavigationViewController.swift | 7 +- .../{Navigation => Bindings}/NSAlert.swift | 0 .../AppKitNavigation/Navigation/Modal.swift | 195 ++++++++++++++++++ .../AppKitNavigation/Navigation/Sheet.swift | 50 ++--- 4 files changed, 225 insertions(+), 27 deletions(-) rename Sources/AppKitNavigation/{Navigation => Bindings}/NSAlert.swift (100%) create mode 100644 Sources/AppKitNavigation/Navigation/Modal.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index eeede2e017..d5497efe8a 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -20,6 +20,9 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS let showSheetButton = NSButton { [weak self] _ in self?.model.sheet = .random(in: 1 ... 1_000) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self?.model.sheet = nil + } } let showSheetFromBooleanButton = NSButton { [weak self] _ in @@ -60,8 +63,8 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS // alert.addAction(UIAlertAction(title: "OK", style: .default)) // return alert // } - sheet(item: $model.sheet, id: \.self) { count in - NSAlert(error: CocoaError.error(.coderInvalidValue)) + modal(item: $model.sheet, id: \.self) { count in + NSWindow(contentViewController: XiblessViewController()) } // present(item: $model.sheet, id: \.self, style: .sheet) { count in //// let vc = NSHostingController( diff --git a/Sources/AppKitNavigation/Navigation/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift similarity index 100% rename from Sources/AppKitNavigation/Navigation/NSAlert.swift rename to Sources/AppKitNavigation/Bindings/NSAlert.swift diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift new file mode 100644 index 0000000000..bd341b3529 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -0,0 +1,195 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +@objc +public protocol ModalRepresentable where Self: NSObject { + @objc @discardableResult func runModal() -> NSApplication.ModalResponse + @objc var window: NSWindow { get } +} + +extension NSWindow: ModalRepresentable { + + public var window: NSWindow { self } + + public func runModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} + +extension NSAlert: ModalRepresentable {} + +@MainActor +class WindowsObserver: NSObject { + static let shared = WindowsObserver() + + var windowsCancellable: [NSWindow: AnyCancellable] = [:] + + func observeWindow(_ window: NSWindow) { + windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { [weak self] _ in + guard let self else { return } + if NSApplication.shared.modalWindow === window { + NSApplication.shared.stopModal() + } + windowsCancellable.removeValue(forKey: window) + } + } +} + +@MainActor +extension NSObject { + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> ModalRepresentable + ) -> ObservationToken { + modal(item: item, id: id) { $item in + content($item) + } beginSheet: { modalRepresentable, _ in + if NSApplication.shared.modalWindow != nil { + NSApplication.shared.stopModal() + onDismiss?() + DispatchQueue.main.async { + WindowsObserver.shared.observeWindow(modalRepresentable.window) + modalRepresentable.runModal() + modalRepresentable.onEndModal?.invoke() + modalRepresentable.onEndModal = nil + } + + } else { + DispatchQueue.main.async { + WindowsObserver.shared.observeWindow(modalRepresentable.window) + modalRepresentable.runModal() + modalRepresentable.onEndModal?.invoke() + modalRepresentable.onEndModal = nil + } + } + } endSheet: { provider, _ in + NSApplication.shared.stopModal() + onDismiss?() + } + } + + private func modal( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> ModalRepresentable, + beginSheet: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void, + endSheet: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + modal( + item: item, + id: { $0[keyPath: id] }, + content: content, + beginModal: beginSheet, + endModal: endSheet + ) + } + + private func modal( + item: UIBinding, + id: @escaping (Item) -> AnyHashable?, + content: @escaping (UIBinding) -> ModalRepresentable, + beginModal: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void, + endModal: @escaping ( + _ child: ModalRepresentable, + _ transaction: UITransaction + ) -> Void + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = modaledByID[key] { + guard let presentationID = presented.modalID, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let modalRepresentable = content(unwrappedItem) + let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == id(wrappedValue) { + item.wrappedValue = nil + } + } + modalRepresentable.onEndModal = onEndSheet + + self.modaledByID[key] = Modaled(modalRepresentable, id: id(unwrappedItem.wrappedValue)) + let work = { + withUITransaction(transaction) { + beginModal(modalRepresentable, transaction) + } + } + work() + } else if let modaled = modaledByID[key] { + if let modalRepresentable = modaled.provider { + endModal(modalRepresentable, transaction) + } + self.modaledByID[key] = nil + } + } + } + + private var modaledByID: [UIBindingIdentifier: Modaled] { + get { + (objc_getAssociatedObject(self, modaledKey) + as? [UIBindingIdentifier: Modaled]) + ?? [:] + } + set { + objc_setAssociatedObject( + self, modaledKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private var onEndModal: ClosureHolder? { + set { + objc_setAssociatedObject( + self, onEndModalKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + get { + objc_getAssociatedObject(self, onEndModalKey) as? ClosureHolder + } + } +} + +private let onEndModalKey = malloc(1)! +private let modaledKey = malloc(1)! + +@MainActor +private class Modaled { + weak var provider: ModalRepresentable? + let modalID: AnyHashable? + deinit { + // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // deinit alongside it on the main thread. If we use this other places we should force it + // to be a UIViewController as well, to ensure this functionality. + MainActor._assumeIsolated { + NSApplication.shared.stopModal() + } + } + + init(_ provider: ModalRepresentable? = nil, id: AnyHashable?) { + self.provider = provider + self.modalID = id + } +} +#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 199de83e28..348902992f 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -3,58 +3,58 @@ import AppKit @MainActor -public protocol WindowProviderAdapter: NSObject { +public protocol SheetRepresentable: NSObject { var currentWindow: NSWindow? { get } - func beginSheet(for provider: WindowProviderAdapter) async - func endSheet(for provider: WindowProviderAdapter) + func beginSheet(for provider: SheetRepresentable) async + func endSheet(for provider: SheetRepresentable) } -extension WindowProviderAdapter { - public func beginSheet(for provider: any WindowProviderAdapter) async { +extension SheetRepresentable { + public func beginSheet(for provider: any SheetRepresentable) async { if let sheetedWindow = provider.currentWindow { await currentWindow?.beginSheet(sheetedWindow) } } - public func endSheet(for provider: any WindowProviderAdapter) { + public func endSheet(for provider: any SheetRepresentable) { if let sheetedWindow = provider.currentWindow { currentWindow?.endSheet(sheetedWindow) } } } -extension NSWindow: WindowProviderAdapter { +extension NSWindow: SheetRepresentable { public var currentWindow: NSWindow? { self } } -extension NSWindowController: WindowProviderAdapter { +extension NSWindowController: SheetRepresentable { public var currentWindow: NSWindow? { window } } -extension NSViewController: WindowProviderAdapter { +extension NSViewController: SheetRepresentable { public var currentWindow: NSWindow? { view.window } } -extension NSAlert: WindowProviderAdapter { +extension NSAlert: SheetRepresentable { public var currentWindow: NSWindow? { window } - public func beginSheet(for provider: any WindowProviderAdapter) async { + public func beginSheet(for provider: any SheetRepresentable) async { guard let parentWindow = provider.currentWindow else { return } await beginSheetModal(for: parentWindow) } - public func endSheet(for provider: any WindowProviderAdapter) { + public func endSheet(for provider: any SheetRepresentable) { provider.currentWindow?.endSheet(window) } } -extension WindowProviderAdapter { +extension SheetRepresentable { @discardableResult public func sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> WindowProviderAdapter + content: @escaping (UIBinding) -> SheetRepresentable ) -> ObservationToken { sheet(item: item, id: id) { $item in content($item) @@ -84,13 +84,13 @@ extension WindowProviderAdapter { private func sheet( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> WindowProviderAdapter, + content: @escaping (UIBinding) -> SheetRepresentable, beginSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -106,13 +106,13 @@ extension WindowProviderAdapter { private func sheet( item: UIBinding, id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> WindowProviderAdapter, + content: @escaping (UIBinding) -> SheetRepresentable, beginSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: WindowProviderAdapter, + _ child: SheetRepresentable, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -179,15 +179,15 @@ extension WindowProviderAdapter { objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder } } - -// func modal() {} } -private class ClosureHolder: NSObject { +internal class ClosureHolder: NSObject { let closure: () -> Void + init(closure: @escaping () -> Void) { self.closure = closure } + func invoke() { closure() } @@ -198,7 +198,7 @@ private let sheetedKey = malloc(1)! @MainActor private class Sheeted { - weak var provider: WindowProviderAdapter? + weak var provider: SheetRepresentable? let sheetID: AnyHashable? deinit { // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to @@ -209,7 +209,7 @@ private class Sheeted { } } - init(_ provider: WindowProviderAdapter? = nil, id: AnyHashable?) { + init(_ provider: SheetRepresentable? = nil, id: AnyHashable?) { self.provider = provider self.sheetID = id } From cb5aa023844151a0f36f84dc2759b5eb96363f0c Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Thu, 15 Aug 2024 22:27:21 +0800 Subject: [PATCH 06/34] WIP --- .../AppKitNavigation/AppKitAnimation.swift | 21 +-- .../AppKitNavigation/Bindings/NSAlert.swift | 7 +- .../Internal/ClosureHolder.swift | 17 +++ .../Internal/ModalWindowsObserver.swift | 24 ++++ .../AppKitNavigation/Navigation/Modal.swift | 52 ++----- .../Navigation/ModalRepresentable.swift | 23 +++ .../Navigation/Presentation.swift | 14 +- .../AppKitNavigation/Navigation/Sheet.swift | 135 +++++++++++------- .../Navigation/SheetRepresentable.swift | 51 +++++++ Sources/AppKitNavigation/Observe.swift | 22 ++- 10 files changed, 225 insertions(+), 141 deletions(-) create mode 100644 Sources/AppKitNavigation/Internal/ClosureHolder.swift create mode 100644 Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/ModalRepresentable.swift create mode 100644 Sources/AppKitNavigation/Navigation/SheetRepresentable.swift diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 75e40b8f86..0d8230c8fb 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -80,21 +80,6 @@ extension AppKitAnimation { /// - 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. - /// - delay: The amount of time (measured in seconds) to wait before beginning the animations. - /// Specify a value of `0` to begin the animations immediately. - /// - dampingRatio: The damping ratio for the spring animation as it approaches its quiescent - /// state. - /// - /// To smoothly decelerate the animation without oscillation, use a value of `1`. Employ a - /// damping ratio closer to zero to increase oscillation. - /// - velocity: The initial spring velocity. For smooth start to the animation, match this - /// value to the view's velocity as it was prior to attachment. - /// - /// A value of `1` corresponds to the total animation distance traversed in one second. For - /// example, if the total animation distance is 200 points and you want the start of the - /// animation to match a view velocity of 100 pt/s, use a value of `0.5`. - /// - options: A mask of options indicating how you want to perform the animations. For a list - /// of valid constants, see `UIView.AnimationOptions`. /// - Returns: An animation using a timing curve corresponding to the motion of a physical /// spring. public static func animate( @@ -111,11 +96,7 @@ extension AppKitAnimation { /// A default animation instance. public static var `default`: Self { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - return .animate() - } else { - return .animate(withDuration: 0.35) - } + return .animate() } } #endif diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift index ed7dbe924b..37d7bb6ba3 100644 --- a/Sources/AppKitNavigation/Bindings/NSAlert.swift +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -2,7 +2,7 @@ import AppKit extension NSAlert { - /// Creates and returns a view controller for displaying an alert using a data description. + /// Creates and returns a alert for displaying an alert using a data description. /// /// - Parameters: /// - state: A data description of the alert. @@ -38,7 +38,10 @@ extension NSAlert { if buttonState.role == .cancel { button.keyEquivalent = "\u{1b}" } -// self.accessibilityLabel = button.label.accessibilityLabel.map { String(state: $0) } + + if #available(macOS 12, *) { + button.setAccessibilityLabel(buttonState.label.accessibilityLabel.map { String(state: $0) }) + } } } #endif diff --git a/Sources/AppKitNavigation/Internal/ClosureHolder.swift b/Sources/AppKitNavigation/Internal/ClosureHolder.swift new file mode 100644 index 0000000000..814e884dff --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ClosureHolder.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +internal class ClosureHolder: NSObject { + let closure: () -> Void + + init(closure: @escaping () -> Void) { + self.closure = closure + } + + func invoke() { + closure() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift new file mode 100644 index 0000000000..4fb5f90e74 --- /dev/null +++ b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift @@ -0,0 +1,24 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import Combine + +@MainActor +class ModalWindowsObserver: NSObject { + static let shared = ModalWindowsObserver() + + var windowsCancellable: [NSWindow: AnyCancellable] = [:] + + func observeWindow(_ window: NSWindow) { + windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { [weak self] _ in + guard let self else { return } + if NSApplication.shared.modalWindow === window { + NSApplication.shared.stopModal() + } + windowsCancellable.removeValue(forKey: window) + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index bd341b3529..1acc9e7ec1 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -3,42 +3,6 @@ import AppKit import Combine -@MainActor -@objc -public protocol ModalRepresentable where Self: NSObject { - @objc @discardableResult func runModal() -> NSApplication.ModalResponse - @objc var window: NSWindow { get } -} - -extension NSWindow: ModalRepresentable { - - public var window: NSWindow { self } - - public func runModal() -> NSApplication.ModalResponse { - NSApplication.shared.runModal(for: self) - } -} - -extension NSAlert: ModalRepresentable {} - -@MainActor -class WindowsObserver: NSObject { - static let shared = WindowsObserver() - - var windowsCancellable: [NSWindow: AnyCancellable] = [:] - - func observeWindow(_ window: NSWindow) { - windowsCancellable[window] = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) - .sink { [weak self] _ in - guard let self else { return } - if NSApplication.shared.modalWindow === window { - NSApplication.shared.stopModal() - } - windowsCancellable.removeValue(forKey: window) - } - } -} - @MainActor extension NSObject { @discardableResult @@ -50,12 +14,12 @@ extension NSObject { ) -> ObservationToken { modal(item: item, id: id) { $item in content($item) - } beginSheet: { modalRepresentable, _ in + } beginModal: { modalRepresentable, _ in if NSApplication.shared.modalWindow != nil { NSApplication.shared.stopModal() onDismiss?() DispatchQueue.main.async { - WindowsObserver.shared.observeWindow(modalRepresentable.window) + ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) modalRepresentable.runModal() modalRepresentable.onEndModal?.invoke() modalRepresentable.onEndModal = nil @@ -63,13 +27,13 @@ extension NSObject { } else { DispatchQueue.main.async { - WindowsObserver.shared.observeWindow(modalRepresentable.window) + ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) modalRepresentable.runModal() modalRepresentable.onEndModal?.invoke() modalRepresentable.onEndModal = nil } } - } endSheet: { provider, _ in + } endModal: { provider, _ in NSApplication.shared.stopModal() onDismiss?() } @@ -79,11 +43,11 @@ extension NSObject { item: UIBinding, id: KeyPath, content: @escaping (UIBinding) -> ModalRepresentable, - beginSheet: @escaping ( + beginModal: @escaping ( _ child: ModalRepresentable, _ transaction: UITransaction ) -> Void, - endSheet: @escaping ( + endModal: @escaping ( _ child: ModalRepresentable, _ transaction: UITransaction ) -> Void @@ -92,8 +56,8 @@ extension NSObject { item: item, id: { $0[keyPath: id] }, content: content, - beginModal: beginSheet, - endModal: endSheet + beginModal: beginModal, + endModal: endModal ) } diff --git a/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift b/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift new file mode 100644 index 0000000000..f3526ea73e --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift @@ -0,0 +1,23 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +@objc +public protocol ModalRepresentable where Self: NSObject { + @objc @discardableResult func runModal() -> NSApplication.ModalResponse + @objc var window: NSWindow { get } +} + +extension NSWindow: ModalRepresentable { + + public var window: NSWindow { self } + + public func runModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} + +extension NSAlert: ModalRepresentable {} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 50e4bf93c3..8a9f6a52a2 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -7,7 +7,7 @@ import AppKitNavigationShim extension NSViewController { /// Presents a view controller modally when a binding to a Boolean value you provide is true. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - isPresented: A binding to a Boolean value that determines whether to present the view @@ -27,7 +27,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -50,7 +50,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -74,7 +74,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -101,7 +101,7 @@ extension NSViewController { /// Presents a view controller modally using the given item as a data source for its content. /// - /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for UIKit. + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. /// /// - Parameters: /// - item: A binding to an optional source of truth for the view controller. When `item` is @@ -332,9 +332,9 @@ private class Presented { weak var controller: NSViewController? let presentationID: AnyHashable? deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to + // NB: This can only be assumed because it is held in a NSViewController and is guaranteed to // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. + // to be a NSViewController as well, to ensure this functionality. MainActor._assumeIsolated { self.controller?.dismiss(nil) } diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 348902992f..935a2182d4 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -2,53 +2,95 @@ import AppKit -@MainActor -public protocol SheetRepresentable: NSObject { - var currentWindow: NSWindow? { get } - func beginSheet(for provider: SheetRepresentable) async - func endSheet(for provider: SheetRepresentable) -} - extension SheetRepresentable { - public func beginSheet(for provider: any SheetRepresentable) async { - if let sheetedWindow = provider.currentWindow { - await currentWindow?.beginSheet(sheetedWindow) - } + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - isSheeted: A binding to a Boolean value that determines whether to sheet the representable + /// - onDismiss: The closure to execute when dismissing the representable. + /// - content: A closure that returns the representable to display over the current window content. + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> SheetRepresentable + ) -> ObservationToken { + sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } - public func endSheet(for provider: any SheetRepresentable) { - if let sheetedWindow = provider.currentWindow { - currentWindow?.endSheet(sheetedWindow) - } + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> SheetRepresentable + ) -> ObservationToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } -} - -extension NSWindow: SheetRepresentable { - public var currentWindow: NSWindow? { self } -} - -extension NSWindowController: SheetRepresentable { - public var currentWindow: NSWindow? { window } -} - -extension NSViewController: SheetRepresentable { - public var currentWindow: NSWindow? { view.window } -} - -extension NSAlert: SheetRepresentable { - public var currentWindow: NSWindow? { window } - public func beginSheet(for provider: any SheetRepresentable) async { - guard let parentWindow = provider.currentWindow else { return } - await beginSheetModal(for: parentWindow) + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> SheetRepresentable + ) -> ObservationToken { + sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } - public func endSheet(for provider: any SheetRepresentable) { - provider.currentWindow?.endSheet(window) + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> SheetRepresentable + ) -> ObservationToken { + sheet(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } } -} -extension SheetRepresentable { @discardableResult public func sheet( item: UIBinding, @@ -142,11 +184,7 @@ extension SheetRepresentable { beginSheet(childController, transaction) } } -// if hasViewAppeared { work() -// } else { -// onViewAppear.append(work) -// } } else if let presented = sheetedByID[key] { if let controller = presented.provider { endSheet(controller, transaction) @@ -181,18 +219,6 @@ extension SheetRepresentable { } } -internal class ClosureHolder: NSObject { - let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - func invoke() { - closure() - } -} - private let onEndSheetKey = malloc(1)! private let sheetedKey = malloc(1)! @@ -224,5 +250,4 @@ extension NSWindow { } } - #endif diff --git a/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift b/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift new file mode 100644 index 0000000000..03b16aede2 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift @@ -0,0 +1,51 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol SheetRepresentable: NSObject { + var currentWindow: NSWindow? { get } + func beginSheet(for provider: SheetRepresentable) async + func endSheet(for provider: SheetRepresentable) +} + +extension SheetRepresentable { + public func beginSheet(for provider: any SheetRepresentable) async { + if let sheetedWindow = provider.currentWindow { + await currentWindow?.beginSheet(sheetedWindow) + } + } + + public func endSheet(for provider: any SheetRepresentable) { + if let sheetedWindow = provider.currentWindow { + currentWindow?.endSheet(sheetedWindow) + } + } +} + +extension NSWindow: SheetRepresentable { + public var currentWindow: NSWindow? { self } +} + +extension NSWindowController: SheetRepresentable { + public var currentWindow: NSWindow? { window } +} + +extension NSViewController: SheetRepresentable { + public var currentWindow: NSWindow? { view.window } +} + +extension NSAlert: SheetRepresentable { + public var currentWindow: NSWindow? { window } + + public func beginSheet(for provider: any SheetRepresentable) async { + guard let parentWindow = provider.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + public func endSheet(for provider: any SheetRepresentable) { + provider.currentWindow?.endSheet(window) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift index 23190e08bf..8032874494 100644 --- a/Sources/AppKitNavigation/Observe.swift +++ b/Sources/AppKitNavigation/Observe.swift @@ -10,7 +10,7 @@ extension NSObject { /// 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 UIKit views and controller. + /// 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: /// @@ -18,16 +18,16 @@ extension NSObject { /// override func viewDidLoad() { /// super.viewDidLoad() /// - /// let countLabel = UILabel() - /// let incrementButton = UIButton(primaryAction: UIAction { [weak self] _ in + /// let countLabel = NSTextField(labelWithString: "") + /// let incrementButton = NSButton { [weak self] _ in /// self?.model.incrementButtonTapped() - /// }) + /// } /// /// observe { [weak self] in /// guard let self /// else { return } /// - /// countLabel.text = "\(model.count)" + /// countLabel.stringValue = "\(model.count)" /// } /// } /// ``` @@ -38,7 +38,7 @@ extension NSObject { /// again. /// /// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your - /// view, such as `viewDidLoad` for `UIViewController`. This works even if you have many UI + /// view, such as `viewDidLoad` for `NSViewController`. This works even if you have many UI /// components to update: /// /// ```swift @@ -51,9 +51,9 @@ extension NSObject { /// /// countLabel.isHidden = model.isObservingCount /// if !countLabel.isHidden { - /// countLabel.text = "\(model.count)" + /// countLabel.stringValue = "\(model.count)" /// } - /// factLabel.text = model.fact + /// factLabel.stringValue = model.fact /// } /// } /// ``` @@ -124,9 +124,6 @@ extension NSObject { let token = SwiftNavigation.observe { transaction in MainActor._assumeIsolated { withUITransaction(transaction) { - #if os(watchOS) - apply(transaction) - #else if transaction.appKit.disablesAnimations { NSView.performWithoutAnimation { apply(transaction) } for completion in transaction.appKit.animationCompletions { @@ -149,7 +146,6 @@ extension NSObject { completion(true) } } - #endif } } } task: { transaction, work in @@ -174,7 +170,7 @@ extension NSObject { } extension NSView { - static func performWithoutAnimation(_ block: () -> Void) { + fileprivate static func performWithoutAnimation(_ block: () -> Void) { NSAnimationContext.runAnimationGroup { context in context.allowsImplicitAnimation = false block() From 26ef0fa75c47d5f82083f351748d72cb69c6e3bc Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 16 Aug 2024 18:38:19 +0800 Subject: [PATCH 07/34] Refactoring, can't compile for now --- ...ppKit+BasicsNavigationViewController.swift | 4 +- .../AppKitNavigation/Navigation/Modal.swift | 140 +++++------------- .../Navigation/ModalContent.swift | 41 +++++ .../Navigation/ModalRepresentable.swift | 23 --- .../Navigation/NavigationContent.swift | 82 ++++++++++ .../Navigation/NavigationObserver.swift | 86 +++++++++++ .../Navigation/Presentation.swift | 94 +++++++++--- .../AppKitNavigation/Navigation/Sheet.swift | 85 +++++++---- .../Navigation/SheetContent.swift | 90 +++++++++++ .../Navigation/SheetRepresentable.swift | 51 ------- 10 files changed, 462 insertions(+), 234 deletions(-) create mode 100644 Sources/AppKitNavigation/Navigation/ModalContent.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalRepresentable.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationContent.swift create mode 100644 Sources/AppKitNavigation/Navigation/NavigationObserver.swift create mode 100644 Sources/AppKitNavigation/Navigation/SheetContent.swift delete mode 100644 Sources/AppKitNavigation/Navigation/SheetRepresentable.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index d5497efe8a..8ec32db007 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -64,7 +64,9 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS // return alert // } modal(item: $model.sheet, id: \.self) { count in - NSWindow(contentViewController: XiblessViewController()) + let vc = XiblessViewController() + vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) + return NSWindow(contentViewController: vc) } // present(item: $model.sheet, id: \.self, style: .sheet) { count in //// let vc = NSHostingController( diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 1acc9e7ec1..2ce2d03ad6 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -6,154 +6,82 @@ import Combine @MainActor extension NSObject { @discardableResult - public func modal( + public func modal( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> ModalRepresentable + content: @escaping (UIBinding) -> Content ) -> ObservationToken { modal(item: item, id: id) { $item in content($item) - } beginModal: { modalRepresentable, _ in + } beginModal: { modalContent, _ in if NSApplication.shared.modalWindow != nil { NSApplication.shared.stopModal() onDismiss?() DispatchQueue.main.async { - ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) - modalRepresentable.runModal() - modalRepresentable.onEndModal?.invoke() - modalRepresentable.onEndModal = nil + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.runModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil } } else { DispatchQueue.main.async { - ModalWindowsObserver.shared.observeWindow(modalRepresentable.window) - modalRepresentable.runModal() - modalRepresentable.onEndModal?.invoke() - modalRepresentable.onEndModal = nil + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.runModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil } } - } endModal: { provider, _ in + } endModal: { _, _ in NSApplication.shared.stopModal() onDismiss?() } } - private func modal( + private func modal( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> ModalRepresentable, + content: @escaping (UIBinding) -> Content, beginModal: @escaping ( - _ child: ModalRepresentable, + _ child: Content, _ transaction: UITransaction ) -> Void, endModal: @escaping ( - _ child: ModalRepresentable, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - modal( + let modalObserver: ModalObserver = modalObserver() + return modalObserver.observe( item: item, id: { $0[keyPath: id] }, content: content, - beginModal: beginModal, - endModal: endModal + begin: beginModal, + end: endModal ) } - private func modal( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> ModalRepresentable, - beginModal: @escaping ( - _ child: ModalRepresentable, - _ transaction: UITransaction - ) -> Void, - endModal: @escaping ( - _ child: ModalRepresentable, - _ transaction: UITransaction - ) -> Void - ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = modaledByID[key] { - guard let presentationID = presented.modalID, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let modalRepresentable = content(unwrappedItem) - let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { - item.wrappedValue = nil - } - } - modalRepresentable.onEndModal = onEndSheet - - self.modaledByID[key] = Modaled(modalRepresentable, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - beginModal(modalRepresentable, transaction) - } - } - work() - } else if let modaled = modaledByID[key] { - if let modalRepresentable = modaled.provider { - endModal(modalRepresentable, transaction) - } - self.modaledByID[key] = nil - } - } - } - - private var modaledByID: [UIBindingIdentifier: Modaled] { - get { - (objc_getAssociatedObject(self, modaledKey) - as? [UIBindingIdentifier: Modaled]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, modaledKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - private var onEndModal: ClosureHolder? { - set { - objc_setAssociatedObject( - self, onEndModalKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - get { - objc_getAssociatedObject(self, onEndModalKey) as? ClosureHolder + private func modalObserver() -> ModalObserver { + if let observer = objc_getAssociatedObject(self, modalObserverKey) as? ModalObserver { + return observer + } else { + let modalObserver = ModalObserver(owner: self) + objc_setAssociatedObject(self, modalObserverKey, modalObserver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return modalObserver } } } -private let onEndModalKey = malloc(1)! -private let modaledKey = malloc(1)! - +private let modalObserverKey = malloc(1)! @MainActor -private class Modaled { - weak var provider: ModalRepresentable? - let modalID: AnyHashable? - deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - NSApplication.shared.stopModal() - } - } +private var modalObserverKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +private typealias ModalObserver = NavigationObserver - init(_ provider: ModalRepresentable? = nil, id: AnyHashable?) { - self.provider = provider - self.modalID = id +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 0000000000..c141aa1f11 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +public protocol ModalContent: NavigationContent { + @discardableResult func runModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +extension NSWindow: ModalContent { + public var window: NSWindow { self } + + public func runModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } + + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +extension NSAlert: ModalContent { + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift b/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift deleted file mode 100644 index f3526ea73e..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalRepresentable.swift +++ /dev/null @@ -1,23 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -@objc -public protocol ModalRepresentable where Self: NSObject { - @objc @discardableResult func runModal() -> NSApplication.ModalResponse - @objc var window: NSWindow { get } -} - -extension NSWindow: ModalRepresentable { - - public var window: NSWindow { self } - - public func runModal() -> NSApplication.ModalResponse { - NSApplication.shared.runModal(for: self) - } -} - -extension NSAlert: ModalRepresentable {} - -#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift new file mode 100644 index 0000000000..718af62732 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -0,0 +1,82 @@ +import Foundation + +@MainActor +@objc +public protocol NavigationContent where Self: NSObject { + var onBeginNavigation: (() -> Void)? { set get } + var onEndNavigation: (() -> Void)? { set get } +} + +@MainActor +private var onBeginNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] + +@MainActor +private var onEndNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +/// Hashable wrapper for any metatype value. +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)) + } + // Pre Swift 4.2: + // var hashValue: Int { return ObjectIdentifier(base).hashValue } +} + +extension NavigationContent { + static var onBeginNavigationKey: UnsafeMutableRawPointer { + let key = AnyHashableMetatype(Self.self) + if let onBeginNavigationKey = onBeginNavigationKeys[key] { + return onBeginNavigationKey + } else { + let onBeginNavigationKey = malloc(1)! + onBeginNavigationKeys[key] = onBeginNavigationKey + return onBeginNavigationKey + } + } + + static var onEndNavigationKey: UnsafeMutableRawPointer { + let key = AnyHashableMetatype(Self.self) + if let onEndNavigationKey = onEndNavigationKeys[key] { + return onEndNavigationKey + } else { + let onEndNavigationKey = malloc(1)! + onEndNavigationKeys[key] = onEndNavigationKey + return onEndNavigationKey + } + } + + var _onBeginNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, Self.onBeginNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, Self.onBeginNavigationKey) as? () -> Void + } + } + + var _onEndNavigation: (() -> Void)? { + set { + objc_setAssociatedObject(self, Self.onEndNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + get { + objc_getAssociatedObject(self, Self.onEndNavigationKey) as? () -> Void + } + } +} + +@MainActor +protocol NavigatedProtocol: AnyObject { + associatedtype Content: NavigationContent + var content: Content? { get } + var id: AnyHashable? { get } + init(_ content: Content, id: AnyHashable?) +} diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift new file mode 100644 index 0000000000..b7e6b22b96 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -0,0 +1,86 @@ +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 + ) -> ObservationToken { + let key = UIBindingIdentifier(item) + return observe { [weak self] transaction in + guard let self else { return } + if let unwrappedItem = UIBinding(item) { + if let presented = navigatedByID[key] { + guard let presentationID = presented.id, + presentationID != id(unwrappedItem.wrappedValue) + else { + return + } + } + let content = content(unwrappedItem) + let onEndNavigation = { [presentationID = id(unwrappedItem.wrappedValue)] in + if let wrappedValue = item.wrappedValue, + presentationID == 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 + } +} diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 8a9f6a52a2..08e70ddafe 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -20,7 +20,7 @@ extension NSViewController { isPresented: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSViewController + content: @escaping () -> PresentationContent ) -> ObservationToken { present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } @@ -43,7 +43,7 @@ extension NSViewController { item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSViewController + content: @escaping (Item) -> PresentationContent ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -67,7 +67,7 @@ extension NSViewController { item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSViewController + content: @escaping (UIBinding) -> PresentationContent ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -92,7 +92,7 @@ extension NSViewController { id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSViewController + content: @escaping (Item) -> PresentationContent ) -> ObservationToken { present(item: item, id: id, style: style, onDismiss: onDismiss) { content($0.wrappedValue) @@ -120,7 +120,7 @@ extension NSViewController { id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSViewController + content: @escaping (UIBinding) -> PresentationContent ) -> ObservationToken { destination(item: item, id: id) { $item in content($item) @@ -174,10 +174,10 @@ extension NSViewController { @discardableResult public func destination( isPresented: UIBinding, - content: @escaping () -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, + content: @escaping () -> PresentationContent, + present: @escaping (PresentationContent, UITransaction) -> Void, dismiss: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -204,10 +204,10 @@ extension NSViewController { @discardableResult public func destination( item: UIBinding, - content: @escaping (UIBinding) -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, + content: @escaping (UIBinding) -> PresentationContent, + present: @escaping (PresentationContent, UITransaction) -> Void, dismiss: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -239,23 +239,25 @@ extension NSViewController { public func destination( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> NSViewController, + content: @escaping (UIBinding) -> PresentationContent, present: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void, dismiss: @escaping ( - _ child: NSViewController, + _ child: PresentationContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - destination( - item: item, - id: { $0[keyPath: id] }, - content: content, - present: present, - dismiss: dismiss - ) +// destination( +// item: item, +// id: { $0[keyPath: id] }, +// content: content, +// present: present, +// dismiss: dismiss +// ) + presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + } private func destination( @@ -325,6 +327,54 @@ extension NSViewController { } private static let presentedKey = malloc(1)! + + private var presentationObserver: PresentationObserver { + set { + objc_setAssociatedObject(self, presentationObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let observer = objc_getAssociatedObject(self, presentationObserverKey) as? PresentationObserver { + return observer + } else { + let observer = PresentationObserver(owner: self) + self.presentationObserver = observer + return observer + } + } + } + +} +private let presentationObserverKey = malloc(1)! + +public protocol PresentationContent: NavigationContent { + func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) +} +extension NSViewController: PresentationContent {} +extension NavigationContent where Self: NSViewController { + var _onEndNavigation: (() -> Void)? { + set { + onDismiss = newValue + } + get { + onDismiss + } + } +} + +class PresentationObserver: NavigationObserver { + override func commitWork(_ work: @escaping () -> Void) { + if owner.hasViewAppeared { + work() + } else { + owner.onViewAppear.append(work) + } + } +} + +extension Navigated where Content: NSViewController { + func clearup() { + self.content?.dismiss(nil) + } } @MainActor @@ -340,7 +390,7 @@ private class Presented { } } - init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { + required init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { self.controller = controller self.presentationID = presentationID } diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 935a2182d4..350dff6d90 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -2,7 +2,7 @@ import AppKit -extension SheetRepresentable { +extension SheetContent { /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. @@ -15,7 +15,7 @@ extension SheetRepresentable { public func sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> SheetRepresentable + content: @escaping () -> SheetContent ) -> ObservationToken { sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } @@ -37,7 +37,7 @@ extension SheetRepresentable { public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetRepresentable + content: @escaping (Item) -> SheetContent ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -60,7 +60,7 @@ extension SheetRepresentable { public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetRepresentable + content: @escaping (UIBinding) -> SheetContent ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -84,7 +84,7 @@ extension SheetRepresentable { item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetRepresentable + content: @escaping (Item) -> SheetContent ) -> ObservationToken { sheet(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) @@ -96,29 +96,29 @@ extension SheetRepresentable { item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetRepresentable + content: @escaping (UIBinding) -> SheetContent ) -> ObservationToken { sheet(item: item, id: id) { $item in content($item) - } beginSheet: { [weak self] child, transaction in + } 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.onEndSheet?.invoke() - child.onEndSheet = nil + child.onEndNavigation?() + child.onEndNavigation = nil } } else { Task { @MainActor in await self.beginSheet(for: child) - child.onEndSheet?.invoke() - child.onEndSheet = nil + child.onEndNavigation?() + child.onEndNavigation = nil } } - } endSheet: { [weak self] provider, transaction in - self?.endSheet(for: provider) + } endSheet: { [weak self] content, _ in + self?.endSheet(for: content) onDismiss?() } } @@ -126,35 +126,35 @@ extension SheetRepresentable { private func sheet( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> SheetRepresentable, + content: @escaping (UIBinding) -> SheetContent, beginSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - sheet( + sheetObserver.observe( item: item, id: { $0[keyPath: id] }, content: content, - beginSheet: beginSheet, - endSheet: endSheet + begin: beginSheet, + end: endSheet ) } private func sheet( item: UIBinding, id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> SheetRepresentable, + content: @escaping (UIBinding) -> SheetContent, beginSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: SheetRepresentable, + _ child: SheetContent, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -163,7 +163,7 @@ extension SheetRepresentable { guard let self else { return } if let unwrappedItem = UIBinding(item) { if let presented = sheetedByID[key] { - guard let presentationID = presented.sheetID, + guard let presentationID = presented.id, presentationID != id(unwrappedItem.wrappedValue) else { return @@ -186,7 +186,7 @@ extension SheetRepresentable { } work() } else if let presented = sheetedByID[key] { - if let controller = presented.provider { + if let controller = presented.content { endSheet(controller, transaction) } self.sheetedByID[key] = nil @@ -217,27 +217,44 @@ extension SheetRepresentable { objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder } } + + private var sheetObserver: SheetObserver { + set { + objc_setAssociatedObject(self, sheetObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let observer = objc_getAssociatedObject(self, sheetObserverKey) as? SheetObserver { + return observer + } else { + let observer = SheetObserver(owner: self) + self.sheetObserver = observer + return observer + } + } + } } +private typealias SheetObserver = NavigationObserver +private let sheetObserverKey = malloc(1)! private let onEndSheetKey = malloc(1)! private let sheetedKey = malloc(1)! @MainActor -private class Sheeted { - weak var provider: SheetRepresentable? - let sheetID: AnyHashable? +private class Sheeted: NavigatedProtocol { + weak var content: (any SheetContent)? + let id: AnyHashable? deinit { // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to // deinit alongside it on the main thread. If we use this other places we should force it // to be a UIViewController as well, to ensure this functionality. MainActor._assumeIsolated { - self.provider?.currentWindow?.endSheeted() + self.content?.currentWindow?.endSheeted() } } - init(_ provider: SheetRepresentable? = nil, id: AnyHashable?) { - self.provider = provider - self.sheetID = id + required init(_ content: any SheetContent, id: AnyHashable?) { + self.content = content + self.id = id } } @@ -250,4 +267,10 @@ extension NSWindow { } } +extension Navigated where Content: SheetContent { + func clearup() { + self.content?.currentWindow?.endSheeted() + } +} + #endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift new file mode 100644 index 0000000000..2fc4318bac --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -0,0 +1,90 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +@objc +public protocol SheetContent: NavigationContent { + var currentWindow: NSWindow? { get } + func beginSheet(for content: SheetContent) async + func endSheet(for content: SheetContent) +} + +extension SheetContent { + func _beginSheet(for content: any SheetContent) async { + if let sheetedWindow = content.currentWindow { + await currentWindow?.beginSheet(sheetedWindow) + } + } + + func _endSheet(for content: any SheetContent) { + if let sheetedWindow = content.currentWindow { + currentWindow?.endSheet(sheetedWindow) + } + } +} + +extension NSWindow: SheetContent { + public var currentWindow: NSWindow? { self } + public func beginSheet(for content: any SheetContent) async { + await _beginSheet(for: content) + } + public func endSheet(for content: any SheetContent) { + _endSheet(for: content) + } +} + +extension NSWindowController: SheetContent { + public var currentWindow: NSWindow? { window } + public func beginSheet(for content: any SheetContent) async { + await _beginSheet(for: content) + } + public func endSheet(for content: any SheetContent) { + _endSheet(for: content) + } + + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +extension NSViewController: SheetContent { + public var currentWindow: NSWindow? { view.window } + public func beginSheet(for content: any SheetContent) async { + await _beginSheet(for: content) + } + public func endSheet(for content: any SheetContent) { + _endSheet(for: content) + } + + public var onBeginNavigation: (() -> Void)? { + set { _onBeginNavigation = newValue } + get { _onBeginNavigation } + } + + public var onEndNavigation: (() -> Void)? { + set { _onEndNavigation = newValue } + get { _onEndNavigation } + } +} + +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) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift b/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift deleted file mode 100644 index 03b16aede2..0000000000 --- a/Sources/AppKitNavigation/Navigation/SheetRepresentable.swift +++ /dev/null @@ -1,51 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -public protocol SheetRepresentable: NSObject { - var currentWindow: NSWindow? { get } - func beginSheet(for provider: SheetRepresentable) async - func endSheet(for provider: SheetRepresentable) -} - -extension SheetRepresentable { - public func beginSheet(for provider: any SheetRepresentable) async { - if let sheetedWindow = provider.currentWindow { - await currentWindow?.beginSheet(sheetedWindow) - } - } - - public func endSheet(for provider: any SheetRepresentable) { - if let sheetedWindow = provider.currentWindow { - currentWindow?.endSheet(sheetedWindow) - } - } -} - -extension NSWindow: SheetRepresentable { - public var currentWindow: NSWindow? { self } -} - -extension NSWindowController: SheetRepresentable { - public var currentWindow: NSWindow? { window } -} - -extension NSViewController: SheetRepresentable { - public var currentWindow: NSWindow? { view.window } -} - -extension NSAlert: SheetRepresentable { - public var currentWindow: NSWindow? { window } - - public func beginSheet(for provider: any SheetRepresentable) async { - guard let parentWindow = provider.currentWindow else { return } - await beginSheetModal(for: parentWindow) - } - - public func endSheet(for provider: any SheetRepresentable) { - provider.currentWindow?.endSheet(window) - } -} - -#endif From 8e0e22523b1372b7083916d81adeff082ad6aeff Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:20:14 +0800 Subject: [PATCH 08/34] WIP --- ...ppKit+BasicsNavigationViewController.swift | 31 ++- .../AppKitNavigation/Navigation/Modal.swift | 10 +- .../Navigation/ModalContent.swift | 22 +- .../Navigation/NavigationContent.swift | 68 +++---- .../Navigation/Presentation.swift | 191 ++++++------------ .../AppKitNavigation/Navigation/Sheet.swift | 145 +++---------- .../Navigation/SheetContent.swift | 43 +--- 7 files changed, 141 insertions(+), 369 deletions(-) diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index 8ec32db007..5cf3b8a187 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -63,19 +63,28 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS // alert.addAction(UIAlertAction(title: "OK", style: .default)) // return alert // } - modal(item: $model.sheet, id: \.self) { count in - let vc = XiblessViewController() - vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) - return NSWindow(contentViewController: vc) - } -// present(item: $model.sheet, id: \.self, style: .sheet) { count in -//// let vc = NSHostingController( -//// rootView: Form { Text(count.description) } -//// ) + + + +// modal(item: $model.sheet, id: \.self) { count in +// let vc = XiblessViewController() +// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) +// return NSWindow(contentViewController: vc) +// } + +// sheet(item: $model.sheet, id: \.self) { count in // let vc = XiblessViewController() -// vc.preferredContentSize = .init(width: 300, height: 200) -// return vc +// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) +// return NSWindow(contentViewController: vc) // } + present(item: $model.sheet, id: \.self, style: .sheet) { count in +// let vc = NSHostingController( +// rootView: Form { Text(count.description) } +// ) + let vc = XiblessViewController() + vc.preferredContentSize = .init(width: 300, height: 200) + return vc + } present(isPresented: $model.isSheetPresented, style: .sheet) { let vc = NSHostingController( rootView: Form { Text("Hello!") } diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 2ce2d03ad6..927a64d815 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -63,19 +63,19 @@ extension NSObject { } private func modalObserver() -> ModalObserver { - if let observer = objc_getAssociatedObject(self, modalObserverKey) as? ModalObserver { + if let observer = objc_getAssociatedObject(self, modalObserverKeys.key(of: Content.self)) as? ModalObserver { return observer } else { - let modalObserver = ModalObserver(owner: self) - objc_setAssociatedObject(self, modalObserverKey, modalObserver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return modalObserver + let observer = ModalObserver(owner: self) + objc_setAssociatedObject(self, modalObserverKeys.key(of: Content.self), observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer } } } private let modalObserverKey = malloc(1)! @MainActor -private var modalObserverKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +private var modalObserverKeys = AssociatedKeys() private typealias ModalObserver = NavigationObserver extension Navigated where Content: ModalContent { diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index c141aa1f11..a372687396 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -14,28 +14,8 @@ extension NSWindow: ModalContent { public func runModal() -> NSApplication.ModalResponse { NSApplication.shared.runModal(for: self) } - - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } } -extension NSAlert: ModalContent { - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } -} +extension NSAlert: ModalContent {} #endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index 718af62732..3af25d1ecd 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -1,18 +1,33 @@ import Foundation @MainActor -@objc -public protocol NavigationContent where Self: NSObject { +public protocol NavigationContent: AnyObject { var onBeginNavigation: (() -> Void)? { set get } var onEndNavigation: (() -> Void)? { set get } } @MainActor -private var onBeginNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] +private var onBeginNavigationKeys = AssociatedKeys() @MainActor -private var onEndNavigationKeys: [AnyHashableMetatype: UnsafeMutableRawPointer] = [:] -/// Hashable wrapper for any metatype value. +private var onEndNavigationKeys = AssociatedKeys() + +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 @@ -27,56 +42,25 @@ struct AnyHashableMetatype: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(base)) } - // Pre Swift 4.2: - // var hashValue: Int { return ObjectIdentifier(base).hashValue } } extension NavigationContent { - static var onBeginNavigationKey: UnsafeMutableRawPointer { - let key = AnyHashableMetatype(Self.self) - if let onBeginNavigationKey = onBeginNavigationKeys[key] { - return onBeginNavigationKey - } else { - let onBeginNavigationKey = malloc(1)! - onBeginNavigationKeys[key] = onBeginNavigationKey - return onBeginNavigationKey - } - } - - static var onEndNavigationKey: UnsafeMutableRawPointer { - let key = AnyHashableMetatype(Self.self) - if let onEndNavigationKey = onEndNavigationKeys[key] { - return onEndNavigationKey - } else { - let onEndNavigationKey = malloc(1)! - onEndNavigationKeys[key] = onEndNavigationKey - return onEndNavigationKey - } - } - var _onBeginNavigation: (() -> Void)? { + public var onBeginNavigation: (() -> Void)? { set { - objc_setAssociatedObject(self, Self.onBeginNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } get { - objc_getAssociatedObject(self, Self.onBeginNavigationKey) as? () -> Void + objc_getAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self)) as? () -> Void } } - var _onEndNavigation: (() -> Void)? { + public var onEndNavigation: (() -> Void)? { set { - objc_setAssociatedObject(self, Self.onEndNavigationKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + objc_setAssociatedObject(self, onEndNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } get { - objc_getAssociatedObject(self, Self.onEndNavigationKey) as? () -> Void + objc_getAssociatedObject(self, onEndNavigationKeys.key(of: Self.self)) as? () -> Void } } } - -@MainActor -protocol NavigatedProtocol: AnyObject { - associatedtype Content: NavigationContent - var content: Content? { get } - var id: AnyHashable? { get } - init(_ content: Content, id: AnyHashable?) -} diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 08e70ddafe..676c452c76 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -16,11 +16,11 @@ extension NSViewController { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func present( + public func present( isPresented: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping () -> PresentationContent + content: @escaping () -> Content ) -> ObservationToken { present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } } @@ -39,11 +39,11 @@ extension NSViewController { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> PresentationContent + content: @escaping (Item) -> Content ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -63,11 +63,11 @@ extension NSViewController { /// controller's content. @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> PresentationContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) } @@ -87,12 +87,12 @@ extension NSViewController { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> PresentationContent + content: @escaping (Item) -> Content ) -> ObservationToken { present(item: item, id: id, style: style, onDismiss: onDismiss) { content($0.wrappedValue) @@ -115,26 +115,27 @@ extension NSViewController { /// controller's content. @_disfavoredOverload @discardableResult - public func present( + public func present( item: UIBinding, id: KeyPath, style: TransitionStyle, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> PresentationContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { destination(item: item, id: id) { $item in content($item) } present: { [weak self] child, transaction in guard let self else { return } - if presentedViewControllers != nil { - self.dismiss(nil) + if let presentedViewController = presentedViewControllers?.first { + self.dismiss(presentedViewController) onDismiss?() - self.present(child, for: style) + child.presented(from: self, style: style) } else { - self.present(child, for: style) + child.presented(from: self, style: style) } - } dismiss: { [weak self] _, transaction in - self?.dismiss(nil) + } dismiss: { [weak self] child, transaction in + guard let self else { return } + child.dismiss(from: self) onDismiss?() } } @@ -172,12 +173,12 @@ extension NSViewController { /// - present: The closure to execute when presenting the view controller. /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult - public func destination( + public func destination( isPresented: UIBinding, - content: @escaping () -> PresentationContent, - present: @escaping (PresentationContent, UITransaction) -> Void, + content: @escaping () -> Content, + present: @escaping (Content, UITransaction) -> Void, dismiss: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -202,21 +203,22 @@ extension NSViewController { /// - present: The closure to execute when presenting the view controller. /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult - public func destination( + public func destination( item: UIBinding, - content: @escaping (UIBinding) -> PresentationContent, - present: @escaping (PresentationContent, UITransaction) -> Void, + content: @escaping (UIBinding) -> Content, + present: @escaping (Content, UITransaction) -> Void, dismiss: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - destination( + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe( item: item, id: { _ in nil }, content: content, - present: present, - dismiss: dismiss + begin: present, + end: dismiss ) } @@ -236,16 +238,16 @@ extension NSViewController { /// - present: The closure to execute when presenting the view controller. /// - dismiss: The closure to execute when dismissing the view controller. @discardableResult - public func destination( + public func destination( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> PresentationContent, + content: @escaping (UIBinding) -> Content, present: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void, dismiss: @escaping ( - _ child: PresentationContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { @@ -256,100 +258,45 @@ extension NSViewController { // present: present, // dismiss: dismiss // ) - presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) } - private func destination( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> NSViewController, - present: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void - ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = presentedByID[key] { - guard let presentationID = presented.presentationID, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let childController = content(unwrappedItem) - let onDismiss = { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { - item.wrappedValue = nil - } - } - childController.onDismiss = onDismiss - - self.presentedByID[key] = Presented(childController, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - present(childController, transaction) - } - } - if hasViewAppeared { - work() - } else { - onViewAppear.append(work) - } - } else if let presented = presentedByID[key] { - if let controller = presented.controller { - dismiss(controller, transaction) - } - self.presentedByID[key] = nil - } - } - } - - fileprivate var presentedByID: [UIBindingIdentifier: Presented] { - get { - (objc_getAssociatedObject(self, Self.presentedKey) - as? [UIBindingIdentifier: Presented]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, Self.presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - private static let presentedKey = malloc(1)! - private var presentationObserver: PresentationObserver { - set { - objc_setAssociatedObject(self, presentationObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - if let observer = objc_getAssociatedObject(self, presentationObserverKey) as? PresentationObserver { - return observer - } else { - let observer = PresentationObserver(owner: self) - self.presentationObserver = observer - return observer - } + 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 } } } private let presentationObserverKey = malloc(1)! +@MainActor +private var presentationObserverKeys = AssociatedKeys() + +@MainActor public protocol PresentationContent: NavigationContent { func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) + func dismiss(from presentingViewController: NSViewController) } -extension NSViewController: PresentationContent {} + + +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) + } +} + extension NavigationContent where Self: NSViewController { var _onEndNavigation: (() -> Void)? { set { @@ -361,7 +308,7 @@ extension NavigationContent where Self: NSViewController { } } -class PresentationObserver: NavigationObserver { +class PresentationObserver: NavigationObserver { override func commitWork(_ work: @escaping () -> Void) { if owner.hasViewAppeared { work() @@ -377,22 +324,4 @@ extension Navigated where Content: NSViewController { } } -@MainActor -private class Presented { - weak var controller: NSViewController? - let presentationID: AnyHashable? - deinit { - // NB: This can only be assumed because it is held in a NSViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a NSViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - self.controller?.dismiss(nil) - } - } - - required init(_ controller: NSViewController, id presentationID: AnyHashable? = nil) { - self.controller = controller - self.presentationID = presentationID - } -} #endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 350dff6d90..a2ff357ebd 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -12,10 +12,10 @@ extension SheetContent { /// - onDismiss: The closure to execute when dismissing the representable. /// - content: A closure that returns the representable to display over the current window content. @discardableResult - public func sheet( + public func sheet( isSheeted: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> SheetContent + content: @escaping () -> Content ) -> ObservationToken { sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } @@ -34,10 +34,10 @@ extension SheetContent { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func sheet( + public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetContent + content: @escaping (Item) -> Content ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -57,10 +57,10 @@ extension SheetContent { /// controller's content. @_disfavoredOverload @discardableResult - public func sheet( + public func sheet( item: UIBinding, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) } @@ -80,11 +80,11 @@ extension SheetContent { /// - content: A closure that returns the view controller to display over the current view /// controller's content. @discardableResult - public func sheet( + public func sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> SheetContent + content: @escaping (Item) -> Content ) -> ObservationToken { sheet(item: item, id: id, onDismiss: onDismiss) { content($0.wrappedValue) @@ -92,11 +92,11 @@ extension SheetContent { } @discardableResult - public func sheet( + public func sheet( item: UIBinding, id: KeyPath, onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> SheetContent + content: @escaping (UIBinding) -> Content ) -> ObservationToken { sheet(item: item, id: id) { $item in content($item) @@ -123,20 +123,21 @@ extension SheetContent { } } - private func sheet( + private func sheet( item: UIBinding, id: KeyPath, - content: @escaping (UIBinding) -> SheetContent, + content: @escaping (UIBinding) -> Content, beginSheet: @escaping ( - _ child: SheetContent, + _ child: Content, _ transaction: UITransaction ) -> Void, endSheet: @escaping ( - _ child: SheetContent, + _ child: Content, _ transaction: UITransaction ) -> Void ) -> ObservationToken { - sheetObserver.observe( + let sheetObserver: SheetObserver = sheetObserver() + return sheetObserver.observe( item: item, id: { $0[keyPath: id] }, content: content, @@ -145,118 +146,26 @@ extension SheetContent { ) } - private func sheet( - item: UIBinding, - id: @escaping (Item) -> AnyHashable?, - content: @escaping (UIBinding) -> SheetContent, - beginSheet: @escaping ( - _ child: SheetContent, - _ transaction: UITransaction - ) -> Void, - endSheet: @escaping ( - _ child: SheetContent, - _ transaction: UITransaction - ) -> Void - ) -> ObservationToken { - let key = UIBindingIdentifier(item) - return observe { [weak self] transaction in - guard let self else { return } - if let unwrappedItem = UIBinding(item) { - if let presented = sheetedByID[key] { - guard let presentationID = presented.id, - presentationID != id(unwrappedItem.wrappedValue) - else { - return - } - } - let childController = content(unwrappedItem) - let onEndSheet = ClosureHolder { [presentationID = id(unwrappedItem.wrappedValue)] in - if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { - item.wrappedValue = nil - } - } - childController.onEndSheet = onEndSheet - - self.sheetedByID[key] = Sheeted(childController, id: id(unwrappedItem.wrappedValue)) - let work = { - withUITransaction(transaction) { - beginSheet(childController, transaction) - } - } - work() - } else if let presented = sheetedByID[key] { - if let controller = presented.content { - endSheet(controller, transaction) - } - self.sheetedByID[key] = nil - } - } - } - - private var sheetedByID: [UIBindingIdentifier: Sheeted] { - get { - (objc_getAssociatedObject(self, sheetedKey) - as? [UIBindingIdentifier: Sheeted]) - ?? [:] - } - set { - objc_setAssociatedObject( - self, sheetedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - private var onEndSheet: ClosureHolder? { - set { - objc_setAssociatedObject( - self, onEndSheetKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - get { - objc_getAssociatedObject(self, onEndSheetKey) as? ClosureHolder - } - } - - private var sheetObserver: SheetObserver { - set { - objc_setAssociatedObject(self, sheetObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - if let observer = objc_getAssociatedObject(self, sheetObserverKey) as? SheetObserver { - return observer - } else { - let observer = SheetObserver(owner: self) - self.sheetObserver = observer - return observer - } + 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 } } } -private typealias SheetObserver = NavigationObserver +private typealias SheetObserver = NavigationObserver private let sheetObserverKey = malloc(1)! -private let onEndSheetKey = malloc(1)! -private let sheetedKey = malloc(1)! @MainActor -private class Sheeted: NavigatedProtocol { - weak var content: (any SheetContent)? - let id: AnyHashable? - deinit { - // NB: This can only be assumed because it is held in a UIViewController and is guaranteed to - // deinit alongside it on the main thread. If we use this other places we should force it - // to be a UIViewController as well, to ensure this functionality. - MainActor._assumeIsolated { - self.content?.currentWindow?.endSheeted() - } - } +private var sheetObserverKeys = AssociatedKeys() - required init(_ content: any SheetContent, id: AnyHashable?) { - self.content = content - self.id = id - } -} +private let onEndSheetKey = malloc(1)! +private let sheetedKey = malloc(1)! extension NSWindow { func endSheeted() { diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 2fc4318bac..8162d88d3a 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -3,7 +3,6 @@ import AppKit @MainActor -@objc public protocol SheetContent: NavigationContent { var currentWindow: NSWindow? { get } func beginSheet(for content: SheetContent) async @@ -11,13 +10,13 @@ public protocol SheetContent: NavigationContent { } extension SheetContent { - func _beginSheet(for content: any SheetContent) async { + public func beginSheet(for content: any SheetContent) async { if let sheetedWindow = content.currentWindow { await currentWindow?.beginSheet(sheetedWindow) } } - func _endSheet(for content: any SheetContent) { + public func endSheet(for content: any SheetContent) { if let sheetedWindow = content.currentWindow { currentWindow?.endSheet(sheetedWindow) } @@ -26,52 +25,14 @@ extension SheetContent { extension NSWindow: SheetContent { public var currentWindow: NSWindow? { self } - public func beginSheet(for content: any SheetContent) async { - await _beginSheet(for: content) - } - public func endSheet(for content: any SheetContent) { - _endSheet(for: content) - } } extension NSWindowController: SheetContent { public var currentWindow: NSWindow? { window } - public func beginSheet(for content: any SheetContent) async { - await _beginSheet(for: content) - } - public func endSheet(for content: any SheetContent) { - _endSheet(for: content) - } - - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } } extension NSViewController: SheetContent { public var currentWindow: NSWindow? { view.window } - public func beginSheet(for content: any SheetContent) async { - await _beginSheet(for: content) - } - public func endSheet(for content: any SheetContent) { - _endSheet(for: content) - } - - public var onBeginNavigation: (() -> Void)? { - set { _onBeginNavigation = newValue } - get { _onBeginNavigation } - } - - public var onEndNavigation: (() -> Void)? { - set { _onEndNavigation = newValue } - get { _onEndNavigation } - } } extension NSAlert: SheetContent { From 01fafbe19444c6532cd7f99240815af1172b331c Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 16 Aug 2024 23:02:53 +0800 Subject: [PATCH 09/34] WIP --- ...ppKit+BasicsNavigationViewController.swift | 49 ++-- ...+ConciseEnumNavigationViewController.swift | 242 ++++++++---------- .../AppKit/AppKitCaseStudies.swift | 2 +- .../Internal/AssociatedKeys.swift | 36 +++ .../AppKitNavigation/Navigation/Modal.swift | 98 ++++++- .../Navigation/NavigationContent.swift | 33 --- .../Navigation/NavigationObserver.swift | 2 - .../Navigation/Presentation.swift | 69 +---- .../Navigation/PresentationContent.swift | 41 +++ 9 files changed, 306 insertions(+), 266 deletions(-) create mode 100644 Sources/AppKitNavigation/Internal/AssociatedKeys.swift create mode 100644 Sources/AppKitNavigation/Navigation/PresentationContent.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index 5cf3b8a187..a9a20fb710 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -17,7 +17,7 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS let showAlertButton = NSButton { [weak self] _ in self?.model.alert = "Hello!" } - + let showSheetButton = NSButton { [weak self] _ in self?.model.sheet = .random(in: 1 ... 1_000) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { @@ -51,45 +51,26 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" - } -// present(item: $model.alert, id: \.self) { message in -// let alert = UIAlertController( -// title: "This is an alert", -// message: message, -// preferredStyle: .alert -// ) -// alert.addAction(UIAlertAction(title: "OK", style: .default)) -// return alert -// } - - - -// modal(item: $model.sheet, id: \.self) { count in -// let vc = XiblessViewController() -// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) -// return NSWindow(contentViewController: vc) -// } - -// sheet(item: $model.sheet, id: \.self) { count in -// let vc = XiblessViewController() -// vc.view.frame = .init(x: 0, y: 0, width: 500, height: 500) -// return NSWindow(contentViewController: vc) -// } + modal(item: $model.alert, id: \.self) { message in + let alert = NSAlert() + alert.messageText = "This is an alert" + alert.informativeText = message + alert.addButton(withTitle: "OK") + return alert + } + present(item: $model.sheet, id: \.self, style: .sheet) { count in -// let vc = NSHostingController( -// rootView: Form { Text(count.description) } -// ) - let vc = XiblessViewController() - vc.preferredContentSize = .init(width: 300, height: 200) - return vc + NSHostingController( + rootView: Form { Text(count.description) }.frame(width: 100, height: 100, alignment: .center) + ) } + present(isPresented: $model.isSheetPresented, style: .sheet) { - let vc = NSHostingController( - rootView: Form { Text("Hello!") } + NSHostingController( + rootView: Form { Text("Hello!") }.frame(width: 100, height: 100, alignment: .center) ) - return vc } } diff --git a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift index af08f37dfe..8abcdec54c 100644 --- a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift @@ -1,138 +1,108 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) -//import SwiftUI -//import UIKit -//import UIKitNavigation -// -//class ConciseEnumNavigationViewController: UIViewController, UIKitCaseStudy { -// let caseStudyNavigationTitle = "Enum navigation" -// let caseStudyTitle = "Concise enum navigation" -// let readMe = """ -// This case study demonstrates how to navigate to multiple destinations from a single optional \ -// enum. -// -// This allows you to be very concise with your domain modeling by having a single enum \ -// describe all the possible destinations you can navigate to. In the case of this demo, we have \ -// four cases in the enum, which means there are exactly 5 possible states, including the case \ -// where none are active. -// -// If you were to instead model this domain with 4 optionals (or booleans), then you would have \ -// 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ -// because you can never be sure of exactly what is presented at a given time. -// """ -// @UIBindable var model = Model() -// -// override func viewDidLoad() { -// super.viewDidLoad() -// view.backgroundColor = .systemBackground -// -// let showAlertButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .alert("Hello!") -// }) -// let showSheetButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .sheet(.random(in: 1...1_000)) -// }) -// let showSheetFromBooleanButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .sheetWithoutPayload -// }) -// let drillDownButton = UIButton( -// type: .system, -// primaryAction: UIAction { [weak self] _ in -// self?.model.destination = .drillDown(.random(in: 1...1_000)) -// }) -// -// let stack = UIStackView(arrangedSubviews: [ -// showAlertButton, -// showSheetButton, -// drillDownButton, -// showSheetFromBooleanButton, -// ]) -// stack.axis = .vertical -// stack.spacing = 12 -// stack.translatesAutoresizingMaskIntoConstraints = false -// -// view.addSubview(stack) -// NSLayoutConstraint.activate([ -// stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), -// stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// ]) -// -// observe { [weak self] in -// guard let self else { return } -// -// showAlertButton.setTitle( -// "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")", -// for: .normal -// ) -// showSheetButton.setTitle( -// "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")", -// for: .normal -// ) -// showSheetFromBooleanButton.setTitle( -// "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")", -// for: .normal -// ) -// drillDownButton.setTitle( -// "Drill-down is presented: \(model.destination?.drillDown != nil ? "✅" : "❌")", -// for: .normal -// ) -// } -// -// present(item: $model.destination.alert, id: \.self) { message in -// let alert = UIAlertController( -// title: "This is an alert", -// message: message, -// preferredStyle: .alert -// ) -// alert.addAction(UIAlertAction(title: "OK", style: .default)) -// return alert -// } -// present(item: $model.destination.sheet, id: \.self) { count in -// let vc = UIHostingController( -// rootView: Form { Text(count.description) } -// ) -// vc.mediumDetents() -// return vc -// } -// present(isPresented: UIBinding($model.destination.sheetWithoutPayload)) { -// let vc = UIHostingController( -// rootView: Form { Text("Hello!") } -// ) -// vc.mediumDetents() -// return vc -// } -// navigationDestination(item: $model.destination.drillDown) { count in -// UIHostingController( -// rootView: Form { -// Text(count.description) -// } -// ) -// } -// } -// -// @Observable -// class Model { -// var destination: Destination? -// @CasePathable -// @dynamicMemberLookup -// enum Destination { -// case alert(String) -// case drillDown(Int) -// case sheet(Int) -// case sheetWithoutPayload -// } -// } -//} -// -//#Preview { -// UINavigationController( -// rootViewController: BasicsNavigationViewController() -// ) -//} +import SwiftUI +import AppKit +import AppKitNavigation + +class ConciseEnumNavigationViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyNavigationTitle = "Enum navigation" + let caseStudyTitle = "Concise enum navigation" + let readMe = """ + This case study demonstrates how to navigate to multiple destinations from a single optional \ + enum. + + This allows you to be very concise with your domain modeling by having a single enum \ + describe all the possible destinations you can navigate to. In the case of this demo, we have \ + four cases in the enum, which means there are exactly 5 possible states, including the case \ + where none are active. + + If you were to instead model this domain with 4 optionals (or booleans), then you would have \ + 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ + because you can never be sure of exactly what is presented at a given time. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let showAlertButton = NSButton { [weak self] _ in + self?.model.destination = .alert("Hello!") + } + let showSheetButton = NSButton { [weak self] _ in + self?.model.destination = .sheet(.random(in: 1 ... 1_000)) + } + let showSheetFromBooleanButton = NSButton { [weak self] _ in + self?.model.destination = .sheetWithoutPayload + } + + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + showAlertButton.title = "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")" + showSheetButton.title = "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")" + } + + modal(item: $model.destination.alert, id: \.self) { message in + let alert = NSAlert() + alert.messageText = "This is an alert" + alert.informativeText = message + alert.addButton(withTitle: "OK") + return alert + } + present(item: $model.destination.sheet, id: \.self, style: .sheet) { [unowned self] count in + + NSHostingController( + rootView: Form { + Text(count.description) + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) + } + present(isPresented: UIBinding($model.destination.sheetWithoutPayload), style: .sheet) { [unowned self] in + NSHostingController( + rootView: Form { + Text("Hello!") + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) + } + } + + @Observable + class Model { + var destination: Destination? + @CasePathable + @dynamicMemberLookup + enum Destination { + case alert(String) + case drillDown(Int) + case sheet(Int) + case sheetWithoutPayload + } + } +} + +#Preview { + ConciseEnumNavigationViewController() +} #endif diff --git a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift index acdf6cb0e2..0a13289c2b 100644 --- a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift +++ b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift @@ -17,7 +17,7 @@ struct AppKitCaseStudiesView: View { CaseStudyGroupView("Optional navigation") { BasicsNavigationViewController() // TODO: Alert/dialog state -// ConciseEnumNavigationViewController() + ConciseEnumNavigationViewController() } // CaseStudyGroupView("Stack navigation") { // StaticNavigationStackController() diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift new file mode 100644 index 0000000000..1df9f50edc --- /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 index 927a64d815..3ab909472b 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -3,8 +3,101 @@ import AppKit import Combine +@MainActor +private var modalObserverKeys = AssociatedKeys() + +private typealias ModalObserver = NavigationObserver + @MainActor extension NSObject { + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - isSheeted: A binding to a Boolean value that determines whether to sheet the representable + /// - onDismiss: The closure to execute when dismissing the representable. + /// - content: A closure that returns the representable to display over the current window content. + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObservationToken { + modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObservationToken { + modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + /// Sheet a representable modally when a binding to a Boolean value you provide is true. + /// + /// Like SwiftUI's `sheet`, `fullScreenCover`, and `popover` view modifiers, but for AppKit. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the view controller. When `item` is + /// non-`nil`, the item's content is passed to the `content` closure. You display this + /// content in a view controller that you create that is displayed to the user. If `item`'s + /// identity changes, the view controller is dismissed and replaced with a new one using the + /// same process. + /// - id: The key path to the provided item's identifier. + /// - onDismiss: The closure to execute when dismissing the view controller. + /// - content: A closure that returns the view controller to display over the current view + /// controller's content. + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modal(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + @discardableResult public func modal( item: UIBinding, @@ -73,11 +166,6 @@ extension NSObject { } } -private let modalObserverKey = malloc(1)! -@MainActor -private var modalObserverKeys = AssociatedKeys() -private typealias ModalObserver = NavigationObserver - extension Navigated where Content: ModalContent { func clearup() { NSApplication.shared.stopModal() diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index 3af25d1ecd..a67417c95f 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -12,40 +12,7 @@ private var onBeginNavigationKeys = AssociatedKeys() @MainActor private var onEndNavigationKeys = AssociatedKeys() -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)) - } -} - extension NavigationContent { - public var onBeginNavigation: (() -> Void)? { set { objc_setAssociatedObject(self, onBeginNavigationKeys.key(of: Self.self), newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift index b7e6b22b96..a666af9959 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -3,8 +3,6 @@ import SwiftNavigation @MainActor class NavigationObserver: NSObject { - - private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] unowned let owner: Owner diff --git a/Sources/AppKitNavigation/Navigation/Presentation.swift b/Sources/AppKitNavigation/Navigation/Presentation.swift index 676c452c76..f8c4cc5e4c 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -4,6 +4,19 @@ import IssueReporting 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 { /// Presents a view controller modally when a binding to a Boolean value you provide is true. /// @@ -140,26 +153,6 @@ extension NSViewController { } } - public enum TransitionStyle { - case sheet - case modalWindow - case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) - } - - fileprivate 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) - } - } - /// Presents a view controller when a binding to a Boolean value you provide is true. /// /// This helper powers ``present(isPresented:onDismiss:content:)`` and @@ -260,10 +253,8 @@ extension NSViewController { // ) 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 @@ -273,28 +264,6 @@ extension NSViewController { return observer } } - -} -private let presentationObserverKey = malloc(1)! - -@MainActor -private var presentationObserverKeys = AssociatedKeys() - -@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) - } } extension NavigationContent where Self: NSViewController { @@ -308,19 +277,9 @@ extension NavigationContent where Self: NSViewController { } } -class PresentationObserver: NavigationObserver { - override func commitWork(_ work: @escaping () -> Void) { - if owner.hasViewAppeared { - work() - } else { - owner.onViewAppear.append(work) - } - } -} - extension Navigated where Content: NSViewController { func clearup() { - self.content?.dismiss(nil) + content?.dismiss(nil) } } diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift new file mode 100644 index 0000000000..eba08ad600 --- /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 From cd4422f1951113f3a35747df638d7f6309a5f0fc Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sat, 17 Aug 2024 01:31:20 +0800 Subject: [PATCH 10/34] WIP-Fix bugs and support NSSave/OpenPanel --- ...ppKit+BasicsNavigationViewController.swift | 22 ++- Package@swift-6.0.swift | 15 ++ .../Bindings/NSFontManager.swift | 1 + .../Bindings/NSOpenPanel.swift | 60 ++++++ .../Bindings/NSTargetAction.swift | 16 +- .../Internal/AssumeIsolated.swift | 5 + .../AppKitNavigation/Navigation/Modal.swift | 5 +- .../Navigation/ModalContent.swift | 20 +- .../Navigation/NavigationContent.swift | 5 + .../Navigation/NavigationObserver.swift | 15 +- .../AppKitNavigation/Navigation/Sheet.swift | 17 +- Sources/AppKitNavigationShim/include/shim.h | 28 +-- Sources/AppKitNavigationShim/shim.m | 180 +++++++++++------- 13 files changed, 281 insertions(+), 108 deletions(-) diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index a9a20fb710..c7a71253f3 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -48,17 +48,24 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS observe { [weak self] in guard let self else { return } - showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + if let url = model.url { + showAlertButton.title = "URL is: \(url)" + } else { + showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + } showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" } - modal(item: $model.alert, id: \.self) { message in - let alert = NSAlert() - alert.messageText = "This is an alert" - alert.informativeText = message - alert.addButton(withTitle: "OK") - return alert + modal(item: $model.alert, id: \.self) { [unowned self] message in +// let alert = NSAlert() +// alert.messageText = "This is an alert" +// alert.informativeText = message +// alert.addButton(withTitle: "OK") +// return alert + let openPanel = NSOpenPanel(url: $model.url) + openPanel.message = message + return openPanel } present(item: $model.sheet, id: \.self, style: .sheet) { count in @@ -79,6 +86,7 @@ class BasicsNavigationViewController: XiblessViewController, AppKitCaseS var alert: String? var isSheetPresented = false var sheet: Int? + var url: URL? } } diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 9ed432ff67..96bf3a52ea 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"), @@ -73,6 +77,17 @@ let package = Package( .target( name: "UIKitNavigationShim" ), + .target( + name: "AppKitNavigation", + dependencies: [ + "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ] + ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 0525f42582..752c4a62dc 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -1,6 +1,7 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit +import SwiftNavigation extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { public var appkitNavigationTarget: AnyObject? { diff --git a/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift index 893f31d20e..896135f71e 100644 --- a/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift @@ -1,5 +1,65 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit +import AppKitNavigationShim + +extension NSSavePanel { + public convenience init(url: UIBinding) { + self.init() + bind(url: url) + } + + @discardableResult + public func bind(url binding: UIBinding) -> ObservationToken { + appKitNavigation_onFinalURL = { url in + binding.wrappedValue = url + } + + let observationToken = ObservationToken { [weak self] in + guard let self else { return } + MainActor._assumeIsolated { + self.appKitNavigation_onFinalURL = nil + } + } + observationTokens[\NSSavePanel.url] = observationToken + return observationToken + } + + public func unbindURL() { + observationTokens[\NSSavePanel.url]?.cancel() + observationTokens[\NSSavePanel.url] = nil + } + + +} + +extension NSOpenPanel { + + public convenience init(urls: UIBinding<[URL]>) { + self.init() + bind(urls: urls) + } + + @discardableResult + public func bind(urls binding: UIBinding<[URL]>) -> ObservationToken { + appKitNavigation_onFinalURLs = { urls in + binding.wrappedValue = urls + } + + let observationToken = ObservationToken { [weak self] in + guard let self else { return } + MainActor._assumeIsolated { + self.appKitNavigation_onFinalURLs = nil + } + } + observationTokens[\NSOpenPanel.urls] = observationToken + return observationToken + } + + public func unbindURLs() { + observationTokens[\NSOpenPanel.urls]?.cancel() + observationTokens[\NSOpenPanel.urls] = nil + } +} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 3412960d4d..4c0dce7971 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -134,6 +134,21 @@ extension NSTargetActionProtocol { observationTokens[keyPath] = nil } +// var observationTokens: [AnyKeyPath: ObservationToken] { +// get { +// objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] +// ?? [:] +// } +// set { +// objc_setAssociatedObject( +// self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC +// ) +// } +// } +} + +@MainActor +extension NSObject { var observationTokens: [AnyKeyPath: ObservationToken] { get { objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] @@ -148,7 +163,6 @@ extension NSTargetActionProtocol { } - @MainActor private let observationTokensKey = malloc(1)! @MainActor diff --git a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift index 9a7465a99f..93f1c40098 100644 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ b/Sources/AppKitNavigation/Internal/AssumeIsolated.swift @@ -1,3 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import Foundation extension MainActor { @@ -28,3 +30,6 @@ extension MainActor { #endif } } + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 3ab909472b..615f985e1d 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -1,7 +1,6 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit -import Combine @MainActor private var modalObserverKeys = AssociatedKeys() @@ -113,7 +112,7 @@ extension NSObject { onDismiss?() DispatchQueue.main.async { ModalWindowsObserver.shared.observeWindow(modalContent.window) - modalContent.runModal() + modalContent.appKitNavigationRunModal() modalContent.onEndNavigation?() modalContent.onEndNavigation = nil } @@ -121,7 +120,7 @@ extension NSObject { } else { DispatchQueue.main.async { ModalWindowsObserver.shared.observeWindow(modalContent.window) - modalContent.runModal() + modalContent.appKitNavigationRunModal() modalContent.onEndNavigation?() modalContent.onEndNavigation = nil } diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index a372687396..00772647f2 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -4,18 +4,32 @@ import AppKit @MainActor public protocol ModalContent: NavigationContent { - @discardableResult func runModal() -> NSApplication.ModalResponse + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse var window: NSWindow { get } } extension NSWindow: ModalContent { public var window: NSWindow { self } - public func runModal() -> NSApplication.ModalResponse { + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + __appKitNavigationRunModal() + } + + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { NSApplication.shared.runModal(for: self) } } -extension NSAlert: ModalContent {} +extension NSSavePanel { + override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} + +extension NSAlert: ModalContent { + public func appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} #endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift index a67417c95f..762032cf09 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -1,3 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import Foundation @MainActor @@ -31,3 +33,6 @@ extension NavigationContent { } } } + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift index a666af9959..c9af34445f 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -1,3 +1,5 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + import Foundation import SwiftNavigation @@ -29,17 +31,17 @@ class NavigationObserver: NSObject return observe { [weak self] transaction in guard let self else { return } if let unwrappedItem = UIBinding(item) { - if let presented = navigatedByID[key] { - guard let presentationID = presented.id, - presentationID != id(unwrappedItem.wrappedValue) + if let navigated = navigatedByID[key] { + guard let navigationID = navigated.id, + navigationID != id(unwrappedItem.wrappedValue) else { return } } let content = content(unwrappedItem) - let onEndNavigation = { [presentationID = id(unwrappedItem.wrappedValue)] in + let onEndNavigation = { [navigationID = id(unwrappedItem.wrappedValue)] in if let wrappedValue = item.wrappedValue, - presentationID == id(wrappedValue) { + navigationID == id(wrappedValue) { item.wrappedValue = nil } } @@ -82,3 +84,6 @@ class Navigated { self.id = id } } + + +#endif diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index a2ff357ebd..76d8786698 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -2,6 +2,11 @@ import AppKit +private typealias SheetObserver = NavigationObserver + +@MainActor +private var sheetObserverKeys = AssociatedKeys() + extension SheetContent { /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -146,7 +151,6 @@ extension SheetContent { ) } - private func sheetObserver() -> SheetObserver { if let observer = objc_getAssociatedObject(self, sheetObserverKeys.key(of: Content.self)) as? SheetObserver { return observer @@ -158,15 +162,6 @@ extension SheetContent { } } -private typealias SheetObserver = NavigationObserver -private let sheetObserverKey = malloc(1)! - -@MainActor -private var sheetObserverKeys = AssociatedKeys() - -private let onEndSheetKey = malloc(1)! -private let sheetedKey = malloc(1)! - extension NSWindow { func endSheeted() { guard sheetParent != nil else { @@ -178,7 +173,7 @@ extension NSWindow { extension Navigated where Content: SheetContent { func clearup() { - self.content?.currentWindow?.endSheeted() + content?.currentWindow?.endSheeted() } } diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h index 1d2054f3ca..e1844275f7 100644 --- a/Sources/AppKitNavigationShim/include/shim.h +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -1,19 +1,25 @@ #if __has_include() - #include +#include - #if __has_include() && !TARGET_OS_MACCATALYST - @import AppKit; +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; - NS_ASSUME_NONNULL_BEGIN +NS_ASSUME_NONNULL_BEGIN - @interface NSViewController (AppKitNavigation) +@interface NSViewController (AppKitNavigation) - @property BOOL hasViewAppeared; - @property (nullable) void (^onDismiss)(); - @property NSArray *onViewAppear; +@property BOOL hasViewAppeared; +@property (nullable) void (^ onDismiss)(); +@property NSArray *onViewAppear; - @end +@end - NS_ASSUME_NONNULL_END - #endif +@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 index a2fb9479a5..21f5f9c08b 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -1,106 +1,152 @@ #if __has_include() - #include +#include - #if __has_include() && !TARGET_OS_MACCATALYST - @import ObjectiveC; - @import AppKit; - #import "shim.h" +#if __has_include() && !TARGET_OS_MACCATALYST +@import ObjectiveC; +@import AppKit; +#import "shim.h" - @interface AppKitNavigationShim : NSObject +@interface AppKitNavigationShim : NSObject - @end +@end - @implementation AppKitNavigationShim +@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. +// 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( ++ (void)load { + method_exchangeImplementations( class_getInstanceMethod(NSViewController.class, @selector(viewDidAppear)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidAppear)) - ); - method_exchangeImplementations( + ); + method_exchangeImplementations( class_getInstanceMethod(NSViewController.class, @selector(viewDidDisappear)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_viewDidDisappear)) - ); - method_exchangeImplementations( + ); + 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); } +} - @end +- (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; +static void *hasViewAppearedKey = &hasViewAppearedKey; +static void *onDismissKey = &onDismissKey; +static void *onViewAppearKey = &onViewAppearKey; - @implementation NSViewController (AppKitNavigation) +@implementation NSViewController (AppKitNavigation) - - (void)AppKitNavigation_viewDidAppear { - [self AppKitNavigation_viewDidAppear]; +- (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; - if (self.hasViewAppeared) { + 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); - } + self.hasViewAppeared = YES; - - (BOOL)isBeingDismissed { - return [objc_getAssociatedObject(self, @selector(isBeingDismissed)) boolValue]; + for (void (^work)() in self.onViewAppear) { + work(); } - - (void)AppKitNavigation_viewDidDisappear { - [self AppKitNavigation_viewDidDisappear]; + 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) { + 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]; - } +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} - - (BOOL)hasViewAppeared { - return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; - } +- (BOOL)hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} - - (void)setHasViewAppeared:(BOOL)hasViewAppeared { - objc_setAssociatedObject( +- (void)setHasViewAppeared:(BOOL)hasViewAppeared { + objc_setAssociatedObject( self, hasViewAppearedKey, @(hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC - ); - } + ); +} - - (void (^)())onDismiss { - return objc_getAssociatedObject(self, onDismissKey); - } +- (void (^)())onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} - - (void)setOnDismiss:(void (^)())onDismiss { - objc_setAssociatedObject(self, onDismissKey, [onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); - } +- (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; - } +- (NSMutableArray *)onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); - - (void)setOnViewAppear:(NSMutableArray *)onViewAppear { - objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); - } + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)setOnViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} - @end - #endif -#endif +@end +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ From 27c61522cf309d39386c898b03ea66a8f1323e8b Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 18 Aug 2024 22:25:00 +0800 Subject: [PATCH 11/34] WIP-Support WiFiFeature Case Study --- ...pKit+NSControlBindingsViewController.swift | 11 +- .../AppKit/AppKitCaseStudies.swift | 10 +- Examples/CaseStudies/Internal/CaseStudy.swift | 6 +- .../WiFiFeature/ConnectToNetworkFeature.swift | 113 ------ .../WiFiFeature/NetworkDetailFeature.swift | 101 ----- .../WiFiFeature/WiFiSettingsFeature.swift | 313 ---------------- ...ppKit+ConnectToNetworkViewController.swift | 177 +++++++++ .../ConnectToNetworkModel.swift | 36 ++ ...UIKit+ConnectToNetworkViewController.swift | 90 +++++ .../{UIKit => }/WiFiFeature/Network.swift | 0 .../AppKit+NetworkDetailViewController.swift | 65 ++++ .../NetworkDetailModel.swift | 32 ++ .../UIKit+NetworkDetailViewController.swift | 76 ++++ .../AppKit+WiFiSettingsViewController.swift | 352 ++++++++++++++++++ .../UIKit+WiFiSettingsViewController.swift | 199 ++++++++++ .../WiFiSettingsModel.swift | 75 ++++ .../WiFiSettingsViewController+.swift | 60 +++ Examples/Examples.xcodeproj/project.pbxproj | 82 +++- .../AppKitNavigation/Bindings/NSAlert.swift | 2 +- .../Bindings/NSColorPanel.swift | 2 +- .../AppKitNavigation/Bindings/NSControl.swift | 13 +- .../Bindings/NSFontManager.swift | 10 +- ...SOpenPanel.swift => NSSaveOpenPanel.swift} | 0 .../Bindings/NSTargetAction.swift | 70 +++- .../Bindings/NSTextField.swift | 19 +- .../Internal/ModalWindowsObserver.swift | 13 +- .../NavigationObserver.swift | 0 .../AppKitNavigation/Navigation/Modal.swift | 74 ++++ .../ModalSession.swift} | 2 + .../Navigation/ModalSessionContent.swift | 29 ++ .../Navigation/SheetContent.swift | 21 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- 32 files changed, 1463 insertions(+), 598 deletions(-) delete mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift delete mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift delete mode 100644 Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift create mode 100644 Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift create mode 100644 Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift rename Examples/CaseStudies/{UIKit => }/WiFiFeature/Network.swift (100%) create mode 100644 Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift create mode 100644 Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift create mode 100644 Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift rename Sources/AppKitNavigation/Bindings/{NSOpenPanel.swift => NSSaveOpenPanel.swift} (100%) rename Sources/AppKitNavigation/{Navigation => Internal}/NavigationObserver.swift (100%) rename Sources/AppKitNavigation/{Bindings/NSSavePanel.swift => Navigation/ModalSession.swift} (97%) create mode 100644 Sources/AppKitNavigation/Navigation/ModalSessionContent.swift diff --git a/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift index fbecc6031d..75fe38967b 100644 --- a/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift @@ -8,6 +8,15 @@ class XiblessViewController: NSViewController { override func loadView() { view = contentView } + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("") + } } class NSControlBindingsViewController: XiblessViewController, AppKitCaseStudy { @@ -117,10 +126,8 @@ extension String { } } - #Preview(traits: .fixedLayout(width: 500, height: 800)) { NSControlBindingsViewController() } - #endif diff --git a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift index 0a13289c2b..5fb7bee5c3 100644 --- a/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift +++ b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift @@ -24,11 +24,11 @@ struct AppKitCaseStudiesView: View { // ErasedNavigationStackController(model: ErasedNavigationStackController.Model()) // // TODO: state restoration // } -// CaseStudyGroupView("Advanced") { -// // TODO: Deep link -// // TODO: Dismissal (show off from VCs and views) -// WiFiSettingsViewController(model: WiFiSettingsModel(foundNetworks: .mocks)) -// } + CaseStudyGroupView("Advanced") { + // TODO: Deep link + // TODO: Dismissal (show off from VCs and views) + WiFiSettingsViewController(model: WiFiSettingsModel(foundNetworks: .mocks)) + } } .navigationTitle("AppKit") } diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index 88730a9b21..b6d739cb31 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -1,6 +1,4 @@ import SwiftUI -import AppKitNavigation -import UIKitNavigation protocol CaseStudy { var readMe: String { get } @@ -12,12 +10,16 @@ protocol CaseStudy { protocol SwiftUICaseStudy: CaseStudy, View {} #if canImport(UIKit) && !os(watchOS) +import UIKitNavigation + protocol UIKitCaseStudy: CaseStudy, UIViewController {} extension UIKitCaseStudy { var usesOwnLayout: Bool { true } } #endif #if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKitNavigation + protocol AppKitCaseStudy: CaseStudy, NSViewController {} extension AppKitCaseStudy { var usesOwnLayout: Bool { true } diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift deleted file mode 100644 index fe305a2748..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift +++ /dev/null @@ -1,113 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import SwiftUI -import UIKitNavigation -import XCTestDynamicOverlay - -@Observable -@MainActor -class ConnectToNetworkModel: Identifiable { - var incorrectPasswordAlertIsPresented = false - var isConnecting = false - var onConnect: (Network) -> Void = { _ in - XCTFail("ConnectToNetworkModel.onConnect unimplemented.") - } - let network: Network - var password = "" - init(network: Network) { - self.network = network - } - func joinButtonTapped() async { - isConnecting = true - defer { isConnecting = false } - try? await Task.sleep(for: .seconds(1)) - if password == "blob" { - onConnect(network) - } else { - incorrectPasswordAlertIsPresented = true - } - } -} - -final class ConnectToNetworkViewController: UIViewController { - @UIBindable var model: ConnectToNetworkModel - - init(model: ConnectToNetworkModel) { - self.model = model - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - navigationItem.title = "Enter the password for “\(model.network.name)”" - - let passwordTextField = UITextField(text: $model.password) - passwordTextField.borderStyle = .line - passwordTextField.isSecureTextEntry = true - passwordTextField.becomeFirstResponder() - passwordTextField.placeholder = "The password is 'blob'" - let joinButton = UIButton( - type: .system, - primaryAction: UIAction { [weak self] _ in - guard let self else { return } - Task { - await self.model.joinButtonTapped() - } - }) - joinButton.setTitle("Join network", for: .normal) - let activityIndicator = UIActivityIndicatorView(style: .medium) - activityIndicator.startAnimating() - - let stack = UIStackView(arrangedSubviews: [ - passwordTextField, - joinButton, - activityIndicator, - ]) - stack.axis = .vertical - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), - stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), - stack.widthAnchor.constraint(equalToConstant: 200), - ]) - - observe { [weak self, weak passwordTextField] in - guard - let self, - let passwordTextField - else { return } - - passwordTextField.isEnabled = !model.isConnecting - joinButton.isEnabled = !model.isConnecting - activityIndicator.isHidden = !model.isConnecting - } - - present(isPresented: $model.incorrectPasswordAlertIsPresented) { [unowned self] in - let controller = UIAlertController( - title: "Incorrect password for “\(model.network.name)”", - message: nil, - preferredStyle: .alert - ) - controller.addAction(UIAlertAction(title: "OK", style: .default)) - return controller - } - } -} - -#Preview { - UIViewControllerRepresenting { - UINavigationController( - rootViewController: ConnectToNetworkViewController( - model: ConnectToNetworkModel( - network: Network(name: "Blob's WiFi") - ) - ) - ) - } -} -#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift deleted file mode 100644 index 124b64124c..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift +++ /dev/null @@ -1,101 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import SwiftUI -import UIKitNavigation -import XCTestDynamicOverlay - -@MainActor -@Observable -class NetworkDetailModel { - var forgetAlertIsPresented = false - var onConfirmForget: () -> Void = { - XCTFail("NetworkDetailModel.onConfirmForget unimplemented.") - } - let network: Network - let selectedNetworkID: Network.ID? - - init( - network: Network, - selectedNetworkID: Network.ID? - ) { - self.network = network - self.selectedNetworkID = selectedNetworkID - } - - func forgetNetworkButtonTapped() { - forgetAlertIsPresented = true - } - - func confirmForgetNetworkButtonTapped() { - onConfirmForget() - } -} - -final class NetworkDetailViewController: UIViewController { - @UIBindable var model: NetworkDetailModel - - init(model: NetworkDetailModel) { - self.model = model - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - navigationItem.title = model.network.name - - let forgetButton = UIButton( - type: .system, - primaryAction: UIAction { [weak self] _ in - self?.model.forgetNetworkButtonTapped() - }) - forgetButton.setTitle("Forget network", for: .normal) - forgetButton.setTitleColor(.red, for: .normal) - forgetButton.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(forgetButton) - NSLayoutConstraint.activate([ - forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - observe { [weak self] in - guard let self else { return } - - forgetButton.isHidden = model.network.id != model.selectedNetworkID - } - - present(isPresented: $model.forgetAlertIsPresented) { [unowned self] in - let controller = UIAlertController( - title: "Forget Wi-Fi Network “\(model.network.name)”?", - message: """ - Your iPhone and other devices using iCloud Keychain will no longer join this Wi-Fi \ - network. - """, - preferredStyle: .alert - ) - controller.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - controller.addAction( - UIAlertAction(title: "Forget", style: .destructive) { [weak self] _ in - self?.model.confirmForgetNetworkButtonTapped() - }) - return controller - } - } -} - -#Preview { - UIViewControllerRepresenting { - UINavigationController( - rootViewController: NetworkDetailViewController( - model: NetworkDetailModel( - network: Network(name: "Blob's WiFi"), - selectedNetworkID: UUID() - ) - ) - ) - } -} -#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift deleted file mode 100644 index a025d7855e..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift +++ /dev/null @@ -1,313 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import SwiftUI -import UIKitNavigation - -@Observable -@MainActor -class WiFiSettingsModel { - var destination: Destination? { - didSet { bind() } - } - var foundNetworks: [Network] - var isOn: Bool - var selectedNetworkID: Network.ID? - - @CasePathable - enum Destination { - case connect(ConnectToNetworkModel) - case detail(NetworkDetailModel) - } - - init( - foundNetworks: [Network] = [], - isOn: Bool = true, - selectedNetworkID: Network.ID? = nil - ) { - self.foundNetworks = foundNetworks - self.isOn = isOn - self.selectedNetworkID = selectedNetworkID - bind() - } - - private func bind() { - switch destination { - case .connect(let model): - model.onConnect = { [weak self] network in - guard let self else { return } - destination = nil - selectedNetworkID = network.id - } - - case .detail(let model): - model.onConfirmForget = { [weak self] in - guard let self else { return } - self.destination = nil - self.selectedNetworkID = nil - } - - case .none: - break - } - } - - func infoButtonTapped(network: Network) { - destination = .detail( - NetworkDetailModel( - network: network, - selectedNetworkID: selectedNetworkID - ) - ) - } - - func networkTapped(_ network: Network) { - if network.id == selectedNetworkID { - infoButtonTapped(network: network) - } else if network.isSecured { - destination = .connect(ConnectToNetworkModel(network: network)) - } else { - selectedNetworkID = network.id - } - } -} - -class WiFiSettingsViewController: UICollectionViewController, UIKitCaseStudy { - let caseStudyTitle = "Wi-Fi Settings" - let readMe = """ - This demo shows how to built a moderately complex feature using the tools of the library. \ - There are multiple features that communicate with each other, there are multiple navigation \ - patterns, and the root feature has a complex collection view that updates dynamically. - """ - let isPresentedInSheet = true - - @UIBindable var model: WiFiSettingsModel - var dataSource: UICollectionViewDiffableDataSource! - - init(model: WiFiSettingsModel) { - self.model = model - super.init( - collectionViewLayout: UICollectionViewCompositionalLayout.list( - using: UICollectionLayoutListConfiguration(appearance: .insetGrouped) - ) - ) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - navigationItem.title = "Wi-Fi" - - let cellRegistration = UICollectionView.CellRegistration { - [weak self] cell, indexPath, item in - - guard let self else { return } - configure(cell: cell, indexPath: indexPath, item: item) - } - - self.dataSource = UICollectionViewDiffableDataSource( - collectionView: self.collectionView - ) { collectionView, indexPath, item in - collectionView.dequeueConfiguredReusableCell( - using: cellRegistration, - for: indexPath, - item: item - ) - } - - observe { [weak self] in - guard let self else { return } - dataSource.apply( - NSDiffableDataSourceSnapshot(model: model), - animatingDifferences: true - ) - } - - present(item: $model.destination.connect) { model in - UINavigationController( - rootViewController: ConnectToNetworkViewController(model: model) - ) - } - - navigationDestination(item: $model.destination.detail) { model in - NetworkDetailViewController(model: model) - } - } - - private func configure( - cell: UICollectionViewListCell, - indexPath: IndexPath, - item: Item - ) { - var configuration = cell.defaultContentConfiguration() - defer { cell.contentConfiguration = configuration } - cell.accessories = [] - - switch item { - case .isOn: - configuration.text = "Wi-Fi" - cell.accessories = [ - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: UISwitch(isOn: $model.isOn), - placement: .trailing(displayed: .always) - ) - ) - ] - - case let .selectedNetwork(networkID): - guard let network = model.foundNetworks.first(where: { $0.id == networkID }) - else { return } - configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) - - case let .foundNetwork(network): - configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) - } - - func configureNetwork( - cell: UICollectionViewListCell, - network: Network, - indexPath: IndexPath, - item: Item - ) { - configuration.text = network.name - cell.accessories.append( - .detail(displayed: .always) { [weak self] in - guard let self else { return } - model.infoButtonTapped(network: network) - } - ) - if network.isSecured { - let image = UIImage(systemName: "lock.fill")! - let imageView = UIImageView(image: image) - imageView.tintColor = .darkText - cell.accessories.append( - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: imageView, - placement: .trailing(displayed: .always) - ) - ) - ) - } - let image = UIImage(systemName: "wifi", variableValue: network.connectivity)! - let imageView = UIImageView(image: image) - imageView.tintColor = .darkText - cell.accessories.append( - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: imageView, - placement: .trailing(displayed: .always) - ) - ) - ) - if network.id == model.selectedNetworkID { - cell.accessories.append( - .customView( - configuration: UICellAccessory.CustomViewConfiguration( - customView: UIImageView(image: UIImage(systemName: "checkmark")!), - placement: .leading(displayed: .always), - reservedLayoutWidth: .custom(1) - ) - ) - ) - } - } - } - - override func collectionView( - _ collectionView: UICollectionView, - shouldSelectItemAt indexPath: IndexPath - ) -> Bool { - indexPath.section != 0 || indexPath.row != 0 - } - - override func collectionView( - _ collectionView: UICollectionView, - didSelectItemAt indexPath: IndexPath - ) { - guard let network = dataSource.itemIdentifier(for: indexPath)?.foundNetwork - else { return } - model.networkTapped(network) - } - - enum Section: Hashable, Sendable { - case top - case foundNetworks - } - - @CasePathable - @dynamicMemberLookup - enum Item: Hashable, Sendable { - case isOn - case selectedNetwork(Network.ID) - case foundNetwork(Network) - } -} - -extension NSDiffableDataSourceSnapshot< - WiFiSettingsViewController.Section, - WiFiSettingsViewController.Item -> { - @MainActor - init(model: WiFiSettingsModel) { - self.init() - - appendSections([.top]) - appendItems([.isOn], toSection: .top) - - guard model.isOn - else { return } - - if let selectedNetworkID = model.selectedNetworkID { - appendItems([.selectedNetwork(selectedNetworkID)], toSection: .top) - } - - appendSections([.foundNetworks]) - appendItems( - model.foundNetworks - .sorted { lhs, rhs in - (lhs.isSecured ? 1 : 0, lhs.connectivity) - > (rhs.isSecured ? 1 : 0, rhs.connectivity) - } - .compactMap { network in - network.id == model.selectedNetworkID - ? nil - : .foundNetwork(network) - }, - toSection: .foundNetworks - ) - } -} - -#Preview { - let model = WiFiSettingsModel(foundNetworks: .mocks) - return UIViewControllerRepresenting { - UINavigationController( - rootViewController: WiFiSettingsViewController(model: model) - ) - } - .task { - while true { - try? await Task.sleep(for: .seconds(1)) - guard Bool.random() else { continue } - if Bool.random() { - guard let randomIndex = (0.. { + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.frame = .init(origin: .zero, size: .init(width: 450, height: 234)) + let wifiIconImage = NSImageView(systemSymbolName: "wifi") + wifiIconImage.contentTintColor = .systemBlue + wifiIconImage.symbolConfiguration = .init(pointSize: 60, weight: .regular) + + let titleLabel = NSTextField(labelWithString: "Enter the password for “\(model.network.name)”") + titleLabel.font = .boldSystemFont(ofSize: 13) + let detailLabel = NSTextField(wrappingLabelWithString: "You can also access this Wi-Fi network by sharing the password from a nearby iPhone, iPad, or Mac which has connected to this network and has you in their contacts.") + detailLabel.font = .systemFont(ofSize: 12) + let textStackView = NSStackView(views: [ + titleLabel, + detailLabel, + ]) + textStackView.orientation = .vertical + textStackView.alignment = .leading + + let topStackView = NSStackView(views: [ + Spacer(size: .init(width: 15, height: 0)), + wifiIconImage, + textStackView, + HorizontalMaxSpacer(), + ]) + topStackView.orientation = .horizontal + + let passwordLabel = NSTextField(labelWithString: "Password:") + + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.becomeFirstResponder() + passwordTextField.placeholderString = "The password is 'blob'" + + let centerStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + passwordLabel, + passwordTextField, + ]) + centerStackView.orientation = .horizontal + NSLayoutConstraint.activate([ + passwordTextField.widthAnchor.constraint(equalToConstant: 253), + ]) + + let cancelButton = NSButton { [weak self] _ in + guard let self else { return } + model.cancelButtonTapped() + } + cancelButton.title = "Cancel" + + let joinButton = NSButton { [weak self] _ in + guard let self else { return } + Task { + await self.model.joinButtonTapped() + } + } + joinButton.title = "Join" + + let progress = NSProgressIndicator() + progress.isIndeterminate = true + progress.style = .spinning + progress.controlSize = .small + progress.startAnimation(nil) + + let progressStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + progress, + ]) + + let bottomStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + cancelButton, + joinButton, + ]) + bottomStackView.orientation = .horizontal + + NSLayoutConstraint.activate([ + joinButton.widthAnchor.constraint(equalToConstant: 70), + cancelButton.widthAnchor.constraint(equalToConstant: 70), + ]) + + let stack = NSStackView(views: [ + topStackView, + centerStackView, + progressStackView, + bottomStackView, + ]) + + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + stack.edgeInsets = .init(top: 20, left: 0, bottom: 20, right: 20) + stack.distribution = .equalSpacing + stack.detachesHiddenViews = false + view.addSubview(stack) + progressStackView.isHidden = true + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + stack.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), + stack.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), + ]) + + observe { [weak self, weak passwordTextField] in + guard + let self, + let passwordTextField + else { return } + + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + progressStackView.isHidden = !model.isConnecting + } + + modal(isModaled: $model.incorrectPasswordAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Incorrect password for “\(model.network.name)”" + alert.addButton(withTitle: "OK") + return alert + } + } +} + +class HorizontalMaxSpacer: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setContentHuggingPriority(.fittingSizeCompression, for: .horizontal) + setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class Spacer: NSView { + init(size: NSSize) { + super.init(frame: .zero) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: size.width), + heightAnchor.constraint(equalToConstant: size.height), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#Preview(traits: .fixedLayout(width: 450, height: 262)) { + let vc = ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi") + ) + ) + vc.preferredContentSize = .init(width: 450, height: 234) + return vc +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift new file mode 100644 index 0000000000..305710387b --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/ConnectToNetworkModel.swift @@ -0,0 +1,36 @@ +import Foundation +import XCTestDynamicOverlay + +@Observable +@MainActor +class ConnectToNetworkModel: Identifiable { + var incorrectPasswordAlertIsPresented = false + var isConnecting = false + var onCancel: () -> Void = { + XCTFail("ConnectToNetworkModel.onCancel unimplemented.") + } + var onConnect: (Network) -> Void = { _ in + XCTFail("ConnectToNetworkModel.onConnect unimplemented.") + } + + let network: Network + var password = "" + init(network: Network) { + self.network = network + } + + func cancelButtonTapped() { + onCancel() + } + + func joinButtonTapped() async { + isConnecting = true + defer { isConnecting = false } + try? await Task.sleep(for: .seconds(1)) + if password == "blob" { + onConnect(network) + } else { + incorrectPasswordAlertIsPresented = true + } + } +} diff --git a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift new file mode 100644 index 0000000000..389a6b345f --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/UIKit+ConnectToNetworkViewController.swift @@ -0,0 +1,90 @@ +#if canImport(UIKit) && !os(watchOS) + +import SwiftUI +import UIKitNavigation + +final class ConnectToNetworkViewController: UIViewController { + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = "Enter the password for “\(model.network.name)”" + + let passwordTextField = UITextField(text: $model.password) + passwordTextField.borderStyle = .line + passwordTextField.isSecureTextEntry = true + passwordTextField.becomeFirstResponder() + passwordTextField.placeholder = "The password is 'blob'" + let joinButton = UIButton( + type: .system, + primaryAction: UIAction { [weak self] _ in + guard let self else { return } + Task { + await self.model.joinButtonTapped() + } + } + ) + joinButton.setTitle("Join network", for: .normal) + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + + let stack = UIStackView(arrangedSubviews: [ + passwordTextField, + joinButton, + activityIndicator, + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.widthAnchor.constraint(equalToConstant: 200), + ]) + + observe { [weak self, weak passwordTextField] in + guard + let self, + let passwordTextField + else { return } + + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + activityIndicator.isHidden = !model.isConnecting + } + + present(isPresented: $model.incorrectPasswordAlertIsPresented) { [unowned self] in + let controller = UIAlertController( + title: "Incorrect password for “\(model.network.name)”", + message: nil, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "OK", style: .default)) + return controller + } + } +} + +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi") + ) + ) + ) + } +} +#endif diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/Network.swift b/Examples/CaseStudies/WiFiFeature/Network.swift similarity index 100% rename from Examples/CaseStudies/UIKit/WiFiFeature/Network.swift rename to Examples/CaseStudies/WiFiFeature/Network.swift diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift new file mode 100644 index 0000000000..0c6219045a --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift @@ -0,0 +1,65 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation + +final class NetworkDetailViewController: XiblessViewController { + @UIBindable var model: NetworkDetailModel + + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + +// navigationItem.title = model.network.name + + let forgetButton = NSButton { [weak self] _ in + guard let self else { return } + model.forgetNetworkButtonTapped() + } + + forgetButton.title = "Forget network" +// forgetButton.hasDestructiveAction = true + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + +// forgetButton.isHidden = model.network.id != model.selectedNetworkID + } + + modal(isModaled: $model.forgetAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Forget Wi-Fi Network “\(model.network.name)”?" + alert.informativeText = """ + Your Mac and other devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """ + + alert.addButton(ButtonState(role: .cancel) { TextState("Cancel") }) { _ in } + alert.addButton(ButtonState(role: .destructive) { TextState("Forget") }) { [weak self] _ in + guard let self else { return } + model.confirmForgetNetworkButtonTapped() + } + return alert + } + } +} + +#Preview { + NetworkDetailViewController( + model: NetworkDetailModel( + network: Network(name: "Blob's WiFi"), + selectedNetworkID: UUID() + ) + ) +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift new file mode 100644 index 0000000000..db0101b467 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/NetworkDetailModel.swift @@ -0,0 +1,32 @@ +import Foundation +import XCTestDynamicOverlay + +@MainActor +@Observable +class NetworkDetailModel: Identifiable { + var forgetAlertIsPresented = false + var onConfirmForget: () -> Void = { + XCTFail("NetworkDetailModel.onConfirmForget unimplemented.") + } + + let network: Network + let selectedNetworkID: Network.ID? + + let id = UUID() + + init( + network: Network, + selectedNetworkID: Network.ID? + ) { + self.network = network + self.selectedNetworkID = selectedNetworkID + } + + func forgetNetworkButtonTapped() { + forgetAlertIsPresented = true + } + + func confirmForgetNetworkButtonTapped() { + onConfirmForget() + } +} diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift new file mode 100644 index 0000000000..d32e1afc05 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/UIKit+NetworkDetailViewController.swift @@ -0,0 +1,76 @@ +#if canImport(UIKit) && !os(watchOS) + +import SwiftUI +import UIKitNavigation + +final class NetworkDetailViewController: UIViewController { + @UIBindable var model: NetworkDetailModel + + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = model.network.name + + let forgetButton = UIButton( + type: .system, + primaryAction: UIAction { [weak self] _ in + self?.model.forgetNetworkButtonTapped() + } + ) + forgetButton.setTitle("Forget network", for: .normal) + forgetButton.setTitleColor(.red, for: .normal) + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + forgetButton.isHidden = model.network.id != model.selectedNetworkID + } + + present(isPresented: $model.forgetAlertIsPresented) { [unowned self] in + let controller = UIAlertController( + title: "Forget Wi-Fi Network “\(model.network.name)”?", + message: """ + Your iPhone and other devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + controller.addAction( + UIAlertAction(title: "Forget", style: .destructive) { [weak self] _ in + self?.model.confirmForgetNetworkButtonTapped() + }) + return controller + } + } +} + +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: NetworkDetailViewController( + model: NetworkDetailModel( + network: Network(name: "Blob's WiFi"), + selectedNetworkID: UUID() + ) + ) + ) + } +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift new file mode 100644 index 0000000000..698db6d09b --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift @@ -0,0 +1,352 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigation +import SwiftUI + +class WiFiSettingsViewController: XiblessViewController, AppKitCaseStudy { + let caseStudyTitle = "Wi-Fi Settings" + let readMe = """ + This demo shows how to built a moderately complex feature using the tools of the library. \ + There are multiple features that communicate with each other, there are multiple navigation \ + patterns, and the root feature has a complex collection view that updates dynamically. + """ + let isPresentedInSheet = true + + @UIBindable var model: WiFiSettingsModel + + var dataSource: NSCollectionViewDiffableDataSource! + + let collectionView = NSCollectionView() + + init(model: WiFiSettingsModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + collectionView.autoresizingMask = [.width, .height] + contentView.documentView = collectionView + let collectionViewLayout = NSCollectionViewCompositionalLayout { sectionIndex, layoutEnvironment -> NSCollectionLayoutSection? in + // Item + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(sectionIndex == 0 ? 60 : 44)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.contentInsets = .init(top: 0, leading: sectionIndex == 0 ? 10 : 30, bottom: 0, trailing: 10) + // Group + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(sectionIndex == 0 ? 60 : 44)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + // Section + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) + let decorationItem = NSCollectionLayoutDecorationItem.background(elementKind: NSCollectionView.DecorationElementKind.background) + decorationItem.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10) + section.decorationItems = [decorationItem] + return section + } + collectionViewLayout.register(WiFiSettingsSectionBackgroundView.self, forDecorationViewOfKind: .background) + let configuration = NSCollectionViewCompositionalLayoutConfiguration() + configuration.interSectionSpacing = 20 + collectionViewLayout.configuration = configuration + collectionView.collectionViewLayout = collectionViewLayout + collectionView.register(WiFiSettingsSwitchViewItem.self, forItemWithIdentifier: .init(WiFiSettingsSwitchViewItem.self)) + collectionView.register(WiFiSettingsConnectedNetworkViewItem.self, forItemWithIdentifier: .init(WiFiSettingsConnectedNetworkViewItem.self)) + collectionView.register(WiFiSettingsFoundedNetworkViewItem.self, forItemWithIdentifier: .init(WiFiSettingsFoundedNetworkViewItem.self)) + + dataSource = NSCollectionViewDiffableDataSource( + collectionView: collectionView + ) { [unowned self] collectionView, indexPath, item in + + switch item { + case .isOn: + let switchViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsSwitchViewItem.self), for: indexPath) as! WiFiSettingsSwitchViewItem + switchViewItem.switchView.bind(isOn: $model.isOn) + return switchViewItem + case let .selectedNetwork(networkID): + guard let network = model.foundNetworks.first(where: { $0.id == networkID }) + else { return nil } + let connectedViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsConnectedNetworkViewItem.self), for: indexPath) as! WiFiSettingsConnectedNetworkViewItem + connectedViewItem.nameLabel.stringValue = network.name + connectedViewItem.securedIconImageView.isHidden = !network.isSecured + connectedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) + connectedViewItem.detailButton.addAction { _ in + self.model.infoButtonTapped(network: network) + } + return connectedViewItem + case let .foundNetwork(network): + let foundedViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsFoundedNetworkViewItem.self), for: indexPath) as! WiFiSettingsFoundedNetworkViewItem + foundedViewItem.titleLabel.stringValue = network.name + foundedViewItem.securedIconImageView.isHidden = !network.isSecured + foundedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) + foundedViewItem.connectButton.addAction { _ in + self.model.networkTapped(network) + } + return foundedViewItem + } + } + + observe { [weak self] in + guard let self else { return } + dataSource.apply( + NSDiffableDataSourceSnapshot(model: model), + animatingDifferences: true + ) + } + + modalSession(item: $model.destination.connect) { model in + let panel = NSPanel(contentViewController: ConnectToNetworkViewController(model: model)) + panel.styleMask = [.titled] + panel.animationBehavior = .none + return panel + } + + present(item: $model.destination.detail, style: .sheet) { model in + let vc = NetworkDetailViewController(model: model) + vc.preferredContentSize = .init(width: 300, height: 200) + return vc + } + } +} + +#Preview { + let model = WiFiSettingsModel(foundNetworks: .mocks) + return NSViewControllerRepresenting { + WiFiSettingsViewController(model: model) + } + .task { + while true { + try? await Task.sleep(for: .seconds(1)) + guard Bool.random() else { continue } + if Bool.random() { + guard let randomIndex = (0 ..< model.foundNetworks.count).randomElement() + else { continue } + if model.foundNetworks[randomIndex].id != model.selectedNetworkID { + model.foundNetworks.remove(at: randomIndex) + } + } else { + model.foundNetworks.append( + Network( + name: goofyWiFiNames.randomElement()!, + isSecured: !(1 ... 1000).randomElement()!.isMultiple(of: 5), + connectivity: Double((1 ... 100).randomElement()!) / 100 + ) + ) + } + } + } +} + +extension NSCollectionView.DecorationElementKind { + static let background = "BackgroundKind" +} + +class WiFiSettingsSectionBackgroundView: NSBox, NSCollectionViewElement { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + func commonInit() { + titlePosition = .noTitle + } +} + +class XiblessCollectionViewItem: NSCollectionViewItem { + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = View() + } +} + +class WiFiSettingsSwitchViewItem: XiblessCollectionViewItem { + let iconImageView = NSImageView() + + let titleLabel = NSTextField(labelWithString: "Wi-Fi") + + let switchView = NSSwitch() + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + let stackView = NSStackView() + stackView.addView(iconImageView, in: .leading) + stackView.addView(titleLabel, in: .leading) + stackView.addView(switchView, in: .trailing) + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.spacing = 15 + view.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leftAnchor.constraint(equalTo: view.leftAnchor), + stackView.rightAnchor.constraint(equalTo: view.rightAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + let image = NSImage(systemSymbolName: "wifi", accessibilityDescription: "Wi-Fi")! + iconImageView.image = image.withSymbolConfiguration(.init(pointSize: 15, weight: .regular)) + iconImageView.wantsLayer = true + iconImageView.layer?.cornerRadius = 5 + iconImageView.layer?.backgroundColor = NSColor.systemBlue.cgColor + iconImageView.contentTintColor = .white + NSLayoutConstraint.activate([ + iconImageView.widthAnchor.constraint(equalToConstant: 26), + iconImageView.heightAnchor.constraint(equalToConstant: 20), + ]) + switchView.controlSize = .large + } +} + +class WiFiSettingsConnectedNetworkViewItem: XiblessCollectionViewItem { + let nameLabel = NSTextField(labelWithString: "WiFi Name") + let statusButton = NSButton() + let securedIconImageView = NSImageView(systemSymbolName: "lock.fill") + let wifiIconImageView = NSImageView(systemSymbolName: "wifi") + let detailButton = NSButton(title: "Detail...", target: nil, action: nil) + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + let stackView = NSStackView() + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.spacing = 15 + view.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leftAnchor.constraint(equalTo: view.leftAnchor), + stackView.rightAnchor.constraint(equalTo: view.rightAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let textStackView = NSStackView() + textStackView.orientation = .vertical + textStackView.alignment = .leading + textStackView.spacing = 3 + textStackView.addArrangedSubview(nameLabel) + textStackView.addArrangedSubview(statusButton) + stackView.addView(textStackView, in: .leading) + stackView.addView(securedIconImageView, in: .trailing) + stackView.addView(wifiIconImageView, in: .trailing) + stackView.addView(detailButton, in: .trailing) + + statusButton.isBordered = false + statusButton.image = NSImage(named: NSImage.statusAvailableName) + (statusButton.cell as! NSButtonCell).highlightsBy = [] + statusButton.attributedTitle = NSAttributedString(string: "Connected", attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + ]) + statusButton.imagePosition = .imageLeft + securedIconImageView.contentTintColor = .labelColor + wifiIconImageView.contentTintColor = .labelColor + } + + override func prepareForReuse() { + super.prepareForReuse() + + detailButton.removeAllActions() + } +} + +class WiFiSettingsFoundedNetworkViewItem: XiblessCollectionViewItem { + let selectedIconImageView = NSImageView(systemSymbolName: "checkmark") + let titleLabel = NSTextField(labelWithString: "WiFi Name") + let securedIconImageView = NSImageView(systemSymbolName: "lock.fill") + let wifiIconImageView = NSImageView(systemSymbolName: "wifi") + let connectButton = NSButton(title: "Connect", target: nil, action: nil) + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + let stackView = NSStackView() + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.spacing = 10 + view.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leftAnchor.constraint(equalTo: view.leftAnchor), + stackView.rightAnchor.constraint(equalTo: view.rightAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) +// stackView.addView(selectedIconImageView, in: .leading) + stackView.addView(titleLabel, in: .leading) + stackView.addView(connectButton, in: .trailing) + stackView.addView(securedIconImageView, in: .trailing) + stackView.addView(wifiIconImageView, in: .trailing) + selectedIconImageView.symbolConfiguration = .init(pointSize: 12, weight: .heavy) + selectedIconImageView.contentTintColor = .labelColor + securedIconImageView.contentTintColor = .labelColor + wifiIconImageView.contentTintColor = .labelColor + connectButton.isHidden = true + } + + override func viewDidLayout() { + super.viewDidLayout() + view.trackingAreas.forEach(view.removeTrackingArea(_:)) + view.addTrackingArea(.init(rect: view.bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .assumeInside], owner: self)) + } + + override func mouseEntered(with event: NSEvent) { + connectButton.isHidden = false + } + + override func mouseMoved(with event: NSEvent) { + connectButton.isHidden = false + } + + override func mouseExited(with event: NSEvent) { + connectButton.isHidden = true + } + + override func prepareForReuse() { + super.prepareForReuse() + + connectButton.removeAllActions() + } +} + +@available(macOS 14.0, *) +#Preview { + let item = WiFiSettingsConnectedNetworkViewItem() + item.preferredContentSize = .init(width: 500, height: 200) + return item +} + +extension NSUserInterfaceItemIdentifier { + init(_ anyClass: AnyClass) { + self.init(rawValue: "\(anyClass)") + } +} + +extension NSImageView { + convenience init(systemSymbolName: String) { + self.init() + self.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: nil) + } + + convenience init(systemSymbolName: String, variableValue: Double) { + self.init() + self.image = NSImage(systemSymbolName: systemSymbolName, variableValue: variableValue, accessibilityDescription: nil) + } +} + +extension NSButton { + convenience init(systemSymbolName: String) { + self.init() + self.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: nil) + } +} + +#endif diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift new file mode 100644 index 0000000000..ae1081985b --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/UIKit+WiFiSettingsViewController.swift @@ -0,0 +1,199 @@ +#if canImport(UIKit) && !os(watchOS) + +import SwiftUI +import UIKitNavigation + +class WiFiSettingsViewController: UICollectionViewController, UIKitCaseStudy { + let caseStudyTitle = "Wi-Fi Settings" + let readMe = """ + This demo shows how to built a moderately complex feature using the tools of the library. \ + There are multiple features that communicate with each other, there are multiple navigation \ + patterns, and the root feature has a complex collection view that updates dynamically. + """ + let isPresentedInSheet = true + + @UIBindable var model: WiFiSettingsModel + var dataSource: UICollectionViewDiffableDataSource! + + init(model: WiFiSettingsModel) { + self.model = model + super.init( + collectionViewLayout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration(appearance: .insetGrouped) + ) + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.title = "Wi-Fi" + + let cellRegistration = UICollectionView.CellRegistration { + [weak self] cell, indexPath, item in + + guard let self else { return } + configure(cell: cell, indexPath: indexPath, item: item) + } + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: item + ) + } + + observe { [weak self] in + guard let self else { return } + dataSource.apply( + NSDiffableDataSourceSnapshot(model: model), + animatingDifferences: true + ) + } + + present(item: $model.destination.connect) { model in + UINavigationController( + rootViewController: ConnectToNetworkViewController(model: model) + ) + } + + navigationDestination(item: $model.destination.detail) { model in + NetworkDetailViewController(model: model) + } + } + + private func configure( + cell: UICollectionViewListCell, + indexPath: IndexPath, + item: Item + ) { + var configuration = cell.defaultContentConfiguration() + defer { cell.contentConfiguration = configuration } + cell.accessories = [] + + switch item { + case .isOn: + configuration.text = "Wi-Fi" + cell.accessories = [ + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UISwitch(isOn: $model.isOn), + placement: .trailing(displayed: .always) + ) + ), + ] + + case let .selectedNetwork(networkID): + guard let network = model.foundNetworks.first(where: { $0.id == networkID }) + else { return } + configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) + + case let .foundNetwork(network): + configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) + } + + func configureNetwork( + cell: UICollectionViewListCell, + network: Network, + indexPath: IndexPath, + item: Item + ) { + configuration.text = network.name + cell.accessories.append( + .detail(displayed: .always) { [weak self] in + guard let self else { return } + model.infoButtonTapped(network: network) + } + ) + if network.isSecured { + let image = UIImage(systemName: "lock.fill")! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + } + let image = UIImage(systemName: "wifi", variableValue: network.connectivity)! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + if network.id == model.selectedNetworkID { + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UIImageView(image: UIImage(systemName: "checkmark")!), + placement: .leading(displayed: .always), + reservedLayoutWidth: .custom(1) + ) + ) + ) + } + } + } + + override func collectionView( + _ collectionView: UICollectionView, + shouldSelectItemAt indexPath: IndexPath + ) -> Bool { + indexPath.section != 0 || indexPath.row != 0 + } + + override func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + guard let network = dataSource.itemIdentifier(for: indexPath)?.foundNetwork + else { return } + model.networkTapped(network) + } +} + +#Preview { + let model = WiFiSettingsModel(foundNetworks: .mocks) + return UIViewControllerRepresenting { + UINavigationController( + rootViewController: WiFiSettingsViewController(model: model) + ) + } + .task { + while true { + try? await Task.sleep(for: .seconds(1)) + guard Bool.random() else { continue } + if Bool.random() { + guard let randomIndex = (0 ..< model.foundNetworks.count).randomElement() + else { continue } + if model.foundNetworks[randomIndex].id != model.selectedNetworkID { + model.foundNetworks.remove(at: randomIndex) + } + } else { + model.foundNetworks.append( + Network( + name: goofyWiFiNames.randomElement()!, + isSecured: !(1 ... 1000).randomElement()!.isMultiple(of: 5), + connectivity: Double((1 ... 100).randomElement()!) / 100 + ) + ) + } + } + } +} +#endif diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift new file mode 100644 index 0000000000..a212f71177 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsModel.swift @@ -0,0 +1,75 @@ +import Foundation +import CasePaths + +@Observable +@MainActor +class WiFiSettingsModel { + var destination: Destination? { + didSet { bind() } + } + + var foundNetworks: [Network] + var isOn: Bool + var selectedNetworkID: Network.ID? + + @CasePathable + enum Destination { + case connect(ConnectToNetworkModel) + case detail(NetworkDetailModel) + } + + init( + foundNetworks: [Network] = [], + isOn: Bool = true, + selectedNetworkID: Network.ID? = nil + ) { + self.foundNetworks = foundNetworks + self.isOn = isOn + self.selectedNetworkID = selectedNetworkID + bind() + } + + private func bind() { + switch destination { + case let .connect(model): + model.onConnect = { [weak self] network in + guard let self else { return } + destination = nil + selectedNetworkID = network.id + } + model.onCancel = { [weak self] in + guard let self else { return } + destination = nil + } + + case let .detail(model): + model.onConfirmForget = { [weak self] in + guard let self else { return } + self.destination = nil + self.selectedNetworkID = nil + } + + case .none: + break + } + } + + func infoButtonTapped(network: Network) { + destination = .detail( + NetworkDetailModel( + network: network, + selectedNetworkID: selectedNetworkID + ) + ) + } + + func networkTapped(_ network: Network) { + if network.id == selectedNetworkID { + infoButtonTapped(network: network) + } else if network.isSecured { + destination = .connect(ConnectToNetworkModel(network: network)) + } else { + selectedNetworkID = network.id + } + } +} diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift new file mode 100644 index 0000000000..a94213b0e9 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/WiFiSettingsViewController+.swift @@ -0,0 +1,60 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +import CasePaths + +extension NSDiffableDataSourceSnapshot< + WiFiSettingsViewController.Section, + WiFiSettingsViewController.Item +> { + @MainActor + init(model: WiFiSettingsModel) { + self.init() + + appendSections([.top]) + appendItems([.isOn], toSection: .top) + + guard model.isOn + else { return } + + if let selectedNetworkID = model.selectedNetworkID { + appendItems([.selectedNetwork(selectedNetworkID)], toSection: .top) + } + + appendSections([.foundNetworks]) + appendItems( + model.foundNetworks + .sorted { lhs, rhs in + (lhs.isSecured ? 1 : 0, lhs.connectivity) + > (rhs.isSecured ? 1 : 0, rhs.connectivity) + } + .compactMap { network in + network.id == model.selectedNetworkID + ? nil + : .foundNetwork(network) + }, + toSection: .foundNetworks + ) + } +} + +extension WiFiSettingsViewController { + enum Section: Hashable, Sendable { + case top + case foundNetworks + } + + @CasePathable + @dynamicMemberLookup + enum Item: Hashable, Sendable { + case isOn + case selectedNetwork(Network.ID) + case foundNetwork(Network) + } +} + diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index f3552b0cfa..ea0c563924 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -8,10 +8,10 @@ /* Begin PBXBuildFile section */ CA26036D2C20973600822CA5 /* DetentsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA26036C2C20973600822CA5 /* DetentsHelper.swift */; }; - CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9B82C0525980034B757 /* ConnectToNetworkFeature.swift */; }; - CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BA2C0525980034B757 /* NetworkDetailFeature.swift */; }; + CA3D0E322C20B27B00CCF7CD /* UIKit+ConnectToNetworkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9B82C0525980034B757 /* UIKit+ConnectToNetworkViewController.swift */; }; + CA3D0E332C20B27B00CCF7CD /* UIKit+NetworkDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BA2C0525980034B757 /* UIKit+NetworkDetailViewController.swift */; }; CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9B92C0525980034B757 /* Network.swift */; }; - CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BB2C0525980034B757 /* WiFiSettingsFeature.swift */; }; + CA3D0E352C20B27B00CCF7CD /* WiFiSettingsViewController+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5EB9BB2C0525980034B757 /* WiFiSettingsViewController+.swift */; }; CA4737CF272F09600012CAC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA4737CE272F09600012CAC3 /* Assets.xcassets */; }; CA4737F4272F09780012CAC3 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA4737F3272F09780012CAC3 /* SwiftUINavigation */; }; CA4737F9272F09D00012CAC3 /* ItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA4737F5272F09D00012CAC3 /* ItemRow.swift */; }; @@ -24,7 +24,7 @@ CA473837272F0D860012CAC3 /* FactClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473831272F0D860012CAC3 /* FactClient.swift */; }; CA473838272F0D860012CAC3 /* OptionalNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA473832272F0D860012CAC3 /* OptionalNavigation.swift */; }; CA48F2FA2C49645100BE2C3C /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F2F92C49645100BE2C3C /* SwiftUINavigation */; }; - CA48F2FC2C49645100BE2C3C /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F2FB2C49645100BE2C3C /* UIKitNavigation */; }; + CA48F2FC2C49645100BE2C3C /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; platformFilter = ios; productRef = CA48F2FB2C49645100BE2C3C /* UIKitNavigation */; }; CA48F3022C49650100BE2C3C /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F3012C49650100BE2C3C /* IdentifiedCollections */; }; CA48F3052C49650F00BE2C3C /* Tagged in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F3042C49650F00BE2C3C /* Tagged */; }; CA48F3082C49651700BE2C3C /* ConcurrencyExtras in Frameworks */ = {isa = PBXBuildFile; productRef = CA48F3072C49651700BE2C3C /* ConcurrencyExtras */; }; @@ -53,8 +53,15 @@ DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6A8410291F227400B3F6C9 /* SynchronizedBindings.swift */; }; DC86E8712C208D8D003C0EC9 /* Text+Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC86E8702C208D8A003C0EC9 /* Text+Template.swift */; }; E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */; }; - E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; + E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */ = {isa = PBXBuildFile; platformFilters = (macos, ); productRef = E9836D932C6CA07300FC6EFD /* AppKitNavigation */; }; E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */; }; + E99B05952C70CE36003D3AFF /* WiFiSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05942C70CE36003D3AFF /* WiFiSettingsModel.swift */; }; + E99B05972C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05962C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift */; }; + E99B05992C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05982C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift */; }; + E99B059C2C70CEFE003D3AFF /* ConnectToNetworkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B059B2C70CEFE003D3AFF /* ConnectToNetworkModel.swift */; }; + E99B059E2C70CF18003D3AFF /* NetworkDetailModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B059D2C70CF18003D3AFF /* NetworkDetailModel.swift */; }; + E99B05A22C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05A12C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift */; }; + E99B05A42C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B05A32C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift */; }; E9CD5B762C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */; }; E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */; }; E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */; }; @@ -107,10 +114,10 @@ CABE9FC0272F2C0000AFC150 /* EnumNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumNavigation.swift; sourceTree = ""; }; CADCA3622C1CE75500DE645F /* SwiftUICaseStudies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUICaseStudies.swift; sourceTree = ""; }; CADCA3652C1CE8BE00DE645F /* CaseStudy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudy.swift; sourceTree = ""; }; - DC5EB9B82C0525980034B757 /* ConnectToNetworkFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectToNetworkFeature.swift; sourceTree = ""; }; + DC5EB9B82C0525980034B757 /* UIKit+ConnectToNetworkViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIKit+ConnectToNetworkViewController.swift"; sourceTree = ""; }; DC5EB9B92C0525980034B757 /* Network.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; - DC5EB9BA2C0525980034B757 /* NetworkDetailFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkDetailFeature.swift; sourceTree = ""; }; - DC5EB9BB2C0525980034B757 /* WiFiSettingsFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WiFiSettingsFeature.swift; sourceTree = ""; }; + DC5EB9BA2C0525980034B757 /* UIKit+NetworkDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIKit+NetworkDetailViewController.swift"; sourceTree = ""; }; + DC5EB9BB2C0525980034B757 /* WiFiSettingsViewController+.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WiFiSettingsViewController+.swift"; sourceTree = ""; }; DC5EB9CB2C0525E80034B757 /* AssertEventually.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssertEventually.swift; sourceTree = ""; }; DC5EB9CC2C0525E80034B757 /* SetUp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetUp.swift; sourceTree = ""; }; DC5EB9CD2C0525E80034B757 /* XCTTODO.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XCTTODO.swift; sourceTree = ""; }; @@ -120,6 +127,13 @@ DC86E8702C208D8A003C0EC9 /* Text+Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Template.swift"; sourceTree = ""; }; E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+MinimalObservationViewController.swift"; sourceTree = ""; }; E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+NSControlBindingsViewController.swift"; sourceTree = ""; }; + E99B05942C70CE36003D3AFF /* WiFiSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WiFiSettingsModel.swift; sourceTree = ""; }; + E99B05962C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+WiFiSettingsViewController.swift"; sourceTree = ""; }; + E99B05982C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+WiFiSettingsViewController.swift"; sourceTree = ""; }; + E99B059B2C70CEFE003D3AFF /* ConnectToNetworkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToNetworkModel.swift; sourceTree = ""; }; + E99B059D2C70CF18003D3AFF /* NetworkDetailModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDetailModel.swift; sourceTree = ""; }; + E99B05A12C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+NetworkDetailViewController.swift"; sourceTree = ""; }; + E99B05A32C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+ConnectToNetworkViewController.swift"; sourceTree = ""; }; E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+EnumControlsViewController.swift"; sourceTree = ""; }; E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+FocusViewController.swift"; sourceTree = ""; }; E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppKit+BasicsNavigationViewController.swift"; sourceTree = ""; }; @@ -207,6 +221,7 @@ CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */, CA47382E272F0D860012CAC3 /* RootView.swift */, CA47380A272F0D340012CAC3 /* Assets.xcassets */, + DC5EB9BC2C0525980034B757 /* WiFiFeature */, CA9D70492C1F642A003B672A /* Internal */, CADCA3642C1CE80B00DE645F /* SwiftUI */, CA9D704A2C1F6431003B672A /* UIKit */, @@ -252,7 +267,6 @@ CA9D704F2C1FB431003B672A /* StaticNavigationStackController.swift */, CA9D70592C208977003B672A /* UIControlBindingsViewController.swift */, CA9D70442C1F6001003B672A /* UIKitCaseStudies.swift */, - DC5EB9BC2C0525980034B757 /* WiFiFeature */, ); path = UIKit; sourceTree = ""; @@ -273,10 +287,10 @@ DC5EB9BC2C0525980034B757 /* WiFiFeature */ = { isa = PBXGroup; children = ( - DC5EB9B82C0525980034B757 /* ConnectToNetworkFeature.swift */, DC5EB9B92C0525980034B757 /* Network.swift */, - DC5EB9BA2C0525980034B757 /* NetworkDetailFeature.swift */, - DC5EB9BB2C0525980034B757 /* WiFiSettingsFeature.swift */, + E99B05A02C70CF43003D3AFF /* ConnectToNetworkFeature */, + E99B059F2C70CF38003D3AFF /* NetworkDetailFeature */, + E99B059A2C70CEE1003D3AFF /* WiFiSettingsFeature */, ); path = WiFiFeature; sourceTree = ""; @@ -305,6 +319,37 @@ path = AppKit; sourceTree = ""; }; + E99B059A2C70CEE1003D3AFF /* WiFiSettingsFeature */ = { + isa = PBXGroup; + children = ( + E99B05942C70CE36003D3AFF /* WiFiSettingsModel.swift */, + DC5EB9BB2C0525980034B757 /* WiFiSettingsViewController+.swift */, + E99B05962C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift */, + E99B05982C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift */, + ); + path = WiFiSettingsFeature; + sourceTree = ""; + }; + E99B059F2C70CF38003D3AFF /* NetworkDetailFeature */ = { + isa = PBXGroup; + children = ( + E99B059D2C70CF18003D3AFF /* NetworkDetailModel.swift */, + E99B05A12C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift */, + DC5EB9BA2C0525980034B757 /* UIKit+NetworkDetailViewController.swift */, + ); + path = NetworkDetailFeature; + sourceTree = ""; + }; + E99B05A02C70CF43003D3AFF /* ConnectToNetworkFeature */ = { + isa = PBXGroup; + children = ( + E99B059B2C70CEFE003D3AFF /* ConnectToNetworkModel.swift */, + E99B05A32C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift */, + DC5EB9B82C0525980034B757 /* UIKit+ConnectToNetworkViewController.swift */, + ); + path = ConnectToNetworkFeature; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -466,8 +511,12 @@ CABE9FC1272F2C0000AFC150 /* EnumNavigation.swift in Sources */, E9CD5B782C6CDEA500CE7947 /* AppKit+FocusViewController.swift in Sources */, E9836D922C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift in Sources */, + E99B059E2C70CF18003D3AFF /* NetworkDetailModel.swift in Sources */, + E99B059C2C70CEFE003D3AFF /* ConnectToNetworkModel.swift in Sources */, E9CD5B7B2C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift in Sources */, DC6A8411291F227400B3F6C9 /* SynchronizedBindings.swift in Sources */, + E99B05992C70CE6E003D3AFF /* UIKit+WiFiSettingsViewController.swift in Sources */, + E99B05A42C70DC50003D3AFF /* AppKit+ConnectToNetworkViewController.swift in Sources */, CA9D70502C1FB431003B672A /* StaticNavigationStackController.swift in Sources */, CA473837272F0D860012CAC3 /* FactClient.swift in Sources */, E9CD5B7C2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift in Sources */, @@ -476,18 +525,21 @@ CA9D70562C2087DC003B672A /* EnumControls.swift in Sources */, CA473836272F0D860012CAC3 /* CaseStudiesApp.swift in Sources */, CA473834272F0D860012CAC3 /* RootView.swift in Sources */, - CA3D0E322C20B27B00CCF7CD /* ConnectToNetworkFeature.swift in Sources */, + CA3D0E322C20B27B00CCF7CD /* UIKit+ConnectToNetworkViewController.swift in Sources */, E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift in Sources */, - CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */, + CA3D0E332C20B27B00CCF7CD /* UIKit+NetworkDetailViewController.swift in Sources */, CA3D0E342C20B27B00CCF7CD /* Network.swift in Sources */, - CA3D0E352C20B27B00CCF7CD /* WiFiSettingsFeature.swift in Sources */, + CA3D0E352C20B27B00CCF7CD /* WiFiSettingsViewController+.swift in Sources */, + E99B05972C70CE59003D3AFF /* AppKit+WiFiSettingsViewController.swift in Sources */, CA26036D2C20973600822CA5 /* DetentsHelper.swift in Sources */, CA9D705A2C208977003B672A /* UIControlBindingsViewController.swift in Sources */, CA601BB02C236544006B0C5F /* FocusViewController.swift in Sources */, + E99B05952C70CE36003D3AFF /* WiFiSettingsModel.swift in Sources */, CA9D70472C1F6108003B672A /* AlertDialogState.swift in Sources */, CA9D70452C1F6001003B672A /* UIKitCaseStudies.swift in Sources */, CADCA3632C1CE75500DE645F /* SwiftUICaseStudies.swift in Sources */, CA9D705E2C2089DD003B672A /* EnumControlsViewController.swift in Sources */, + E99B05A22C70D5E1003D3AFF /* AppKit+NetworkDetailViewController.swift in Sources */, CA601BAE2C234613006B0C5F /* AnimationsViewController.swift in Sources */, CADCA3662C1CE8BE00DE645F /* CaseStudy.swift in Sources */, CA49D9542C20D4DF00E6C5BB /* ErasedNavigationStackController.swift in Sources */, diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift index 37d7bb6ba3..ad4f4bf0df 100644 --- a/Sources/AppKitNavigation/Bindings/NSAlert.swift +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -28,7 +28,7 @@ extension NSAlert { ) { let button = addButton(withTitle: String(state: buttonState.label)) - button.createActionHandlerIfNeeded().addAction { _ in + button.createActionProxyIfNeeded().addBindingAction { _ in buttonState.withAction(handler) } diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift index e266d8bc8a..4e7a88ffae 100644 --- a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -26,7 +26,7 @@ extension NSColorPanel { self.init() bind(color: color) } - + /// Establishes a two-way connection between a binding and the color panel's selected color. /// /// - Parameter color: The binding to read from for the selected color, and write to diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index d1ee6d68b3..02294ec437 100644 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -17,11 +17,22 @@ extension NSControl: NSTargetActionProtocol { extension NSControl { public convenience init(action: @escaping (Self) -> Void) { self.init(frame: .zero) - createActionHandlerIfNeeded().addAction { [weak self] _ in + createActionProxyIfNeeded().addAction { [weak self] _ in guard let self else { return } action(self) } } + + public func addAction(_ action: @escaping (NSControl) -> Void) { + createActionProxyIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } + + public func removeAllActions() { + createActionProxyIfNeeded().removeAllActions() + } } #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 752c4a62dc..a24aba654c 100644 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -15,7 +15,7 @@ extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { } private static let appkitNavigationDelegateKey = malloc(1)! - + private var appkitNavigationDelegate: Delegate { set { objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) @@ -25,17 +25,17 @@ extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { return delegate } else { let delegate = Delegate() - self.target = delegate + target = delegate self.appkitNavigationDelegate = delegate return delegate } } } - + private class Delegate: NSObject, NSFontChanging { var target: AnyObject? var action: Selector? - + func changeFont(_ sender: NSFontManager?) { if let action { NSApplication.shared.sendAction(action, to: target, from: sender) @@ -67,7 +67,7 @@ extension NSFontManager { public func bind(font: UIBinding) -> ObservationToken { bind(font, to: \._selectedFont) } - + @objc private var _selectedFont: NSFont { set { setSelectedFont(newValue, isMultiple: false) } get { convert(.systemFont(ofSize: 0)) } diff --git a/Sources/AppKitNavigation/Bindings/NSOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift similarity index 100% rename from Sources/AppKitNavigation/Bindings/NSOpenPanel.swift rename to Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 4c0dce7971..6362757314 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -11,24 +11,59 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { } @MainActor -internal class NSTargetActionHandler: NSObject { +class NSTargetActionProxy: NSObject { typealias Action = (Any?) -> Void - var actions: [Action] = [] + + internal private(set) var bindingActions: [Action] = [] + + internal private(set) var actions: [Action] = [] var originTarget: AnyObject? var originAction: Selector? + weak var owner: NSTargetActionProtocol? + + required init(owner: NSTargetActionProtocol) { + self.owner = owner + super.init() + self.originTarget = owner.appkitNavigationTarget + self.originAction = owner.appkitNavigationAction + owner.appkitNavigationTarget = self + owner.appkitNavigationAction = #selector(invokeAction(_:)) + if let textField = owner as? NSTextField { + NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) + } + } + + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0(obj.object) } + actions.forEach { $0(obj.object) } + } + @objc func invokeAction(_ sender: Any?) { if let originTarget, let originAction { NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) } + bindingActions.forEach { $0(sender) } actions.forEach { $0(sender) } } func addAction(_ action: @escaping Action) { actions.append(action) } + + func removeAllActions() { + actions.removeAll() + } + + func addBindingAction(_ bindingAction: @escaping Action) { + bindingActions.append(bindingAction) + } + + func removeAllBindingActions() { + bindingActions.removeAll() + } } extension NSTargetActionProtocol { @@ -49,26 +84,22 @@ extension NSTargetActionProtocol { } } - internal var actionHandler: NSTargetActionHandler? { + var actionProxy: NSTargetActionProxy? { set { - objc_setAssociatedObject(self, actionHandlerKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - objc_getAssociatedObject(self, actionHandlerKey) as? NSTargetActionHandler + objc_getAssociatedObject(self, actionProxyKey) as? NSTargetActionProxy } } - internal func createActionHandlerIfNeeded() -> NSTargetActionHandler { - if let actionHandler { - return actionHandler + func createActionProxyIfNeeded() -> NSTargetActionProxy { + if let actionProxy { + return actionProxy } else { - let actionHandler = NSTargetActionHandler() - actionHandler.originTarget = appkitNavigationTarget - actionHandler.originAction = appkitNavigationAction - self.actionHandler = actionHandler - appkitNavigationTarget = actionHandler - appkitNavigationAction = #selector(NSTargetActionHandler.invokeAction(_:)) - return actionHandler + let actionProxy = NSTargetActionProxy(owner: self) + self.actionProxy = actionProxy + return actionProxy } } @@ -89,8 +120,8 @@ extension NSTargetActionProtocol { set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void ) -> ObservationToken { unbind(keyPath) - let actionHandler = createActionHandlerIfNeeded() - actionHandler.addAction { [weak self] _ in + let actionProxy = createActionProxyIfNeeded() + actionProxy.addBindingAction { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } @@ -120,7 +151,7 @@ extension NSTargetActionProtocol { MainActor._assumeIsolated { self?.appkitNavigationTarget = nil self?.appkitNavigationAction = nil - self?.actionHandler = nil + self?.actionProxy = nil } token.cancel() observation.invalidate() @@ -162,10 +193,9 @@ extension NSObject { } } - @MainActor private let observationTokensKey = malloc(1)! @MainActor -private let actionHandlerKey = malloc(1)! +private let actionProxyKey = malloc(1)! #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index 64d1c0de7a..a5faaaf38f 100644 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -1,7 +1,9 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKit +import Combine import SwiftNavigation +@MainActor extension NSTextField: NSTextViewDelegate { /// Creates a new text field with the specified frame and registers the binding against its /// text. @@ -69,14 +71,14 @@ extension NSTextField: NSTextViewDelegate { selection.wrappedValue = control.textSelection } } - + let observationToken = ObservationToken { [weak self] in -// MainActor._assumeIsolated { - editingChangedAction.cancel() - editingDidEndAction.cancel() -// } - token.cancel() - self?.textSelectionObserver = nil + MainActor._assumeIsolated { + editingChangedAction.cancel() + editingDidEndAction.cancel() + token.cancel() + self?.textSelectionObserver = nil + } } observationTokens[\NSTextField.selectedRange] = observationToken return observationToken @@ -334,4 +336,7 @@ public struct AppKitTextSelection: Hashable, Sendable { range.isEmpty } } + +extension AnyCancellable: @unchecked Sendable {} + #endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift index 4fb5f90e74..bc63b33443 100644 --- a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift +++ b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift @@ -9,13 +9,22 @@ class ModalWindowsObserver: NSObject { var windowsCancellable: [NSWindow: AnyCancellable] = [:] - func observeWindow(_ window: NSWindow) { + 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 NSApplication.shared.modalWindow === window { + 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) } } diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Internal/NavigationObserver.swift similarity index 100% rename from Sources/AppKitNavigation/Navigation/NavigationObserver.swift rename to Sources/AppKitNavigation/Internal/NavigationObserver.swift diff --git a/Sources/AppKitNavigation/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 615f985e1d..95eca9b464 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -25,6 +25,15 @@ extension NSObject { ) -> ObservationToken { modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } } + + @discardableResult + public func modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObservationToken { + modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -47,6 +56,15 @@ extension NSObject { ) -> ObservationToken { modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } + + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -70,6 +88,16 @@ extension NSObject { ) -> ObservationToken { modal(item: item, id: \.id, onDismiss: onDismiss, content: content) } + + @_disfavoredOverload + @discardableResult + public func modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObservationToken { + modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } /// Sheet a representable modally when a binding to a Boolean value you provide is true. /// @@ -97,6 +125,18 @@ extension NSObject { } } + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObservationToken { + modalSession(item: item, id: id, onDismiss: onDismiss) { + content($0.wrappedValue) + } + } + @discardableResult public func modal( item: UIBinding, @@ -131,6 +171,40 @@ extension NSObject { } } + @discardableResult + public func modalSession( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObservationToken { + 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, diff --git a/Sources/AppKitNavigation/Bindings/NSSavePanel.swift b/Sources/AppKitNavigation/Navigation/ModalSession.swift similarity index 97% rename from Sources/AppKitNavigation/Bindings/NSSavePanel.swift rename to Sources/AppKitNavigation/Navigation/ModalSession.swift index 893f31d20e..51a47075aa 100644 --- a/Sources/AppKitNavigation/Bindings/NSSavePanel.swift +++ b/Sources/AppKitNavigation/Navigation/ModalSession.swift @@ -2,4 +2,6 @@ import AppKit + + #endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift new file mode 100644 index 0000000000..5a27dab29c --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift @@ -0,0 +1,29 @@ +#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) +// NSApplication.shared.runModalSession(modalSession) + return modalSession + } +} + +//extension NSSavePanel { +// override func __appKitNavigationBeginModalSession() -> NSApplication.ModalSession { +// begin +// } +//} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 8162d88d3a..eb06919cd8 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -11,15 +11,13 @@ public protocol SheetContent: NavigationContent { extension SheetContent { public func beginSheet(for content: any SheetContent) async { - if let sheetedWindow = content.currentWindow { - await currentWindow?.beginSheet(sheetedWindow) - } + guard let sheetedWindow = content.currentWindow else { return } + await currentWindow?.beginSheet(sheetedWindow) } public func endSheet(for content: any SheetContent) { - if let sheetedWindow = content.currentWindow { - currentWindow?.endSheet(sheetedWindow) - } + guard let sheetedWindow = content.currentWindow else { return } + currentWindow?.endSheet(sheetedWindow) } } @@ -48,4 +46,15 @@ extension NSAlert: SheetContent { } } +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/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index df6134abdd..d537d53be2 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "06b5cdc432e93b60e3bdf53aff2857c6b312991a", - "version" : "600.0.0-prerelease-2024-07-30" + "revision" : "515f79b522918f83483068d99c68daeb5116342d", + "version" : "600.0.0-prerelease-2024-08-14" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", - "version" : "1.2.2" + "revision" : "c6809a193975cab4d5308c64bd1d51e0df467928", + "version" : "1.2.3" } } ], From 7278ccc691b1950a2c26a0e2aacb1054a987d236 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 18 Aug 2024 22:57:20 +0800 Subject: [PATCH 12/34] WIP --- Examples/CaseStudies/Internal/CaseStudy.swift | 7 +++++++ .../AppKit+WiFiSettingsViewController.swift | 13 ++++++++----- Examples/Examples.xcodeproj/project.pbxproj | 2 +- Sources/AppKitNavigation/Bindings/NSAlert.swift | 3 ++- .../Bindings/NSSaveOpenPanel.swift | 15 ++++++--------- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Examples/CaseStudies/Internal/CaseStudy.swift b/Examples/CaseStudies/Internal/CaseStudy.swift index b6d739cb31..c55a82c934 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -9,6 +9,7 @@ protocol CaseStudy { } protocol SwiftUICaseStudy: CaseStudy, View {} + #if canImport(UIKit) && !os(watchOS) import UIKitNavigation @@ -17,6 +18,7 @@ extension UIKitCaseStudy { var usesOwnLayout: Bool { true } } #endif + #if canImport(AppKit) && !targetEnvironment(macCatalyst) import AppKitNavigation @@ -187,6 +189,11 @@ struct CaseStudyModifier: ViewModifier { .sheet(isPresented: $isAboutPresented) { Form { Text(template: caseStudy.readMe) + #if os(macOS) + Button("Close") { + isAboutPresented = false + } + #endif } .presentationDetents([.medium]) } diff --git a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift index 698db6d09b..9f72cd5b61 100644 --- a/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift @@ -56,8 +56,8 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas dataSource = NSCollectionViewDiffableDataSource( collectionView: collectionView - ) { [unowned self] collectionView, indexPath, item in - + ) { [weak self] collectionView, indexPath, item in + guard let self else { return nil } switch item { case .isOn: let switchViewItem = collectionView.makeItem(withIdentifier: .init(WiFiSettingsSwitchViewItem.self), for: indexPath) as! WiFiSettingsSwitchViewItem @@ -70,7 +70,8 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas connectedViewItem.nameLabel.stringValue = network.name connectedViewItem.securedIconImageView.isHidden = !network.isSecured connectedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) - connectedViewItem.detailButton.addAction { _ in + connectedViewItem.detailButton.addAction { [weak self] _ in + guard let self else { return } self.model.infoButtonTapped(network: network) } return connectedViewItem @@ -79,7 +80,8 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas foundedViewItem.titleLabel.stringValue = network.name foundedViewItem.securedIconImageView.isHidden = !network.isSecured foundedViewItem.wifiIconImageView.image = NSImage(systemSymbolName: "wifi", variableValue: network.connectivity, accessibilityDescription: nil) - foundedViewItem.connectButton.addAction { _ in + foundedViewItem.connectButton.addAction { [weak self] _ in + guard let self else { return } self.model.networkTapped(network) } return foundedViewItem @@ -97,10 +99,11 @@ class WiFiSettingsViewController: XiblessViewController, AppKitCas modalSession(item: $model.destination.connect) { model in let panel = NSPanel(contentViewController: ConnectToNetworkViewController(model: model)) panel.styleMask = [.titled] + panel.title = "" panel.animationBehavior = .none return panel } - + present(item: $model.destination.detail, style: .sheet) { model in let vc = NetworkDetailViewController(model: model) vc.preferredContentSize = .init(width: 300, height: 200) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index ea0c563924..34e4997f5f 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -221,11 +221,11 @@ CA473830272F0D860012CAC3 /* CaseStudiesApp.swift */, CA47382E272F0D860012CAC3 /* RootView.swift */, CA47380A272F0D340012CAC3 /* Assets.xcassets */, - DC5EB9BC2C0525980034B757 /* WiFiFeature */, CA9D70492C1F642A003B672A /* Internal */, CADCA3642C1CE80B00DE645F /* SwiftUI */, CA9D704A2C1F6431003B672A /* UIKit */, E9836D902C6C9E3000FC6EFD /* AppKit */, + DC5EB9BC2C0525980034B757 /* WiFiFeature */, ); path = CaseStudies; sourceTree = ""; diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift index ad4f4bf0df..812f894812 100644 --- a/Sources/AppKitNavigation/Bindings/NSAlert.swift +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -24,7 +24,7 @@ extension NSAlert { extension NSAlert { public func addButton( _ buttonState: ButtonState, - action handler: @escaping (_ action: Action?) -> Void + action handler: @escaping (_ action: Action?) -> Void = { (_: Never?) in } ) { let button = addButton(withTitle: String(state: buttonState.label)) @@ -35,6 +35,7 @@ extension NSAlert { if buttonState.role == .destructive, #available(macOS 11.0, *) { button.hasDestructiveAction = true } + if buttonState.role == .cancel { button.keyEquivalent = "\u{1b}" } diff --git a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift index 896135f71e..b781c72116 100644 --- a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift @@ -8,13 +8,13 @@ extension NSSavePanel { self.init() bind(url: url) } - + @discardableResult public func bind(url binding: UIBinding) -> ObservationToken { appKitNavigation_onFinalURL = { url in binding.wrappedValue = url } - + let observationToken = ObservationToken { [weak self] in guard let self else { return } MainActor._assumeIsolated { @@ -24,28 +24,25 @@ extension NSSavePanel { observationTokens[\NSSavePanel.url] = observationToken return observationToken } - + public func unbindURL() { observationTokens[\NSSavePanel.url]?.cancel() observationTokens[\NSSavePanel.url] = nil } - - } extension NSOpenPanel { - public convenience init(urls: UIBinding<[URL]>) { self.init() bind(urls: urls) } - + @discardableResult public func bind(urls binding: UIBinding<[URL]>) -> ObservationToken { appKitNavigation_onFinalURLs = { urls in binding.wrappedValue = urls } - + let observationToken = ObservationToken { [weak self] in guard let self else { return } MainActor._assumeIsolated { @@ -55,7 +52,7 @@ extension NSOpenPanel { observationTokens[\NSOpenPanel.urls] = observationToken return observationToken } - + public func unbindURLs() { observationTokens[\NSOpenPanel.urls]?.cancel() observationTokens[\NSOpenPanel.urls] = nil From 61e1fff119d2a8888052a9d33c8908f46c41424e Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Mon, 19 Aug 2024 00:37:47 +0800 Subject: [PATCH 13/34] WIP --- Package.swift | 2 + .../AppKitNavigation/Bindings/NSControl.swift | 7 +- .../Bindings/NSTargetAction.swift | 62 +------------ .../Bindings/NSTargetActionProxy.swift | 88 +++++++++++++++++++ .../Internal/ClosureHolder.swift | 17 ---- .../ModalWindowsObserver.swift | 0 .../NavigationObserver.swift | 0 7 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift delete mode 100644 Sources/AppKitNavigation/Internal/ClosureHolder.swift rename Sources/AppKitNavigation/{Internal => Navigation}/ModalWindowsObserver.swift (100%) rename Sources/AppKitNavigation/{Internal => Navigation}/NavigationObserver.swift (100%) diff --git a/Package.swift b/Package.swift index 480e3861b8..fc20ea17dc 100644 --- a/Package.swift +++ b/Package.swift @@ -35,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( @@ -83,6 +84,7 @@ let package = Package( "SwiftNavigation", "AppKitNavigationShim", .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), .target( diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 02294ec437..7edc6aaef2 100644 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -23,13 +23,18 @@ extension NSControl { } } - public func addAction(_ action: @escaping (NSControl) -> Void) { + @discardableResult + public func addAction(_ action: @escaping (NSControl) -> Void) -> UUID { createActionProxyIfNeeded().addAction { [weak self] _ in guard let self else { return } action(self) } } + public func removeAction(for id: UUID) { + createActionProxyIfNeeded().removeAction(for: id) + } + public func removeAllActions() { createActionProxyIfNeeded().removeAllActions() } diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift index 6362757314..fb6e9553fa 100644 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -10,62 +10,6 @@ public protocol NSTargetActionProtocol: NSObject, Sendable { var appkitNavigationAction: Selector? { set get } } -@MainActor -class NSTargetActionProxy: NSObject { - typealias Action = (Any?) -> Void - - internal private(set) var bindingActions: [Action] = [] - - internal private(set) var actions: [Action] = [] - - var originTarget: AnyObject? - - var originAction: Selector? - - weak var owner: NSTargetActionProtocol? - - required init(owner: NSTargetActionProtocol) { - self.owner = owner - super.init() - self.originTarget = owner.appkitNavigationTarget - self.originAction = owner.appkitNavigationAction - owner.appkitNavigationTarget = self - owner.appkitNavigationAction = #selector(invokeAction(_:)) - if let textField = owner as? NSTextField { - NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) - } - } - - @objc func controlTextDidChange(_ obj: Notification) { - bindingActions.forEach { $0(obj.object) } - actions.forEach { $0(obj.object) } - } - - @objc func invokeAction(_ sender: Any?) { - if let originTarget, let originAction { - NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) - } - bindingActions.forEach { $0(sender) } - actions.forEach { $0(sender) } - } - - func addAction(_ action: @escaping Action) { - actions.append(action) - } - - func removeAllActions() { - actions.removeAll() - } - - func addBindingAction(_ bindingAction: @escaping Action) { - bindingActions.append(bindingAction) - } - - func removeAllBindingActions() { - bindingActions.removeAll() - } -} - extension NSTargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. /// @@ -121,7 +65,7 @@ extension NSTargetActionProtocol { ) -> ObservationToken { unbind(keyPath) let actionProxy = createActionProxyIfNeeded() - actionProxy.addBindingAction { [weak self] _ in + let actionID = actionProxy.addBindingAction { [weak self] _ in guard let self else { return } binding.wrappedValue = self[keyPath: keyPath] } @@ -149,9 +93,7 @@ extension NSTargetActionProtocol { } let observationToken = ObservationToken { [weak self] in MainActor._assumeIsolated { - self?.appkitNavigationTarget = nil - self?.appkitNavigationAction = nil - self?.actionProxy = nil + self?.actionProxy?.removeAction(for: actionID) } token.cancel() observation.invalidate() diff --git a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift new file mode 100644 index 0000000000..2d2054d0ec --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift @@ -0,0 +1,88 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import IdentifiedCollections + +@MainActor +class NSTargetActionProxy: NSObject { + typealias ActionClosure = (Any?) -> Void + + typealias ActionIdentifier = UUID + + private struct Action: Identifiable { + let id = UUID() + + var closure: ActionClosure + + func invoke(_ sender: Any?) { + closure(sender) + } + } + + private var bindingActions: IdentifiedArrayOf = [] + + private var actions: IdentifiedArrayOf = [] + + private var originTarget: AnyObject? + + private var originAction: Selector? + + weak var owner: NSTargetActionProtocol? + + required init(owner: NSTargetActionProtocol) { + self.owner = owner + super.init() + self.originTarget = owner.appkitNavigationTarget + self.originAction = owner.appkitNavigationAction + owner.appkitNavigationTarget = self + owner.appkitNavigationAction = #selector(invokeAction(_:)) + if let textField = owner as? NSTextField { + NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) + } + } + + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0.invoke(obj.object) } + actions.forEach { $0.invoke(obj.object) } + } + + @objc func invokeAction(_ sender: Any?) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) + } + bindingActions.forEach { $0.invoke(sender) } + actions.forEach { $0.invoke(sender) } + } + + @discardableResult + func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { + let action = Action(closure: actionClosure) + actions.append(action) + return action.id + } + + func removeAction(for id: ActionIdentifier) { + actions.remove(id: id) + } + + func removeAllActions() { + actions.removeAll() + } + + @discardableResult + func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { + let bindingAction = Action(closure: bindingActionClosure) + bindingActions.append(bindingAction) + return bindingAction.id + } + + func removeBindingAction(for id: ActionIdentifier) { + bindingActions.remove(id: id) + } + + func removeAllBindingActions() { + bindingActions.removeAll() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Internal/ClosureHolder.swift b/Sources/AppKitNavigation/Internal/ClosureHolder.swift deleted file mode 100644 index 814e884dff..0000000000 --- a/Sources/AppKitNavigation/Internal/ClosureHolder.swift +++ /dev/null @@ -1,17 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import Foundation - -internal class ClosureHolder: NSObject { - let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - func invoke() { - closure() - } -} - -#endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift similarity index 100% rename from Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift rename to Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift diff --git a/Sources/AppKitNavigation/Internal/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift similarity index 100% rename from Sources/AppKitNavigation/Internal/NavigationObserver.swift rename to Sources/AppKitNavigation/Navigation/NavigationObserver.swift From 2ba32eb37994d3076f1e23b12efacc1a0518838a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Mon, 19 Aug 2024 22:28:33 +0800 Subject: [PATCH 14/34] Format --- .../SwiftUI/Representable.swift | 20 ++++--- Sources/AppKitNavigation/UIBinding.swift | 14 ++--- Sources/AppKitNavigation/UITransaction.swift | 56 +++++++++---------- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/Sources/AppKitNavigation/SwiftUI/Representable.swift b/Sources/AppKitNavigation/SwiftUI/Representable.swift index 29ac8b2301..e89259acfe 100644 --- a/Sources/AppKitNavigation/SwiftUI/Representable.swift +++ b/Sources/AppKitNavigation/SwiftUI/Representable.swift @@ -1,24 +1,26 @@ #if canImport(SwiftUI) && canImport(AppKit) && !targetEnvironment(macCatalyst) - import SwiftUI - import AppKit +import SwiftUI +import AppKit - public struct NSViewControllerRepresenting< +public struct NSViewControllerRepresenting< NSViewControllerType: NSViewController - >: NSViewControllerRepresentable { +>: NSViewControllerRepresentable { private let base: NSViewControllerType public init(_ base: () -> NSViewControllerType) { - self.base = base() + self.base = base() } + public func makeNSViewController(context _: Context) -> NSViewControllerType { base } public func updateNSViewController(_: NSViewControllerType, context _: Context) {} - } +} - public struct NSViewRepresenting: NSViewRepresentable { +public struct NSViewRepresenting: NSViewRepresentable { private let base: NSViewType public init(_ base: () -> NSViewType) { - self.base = base() + self.base = base() } + public func makeNSView(context _: Context) -> NSViewType { base } public func updateNSView(_: NSViewType, context _: Context) {} - } +} #endif diff --git a/Sources/AppKitNavigation/UIBinding.swift b/Sources/AppKitNavigation/UIBinding.swift index 4a66220b17..ea3499dec7 100644 --- a/Sources/AppKitNavigation/UIBinding.swift +++ b/Sources/AppKitNavigation/UIBinding.swift @@ -1,15 +1,15 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - import SwiftNavigation - - extension UIBinding { +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 + var binding = self + binding.transaction.appKit.animation = animation + return binding } - } +} #endif diff --git a/Sources/AppKitNavigation/UITransaction.swift b/Sources/AppKitNavigation/UITransaction.swift index 88ba674b30..b4f9535f4e 100644 --- a/Sources/AppKitNavigation/UITransaction.swift +++ b/Sources/AppKitNavigation/UITransaction.swift @@ -2,52 +2,52 @@ import SwiftNavigation - extension UITransaction { +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() - self.appKit.animation = animation + 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 } + get { self[AppKitKey.self] } + set { self[AppKitKey.self] = newValue } } private enum AppKitKey: UITransactionKey { - static let defaultValue = AppKit() + 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) - } + /// 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 { +private enum AnimationCompletionsKey: UITransactionKey { static let defaultValue: [@Sendable (Bool?) -> Void] = [] - } +} - private enum DisablesAnimationsKey: UITransactionKey { +private enum DisablesAnimationsKey: UITransactionKey { static let defaultValue = false - } +} #endif 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 15/34] 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 46c1d7edbe..fc20ea17dc 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 9ed432ff67..a97be3d08e 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 0000000000..0d8230c8fb --- /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 0000000000..1df9f50edc --- /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 0000000000..93f1c40098 --- /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 0000000000..1ec4c47b0a --- /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 0000000000..554225adc1 --- /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 0000000000..a11cfaaf1d --- /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 0000000000..8032874494 --- /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 0000000000..ea3499dec7 --- /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 0000000000..b4f9535f4e --- /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 0000000000..e1844275f7 --- /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 0000000000..21f5f9c08b --- /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 16/34] 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 fc20ea17dc..3ca03ba03e 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 a97be3d08e..7d316472db 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 0d8230c8fb..e2c8199a00 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 1df9f50edc..0000000000 --- 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 a11cfaaf1d..0000000000 --- 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 8032874494..6e334545ce 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 ea3499dec7..e69009dfed 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 b4f9535f4e..01b798797c 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 93f1c40098..97054027e7 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 1ec4c47b0a..36cbdd7bc4 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 0000000000..46fa0d119c --- /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 fea107e3ae..0000000000 --- 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 18644daeba..0000000000 --- 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 ed30e639d9..0000000000 --- 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 17/34] 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 e1844275f7..0000000000 --- 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 21f5f9c08b..0000000000 --- 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 18/34] 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 6e334545ce..0000000000 --- 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 01b798797c..99559dbc48 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 19/34] 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 e2c8199a00..346478c856 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 20/34] 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 346478c856..2dab48e14c 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 66569ba62b..6c68de4637 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 21/34] Update Package.swift --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8a8b002389..f3b38e8535 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 22/34] 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 9f37971b8a..2edac05b6d 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 23/34] 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 f3b38e8535..0210bad713 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 2edac05b6d..1859da950d 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 2dab48e14c..b0e8a2cd85 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 0000000000..1df9f50edc --- /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 0000000000..14681eda9e --- /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 0000000000..00772647f2 --- /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 0000000000..9132638de7 --- /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 0000000000..bc63b33443 --- /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 0000000000..762032cf09 --- /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 0000000000..488cf6b4b9 --- /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 0000000000..5b2c0662ca --- /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 0000000000..eba08ad600 --- /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 0000000000..d7bc4fcae2 --- /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 0000000000..eb06919cd8 --- /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 0000000000..5a4e070ea3 --- /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 0000000000..14e82c72dd --- /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 24/34] 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 5a4e070ea3..a19cbd8f53 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 25/34] 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 d7bc4fcae2..6ec8b0d806 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 26/34] 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 9132638de7..cc50c0afdf 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 27/34] 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 5b2c0662ca..e5f0197e7e 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 14e82c72dd..9ebf4859ba 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 593cae881ffd62d0f2e59bc403106f4951f64b3e Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:42:57 +0800 Subject: [PATCH 28/34] Support control bindings --- Package.swift | 8 +- Package@swift-6.0.swift | 4 + .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../AppKitNavigation/Bindings/NSAlert.swift | 48 +++ .../Bindings/NSColorPanel.swift | 41 +++ .../Bindings/NSColorWell.swift | 27 ++ .../AppKitNavigation/Bindings/NSControl.swift | 43 +++ .../Bindings/NSDatePicker.swift | 27 ++ .../Bindings/NSFontManager.swift | 77 ++++ .../Bindings/NSMenuItem.swift | 17 + .../Bindings/NSPathControl.swift | 29 ++ .../Bindings/NSSaveOpenPanel.swift | 62 ++++ .../Bindings/NSSegmentedControl.swift | 79 ++++ .../AppKitNavigation/Bindings/NSSlider.swift | 26 ++ .../AppKitNavigation/Bindings/NSStepper.swift | 26 ++ .../AppKitNavigation/Bindings/NSSwitch.swift | 34 ++ .../Bindings/NSTargetAction.swift | 143 ++++++++ .../Bindings/NSTargetActionProxy.swift | 88 +++++ .../Bindings/NSTextField.swift | 342 ++++++++++++++++++ .../Bindings/NSToolbarItem.swift | 17 + Sources/AppKitNavigationShim/include/shim.h | 17 + Sources/AppKitNavigationShim/shim.m | 66 ++++ .../xcshareddata/swiftpm/Package.resolved | 34 +- 23 files changed, 1238 insertions(+), 19 deletions(-) create mode 100755 Sources/AppKitNavigation/Bindings/NSAlert.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSColorPanel.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSColorWell.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSControl.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSDatePicker.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSFontManager.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSMenuItem.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSPathControl.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSlider.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSStepper.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSSwitch.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSTargetAction.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSTextField.swift create mode 100755 Sources/AppKitNavigation/Bindings/NSToolbarItem.swift create mode 100755 Sources/AppKitNavigationShim/include/shim.h create mode 100755 Sources/AppKitNavigationShim/shim.m diff --git a/Package.swift b/Package.swift index 2194c6fcfc..2fb60be222 100644 --- a/Package.swift +++ b/Package.swift @@ -36,6 +36,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.1.0"), ], targets: [ .target( @@ -82,9 +83,14 @@ let package = Package( .target( name: "AppKitNavigation", dependencies: [ - "SwiftNavigation" + "SwiftNavigation", + "AppKitNavigationShim", + .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 490fe55fcd..169fd95403 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 096761ab34..7d469824ea 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -52,8 +52,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/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift new file mode 100755 index 0000000000..812f894812 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSAlert.swift @@ -0,0 +1,48 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSAlert { + /// Creates and returns a alert for displaying an alert using a data description. + /// + /// - Parameters: + /// - state: A data description of the alert. + /// - handler: A closure that is invoked with an action held in `state`. + public convenience init( + state: AlertState, + handler: @escaping (_ action: Action?) -> Void + ) { + self.init() + self.messageText = String(state: state.title) + state.message.map { self.informativeText = String(state: $0) } + + for button in state.buttons { + addButton(button, action: handler) + } + } +} + +extension NSAlert { + public func addButton( + _ buttonState: ButtonState, + action handler: @escaping (_ action: Action?) -> Void = { (_: Never?) in } + ) { + let button = addButton(withTitle: String(state: buttonState.label)) + + button.createActionProxyIfNeeded().addBindingAction { _ in + buttonState.withAction(handler) + } + + if buttonState.role == .destructive, #available(macOS 11.0, *) { + button.hasDestructiveAction = true + } + + if buttonState.role == .cancel { + button.keyEquivalent = "\u{1b}" + } + + if #available(macOS 12, *) { + button.setAccessibilityLabel(buttonState.label.accessibilityLabel.map { String(state: $0) }) + } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift new file mode 100755 index 0000000000..d2287caf93 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -0,0 +1,41 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSColorPanel: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { setTarget(newValue) } + get { value(forKeyPath: "target") as? AnyObject } + } + + public var appkitNavigationAction: Selector? { + set { setAction(newValue) } + get { value(forKeyPath: "action") as? Selector } + } +} + +extension NSColorPanel { + /// Creates a new color panel and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - color: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(color: UIBinding) { + self.init() + bind(color: color) + } + + /// Establishes a two-way connection between a binding and the color panel's selected color. + /// + /// - Parameter color: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(color: UIBinding) -> ObserveToken { + bind(color, to: \.color) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSColorWell.swift b/Sources/AppKitNavigation/Bindings/NSColorWell.swift new file mode 100755 index 0000000000..71eab79865 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSColorWell.swift @@ -0,0 +1,27 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSColorWell { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - color: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init(frame: CGRect = .zero, color: UIBinding) { + self.init(frame: frame) + bind(color: color) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter color: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(color: UIBinding) -> ObserveToken { + bind(color, to: \.color) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift new file mode 100755 index 0000000000..7edc6aaef2 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -0,0 +1,43 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSControl: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +extension NSControl { + public convenience init(action: @escaping (Self) -> Void) { + self.init(frame: .zero) + createActionProxyIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } + + @discardableResult + public func addAction(_ action: @escaping (NSControl) -> Void) -> UUID { + createActionProxyIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) + } + } + + public func removeAction(for id: UUID) { + createActionProxyIfNeeded().removeAction(for: id) + } + + public func removeAllActions() { + createActionProxyIfNeeded().removeAllActions() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSDatePicker.swift b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift new file mode 100755 index 0000000000..17e3d26ecd --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSDatePicker.swift @@ -0,0 +1,27 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSDatePicker { + /// Creates a new date picker with the specified frame and registers the binding against the + /// selected date. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected date, and write to when the selected + /// date changes. + public convenience init(frame: CGRect = .zero, date: UIBinding) { + self.init(frame: frame) + bind(date: date) + } + + /// Establishes a two-way connection between a binding and the date picker's selected date. + /// + /// - Parameter date: The binding to read from for the selected date, and write to when the + /// selected date changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(date: UIBinding) -> ObserveToken { + bind(date, to: \.dateValue) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift new file mode 100755 index 0000000000..9797d82ae6 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -0,0 +1,77 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import SwiftNavigation + +extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { + public var appkitNavigationTarget: AnyObject? { + set { appkitNavigationDelegate.target = newValue } + get { appkitNavigationDelegate.target } + } + + public var appkitNavigationAction: Selector? { + set { appkitNavigationDelegate.action = newValue } + get { appkitNavigationDelegate.action } + } + + private static let appkitNavigationDelegateKey = malloc(1)! + + private var appkitNavigationDelegate: Delegate { + set { + objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let delegate = objc_getAssociatedObject(self, Self.appkitNavigationDelegateKey) as? Delegate { + return delegate + } else { + let delegate = Delegate() + target = delegate + self.appkitNavigationDelegate = delegate + return delegate + } + } + } + + private class Delegate: NSObject, NSFontChanging { + var target: AnyObject? + var action: Selector? + + func changeFont(_ sender: NSFontManager?) { + if let action { + NSApplication.shared.sendAction(action, to: target, from: sender) + } + } + } +} + +@MainActor +extension NSFontManager { + /// Creates a new date picker with the specified frame and registers the binding against the + /// selected date. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected date, and write to when the selected + /// date changes. + public convenience init(font: UIBinding) { + self.init() + bind(font: font) + } + + /// Establishes a two-way connection between a binding and the date picker's selected date. + /// + /// - Parameter date: The binding to read from for the selected date, and write to when the + /// selected date changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(font: UIBinding) -> ObserveToken { + bind(font, to: \._selectedFont) + } + + @objc private var _selectedFont: NSFont { + set { setSelectedFont(newValue, isMultiple: false) } + get { convert(.systemFont(ofSize: 0)) } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift new file mode 100755 index 0000000000..25d7dd5c0a --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSMenuItem: NSTargetActionProtocol, @unchecked Sendable { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSPathControl.swift b/Sources/AppKitNavigation/Bindings/NSPathControl.swift new file mode 100755 index 0000000000..fda06940be --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSPathControl.swift @@ -0,0 +1,29 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSPathControl { + /// Creates a new path control with the specified frame and registers the binding against the + /// selected url. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected url, and write to when the selected + /// url changes. + public convenience init(frame: CGRect = .zero, date: UIBinding) { + self.init(frame: frame) + bind(url: date) + } + + /// Establishes a two-way connection between a binding and the path control's selected url. + /// + /// - Parameter url: The binding to read from for the selected url, and write to when the + /// selected url changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(url: UIBinding) -> ObserveToken { + bind(url, to: \.url) + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift new file mode 100755 index 0000000000..8220b89367 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSaveOpenPanel.swift @@ -0,0 +1,62 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import AppKitNavigationShim + +extension NSSavePanel { + public convenience init(url: UIBinding) { + self.init() + bind(url: url) + } + + @discardableResult + public func bind(url binding: UIBinding) -> ObserveToken { + appKitNavigation_onFinalURL = { url in + binding.wrappedValue = url + } + + let observationToken = ObserveToken { [weak self] in + guard let self else { return } + MainActor._assumeIsolated { + self.appKitNavigation_onFinalURL = nil + } + } + observationTokens[\NSSavePanel.url] = observationToken + return observationToken + } + + public func unbindURL() { + observationTokens[\NSSavePanel.url]?.cancel() + observationTokens[\NSSavePanel.url] = nil + } +} + +extension NSOpenPanel { + public convenience init(urls: UIBinding<[URL]>) { + self.init() + bind(urls: urls) + } + + @discardableResult + public func bind(urls binding: UIBinding<[URL]>) -> ObserveToken { + appKitNavigation_onFinalURLs = { urls in + binding.wrappedValue = urls + } + + let observationToken = ObserveToken { [weak self] in + guard let self else { return } + MainActor._assumeIsolated { + self.appKitNavigation_onFinalURLs = nil + } + } + observationTokens[\NSOpenPanel.urls] = observationToken + return observationToken + } + + public func unbindURLs() { + observationTokens[\NSOpenPanel.urls]?.cancel() + observationTokens[\NSOpenPanel.urls] = nil + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift new file mode 100755 index 0000000000..25b1bdd441 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSegmentedControl.swift @@ -0,0 +1,79 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import IssueReporting +import AppKit + +extension NSSegmentedControl { + /// Creates a new color well with the specified frame and registers the binding against the + /// selected color. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - selectedSegment: The binding to read from for the selected color, and write to when the + /// selected color is changes. + public convenience init( + frame: CGRect = .zero, selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.init(frame: frame) + bind( + selectedSegment: selectedSegment, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + + /// Establishes a two-way connection between a binding and the color well's selected color. + /// + /// - Parameter selectedSegment: The binding to read from for the selected color, and write to + /// when the selected color changes. + /// - Returns: A cancel token. + @discardableResult + public func bind( + selectedSegment: UIBinding>, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> ObserveToken { + let fileID = HashableStaticString(rawValue: fileID) + let filePath = HashableStaticString(rawValue: filePath) + return bind( + selectedSegment[fileID: fileID, filePath: filePath, line: line, column: column], + to: \.selectedSegment + ) + } +} + +extension RawRepresentable { + fileprivate subscript( + fileID fileID: HashableStaticString, + filePath filePath: HashableStaticString, + line line: UInt, + column column: UInt + ) -> Int { + get { rawValue } + set { + guard let rawRepresentable = Self(rawValue: newValue) + else { + reportIssue( + """ + Raw-representable 'UIBinding<\(Self.self)>' attempted to write an invalid raw value \ + ('\(newValue)'). + """, + fileID: fileID.rawValue, + filePath: filePath.rawValue, + line: line, + column: column + ) + return + } + self = rawRepresentable + } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSlider.swift b/Sources/AppKitNavigation/Bindings/NSSlider.swift new file mode 100755 index 0000000000..7931a6d921 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSlider.swift @@ -0,0 +1,26 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSSlider { + /// Creates a new slider with the specified frame and registers the binding against the value. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - value: The binding to read from for the current value, and write to when the value + /// changes. + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + bind(value: value) + } + + /// Establishes a two-way connection between a binding and the slider's current value. + /// + /// - Parameter value: The binding to read from for the current value, and write to when the + /// value changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(value: UIBinding) -> ObserveToken { + bind(value, to: \.floatValue) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSStepper.swift b/Sources/AppKitNavigation/Bindings/NSStepper.swift new file mode 100755 index 0000000000..59a679d71f --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSStepper.swift @@ -0,0 +1,26 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSStepper { + /// Creates a new stepper with the specified frame and registers the binding against the value. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - value: The binding to read from for the current value, and write to when the value + /// changes. + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + bind(value: value) + } + + /// Establishes a two-way connection between a binding and the stepper's current value. + /// + /// - Parameter value: The binding to read from for the current value, and write to when the + /// value changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(value: UIBinding) -> ObserveToken { + bind(value, to: \.doubleValue) + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSSwitch.swift b/Sources/AppKitNavigation/Bindings/NSSwitch.swift new file mode 100755 index 0000000000..60a92b14fa --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSSwitch.swift @@ -0,0 +1,34 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +extension NSSwitch { + /// Creates a new switch with the specified frame and registers the binding against whether or + /// not the switch is on. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - isOn: The binding to read from for the current state, and write to when the state + /// changes. + public convenience init(frame: CGRect = .zero, isOn: UIBinding) { + self.init(frame: frame) + bind(isOn: isOn) + } + + /// Establishes a two-way connection between a binding and the switch's current state. + /// + /// - Parameter isOn: The binding to read from for the current state, and write to when the + /// state changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(isOn: UIBinding) -> ObserveToken { + bind(isOn, to: \.boolValue) { control, isOn, transaction in + control.boolValue = isOn + } + } + + @objc var boolValue: Bool { + set { state = newValue ? .on : .off } + get { state == .on } + } +} +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift new file mode 100755 index 0000000000..1d72abb4eb --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift @@ -0,0 +1,143 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import ConcurrencyExtras +@_spi(Internals) import SwiftNavigation +import AppKit + +/// A protocol used to extend `NSControl, NSMenuItem...`. +@MainActor +public protocol NSTargetActionProtocol: NSObject, Sendable { + var appkitNavigationTarget: AnyObject? { set get } + var appkitNavigationAction: Selector? { set get } +} + +extension NSTargetActionProtocol { + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath + ) -> ObserveToken { + bind(binding, to: keyPath) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + var actionProxy: NSTargetActionProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionProxyKey) as? NSTargetActionProxy + } + } + + func createActionProxyIfNeeded() -> NSTargetActionProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = NSTargetActionProxy(owner: self) + self.actionProxy = actionProxy + return actionProxy + } + } + + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - set: A closure that is called when the binding's value changes with a weakly-captured + /// control, a new value that can be used to configure the control, and a transaction, which + /// can be used to determine how and if the change should be animated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: KeyPath, + set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObservationToken { + unbind(keyPath) + let actionProxy = createActionProxyIfNeeded() + let actionID = actionProxy.addBindingAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + + let isSetting = LockIsolated(false) + let token = observe { [weak self] transaction in + guard let self else { return } + isSetting.withValue { $0 = true } + defer { isSetting.withValue { $0 = false } } + set( + self, + binding.wrappedValue, + transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations + ? binding.transaction + : transaction + ) + } + // NB: This key path must only be accessed on the main actor + @UncheckedSendable var uncheckedKeyPath = keyPath + let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in + guard isSetting.withValue({ !$0 }) else { return } + MainActor._assumeIsolated { + binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + } + } + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + self?.actionProxy?.removeAction(for: actionID) + } + token.cancel() + observation.invalidate() + } + observationTokens[keyPath] = observationToken + return observationToken + } + + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } + +// var observationTokens: [AnyKeyPath: ObservationToken] { +// get { +// objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] +// ?? [:] +// } +// set { +// objc_setAssociatedObject( +// self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC +// ) +// } +// } +} + +@MainActor +extension NSObject { + var observationTokens: [AnyKeyPath: ObservationToken] { + get { + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] + ?? [:] + } + set { + objc_setAssociatedObject( + self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} + +@MainActor +private let observationTokensKey = malloc(1)! +@MainActor +private let actionProxyKey = malloc(1)! + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift new file mode 100755 index 0000000000..2d2054d0ec --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift @@ -0,0 +1,88 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import IdentifiedCollections + +@MainActor +class NSTargetActionProxy: NSObject { + typealias ActionClosure = (Any?) -> Void + + typealias ActionIdentifier = UUID + + private struct Action: Identifiable { + let id = UUID() + + var closure: ActionClosure + + func invoke(_ sender: Any?) { + closure(sender) + } + } + + private var bindingActions: IdentifiedArrayOf = [] + + private var actions: IdentifiedArrayOf = [] + + private var originTarget: AnyObject? + + private var originAction: Selector? + + weak var owner: NSTargetActionProtocol? + + required init(owner: NSTargetActionProtocol) { + self.owner = owner + super.init() + self.originTarget = owner.appkitNavigationTarget + self.originAction = owner.appkitNavigationAction + owner.appkitNavigationTarget = self + owner.appkitNavigationAction = #selector(invokeAction(_:)) + if let textField = owner as? NSTextField { + NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) + } + } + + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0.invoke(obj.object) } + actions.forEach { $0.invoke(obj.object) } + } + + @objc func invokeAction(_ sender: Any?) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) + } + bindingActions.forEach { $0.invoke(sender) } + actions.forEach { $0.invoke(sender) } + } + + @discardableResult + func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { + let action = Action(closure: actionClosure) + actions.append(action) + return action.id + } + + func removeAction(for id: ActionIdentifier) { + actions.remove(id: id) + } + + func removeAllActions() { + actions.removeAll() + } + + @discardableResult + func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { + let bindingAction = Action(closure: bindingActionClosure) + bindingActions.append(bindingAction) + return bindingAction.id + } + + func removeBindingAction(for id: ActionIdentifier) { + bindingActions.remove(id: id) + } + + func removeAllBindingActions() { + bindingActions.removeAll() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift new file mode 100755 index 0000000000..897efc8417 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -0,0 +1,342 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import Combine +import SwiftNavigation + +@MainActor +extension NSTextField: NSTextViewDelegate { + /// Creates a new text field with the specified frame and registers the binding against its + /// text. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - text: The binding to read from for the current text, and write to when the text + /// changes. + public convenience init(frame: CGRect = .zero, text: UIBinding) { + self.init(frame: frame) + bind(text: text) + } + + /// Creates a new text field with the specified frame and registers the binding against its + /// text. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - attributedText: The binding to read from for the current text, and write to when the + /// attributed text changes. + public convenience init(frame: CGRect = .zero, attributedText: UIBinding) { + self.init(frame: frame) + bind(attributedText: attributedText) + } + + /// Establishes a two-way connection between a binding and the text field's current text. + /// + /// - Parameter text: The binding to read from for the current text, and write to when the text + /// changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(text: UIBinding) -> ObserveToken { + bind(text, to: \.stringValue) + } + + /// Establishes a two-way connection between a binding and the text field's current text. + /// + /// - Parameter attributedText: The binding to read from for the current text, and write to when + /// the attributed text changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(attributedText: UIBinding) -> ObserveToken { + bind(attributedText, to: \.attributedStringValue) + } + + /// Establishes a two-way connection between a binding and the text field's current selection. + /// + /// - Parameter selection: The binding to read from for the current selection, and write to when + /// the selected text range changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(selection: UIBinding) -> ObserveToken { + let editingChangedAction = NotificationCenter.default.publisher(for: NSTextField.textDidChangeNotification, object: self) + .sink { [weak self] _ in + guard let self else { return } + selection.wrappedValue = self.textSelection + } + let editingDidEndAction = NotificationCenter.default.publisher(for: NSTextField.textDidEndEditingNotification, object: self).sink { _ in selection.wrappedValue = nil } + let token = observe { [weak self] in + guard let self else { return } + textSelection = selection.wrappedValue + } + textSelectionObserver = TextSelectionObserver { control in + MainActor._assumeIsolated { + selection.wrappedValue = control.textSelection + } + } + + let observationToken = ObservationToken { [weak self] in + MainActor._assumeIsolated { + editingChangedAction.cancel() + editingDidEndAction.cancel() + token.cancel() + self?.textSelectionObserver = nil + } + } + observationTokens[\NSTextField.selectedRange] = observationToken + return observationToken + } + + fileprivate var selectedRange: NSRange? { + set { + currentEditor()?.selectedRange = newValue ?? .init(location: 0, length: 0) + } + + get { + currentEditor()?.selectedRange + } + } + + fileprivate class TextSelectionObserver: NSObject { + let observer: (NSTextField) -> Void + + init(observer: @escaping (NSTextField) -> Void) { + self.observer = observer + } + } + + private static let textSelectionObserverKey = malloc(1)! + private var textSelectionObserver: TextSelectionObserver? { + set { + objc_setAssociatedObject(self, Self.textSelectionObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, Self.textSelectionObserverKey) as? TextSelectionObserver + } + } + + public func textViewDidChangeSelection(_ notification: Notification) { + textSelectionObserver?.observer(self) + } + + fileprivate var textSelection: AppKitTextSelection? { + get { + guard + let textRange = selectedRange + else { + return nil + } + let text = stringValue + let lowerBound = + text.index( + text.startIndex, + offsetBy: textRange.location, + limitedBy: text.endIndex + ) ?? text.endIndex + let upperBound = + text.index( + text.startIndex, + offsetBy: NSMaxRange(textRange), + limitedBy: text.endIndex + ) ?? text.endIndex + return AppKitTextSelection(range: lowerBound ..< upperBound) + } + set { + guard let selection = newValue?.range else { + selectedRange = nil + return + } + let text = stringValue + let from = text.distance( + from: text.startIndex, to: min(selection.lowerBound, text.endIndex) + ) + let to = text.distance( + from: text.startIndex, to: min(selection.upperBound, text.endIndex) + ) + selectedRange = .init(location: from, length: to - from) + } + } + + /// Modifies this text field by binding its focus state to the given state value. + /// + /// Use this modifier to cause the text field to receive focus whenever the the `binding` equals + /// the `value`. Typically, you create an enumeration of fields that may receive focus, bind an + /// instance of this enumeration, and assign its cases to focusable text fields. + /// + /// The following example uses the cases of a `LoginForm` enumeration to bind the focus state of + /// two `UITextField` views. A sign-in button validates the fields and sets the bound + /// `focusedField` value to any field that requires the user to correct a problem. + /// + /// ```swift + /// final class LoginViewController: UIViewController { + /// enum Field { + /// case usernameField + /// case passwordField + /// } + /// + /// @UIBinding private var username = "" + /// @UIBinding private var password = "" + /// @UIBinding private var focusedField: Field? + /// + /// // ... + /// + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let usernameTextField = UITextField(text: $username) + /// usernameTextField.focus($focusedField, equals: .usernameField) + /// + /// let passwordTextField = UITextField(text: $password) + /// passwordTextField.focus($focusedField, equals: .passwordField) + /// passwordTextField.isSecureTextEntry = true + /// + /// let signInButton = UIButton( + /// style: .system, + /// primaryAction: UIAction { [weak self] _ in + /// guard let self else { return } + /// if username.isEmpty { + /// focusedField = .usernameField + /// } else if password.isEmpty { + /// focusedField = .passwordField + /// } else { + /// handleLogin(username, password) + /// } + /// } + /// ) + /// signInButton.setTitle("Sign In", for: .normal) + /// + /// // ... + /// } + /// } + /// ``` + /// + /// To control focus using a Boolean, use the ``UIKit/UITextField/bind(focus:)`` method instead. + /// + /// - Parameters: + /// - focus: The state binding to register. When focus moves to the text field, the binding + /// sets the bound value to the corresponding match value. If a caller sets the state value + /// programmatically to the matching value, then focus moves to the text field. When focus + /// leaves the text field, the binding sets the bound value to `nil`. If a caller sets the + /// value to `nil`, UIKit automatically dismisses focus. + /// - value: The value to match against when determining whether the binding should change. + /// - Returns: A cancel token. + @discardableResult + public func bind( + focus: UIBinding, equals value: Value + ) -> ObserveToken { + focusToken?.cancel() + let editingDidBeginAction = NotificationCenter.default.publisher(for: NSTextField.textDidBeginEditingNotification, object: self).sink { _ in + focus.wrappedValue = value + } + + let editingDidEndAction = NotificationCenter.default.publisher(for: NSTextField.textDidEndEditingNotification, object: self).sink { _ in + guard focus.wrappedValue == value else { return } + focus.wrappedValue = nil + } + + let innerToken = observe { [weak self] in + guard let self else { return } + switch (focus.wrappedValue, currentEditor() != nil) { + case (value, false): + becomeFirstResponder() + case (nil, true): + window?.makeFirstResponder(nil) + default: + break + } + } + let outerToken = ObservationToken { + editingDidBeginAction.cancel() + editingDidEndAction.cancel() + innerToken.cancel() + } + focusToken = outerToken + return outerToken + } + + /// Binds this text field's focus state to the given Boolean state value. + /// + /// Use this method to cause the text field to receive focus whenever the the `condition` value + /// is `true`. You can use this method to observe the focus state of a text field, or + /// programmatically set and remove focus from the text field. + /// + /// In the following example, a single `UITextField` accepts a user's desired `username`. The + /// text field binds its focus state to the Boolean value `usernameFieldIsFocused`. A "Submit" + /// button's action verifies whether the name is available. If the name is unavailable, the + /// button sets `usernameFieldIsFocused` to `true`, which causes focus to return to the text + /// field, so the user can enter a different name. + /// + /// ```swift + /// final class LoginViewController: UIViewController { + /// @UIBindable private var username = "" + /// @UIBindable private var usernameFieldIsFocused = false + /// + /// // ... + /// + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// let textField = UITextField(text: $username) + /// textField.focus($usernameFieldIsFocused) + /// + /// let submitButton = UIButton( + /// style: .system, + /// primaryAction: UIAction { [weak self] _ in + /// guard let self else { return } + /// if !isUserNameAvailable(username: username) { + /// usernameFieldIsFocused = true + /// } + /// } + /// ) + /// submitButton.setTitle("Sign In", for: .normal) + /// + /// // ... + /// } + /// } + /// ``` + /// + /// To control focus by matching a value, use the ``UIKit/UITextField/bind(focus:equals:)`` + /// method instead. + /// + /// - Parameter condition: The focus state to bind. When focus moves to the text field, the + /// binding sets the bound value to `true`. If a caller sets the value to `true` + /// programmatically, then focus moves to the text field. When focus leaves the text field, + /// the binding sets the value to `false`. If a caller sets the value to `false`, UIKit + /// automatically dismisses focus. + /// - Returns: A cancel token. + @discardableResult + public func bind(focus condition: UIBinding) -> ObserveToken { + bind(focus: condition.toOptionalUnit, equals: Bool.Unit()) + } + + private var focusToken: ObserveToken? { + get { objc_getAssociatedObject(self, Self.focusTokenKey) as? ObserveToken } + set { + objc_setAssociatedObject( + self, Self.focusTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static let focusTokenKey = malloc(1)! +} + +/// Represents a selection of text. +/// +/// Like SwiftUI's `TextSelection`, but for UIKit. +public struct AppKitTextSelection: Hashable, Sendable { + public var range: Range + + public init(range: Range) { + self.range = range + } + + public init(insertionPoint: String.Index) { + self.range = insertionPoint ..< insertionPoint + } + + public var isInsertion: Bool { + range.isEmpty + } +} + +extension AnyCancellable: @unchecked Sendable {} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift new file mode 100755 index 0000000000..478253a51e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift @@ -0,0 +1,17 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSToolbarItem: NSTargetActionProtocol { + public var appkitNavigationTarget: AnyObject? { + set { target = newValue } + get { target } + } + + public var appkitNavigationAction: Selector? { + set { action = newValue } + get { action } + } +} + +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100755 index 0000000000..9bd7111902 --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,17 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; + +NS_ASSUME_NONNULL_BEGIN + +@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 100755 index 0000000000..8101d55352 --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,66 @@ +#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(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 + +#endif /* if __has_include() && !TARGET_OS_MACCATALYST */ +#endif /* if __has_include() */ diff --git a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index 23b8079026..e5c147398a 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", - "version" : "1.5.4" + "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", + "version" : "1.5.6" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "3581e280bf0d90c3fb9236fb23e75a5d8c46b533", - "version" : "1.0.4" + "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", + "version" : "1.0.5" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "d7472be6b3c89251ce4c0db07d32405b43426781", - "version" : "1.3.7" + "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", + "version" : "1.4.1" } }, { @@ -68,14 +68,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", - "version" : "1.3.0" + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" } }, { "identity" : "swift-docc-symbolkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-symbolkit", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", "state" : { "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "1552c8f722ac256cc0b8daaf1a7073217d4fcdfb", - "version" : "1.3.4" + "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", + "version" : "1.3.5" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "06b5cdc432e93b60e3bdf53aff2857c6b312991a", - "version" : "600.0.0-prerelease-2024-07-30" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", - "version" : "1.2.2" + "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", + "version" : "1.4.2" } } ], 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 29/34] 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 14681eda9e..79075a67be 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 00772647f2..676b8857b8 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 cc50c0afdf..a897018c9e 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 762032cf09..2e35321c1b 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 e5f0197e7e..a9e28256fd 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 eba08ad600..93de778e57 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 6ec8b0d806..ee0cda49ab 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 eb06919cd8..25d27c466b 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 30/34] 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 79075a67be..0d053d6f0d 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 a9e28256fd..fbd5f46777 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 93de778e57..0000000000 --- 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 ee0cda49ab..ba9cbd1846 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 25d27c466b..3da396258f 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 } From 2963dd1faae5afd40e71ebc0a54b1b6916e0b66a Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:50:33 +0800 Subject: [PATCH 31/34] Fixes --- Package.swift | 2 +- Package@swift-6.0.swift | 2 + .../Bindings/NSColorPanel.swift | 6 +- .../AppKitNavigation/Bindings/NSControl.swift | 12 +- .../Bindings/NSFontManager.swift | 202 ++++++++++++++++-- .../Bindings/NSMenuItem.swift | 12 +- .../Bindings/NSTextField.swift | 4 +- .../Bindings/NSToolbarItem.swift | 17 -- ...ction.swift => TargetActionProtocol.swift} | 35 ++- ...ionProxy.swift => TargetActionProxy.swift} | 14 +- 10 files changed, 219 insertions(+), 87 deletions(-) delete mode 100755 Sources/AppKitNavigation/Bindings/NSToolbarItem.swift rename Sources/AppKitNavigation/Bindings/{NSTargetAction.swift => TargetActionProtocol.swift} (79%) rename Sources/AppKitNavigation/Bindings/{NSTargetActionProxy.swift => TargetActionProxy.swift} (85%) diff --git a/Package.swift b/Package.swift index 2fb60be222..d8e88c6c6d 100644 --- a/Package.swift +++ b/Package.swift @@ -85,7 +85,7 @@ let package = Package( dependencies: [ "SwiftNavigation", "AppKitNavigationShim", - .product(name: "IdentifiedCollections", package: "swift-identified-collections") + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), .target( diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 169fd95403..5db189cb1a 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -36,6 +36,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.1.0"), ], targets: [ .target( @@ -84,6 +85,7 @@ let package = Package( dependencies: [ "SwiftNavigation", "AppKitNavigationShim", + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), .target( diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift index d2287caf93..36715c56cd 100755 --- a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -2,13 +2,13 @@ import AppKit -extension NSColorPanel: NSTargetActionProtocol { - public var appkitNavigationTarget: AnyObject? { +extension NSColorPanel: TargetActionProtocol { + public var target: AnyObject? { set { setTarget(newValue) } get { value(forKeyPath: "target") as? AnyObject } } - public var appkitNavigationAction: Selector? { + public var action: Selector? { set { setAction(newValue) } get { value(forKeyPath: "action") as? Selector } } diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 7edc6aaef2..05b5f7fa13 100755 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -2,17 +2,7 @@ import AppKit -extension NSControl: NSTargetActionProtocol { - public var appkitNavigationTarget: AnyObject? { - set { target = newValue } - get { target } - } - - public var appkitNavigationAction: Selector? { - set { action = newValue } - get { action } - } -} +extension NSControl: TargetActionProtocol {} extension NSControl { public convenience init(action: @escaping (Self) -> Void) { diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 9797d82ae6..51944e85ac 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -1,19 +1,12 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) +import ConcurrencyExtras +@_spi(Internals) import SwiftNavigation import AppKit -import SwiftNavigation - -extension NSFontManager: NSTargetActionProtocol, @unchecked Sendable { - public var appkitNavigationTarget: AnyObject? { - set { appkitNavigationDelegate.target = newValue } - get { appkitNavigationDelegate.target } - } - - public var appkitNavigationAction: Selector? { - set { appkitNavigationDelegate.action = newValue } - get { appkitNavigationDelegate.action } - } +import IdentifiedCollections +extension NSFontManager: @unchecked Sendable { + private static let appkitNavigationDelegateKey = malloc(1)! private var appkitNavigationDelegate: Delegate { @@ -74,4 +67,189 @@ extension NSFontManager { } } + +@MainActor +extension NSFontManager { + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath + ) -> ObserveToken { + bind(binding, to: keyPath) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + private var actionProxy: FontManagerProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionProxyKey) as? FontManagerProxy + } + } + + private func createActionProxyIfNeeded() -> FontManagerProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = FontManagerProxy(owner: self) + self.actionProxy = actionProxy + return actionProxy + } + } + + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - set: A closure that is called when the binding's value changes with a weakly-captured + /// control, a new value that can be used to configure the control, and a transaction, which + /// can be used to determine how and if the change should be animated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: KeyPath, + set: @escaping (_ control: NSFontManager, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObserveToken { + unbind(keyPath) + let actionProxy = createActionProxyIfNeeded() + let actionID = actionProxy.addBindingAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + + let isSetting = LockIsolated(false) + let token = observe { [weak self] transaction in + guard let self else { return } + isSetting.withValue { $0 = true } + defer { isSetting.withValue { $0 = false } } + set( + self, + binding.wrappedValue, + transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations + ? binding.transaction + : transaction + ) + } + // NB: This key path must only be accessed on the main actor + @UncheckedSendable var uncheckedKeyPath = keyPath + let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in + guard isSetting.withValue({ !$0 }) else { return } + MainActor._assumeIsolated { + binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + } + } + let observationToken = ObserveToken { [weak self] in + MainActor._assumeIsolated { + self?.actionProxy?.removeAction(for: actionID) + } + token.cancel() + observation.invalidate() + } + observationTokens[keyPath] = observationToken + return observationToken + } + + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } +} + + +@MainActor +private let observationTokensKey = malloc(1)! +@MainActor +private let actionProxyKey = malloc(1)! + +@MainActor +private class FontManagerProxy: NSObject { + typealias ActionClosure = (Any?) -> Void + + typealias ActionIdentifier = UUID + + private struct Action: Identifiable { + let id = UUID() + + var closure: ActionClosure + + func invoke(_ sender: Any?) { + closure(sender) + } + } + + private var bindingActions: IdentifiedArrayOf = [] + + private var actions: IdentifiedArrayOf = [] + + private var originTarget: AnyObject? + + private var originAction: Selector? + + weak var owner: NSFontManager? + + required init(owner: NSFontManager) { + self.owner = owner + super.init() + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) + } + + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0.invoke(obj.object) } + actions.forEach { $0.invoke(obj.object) } + } + + @objc func invokeAction(_ sender: Any?) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) + } + bindingActions.forEach { $0.invoke(sender) } + actions.forEach { $0.invoke(sender) } + } + + @discardableResult + func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { + let action = Action(closure: actionClosure) + actions.append(action) + return action.id + } + + func removeAction(for id: ActionIdentifier) { + actions.remove(id: id) + } + + func removeAllActions() { + actions.removeAll() + } + + @discardableResult + func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { + let bindingAction = Action(closure: bindingActionClosure) + bindingActions.append(bindingAction) + return bindingAction.id + } + + func removeBindingAction(for id: ActionIdentifier) { + bindingActions.remove(id: id) + } + + func removeAllBindingActions() { + bindingActions.removeAll() + } +} + #endif diff --git a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift index 25d7dd5c0a..01bb9ac88b 100755 --- a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -2,16 +2,6 @@ import AppKit -extension NSMenuItem: NSTargetActionProtocol, @unchecked Sendable { - public var appkitNavigationTarget: AnyObject? { - set { target = newValue } - get { target } - } - - public var appkitNavigationAction: Selector? { - set { action = newValue } - get { action } - } -} +extension NSMenuItem: TargetActionProtocol, @unchecked Sendable {} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index 897efc8417..96a45cf170 100755 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -72,7 +72,7 @@ extension NSTextField: NSTextViewDelegate { } } - let observationToken = ObservationToken { [weak self] in + let observationToken = ObserveToken { [weak self] in MainActor._assumeIsolated { editingChangedAction.cancel() editingDidEndAction.cancel() @@ -242,7 +242,7 @@ extension NSTextField: NSTextViewDelegate { break } } - let outerToken = ObservationToken { + let outerToken = ObserveToken { editingDidBeginAction.cancel() editingDidEndAction.cancel() innerToken.cancel() diff --git a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift deleted file mode 100755 index 478253a51e..0000000000 --- a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift +++ /dev/null @@ -1,17 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -extension NSToolbarItem: NSTargetActionProtocol { - public var appkitNavigationTarget: AnyObject? { - set { target = newValue } - get { target } - } - - public var appkitNavigationAction: Selector? { - set { action = newValue } - get { action } - } -} - -#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift similarity index 79% rename from Sources/AppKitNavigation/Bindings/NSTargetAction.swift rename to Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift index 1d72abb4eb..c7bbedb5c6 100755 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift @@ -5,12 +5,12 @@ import AppKit /// A protocol used to extend `NSControl, NSMenuItem...`. @MainActor -public protocol NSTargetActionProtocol: NSObject, Sendable { - var appkitNavigationTarget: AnyObject? { set get } - var appkitNavigationAction: Selector? { set get } +public protocol TargetActionProtocol: NSObject, Sendable { + var target: AnyObject? { set get } + var action: Selector? { set get } } -extension NSTargetActionProtocol { +extension TargetActionProtocol { /// Establishes a two-way connection between a source of truth and a property of this control. /// /// - Parameters: @@ -28,20 +28,20 @@ extension NSTargetActionProtocol { } } - var actionProxy: NSTargetActionProxy? { + var actionProxy: TargetActionProxy? { set { objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } get { - objc_getAssociatedObject(self, actionProxyKey) as? NSTargetActionProxy + objc_getAssociatedObject(self, actionProxyKey) as? TargetActionProxy } } - func createActionProxyIfNeeded() -> NSTargetActionProxy { + func createActionProxyIfNeeded() -> TargetActionProxy { if let actionProxy { return actionProxy } else { - let actionProxy = NSTargetActionProxy(owner: self) + let actionProxy = TargetActionProxy(owner: self) self.actionProxy = actionProxy return actionProxy } @@ -62,7 +62,7 @@ extension NSTargetActionProtocol { _ binding: UIBinding, to keyPath: KeyPath, set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObservationToken { + ) -> ObserveToken { unbind(keyPath) let actionProxy = createActionProxyIfNeeded() let actionID = actionProxy.addBindingAction { [weak self] _ in @@ -91,7 +91,7 @@ extension NSTargetActionProtocol { binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] } } - let observationToken = ObservationToken { [weak self] in + let observationToken = ObserveToken { [weak self] in MainActor._assumeIsolated { self?.actionProxy?.removeAction(for: actionID) } @@ -107,24 +107,13 @@ extension NSTargetActionProtocol { observationTokens[keyPath] = nil } -// var observationTokens: [AnyKeyPath: ObservationToken] { -// get { -// objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] -// ?? [:] -// } -// set { -// objc_setAssociatedObject( -// self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC -// ) -// } -// } } @MainActor extension NSObject { - var observationTokens: [AnyKeyPath: ObservationToken] { + var observationTokens: [AnyKeyPath: ObserveToken] { get { - objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObserveToken] ?? [:] } set { diff --git a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift similarity index 85% rename from Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift rename to Sources/AppKitNavigation/Bindings/TargetActionProxy.swift index 2d2054d0ec..c7e82fb291 100755 --- a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -4,7 +4,7 @@ import AppKit import IdentifiedCollections @MainActor -class NSTargetActionProxy: NSObject { +class TargetActionProxy: NSObject { typealias ActionClosure = (Any?) -> Void typealias ActionIdentifier = UUID @@ -27,15 +27,15 @@ class NSTargetActionProxy: NSObject { private var originAction: Selector? - weak var owner: NSTargetActionProtocol? + weak var owner: TargetActionProtocol? - required init(owner: NSTargetActionProtocol) { + required init(owner: TargetActionProtocol) { self.owner = owner super.init() - self.originTarget = owner.appkitNavigationTarget - self.originAction = owner.appkitNavigationAction - owner.appkitNavigationTarget = self - owner.appkitNavigationAction = #selector(invokeAction(_:)) + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) if let textField = owner as? NSTextField { NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) } From 9d0df7e6732ee86bf6e53dc122930c0a65b87aad Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Sun, 1 Dec 2024 02:20:55 +0800 Subject: [PATCH 32/34] Fix errors --- Sources/AppKitNavigation/Bindings/NSColorPanel.swift | 1 + Sources/AppKitNavigation/Bindings/NSControl.swift | 1 + Sources/AppKitNavigation/Bindings/NSFontManager.swift | 5 +++-- Sources/AppKitNavigation/Bindings/NSMenuItem.swift | 2 +- Sources/AppKitNavigation/Bindings/NSTextField.swift | 5 +++-- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift index 36715c56cd..79adfd1851 100755 --- a/Sources/AppKitNavigation/Bindings/NSColorPanel.swift +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -2,6 +2,7 @@ import AppKit +extension NSColorPanel: @retroactive Sendable {} extension NSColorPanel: TargetActionProtocol { public var target: AnyObject? { set { setTarget(newValue) } diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 05b5f7fa13..33ae9d4348 100755 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -2,6 +2,7 @@ import AppKit +extension NSControl: @retroactive Sendable {} extension NSControl: TargetActionProtocol {} extension NSControl { diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 51944e85ac..01bc6b00ca 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -5,9 +5,10 @@ import ConcurrencyExtras import AppKit import IdentifiedCollections -extension NSFontManager: @unchecked Sendable { +@MainActor +extension NSFontManager: @unchecked @retroactive Sendable { - private static let appkitNavigationDelegateKey = malloc(1)! + private static let appkitNavigationDelegateKey = malloc(1)! private var appkitNavigationDelegate: Delegate { set { diff --git a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift index 01bb9ac88b..663c1302e9 100755 --- a/Sources/AppKitNavigation/Bindings/NSMenuItem.swift +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -2,6 +2,6 @@ import AppKit -extension NSMenuItem: TargetActionProtocol, @unchecked Sendable {} +extension NSMenuItem: TargetActionProtocol, @unchecked @retroactive Sendable {} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift index 96a45cf170..8b9182aa08 100755 --- a/Sources/AppKitNavigation/Bindings/NSTextField.swift +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -4,7 +4,8 @@ import Combine import SwiftNavigation @MainActor -extension NSTextField: NSTextViewDelegate { +extension NSTextField: @retroactive NSTextDelegate {} +extension NSTextField: @retroactive NSTextViewDelegate { /// Creates a new text field with the specified frame and registers the binding against its /// text. /// @@ -337,6 +338,6 @@ public struct AppKitTextSelection: Hashable, Sendable { } } -extension AnyCancellable: @unchecked Sendable {} +extension AnyCancellable: @unchecked @retroactive Sendable {} #endif From 567a607430cf40f796f6a300f3464ff63fbc6f66 Mon Sep 17 00:00:00 2001 From: Mx-Iris <61279231+Mx-Iris@users.noreply.github.com> Date: Tue, 4 Feb 2025 15:36:43 +0800 Subject: [PATCH 33/34] Merge upstream main branch --- Examples/Examples.xcodeproj/project.pbxproj | 1 - .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../Bindings/NSTargetAction.swift | 143 ---- .../Bindings/NSTargetActionProxy.swift | 88 --- .../Bindings/NSToolbarItem.swift | 13 +- .../Bindings/TargetActionProxy.swift | 126 +-- .../Internal/AssociatedKeys.swift | 42 +- .../Internal/AssumeIsolated.swift | 35 - .../Internal/ErrorMechanism.swift | 20 - .../AppKitNavigation/Internal/Exports.swift | 2 +- .../Internal/ModalWindowsObserver.swift | 33 + .../Internal/ToOptionalUnit.swift | 12 - .../AppKitNavigation/Navigation/Modal.swift | 740 +++++++++--------- .../Navigation/ModalContent.swift | 41 +- .../Navigation/ModalSession.swift | 7 - .../Navigation/ModalSessionContent.swift | 16 - .../Navigation/ModalWindowsObserver.swift | 33 - .../Navigation/NavigationContent.swift | 33 +- .../Navigation/NavigationObserver.swift | 130 +-- .../Navigation/Presentation.swift | 304 ++++--- .../Navigation/PresentationContent.swift | 52 +- .../AppKitNavigation/Navigation/Sheet.swift | 455 ++++++----- .../Navigation/SheetContent.swift | 58 +- Sources/AppKitNavigation/Observe.swift | 181 ----- Sources/AppKitNavigationShim/shim.m | 2 + .../xcshareddata/swiftpm/Package.resolved | 39 +- 26 files changed, 1044 insertions(+), 1564 deletions(-) delete mode 100644 Sources/AppKitNavigation/Bindings/NSTargetAction.swift delete mode 100644 Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift delete mode 100644 Sources/AppKitNavigation/Internal/AssumeIsolated.swift delete mode 100644 Sources/AppKitNavigation/Internal/ErrorMechanism.swift create mode 100644 Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift delete mode 100644 Sources/AppKitNavigation/Internal/ToOptionalUnit.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalSession.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalSessionContent.swift delete mode 100644 Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift delete mode 100644 Sources/AppKitNavigation/Observe.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 74a5badb07..ac06aec67b 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1176,7 +1176,6 @@ isa = XCSwiftPackageProductDependency; productName = SwiftUINavigation; }; ->>>>>>> origin/appkit-navigation-bindings /* End XCSwiftPackageProductDependency section */ }; rootObject = CA47378C272F08EF0012CAC3 /* Project object */; diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 7d469824ea..096761ab34 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -52,8 +52,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/Bindings/NSTargetAction.swift b/Sources/AppKitNavigation/Bindings/NSTargetAction.swift deleted file mode 100644 index fb6e9553fa..0000000000 --- a/Sources/AppKitNavigation/Bindings/NSTargetAction.swift +++ /dev/null @@ -1,143 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) -import ConcurrencyExtras -@_spi(Internals) import SwiftNavigation -import AppKit - -/// A protocol used to extend `NSControl, NSMenuItem...`. -@MainActor -public protocol NSTargetActionProtocol: NSObject, Sendable { - var appkitNavigationTarget: AnyObject? { set get } - var appkitNavigationAction: Selector? { set get } -} - -extension NSTargetActionProtocol { - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: ReferenceWritableKeyPath - ) -> ObservationToken { - bind(binding, to: keyPath) { control, newValue, _ in - control[keyPath: keyPath] = newValue - } - } - - var actionProxy: NSTargetActionProxy? { - set { - objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - objc_getAssociatedObject(self, actionProxyKey) as? NSTargetActionProxy - } - } - - func createActionProxyIfNeeded() -> NSTargetActionProxy { - if let actionProxy { - return actionProxy - } else { - let actionProxy = NSTargetActionProxy(owner: self) - self.actionProxy = actionProxy - return actionProxy - } - } - - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - set: A closure that is called when the binding's value changes with a weakly-captured - /// control, a new value that can be used to configure the control, and a transaction, which - /// can be used to determine how and if the change should be animated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: KeyPath, - set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObservationToken { - unbind(keyPath) - let actionProxy = createActionProxyIfNeeded() - let actionID = actionProxy.addBindingAction { [weak self] _ in - guard let self else { return } - binding.wrappedValue = self[keyPath: keyPath] - } - - let isSetting = LockIsolated(false) - let token = observe { [weak self] transaction in - guard let self else { return } - isSetting.withValue { $0 = true } - defer { isSetting.withValue { $0 = false } } - set( - self, - binding.wrappedValue, - transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations - ? binding.transaction - : transaction - ) - } - // NB: This key path must only be accessed on the main actor - @UncheckedSendable var uncheckedKeyPath = keyPath - let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in - guard isSetting.withValue({ !$0 }) else { return } - MainActor._assumeIsolated { - binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] - } - } - let observationToken = ObservationToken { [weak self] in - MainActor._assumeIsolated { - self?.actionProxy?.removeAction(for: actionID) - } - token.cancel() - observation.invalidate() - } - observationTokens[keyPath] = observationToken - return observationToken - } - - public func unbind(_ keyPath: KeyPath) { - observationTokens[keyPath]?.cancel() - observationTokens[keyPath] = nil - } - -// var observationTokens: [AnyKeyPath: ObservationToken] { -// get { -// objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] -// ?? [:] -// } -// set { -// objc_setAssociatedObject( -// self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC -// ) -// } -// } -} - -@MainActor -extension NSObject { - var observationTokens: [AnyKeyPath: ObservationToken] { - get { - objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObservationToken] - ?? [:] - } - set { - objc_setAssociatedObject( - self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } -} - -@MainActor -private let observationTokensKey = malloc(1)! -@MainActor -private let actionProxyKey = malloc(1)! - -#endif diff --git a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift deleted file mode 100644 index 2d2054d0ec..0000000000 --- a/Sources/AppKitNavigation/Bindings/NSTargetActionProxy.swift +++ /dev/null @@ -1,88 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit -import IdentifiedCollections - -@MainActor -class NSTargetActionProxy: NSObject { - typealias ActionClosure = (Any?) -> Void - - typealias ActionIdentifier = UUID - - private struct Action: Identifiable { - let id = UUID() - - var closure: ActionClosure - - func invoke(_ sender: Any?) { - closure(sender) - } - } - - private var bindingActions: IdentifiedArrayOf = [] - - private var actions: IdentifiedArrayOf = [] - - private var originTarget: AnyObject? - - private var originAction: Selector? - - weak var owner: NSTargetActionProtocol? - - required init(owner: NSTargetActionProtocol) { - self.owner = owner - super.init() - self.originTarget = owner.appkitNavigationTarget - self.originAction = owner.appkitNavigationAction - owner.appkitNavigationTarget = self - owner.appkitNavigationAction = #selector(invokeAction(_:)) - if let textField = owner as? NSTextField { - NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) - } - } - - @objc func controlTextDidChange(_ obj: Notification) { - bindingActions.forEach { $0.invoke(obj.object) } - actions.forEach { $0.invoke(obj.object) } - } - - @objc func invokeAction(_ sender: Any?) { - if let originTarget, let originAction { - NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) - } - bindingActions.forEach { $0.invoke(sender) } - actions.forEach { $0.invoke(sender) } - } - - @discardableResult - func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { - let action = Action(closure: actionClosure) - actions.append(action) - return action.id - } - - func removeAction(for id: ActionIdentifier) { - actions.remove(id: id) - } - - func removeAllActions() { - actions.removeAll() - } - - @discardableResult - func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { - let bindingAction = Action(closure: bindingActionClosure) - bindingActions.append(bindingAction) - return bindingAction.id - } - - func removeBindingAction(for id: ActionIdentifier) { - bindingActions.remove(id: id) - } - - func removeAllBindingActions() { - bindingActions.removeAll() - } -} - -#endif diff --git a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift index 478253a51e..e90fec934d 100644 --- a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift +++ b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift @@ -2,16 +2,7 @@ import AppKit -extension NSToolbarItem: NSTargetActionProtocol { - public var appkitNavigationTarget: AnyObject? { - set { target = newValue } - get { target } - } - - public var appkitNavigationAction: Selector? { - set { action = newValue } - get { action } - } -} +extension NSToolbarItem: @retroactive Sendable {} +extension NSToolbarItem: TargetActionProtocol {} #endif diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift index c7e82fb291..961235ff1e 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -5,84 +5,84 @@ import IdentifiedCollections @MainActor class TargetActionProxy: NSObject { - typealias ActionClosure = (Any?) -> Void + typealias ActionClosure = (Any?) -> Void - typealias ActionIdentifier = UUID + typealias ActionIdentifier = UUID - private struct Action: Identifiable { - let id = UUID() + private struct Action: Identifiable { + let id = UUID() - var closure: ActionClosure + var closure: ActionClosure - func invoke(_ sender: Any?) { - closure(sender) - } + func invoke(_ sender: Any?) { + closure(sender) } + } - private var bindingActions: IdentifiedArrayOf = [] + private var bindingActions: IdentifiedArrayOf = [] - private var actions: IdentifiedArrayOf = [] + private var actions: IdentifiedArrayOf = [] - private var originTarget: AnyObject? + private var originTarget: AnyObject? - private var originAction: Selector? + private var originAction: Selector? - weak var owner: TargetActionProtocol? + weak var owner: TargetActionProtocol? - required init(owner: TargetActionProtocol) { - self.owner = owner - super.init() - self.originTarget = owner.target - self.originAction = owner.action - owner.target = self - owner.action = #selector(invokeAction(_:)) - if let textField = owner as? NSTextField { - NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) - } + required init(owner: TargetActionProtocol) { + self.owner = owner + super.init() + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) + if let textField = owner as? NSTextField { + NotificationCenter.default.addObserver(self, selector: #selector(controlTextDidChange(_:)), name: NSControl.textDidChangeNotification, object: textField) } + } - @objc func controlTextDidChange(_ obj: Notification) { - bindingActions.forEach { $0.invoke(obj.object) } - actions.forEach { $0.invoke(obj.object) } - } - - @objc func invokeAction(_ sender: Any?) { - if let originTarget, let originAction { - NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) - } - bindingActions.forEach { $0.invoke(sender) } - actions.forEach { $0.invoke(sender) } - } - - @discardableResult - func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { - let action = Action(closure: actionClosure) - actions.append(action) - return action.id - } - - func removeAction(for id: ActionIdentifier) { - actions.remove(id: id) - } - - func removeAllActions() { - actions.removeAll() - } - - @discardableResult - func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { - let bindingAction = Action(closure: bindingActionClosure) - bindingActions.append(bindingAction) - return bindingAction.id - } - - func removeBindingAction(for id: ActionIdentifier) { - bindingActions.remove(id: id) - } + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0.invoke(obj.object) } + actions.forEach { $0.invoke(obj.object) } + } - func removeAllBindingActions() { - bindingActions.removeAll() + @objc func invokeAction(_ sender: Any?) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) } + bindingActions.forEach { $0.invoke(sender) } + actions.forEach { $0.invoke(sender) } + } + + @discardableResult + func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { + let action = Action(closure: actionClosure) + actions.append(action) + return action.id + } + + func removeAction(for id: ActionIdentifier) { + actions.remove(id: id) + } + + func removeAllActions() { + actions.removeAll() + } + + @discardableResult + func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { + let bindingAction = Action(closure: bindingActionClosure) + bindingActions.append(bindingAction) + return bindingAction.id + } + + func removeBindingAction(for id: ActionIdentifier) { + bindingActions.remove(id: id) + } + + func removeAllBindingActions() { + bindingActions.removeAll() + } } #endif diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift index 1df9f50edc..f1398d8c8c 100644 --- a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift +++ b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift @@ -3,34 +3,34 @@ 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 - } + 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 - } + static func == (lhs: AnyHashableMetatype, rhs: AnyHashableMetatype) -> Bool { + return lhs.base == rhs.base + } - let base: Any.Type + let base: Any.Type - init(_ base: Any.Type) { - self.base = base - } + init(_ base: Any.Type) { + self.base = base + } - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(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 deleted file mode 100644 index 93f1c40098..0000000000 --- a/Sources/AppKitNavigation/Internal/AssumeIsolated.swift +++ /dev/null @@ -1,35 +0,0 @@ -#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 deleted file mode 100644 index 1ec4c47b0a..0000000000 --- a/Sources/AppKitNavigation/Internal/ErrorMechanism.swift +++ /dev/null @@ -1,20 +0,0 @@ -#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 index 6f94b2acad..554225adc1 100644 --- a/Sources/AppKitNavigation/Internal/Exports.swift +++ b/Sources/AppKitNavigation/Internal/Exports.swift @@ -1,3 +1,3 @@ #if canImport(AppKit) && !targetEnvironment(macCatalyst) - @_exported import SwiftNavigation +@_exported import SwiftNavigation #endif diff --git a/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Internal/ModalWindowsObserver.swift new file mode 100644 index 0000000000..a95607191e --- /dev/null +++ b/Sources/AppKitNavigation/Internal/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/Internal/ToOptionalUnit.swift b/Sources/AppKitNavigation/Internal/ToOptionalUnit.swift deleted file mode 100644 index a11cfaaf1d..0000000000 --- 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/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift index 0d053d6f0d..6ac702ac14 100644 --- a/Sources/AppKitNavigation/Navigation/Modal.swift +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -3,414 +3,414 @@ import AppKit @MainActor -private var modalObserverKeys = AssociatedKeys() +private var ModalObserverKeys = AssociatedKeys() private typealias ModalObserver = NavigationObserver // MARK: - Modal Session - NSWindow + @MainActor extension NSObject { - - @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) - } + @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(isModaled: isModaled, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - public func modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _modal(item: item, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> NSWindow - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } - - @discardableResult - public func modal( - item: UIBinding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSWindow - ) -> ObserveToken { - _modal(item: item, id: id, onDismiss: onDismiss, content: content) - } + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSWindow + ) -> ObserveToken { + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modal(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSWindow + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func modal( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSWindow + ) -> ObserveToken { + _modal(item: item, id: id, onDismiss: onDismiss, content: content) + } } // MARK: - Modal - NSAlert @MainActor extension NSObject { - @discardableResult - public func modal( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSAlert - ) -> ObserveToken { - _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) - } - - @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) - } + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSAlert + ) -> ObserveToken { + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @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 () -> NSSavePanel - ) -> ObserveToken { - _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) - } - - @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 modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> NSSavePanel - ) -> ObserveToken { - _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) - } - - @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) - } + @discardableResult + public func modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSSavePanel + ) -> ObserveToken { + _modal(isModaled: isModaled, onDismiss: onDismiss, content: content) + } + + @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 modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSSavePanel + ) -> ObserveToken { + _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) + } + + @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 - fileprivate func _modal( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content - ) -> ObserveToken { - _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } - } - - @discardableResult - fileprivate func _modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - fileprivate func _modal( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @discardableResult - fileprivate 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 + fileprivate func _modal( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _modal(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + fileprivate func _modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + fileprivate func _modal( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modal(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + fileprivate 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 + fileprivate 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 } - } - @discardableResult - fileprivate 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?() + } else { + DispatchQueue.main.async { + ModalWindowsObserver.shared.observeWindow(modalContent.window) + modalContent.appKitNavigationRunModal() + modalContent.onEndNavigation?() + modalContent.onEndNavigation = nil } - } - - // MARK: - Modal Session - - @discardableResult - fileprivate func _modalSession( - isModaled: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content - ) -> ObserveToken { - _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } - } - - @discardableResult - fileprivate func _modalSession( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @_disfavoredOverload - @discardableResult - fileprivate func _modalSession( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) - } - - @discardableResult - fileprivate 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 - fileprivate 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?() - } + } + } endModal: { _, _ in + NSApplication.shared.stopModal() + onDismiss?() + } + } + + // MARK: - Modal Session + + @discardableResult + fileprivate func _modalSession( + isModaled: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _modalSession(item: isModaled.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } + + @discardableResult + fileprivate func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + fileprivate func _modalSession( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _modalSession(item: item, id: \.id, onDismiss: onDismiss, content: content) + } + + @discardableResult + fileprivate 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 + fileprivate 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) } - } - - 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 + } 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() - } + func clearup() { + NSApplication.shared.stopModal() + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/ModalContent.swift b/Sources/AppKitNavigation/Navigation/ModalContent.swift index 676b8857b8..3098e6a589 100644 --- a/Sources/AppKitNavigation/Navigation/ModalContent.swift +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -4,32 +4,43 @@ import AppKit @MainActor protocol ModalContent: NavigationContent { - @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse - var window: NSWindow { get } + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +@MainActor +protocol ModalSessionContent: ModalContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession } extension NSWindow: ModalContent { - var window: NSWindow { self } + var window: NSWindow { self } - func appKitNavigationRunModal() -> NSApplication.ModalResponse { - __appKitNavigationRunModal() - } + func appKitNavigationRunModal() -> NSApplication.ModalResponse { + __appKitNavigationRunModal() + } + + @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + NSApplication.shared.runModal(for: self) + } +} - @objc func __appKitNavigationRunModal() -> NSApplication.ModalResponse { - NSApplication.shared.runModal(for: self) - } +extension NSWindow: ModalSessionContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { + NSApplication.shared.beginModalSession(for: self) + } } extension NSSavePanel { - override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { - runModal() - } + override func __appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } } extension NSAlert: ModalContent { - func appKitNavigationRunModal() -> NSApplication.ModalResponse { - runModal() - } + func appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSession.swift b/Sources/AppKitNavigation/Navigation/ModalSession.swift deleted file mode 100644 index 51a47075aa..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalSession.swift +++ /dev/null @@ -1,7 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - - - -#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift b/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift deleted file mode 100644 index a897018c9e..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalSessionContent.swift +++ /dev/null @@ -1,16 +0,0 @@ -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -@MainActor -protocol ModalSessionContent: ModalContent { - func appKitNavigationBeginModalSession() -> NSApplication.ModalSession -} - -extension NSWindow: ModalSessionContent { - func appKitNavigationBeginModalSession() -> NSApplication.ModalSession { - NSApplication.shared.beginModalSession(for: self) - } -} - -#endif diff --git a/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift b/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift deleted file mode 100644 index bc63b33443..0000000000 --- a/Sources/AppKitNavigation/Navigation/ModalWindowsObserver.swift +++ /dev/null @@ -1,33 +0,0 @@ -#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 index 2e35321c1b..8c10f8c664 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationContent.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -4,8 +4,8 @@ import Foundation @MainActor protocol NavigationContent: AnyObject { - var onBeginNavigation: (() -> Void)? { set get } - var onEndNavigation: (() -> Void)? { set get } + var onBeginNavigation: (() -> Void)? { set get } + var onEndNavigation: (() -> Void)? { set get } } @MainActor @@ -15,24 +15,23 @@ private var onBeginNavigationKeys = AssociatedKeys() private var onEndNavigationKeys = AssociatedKeys() extension NavigationContent { - 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 - } + 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 + } + } - 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 - } + 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 index 488cf6b4b9..ce952a080a 100644 --- a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift +++ b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift @@ -5,84 +5,84 @@ import SwiftNavigation @MainActor class NavigationObserver: NSObject { - private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] + private var navigatedByID: [UIBindingIdentifier: Navigated] = [:] - unowned let owner: Owner + unowned let owner: Owner - init(owner: Owner) { - self.owner = owner - super.init() - } + 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 + 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 - } + 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() - } + func commitWork(_ work: @escaping () -> Void) { + work() + } } @MainActor class Navigated { - weak var content: Content? - let id: AnyHashable? - func clearup() {} - deinit { - MainActor._assumeIsolated { - clearup() - } + 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 - } + 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 index fbd5f46777..f90ec118a3 100644 --- a/Sources/AppKitNavigation/Navigation/Presentation.swift +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -4,191 +4,171 @@ import AppKit import AppKitNavigationShim class PresentationObserver: NavigationObserver { - override func commitWork(_ work: @escaping () -> Void) { - if owner._AppKitNavigation_hasViewAppeared { - work() - } else { - owner._AppKitNavigation_onViewAppear.append(work) - } + override func commitWork(_ work: @escaping () -> Void) { + if owner._AppKitNavigation_hasViewAppeared { + work() + } else { + owner._AppKitNavigation_onViewAppear.append(work) } + } } extension NSViewController { - @discardableResult - public func present( - isPresented: UIBinding, - style: TransitionStyle, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSViewController - ) -> ObserveToken { - present(item: isPresented.toOptionalUnit, style: style, onDismiss: onDismiss) { _ in content() } - } + @discardableResult + public func present( + isPresented: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> 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) -> NSViewController - ) -> ObserveToken { - present(item: item, id: \.id, style: style, onDismiss: onDismiss, content: content) - } + @discardableResult + public func present( + item: UIBinding, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> 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) -> NSViewController - ) -> 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) -> NSViewController + ) -> 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) -> NSViewController - ) -> ObserveToken { - present(item: item, id: id, style: style, onDismiss: onDismiss) { - content($0.wrappedValue) - } + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> 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) -> NSViewController - ) -> 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?() - self.present(child, for: style) - } else { - self.present(child, for: style) - } - } dismiss: { [weak self] child, transaction in - guard let self else { return } - self.dismiss(child) - onDismiss?() - } + @_disfavoredOverload + @discardableResult + public func present( + item: UIBinding, + id: KeyPath, + style: TransitionStyle, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> 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?() + self.present(child, for: style) + } else { + self.present(child, for: style) + } + } dismiss: { [weak self] child, transaction in + guard let self else { return } + self.dismiss(child) + onDismiss?() } + } - @discardableResult - public func destination( - isPresented: UIBinding, - content: @escaping () -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - destination( - item: isPresented.toOptionalUnit, - content: { _ in content() }, - present: present, - dismiss: dismiss - ) - } + @discardableResult + public func destination( + isPresented: UIBinding, + content: @escaping () -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + destination( + item: isPresented.toOptionalUnit, + content: { _ in content() }, + present: present, + dismiss: dismiss + ) + } - @discardableResult - public func destination( - item: UIBinding, - content: @escaping (UIBinding) -> NSViewController, - present: @escaping (NSViewController, UITransaction) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ 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, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping (NSViewController, UITransaction) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ 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) -> NSViewController, - present: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void, - dismiss: @escaping ( - _ child: NSViewController, - _ transaction: UITransaction - ) -> Void - ) -> ObserveToken { - let presentationObserver: PresentationObserver = presentationObserver() - return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) - } + @discardableResult + public func destination( + item: UIBinding, + id: KeyPath, + content: @escaping (UIBinding) -> NSViewController, + present: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void, + dismiss: @escaping ( + _ child: NSViewController, + _ transaction: UITransaction + ) -> Void + ) -> ObserveToken { + let presentationObserver: PresentationObserver = presentationObserver() + return presentationObserver.observe(item: item, id: { $0[keyPath: id] }, content: content, begin: present, end: dismiss) + } - 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, Self.presentationObserverKey, observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - return observer - } - } - - public enum TransitionStyle { - case sheet - case modalWindow - case popover(rect: NSRect, view: NSView, preferredEdge: NSRectEdge, behavior: NSPopover.Behavior) - case custom(NSViewControllerPresentationAnimator) - } + private static var presentationObserverKey = malloc(1)! - 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) - } + 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, Self.presentationObserverKey, observer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return observer } + } } extension NavigationContent where Self: NSViewController { - var _onEndNavigation: (() -> Void)? { - set { - _AppKitNavigation_onDismiss = newValue - } - get { - _AppKitNavigation_onDismiss - } + var _onEndNavigation: (() -> Void)? { + set { + _AppKitNavigation_onDismiss = newValue } + get { + _AppKitNavigation_onDismiss + } + } } extension Navigated where Content: NSViewController { - func clearup() { - content?.dismiss(nil) - } + func clearup() { + content?.dismiss(nil) + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/PresentationContent.swift b/Sources/AppKitNavigation/Navigation/PresentationContent.swift index eba08ad600..907536422c 100644 --- a/Sources/AppKitNavigation/Navigation/PresentationContent.swift +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -3,39 +3,39 @@ import AppKit @MainActor -public protocol PresentationContent: NavigationContent { - func presented(from presentingViewController: NSViewController, style: NSViewController.TransitionStyle) - func dismiss(from presentingViewController: NSViewController) +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) - } + func presented(from presentingViewController: NSViewController, style: TransitionStyle) { + presentingViewController.present(self, for: style) + } - public func dismiss(from presentingViewController: NSViewController) { - presentingViewController.dismiss(self) - } + 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) - } + 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) - } + 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 ba9cbd1846..565c991704 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -8,268 +8,267 @@ private typealias SheetObserver, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> NSWindow - ) -> ObserveToken { - _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) - } + @discardableResult + public func sheet( + isSheeted: UIBinding, + 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) - } + @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) - } + @_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 (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) - } + @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( + 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) - } + @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) - } + @_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 (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) - } + @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( + 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) - } + @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) - } + @_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 (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) - } + @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 - fileprivate func _sheet( - isSheeted: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content - ) -> ObserveToken { - _sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } - } + @discardableResult + fileprivate func _sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> Content + ) -> ObserveToken { + _sheet(item: isSheeted.toOptionalUnit, onDismiss: onDismiss) { _ in content() } + } - @discardableResult - fileprivate func _sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (Item) -> Content - ) -> ObserveToken { - _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) - } + @discardableResult + fileprivate func _sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content + ) -> ObserveToken { + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } - @_disfavoredOverload - @discardableResult - fileprivate func _sheet( - item: UIBinding, - onDismiss: (() -> Void)? = nil, - content: @escaping (UIBinding) -> Content - ) -> ObserveToken { - _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) - } + @_disfavoredOverload + @discardableResult + fileprivate func _sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> Content + ) -> ObserveToken { + _sheet(item: item, id: \.id, onDismiss: onDismiss, content: content) + } - @discardableResult - fileprivate 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 + fileprivate 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 - fileprivate 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?() + @discardableResult + fileprivate 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 _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 - } + 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 Navigated where Content: SheetContent { - func clearup() { - guard let window = content?.currentWindow else { return } - window.sheetParent?.endSheet(window) - } + func clearup() { + guard let window = content?.currentWindow else { return } + window.sheetParent?.endSheet(window) + } } #endif diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index 3da396258f..b8ce7a2245 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -4,49 +4,49 @@ import AppKit @MainActor protocol SheetContent: NavigationContent { - var currentWindow: NSWindow? { get } - func beginSheet(for content: SheetContent) async - func endSheet(for content: SheetContent) + var currentWindow: NSWindow? { get } + func beginSheet(for content: SheetContent) async + func endSheet(for content: SheetContent) } extension SheetContent { - func beginSheet(for content: any SheetContent) async { - guard let sheetedWindow = content.currentWindow else { return } - await currentWindow?.beginSheet(sheetedWindow) - } - - func endSheet(for content: any SheetContent) { - guard let sheetedWindow = content.currentWindow else { return } - currentWindow?.endSheet(sheetedWindow) - } + func beginSheet(for content: any SheetContent) async { + guard let sheetedWindow = content.currentWindow else { return } + await currentWindow?.beginSheet(sheetedWindow) + } + + func endSheet(for content: any SheetContent) { + guard let sheetedWindow = content.currentWindow else { return } + currentWindow?.endSheet(sheetedWindow) + } } extension NSWindow: SheetContent { - var currentWindow: NSWindow? { self } + var currentWindow: NSWindow? { self } } extension NSAlert: SheetContent { - var currentWindow: NSWindow? { window } + var currentWindow: NSWindow? { window } - func beginSheet(for content: any SheetContent) async { - guard let parentWindow = content.currentWindow else { return } - await beginSheetModal(for: parentWindow) - } + func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } - func endSheet(for content: any SheetContent) { - content.currentWindow?.endSheet(window) - } + func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } } extension NSSavePanel { - func beginSheet(for content: any SheetContent) async { - guard let parentWindow = content.currentWindow else { return } - await beginSheetModal(for: parentWindow) - } - - func endSheet(for content: any SheetContent) { - content.currentWindow?.endSheet(window) - } + func beginSheet(for content: any SheetContent) async { + guard let parentWindow = content.currentWindow else { return } + await beginSheetModal(for: parentWindow) + } + + func endSheet(for content: any SheetContent) { + content.currentWindow?.endSheet(window) + } } #endif diff --git a/Sources/AppKitNavigation/Observe.swift b/Sources/AppKitNavigation/Observe.swift deleted file mode 100644 index 8032874494..0000000000 --- a/Sources/AppKitNavigation/Observe.swift +++ /dev/null @@ -1,181 +0,0 @@ -#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/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m index bbb814a0c9..e36845fde7 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -27,6 +27,8 @@ + (void)load { 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:)) ); diff --git a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved index e5c147398a..4e7a6a30d1 100644 --- a/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SwiftNavigation.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { + "originHash" : "fd561772dbb7da88879cfbd7ad587c7c0f94bd076b6d0e00f7d2d67ac20cb3a8", "pins" : [ { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", - "version" : "1.0.2" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" } }, { @@ -14,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", - "version" : "1.5.6" + "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", + "version" : "1.6.1" } }, { @@ -23,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", - "version" : "1.0.5" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -39,10 +40,10 @@ { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" } }, { @@ -59,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "http://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "0fc0255e780bf742abeef29dec80924f5f0ae7b9", - "version" : "1.4.1" + "revision" : "52b5e1a09dc016e64ce253e19ab3124b7fae9ac9", + "version" : "1.7.0" } }, { @@ -84,10 +85,10 @@ { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", - "version" : "1.1.0" + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" } }, { @@ -95,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" + "revision" : "21811d6230a625fa0f2e6ffa85be857075cc02c4", + "version" : "1.5.0" } }, { @@ -122,10 +123,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", - "version" : "1.4.2" + "revision" : "b444594f79844b0d6d76d70fbfb3f7f71728f938", + "version" : "1.5.1" } } ], - "version" : 2 + "version" : 3 } From b57ed0fa07cb91684f565cc216ee999cfcb963aa Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Wed, 8 Oct 2025 20:02:32 +0800 Subject: [PATCH 34/34] Add NSProgressIndicator binding support --- ...ppKit+BasicsNavigationViewController.swift | 137 +++--- ...+ConciseEnumNavigationViewController.swift | 172 ++++---- .../AppKit+EnumControlsViewController.swift | 191 ++++---- .../AppKit/AppKit+FocusViewController.swift | 237 +++++----- ...ppKit+ConnectToNetworkViewController.swift | 312 ++++++------- .../AppKit+NetworkDetailViewController.swift | 83 ++-- Package.resolved | 39 +- .../AppKitNavigation/AppKitAnimation.swift | 2 +- .../AppKitNavigation/Bindings/NSControl.swift | 26 +- .../Bindings/NSFontManager.swift | 411 +++++++++--------- .../Bindings/NSProgressIndicator.swift | 32 ++ .../Bindings/TargetActionProtocol.swift | 209 ++++----- .../AppKitNavigation/Navigation/Sheet.swift | 54 ++- .../Navigation/SheetContent.swift | 19 + Sources/AppKitNavigationShim/shim.m | 2 +- Sources/UIKitNavigation/UIKitAnimation.swift | 2 +- .../xcschemes/AppKitNavigation.xcscheme | 67 +++ 17 files changed, 1087 insertions(+), 908 deletions(-) create mode 100644 Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift create mode 100644 SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift index c7a71253f3..6140afcab0 100644 --- a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -4,93 +4,96 @@ import AppKit import AppKitNavigation class BasicsNavigationViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyTitle = "Basics" - let readMe = """ - This case study demonstrates how to perform every major form of navigation in UIKit (alerts, \ - sheets, drill-downs) by driving navigation off of optional and boolean state. - """ - @UIBindable var model = Model() + let caseStudyTitle = "Basics" + let readMe = """ + This case study demonstrates how to perform every major form of navigation in UIKit (alerts, \ + sheets, drill-downs) by driving navigation off of optional and boolean state. + """ + @UIBindable var model = Model() - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidLoad() { + super.viewDidLoad() - let showAlertButton = NSButton { [weak self] _ in - self?.model.alert = "Hello!" - } + let showAlertButton = NSButton() + showAlertButton.addAction { [weak self] _ in + self?.model.alert = "Hello!" + } - let showSheetButton = NSButton { [weak self] _ in - self?.model.sheet = .random(in: 1 ... 1_000) - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self?.model.sheet = nil - } - } + let showSheetButton = NSButton() + showSheetButton.addAction { [weak self] _ in + self?.model.sheet = .random(in: 1 ... 1_000) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self?.model.sheet = nil + } + } - let showSheetFromBooleanButton = NSButton { [weak self] _ in - self?.model.isSheetPresented = true - } + let showSheetFromBooleanButton = NSButton() + showSheetFromBooleanButton.addAction { [weak self] _ in + self?.model.isSheetPresented = true + } - let stack = NSStackView(views: [ - showAlertButton, - showSheetButton, - showSheetFromBooleanButton, - ]) - stack.orientation = .vertical - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) - observe { [weak self] in - guard let self else { return } + observe { [weak self] in + guard let self else { return } - if let url = model.url { - showAlertButton.title = "URL is: \(url)" - } else { - showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" - } - showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" - showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" - } + if let url = model.url { + showAlertButton.title = "URL is: \(url)" + } else { + showAlertButton.title = "Alert is presented: \(model.alert != nil ? "✅" : "❌")" + } + showSheetButton.title = "Sheet is presented: \(model.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.isSheetPresented ? "✅" : "❌")" + } - modal(item: $model.alert, id: \.self) { [unowned self] message in + modal(item: $model.alert, id: \.self) { [unowned self] message in // let alert = NSAlert() // alert.messageText = "This is an alert" // alert.informativeText = message // alert.addButton(withTitle: "OK") // return alert - let openPanel = NSOpenPanel(url: $model.url) - openPanel.message = message - return openPanel - } + let openPanel = NSOpenPanel(url: $model.url) + openPanel.message = message + return openPanel + } - present(item: $model.sheet, id: \.self, style: .sheet) { count in - NSHostingController( - rootView: Form { Text(count.description) }.frame(width: 100, height: 100, alignment: .center) - ) - } - - present(isPresented: $model.isSheetPresented, style: .sheet) { - NSHostingController( - rootView: Form { Text("Hello!") }.frame(width: 100, height: 100, alignment: .center) - ) - } + present(item: $model.sheet, id: \.self, style: .sheet) { count in + NSHostingController( + rootView: Form { Text(count.description) }.frame(width: 100, height: 100, alignment: .center) + ) } - @Observable - class Model { - var alert: String? - var isSheetPresented = false - var sheet: Int? - var url: URL? + present(isPresented: $model.isSheetPresented, style: .sheet) { + NSHostingController( + rootView: Form { Text("Hello!") }.frame(width: 100, height: 100, alignment: .center) + ) } + } + + @Observable + class Model { + var alert: String? + var isSheetPresented = false + var sheet: Int? + var url: URL? + } } #Preview { - BasicsNavigationViewController() + BasicsNavigationViewController() } #endif diff --git a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift index 8abcdec54c..dadfc4e8ad 100644 --- a/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift @@ -4,105 +4,107 @@ import AppKit import AppKitNavigation class ConciseEnumNavigationViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyNavigationTitle = "Enum navigation" - let caseStudyTitle = "Concise enum navigation" - let readMe = """ - This case study demonstrates how to navigate to multiple destinations from a single optional \ - enum. + let caseStudyNavigationTitle = "Enum navigation" + let caseStudyTitle = "Concise enum navigation" + let readMe = """ + This case study demonstrates how to navigate to multiple destinations from a single optional \ + enum. - This allows you to be very concise with your domain modeling by having a single enum \ - describe all the possible destinations you can navigate to. In the case of this demo, we have \ - four cases in the enum, which means there are exactly 5 possible states, including the case \ - where none are active. + This allows you to be very concise with your domain modeling by having a single enum \ + describe all the possible destinations you can navigate to. In the case of this demo, we have \ + four cases in the enum, which means there are exactly 5 possible states, including the case \ + where none are active. - If you were to instead model this domain with 4 optionals (or booleans), then you would have \ - 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ - because you can never be sure of exactly what is presented at a given time. - """ - @UIBindable var model = Model() + If you were to instead model this domain with 4 optionals (or booleans), then you would have \ + 16 possible states, of which only 5 are valid. That can leak complexity into your domain \ + because you can never be sure of exactly what is presented at a given time. + """ + @UIBindable var model = Model() - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidLoad() { + super.viewDidLoad() - let showAlertButton = NSButton { [weak self] _ in - self?.model.destination = .alert("Hello!") - } - let showSheetButton = NSButton { [weak self] _ in - self?.model.destination = .sheet(.random(in: 1 ... 1_000)) - } - let showSheetFromBooleanButton = NSButton { [weak self] _ in - self?.model.destination = .sheetWithoutPayload - } + let showAlertButton = NSButton() + showAlertButton.addAction { [weak self] _ in + self?.model.destination = .alert("Hello!") + } + let showSheetButton = NSButton() + showSheetButton.addAction { [weak self] _ in + self?.model.destination = .sheet(.random(in: 1 ... 1_000)) + } + let showSheetFromBooleanButton = NSButton() + showSheetFromBooleanButton.addAction { [weak self] _ in + self?.model.destination = .sheetWithoutPayload + } - let stack = NSStackView(views: [ - showAlertButton, - showSheetButton, - showSheetFromBooleanButton, - ]) - stack.orientation = .vertical - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false + let stack = NSStackView(views: [ + showAlertButton, + showSheetButton, + showSheetFromBooleanButton, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) - observe { [weak self] in - guard let self else { return } + observe { [weak self] in + guard let self else { return } - showAlertButton.title = "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")" - showSheetButton.title = "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")" - showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")" - } + showAlertButton.title = "Alert is presented: \(model.destination?.alert != nil ? "✅" : "❌")" + showSheetButton.title = "Sheet is presented: \(model.destination?.sheet != nil ? "✅" : "❌")" + showSheetFromBooleanButton.title = "Sheet is presented from boolean: \(model.destination?.sheetWithoutPayload != nil ? "✅" : "❌")" + } - modal(item: $model.destination.alert, id: \.self) { message in - let alert = NSAlert() - alert.messageText = "This is an alert" - alert.informativeText = message - alert.addButton(withTitle: "OK") - return alert - } - present(item: $model.destination.sheet, id: \.self, style: .sheet) { [unowned self] count in - - NSHostingController( - rootView: Form { - Text(count.description) - Button("Close") { - self.model.destination = nil - } - }.frame(width: 200, height: 200, alignment: .center) - ) - } - present(isPresented: UIBinding($model.destination.sheetWithoutPayload), style: .sheet) { [unowned self] in - NSHostingController( - rootView: Form { - Text("Hello!") - Button("Close") { - self.model.destination = nil - } - }.frame(width: 200, height: 200, alignment: .center) - ) - } + modal(item: $model.destination.alert, id: \.self) { message in + let alert = NSAlert() + alert.messageText = "This is an alert" + alert.informativeText = message + alert.addButton(withTitle: "OK") + return alert + } + present(item: $model.destination.sheet, id: \.self, style: .sheet) { [unowned self] count in + NSHostingController( + rootView: Form { + Text(count.description) + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) + } + present(isPresented: UIBinding($model.destination.sheetWithoutPayload), style: .sheet) { [unowned self] in + NSHostingController( + rootView: Form { + Text("Hello!") + Button("Close") { + self.model.destination = nil + } + }.frame(width: 200, height: 200, alignment: .center) + ) } + } - @Observable - class Model { - var destination: Destination? - @CasePathable - @dynamicMemberLookup - enum Destination { - case alert(String) - case drillDown(Int) - case sheet(Int) - case sheetWithoutPayload - } + @Observable + class Model { + var destination: Destination? + @CasePathable + @dynamicMemberLookup + enum Destination { + case alert(String) + case drillDown(Int) + case sheet(Int) + case sheetWithoutPayload } + } } #Preview { - ConciseEnumNavigationViewController() + ConciseEnumNavigationViewController() } #endif diff --git a/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift index f6aada2f04..9ec9f3613c 100644 --- a/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+EnumControlsViewController.swift @@ -4,114 +4,115 @@ import AppKit import AppKitNavigation class EnumControlsViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyNavigationTitle = "Enum controls" - let caseStudyTitle = "Concise enum controls" - let readMe = """ - This case study demonstrates how to drive form controls from bindings to enum state. In this \ - example, a single `Status` enum holds two cases: - - • An integer quantity for when an item is in stock, which can drive a stepper. - • A Boolean for whether an item is on back order when it is _not_ in stock, which can drive a \ - switch. - - This library provides tools to chain deeper into a binding's case by applying the \ - `@CasePathable` macro. - """ - - @CasePathable - enum Status { - case inStock(quantity: Int) - case outOfStock(isOnBackOrder: Bool) + let caseStudyNavigationTitle = "Enum controls" + let caseStudyTitle = "Concise enum controls" + let readMe = """ + This case study demonstrates how to drive form controls from bindings to enum state. In this \ + example, a single `Status` enum holds two cases: + + • An integer quantity for when an item is in stock, which can drive a stepper. + • A Boolean for whether an item is on back order when it is _not_ in stock, which can drive a \ + switch. + + This library provides tools to chain deeper into a binding's case by applying the \ + `@CasePathable` macro. + """ + + @CasePathable + enum Status { + case inStock(quantity: Int) + case outOfStock(isOnBackOrder: Bool) + } + + @UIBinding var status: Status = .inStock(quantity: 100) + + override func viewDidLoad() { + super.viewDidLoad() + + let quantityLabel = NSTextField(labelWithString: "") + let quantityStepper = NSStepper() + quantityStepper.maxValue = .infinity + let quantityStack = NSStackView(views: [ + quantityLabel, + quantityStepper, + ]) + let outOfStockButton = NSButton() + outOfStockButton.addAction { [weak self] _ in + self?.status = .outOfStock(isOnBackOrder: false) + } + outOfStockButton.title = "Out of stock" + let inStockStack = NSStackView(views: [ + quantityStack, + outOfStockButton, + ]) + inStockStack.orientation = .vertical + + let isOnBackOrderLabel = NSTextField(labelWithString: "Is on back order?") + let isOnBackOrderSwitch = NSSwitch() + let isOnBackOrderStack = NSStackView(views: [ + isOnBackOrderLabel, + isOnBackOrderSwitch, + ]) + let backInStockButton = NSButton() + backInStockButton.addAction { [weak self] _ in + self?.status = .inStock(quantity: 100) } - @UIBinding var status: Status = .inStock(quantity: 100) - - override func viewDidLoad() { - super.viewDidLoad() - - let quantityLabel = NSTextField(labelWithString: "") - let quantityStepper = NSStepper() - quantityStepper.maxValue = .infinity - let quantityStack = NSStackView(views: [ - quantityLabel, - quantityStepper, - ]) - let outOfStockButton = NSButton { [weak self] _ in - self?.status = .outOfStock(isOnBackOrder: false) - } - outOfStockButton.title = "Out of stock" - let inStockStack = NSStackView(views: [ - quantityStack, - outOfStockButton, - ]) - inStockStack.orientation = .vertical - - let isOnBackOrderLabel = NSTextField(labelWithString: "Is on back order?") - let isOnBackOrderSwitch = NSSwitch() - let isOnBackOrderStack = NSStackView(views: [ - isOnBackOrderLabel, - isOnBackOrderSwitch, - ]) - let backInStockButton = NSButton { [weak self] _ in - self?.status = .inStock(quantity: 100) + backInStockButton.title = "Back in stock!" + let outOfStockStack = NSStackView(views: [ + isOnBackOrderStack, + backInStockButton, + ]) + outOfStockStack.orientation = .vertical + + let stack = NSStackView(views: [ + inStockStack, + outOfStockStack, + ]) + stack.orientation = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + + inStockStack.isHidden = !status.is(\.inStock) + outOfStockStack.isHidden = !status.is(\.outOfStock) + + switch status { + case .inStock: + if let quantity = $status.inStock { + quantityLabel.stringValue = "Quantity: \(quantity.wrappedValue)" + quantityStepper.bind(value: quantity.asDouble) } - backInStockButton.title = "Back in stock!" - let outOfStockStack = NSStackView(views: [ - isOnBackOrderStack, - backInStockButton, - ]) - outOfStockStack.orientation = .vertical - - let stack = NSStackView(views: [ - inStockStack, - outOfStockStack, - ]) - stack.orientation = .vertical - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(stack) - - NSLayoutConstraint.activate([ - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - - observe { [weak self] in - guard let self else { return } - - inStockStack.isHidden = !status.is(\.inStock) - outOfStockStack.isHidden = !status.is(\.outOfStock) - - switch status { - case .inStock: - if let quantity = $status.inStock { - quantityLabel.stringValue = "Quantity: \(quantity.wrappedValue)" - quantityStepper.bind(value: quantity.asDouble) - } - - case .outOfStock: - if let isOnBackOrder = $status.outOfStock { - isOnBackOrderSwitch.bind(isOn: isOnBackOrder) - } - } + case .outOfStock: + if let isOnBackOrder = $status.outOfStock { + isOnBackOrderSwitch.bind(isOn: isOnBackOrder) } + } } + } } @available(macOS 14.0, *) #Preview { - EnumControlsViewController() + EnumControlsViewController() } - extension Int { - fileprivate var asDouble: Double { - get { Double(self) } - set { self = Int(newValue) } - } + fileprivate var asDouble: Double { + get { Double(self) } + set { self = Int(newValue) } + } } #endif diff --git a/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift index 9008fdf714..7623580bbf 100644 --- a/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift +++ b/Examples/CaseStudies/AppKit/AppKit+FocusViewController.swift @@ -4,128 +4,133 @@ import AppKit import AppKitNavigation class FocusViewController: XiblessViewController, AppKitCaseStudy { - let caseStudyTitle = "Focus" - let readMe = """ - This case study demonstrates how to handle `UITextField` focus in a state-driven manner. The \ - focus in the UI is kept in sync with the focus value held in an observable model so that \ - changes in one are immediately reflected in the other. - """ - @UIBindable var model = Model() - - override func viewDidLoad() { - super.viewDidLoad() - - let bioTextField = NSTextField(text: $model.bio) - bioTextField.bind(focus: $model.focus, equals: .bio) - bioTextField.bezelStyle = .roundedBezel - bioTextField.placeholderString = "Bio" - let emailTextField = NSTextField(text: $model.email) - emailTextField.bind(focus: $model.focus, equals: .email) - emailTextField.bezelStyle = .roundedBezel - emailTextField.placeholderString = "Email" - let passwordTextField = NSSecureTextField(text: $model.password) - passwordTextField.bind(focus: $model.focus, equals: .password) - passwordTextField.bezelStyle = .roundedBezel - passwordTextField.placeholderString = "Password" - let usernameTextField = NSTextField(text: $model.username) - usernameTextField.bind(focus: $model.focus, equals: .username) - usernameTextField.bezelStyle = .roundedBezel - usernameTextField.placeholderString = "Username" - - let currentFocusLabel = NSTextField(labelWithString: "") - - let focusBioButton = NSButton { [weak self] _ in - self?.model.focus = .bio - } - - focusBioButton.title = "Focus bio" - let focusEmailButton = NSButton { [weak self] _ in - self?.model.focus = .email - } - - focusEmailButton.title = "Focus email" - let focusPasswordButton = NSButton { [weak self] _ in - self?.model.focus = .password - } - - focusPasswordButton.title = "Focus password" - let focusUsernameButton = NSButton { [weak self] _ in - self?.model.focus = .username - } - - focusUsernameButton.title = "Focus username" - let resignFirstResponder = NSButton { [weak self] _ in - self?.view.window?.makeFirstResponder(nil) - } - - resignFirstResponder.title = "Resign first responder" - - let stack = NSStackView(views: [ - usernameTextField, - emailTextField, - passwordTextField, - bioTextField, - currentFocusLabel, - focusUsernameButton, - focusEmailButton, - focusPasswordButton, - focusBioButton, - resignFirstResponder, - ]) - stack.orientation = .vertical - stack.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(stack) - NSLayoutConstraint.activate([ - stack.topAnchor - .constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - stack.leadingAnchor - .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), - stack.trailingAnchor - .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), - ]) - - observe { [weak self] in - guard let self else { return } - - currentFocusLabel.stringValue = "Current focus: \(model.focus?.rawValue ?? "none")" - bioTextField.backgroundColor = nil - emailTextField.backgroundColor = nil - passwordTextField.backgroundColor = nil - usernameTextField.backgroundColor = nil - switch model.focus { - case .bio: - bioTextField.backgroundColor = .lightGray - case .email: - emailTextField.backgroundColor = .lightGray - case .password: - passwordTextField.backgroundColor = .lightGray - case .username: - usernameTextField.backgroundColor = .lightGray - case .none: - break - } - } + let caseStudyTitle = "Focus" + let readMe = """ + This case study demonstrates how to handle `UITextField` focus in a state-driven manner. The \ + focus in the UI is kept in sync with the focus value held in an observable model so that \ + changes in one are immediately reflected in the other. + """ + @UIBindable var model = Model() + + override func viewDidLoad() { + super.viewDidLoad() + + let bioTextField = NSTextField(text: $model.bio) + bioTextField.bind(focus: $model.focus, equals: .bio) + bioTextField.bezelStyle = .roundedBezel + bioTextField.placeholderString = "Bio" + let emailTextField = NSTextField(text: $model.email) + emailTextField.bind(focus: $model.focus, equals: .email) + emailTextField.bezelStyle = .roundedBezel + emailTextField.placeholderString = "Email" + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bind(focus: $model.focus, equals: .password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.placeholderString = "Password" + let usernameTextField = NSTextField(text: $model.username) + usernameTextField.bind(focus: $model.focus, equals: .username) + usernameTextField.bezelStyle = .roundedBezel + usernameTextField.placeholderString = "Username" + + let currentFocusLabel = NSTextField(labelWithString: "") + + let focusBioButton = NSButton() + focusBioButton.addAction { [weak self] _ in + self?.model.focus = .bio } - @Observable - class Model { - var bio = "" - var email = "" - var focus: Focus? - var password = "" - var username = "" - enum Focus: String { - case bio - case email - case password - case username - } + focusBioButton.title = "Focus bio" + let focusEmailButton = NSButton() + focusEmailButton.addAction { [weak self] _ in + self?.model.focus = .email } + + focusEmailButton.title = "Focus email" + let focusPasswordButton = NSButton() + focusPasswordButton.addAction { [weak self] _ in + self?.model.focus = .password + } + + focusPasswordButton.title = "Focus password" + let focusUsernameButton = NSButton() + focusUsernameButton.addAction { [weak self] _ in + self?.model.focus = .username + } + + focusUsernameButton.title = "Focus username" + let resignFirstResponder = NSButton() + resignFirstResponder.addAction { [weak self] _ in + self?.view.window?.makeFirstResponder(nil) + } + + resignFirstResponder.title = "Resign first responder" + + let stack = NSStackView(views: [ + usernameTextField, + emailTextField, + passwordTextField, + bioTextField, + currentFocusLabel, + focusUsernameButton, + focusEmailButton, + focusPasswordButton, + focusBioButton, + resignFirstResponder, + ]) + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.leadingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), + stack.trailingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + ]) + + observe { [weak self] in + guard let self else { return } + + currentFocusLabel.stringValue = "Current focus: \(model.focus?.rawValue ?? "none")" + bioTextField.backgroundColor = nil + emailTextField.backgroundColor = nil + passwordTextField.backgroundColor = nil + usernameTextField.backgroundColor = nil + switch model.focus { + case .bio: + bioTextField.backgroundColor = .lightGray + case .email: + emailTextField.backgroundColor = .lightGray + case .password: + passwordTextField.backgroundColor = .lightGray + case .username: + usernameTextField.backgroundColor = .lightGray + case .none: + break + } + } + } + + @Observable + class Model { + var bio = "" + var email = "" + var focus: Focus? + var password = "" + var username = "" + enum Focus: String { + case bio + case email + case password + case username + } + } } #Preview { - FocusViewController() + FocusViewController() } #endif diff --git a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift index 203e996393..5fbc9378f0 100644 --- a/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift +++ b/Examples/CaseStudies/WiFiFeature/ConnectToNetworkFeature/AppKit+ConnectToNetworkViewController.swift @@ -4,174 +4,176 @@ import AppKit import AppKitNavigation final class ConnectToNetworkViewController: XiblessViewController { - @UIBindable var model: ConnectToNetworkModel - - init(model: ConnectToNetworkModel) { - self.model = model - super.init(nibName: nil, bundle: nil) + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.frame = .init(origin: .zero, size: .init(width: 450, height: 234)) + let wifiIconImage = NSImageView(systemSymbolName: "wifi") + wifiIconImage.contentTintColor = .systemBlue + wifiIconImage.symbolConfiguration = .init(pointSize: 60, weight: .regular) + + let titleLabel = NSTextField(labelWithString: "Enter the password for “\(model.network.name)”") + titleLabel.font = .boldSystemFont(ofSize: 13) + let detailLabel = NSTextField(wrappingLabelWithString: "You can also access this Wi-Fi network by sharing the password from a nearby iPhone, iPad, or Mac which has connected to this network and has you in their contacts.") + detailLabel.font = .systemFont(ofSize: 12) + let textStackView = NSStackView(views: [ + titleLabel, + detailLabel, + ]) + textStackView.orientation = .vertical + textStackView.alignment = .leading + + let topStackView = NSStackView(views: [ + Spacer(size: .init(width: 15, height: 0)), + wifiIconImage, + textStackView, + HorizontalMaxSpacer(), + ]) + topStackView.orientation = .horizontal + + let passwordLabel = NSTextField(labelWithString: "Password:") + + let passwordTextField = NSSecureTextField(text: $model.password) + passwordTextField.bezelStyle = .roundedBezel + passwordTextField.becomeFirstResponder() + passwordTextField.placeholderString = "The password is 'blob'" + + let centerStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + passwordLabel, + passwordTextField, + ]) + centerStackView.orientation = .horizontal + NSLayoutConstraint.activate([ + passwordTextField.widthAnchor.constraint(equalToConstant: 253), + ]) + + let cancelButton = NSButton() + cancelButton.addAction { [weak self] _ in + guard let self else { return } + model.cancelButtonTapped() } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + cancelButton.title = "Cancel" + + let joinButton = NSButton() + joinButton.addAction { [weak self] _ in + guard let self else { return } + Task { + await self.model.joinButtonTapped() + } + } + joinButton.title = "Join" + + let progress = NSProgressIndicator() + progress.isIndeterminate = true + progress.style = .spinning + progress.controlSize = .small + progress.startAnimation(nil) + + let progressStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + progress, + ]) + + let bottomStackView = NSStackView(views: [ + HorizontalMaxSpacer(), + cancelButton, + joinButton, + ]) + bottomStackView.orientation = .horizontal + + NSLayoutConstraint.activate([ + joinButton.widthAnchor.constraint(equalToConstant: 70), + cancelButton.widthAnchor.constraint(equalToConstant: 70), + ]) + + let stack = NSStackView(views: [ + topStackView, + centerStackView, + progressStackView, + bottomStackView, + ]) + + stack.orientation = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + stack.edgeInsets = .init(top: 20, left: 0, bottom: 20, right: 20) + stack.distribution = .equalSpacing + stack.detachesHiddenViews = false + view.addSubview(stack) + progressStackView.isHidden = true + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + stack.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), + stack.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), + ]) + + observe { [weak self, weak passwordTextField] in + guard + let self, + let passwordTextField + else { return } + + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + progressStackView.isHidden = !model.isConnecting } - override func viewDidLoad() { - super.viewDidLoad() - view.frame = .init(origin: .zero, size: .init(width: 450, height: 234)) - let wifiIconImage = NSImageView(systemSymbolName: "wifi") - wifiIconImage.contentTintColor = .systemBlue - wifiIconImage.symbolConfiguration = .init(pointSize: 60, weight: .regular) - - let titleLabel = NSTextField(labelWithString: "Enter the password for “\(model.network.name)”") - titleLabel.font = .boldSystemFont(ofSize: 13) - let detailLabel = NSTextField(wrappingLabelWithString: "You can also access this Wi-Fi network by sharing the password from a nearby iPhone, iPad, or Mac which has connected to this network and has you in their contacts.") - detailLabel.font = .systemFont(ofSize: 12) - let textStackView = NSStackView(views: [ - titleLabel, - detailLabel, - ]) - textStackView.orientation = .vertical - textStackView.alignment = .leading - - let topStackView = NSStackView(views: [ - Spacer(size: .init(width: 15, height: 0)), - wifiIconImage, - textStackView, - HorizontalMaxSpacer(), - ]) - topStackView.orientation = .horizontal - - let passwordLabel = NSTextField(labelWithString: "Password:") - - let passwordTextField = NSSecureTextField(text: $model.password) - passwordTextField.bezelStyle = .roundedBezel - passwordTextField.becomeFirstResponder() - passwordTextField.placeholderString = "The password is 'blob'" - - let centerStackView = NSStackView(views: [ - HorizontalMaxSpacer(), - passwordLabel, - passwordTextField, - ]) - centerStackView.orientation = .horizontal - NSLayoutConstraint.activate([ - passwordTextField.widthAnchor.constraint(equalToConstant: 253), - ]) - - let cancelButton = NSButton { [weak self] _ in - guard let self else { return } - model.cancelButtonTapped() - } - cancelButton.title = "Cancel" - - let joinButton = NSButton { [weak self] _ in - guard let self else { return } - Task { - await self.model.joinButtonTapped() - } - } - joinButton.title = "Join" - - let progress = NSProgressIndicator() - progress.isIndeterminate = true - progress.style = .spinning - progress.controlSize = .small - progress.startAnimation(nil) - - let progressStackView = NSStackView(views: [ - HorizontalMaxSpacer(), - progress, - ]) - - let bottomStackView = NSStackView(views: [ - HorizontalMaxSpacer(), - cancelButton, - joinButton, - ]) - bottomStackView.orientation = .horizontal - - NSLayoutConstraint.activate([ - joinButton.widthAnchor.constraint(equalToConstant: 70), - cancelButton.widthAnchor.constraint(equalToConstant: 70), - ]) - - let stack = NSStackView(views: [ - topStackView, - centerStackView, - progressStackView, - bottomStackView, - ]) - - stack.orientation = .vertical - stack.translatesAutoresizingMaskIntoConstraints = false - stack.edgeInsets = .init(top: 20, left: 0, bottom: 20, right: 20) - stack.distribution = .equalSpacing - stack.detachesHiddenViews = false - view.addSubview(stack) - progressStackView.isHidden = true - NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - stack.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), - stack.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), - ]) - - observe { [weak self, weak passwordTextField] in - guard - let self, - let passwordTextField - else { return } - - passwordTextField.isEnabled = !model.isConnecting - joinButton.isEnabled = !model.isConnecting - progressStackView.isHidden = !model.isConnecting - } - - modal(isModaled: $model.incorrectPasswordAlertIsPresented) { [unowned self] in - let alert = NSAlert() - alert.messageText = "Incorrect password for “\(model.network.name)”" - alert.addButton(withTitle: "OK") - return alert - } + modal(isModaled: $model.incorrectPasswordAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Incorrect password for “\(model.network.name)”" + alert.addButton(withTitle: "OK") + return alert } + } } class HorizontalMaxSpacer: NSView { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setContentHuggingPriority(.fittingSizeCompression, for: .horizontal) - setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setContentHuggingPriority(.fittingSizeCompression, for: .horizontal) + setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } class Spacer: NSView { - init(size: NSSize) { - super.init(frame: .zero) - NSLayoutConstraint.activate([ - widthAnchor.constraint(equalToConstant: size.width), - heightAnchor.constraint(equalToConstant: size.height), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + init(size: NSSize) { + super.init(frame: .zero) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: size.width), + heightAnchor.constraint(equalToConstant: size.height), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } #Preview(traits: .fixedLayout(width: 450, height: 262)) { - let vc = ConnectToNetworkViewController( - model: ConnectToNetworkModel( - network: Network(name: "Blob's WiFi") - ) + let vc = ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi") ) - vc.preferredContentSize = .init(width: 450, height: 234) - return vc + ) + vc.preferredContentSize = .init(width: 450, height: 234) + return vc } #endif diff --git a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift index 0c6219045a..6f7b7c61fa 100644 --- a/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift +++ b/Examples/CaseStudies/WiFiFeature/NetworkDetailFeature/AppKit+NetworkDetailViewController.swift @@ -4,62 +4,63 @@ import AppKit import AppKitNavigation final class NetworkDetailViewController: XiblessViewController { - @UIBindable var model: NetworkDetailModel + @UIBindable var model: NetworkDetailModel - init(model: NetworkDetailModel) { - self.model = model - super.init(nibName: nil, bundle: nil) - } + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidLoad() { + super.viewDidLoad() // navigationItem.title = model.network.name - let forgetButton = NSButton { [weak self] _ in - guard let self else { return } - model.forgetNetworkButtonTapped() - } + let forgetButton = NSButton() + forgetButton.addAction { [weak self] _ in + guard let self else { return } + model.forgetNetworkButtonTapped() + } - forgetButton.title = "Forget network" + forgetButton.title = "Forget network" // forgetButton.hasDestructiveAction = true - forgetButton.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(forgetButton) - NSLayoutConstraint.activate([ - forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) - observe { [weak self] in - guard let self else { return } + observe { [weak self] in + guard let self else { return } // forgetButton.isHidden = model.network.id != model.selectedNetworkID - } + } + + modal(isModaled: $model.forgetAlertIsPresented) { [unowned self] in + let alert = NSAlert() + alert.messageText = "Forget Wi-Fi Network “\(model.network.name)”?" + alert.informativeText = """ + Your Mac and other devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """ - modal(isModaled: $model.forgetAlertIsPresented) { [unowned self] in - let alert = NSAlert() - alert.messageText = "Forget Wi-Fi Network “\(model.network.name)”?" - alert.informativeText = """ - Your Mac and other devices using iCloud Keychain will no longer join this Wi-Fi \ - network. - """ - - alert.addButton(ButtonState(role: .cancel) { TextState("Cancel") }) { _ in } - alert.addButton(ButtonState(role: .destructive) { TextState("Forget") }) { [weak self] _ in - guard let self else { return } - model.confirmForgetNetworkButtonTapped() - } - return alert - } + alert.addButton(ButtonState(role: .cancel) { TextState("Cancel") }) { _ in } + alert.addButton(ButtonState(role: .destructive) { TextState("Forget") }) { [weak self] _ in + guard let self else { return } + model.confirmForgetNetworkButtonTapped() + } + return alert } + } } #Preview { - NetworkDetailViewController( - model: NetworkDetailModel( - network: Network(name: "Blob's WiFi"), - selectedNetworkID: UUID() - ) + NetworkDetailViewController( + model: NetworkDetailModel( + network: Network(name: "Blob's WiFi"), + selectedNetworkID: UUID() ) + ) } #endif diff --git a/Package.resolved b/Package.resolved index 1354e7886c..3ee1e092b3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "84b747bb349cd4db8652b1d5a9a2696c12f4c9f15441533d0f2e2553cb31f019", + "originHash" : "15cd96b975d5e3807a52c2b1c6d570b25f285bb14bd48c163416ecbb6bbac386", "pins" : [ { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", - "version" : "1.6.1" + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", - "version" : "1.2.0" + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "2eb22993b3dfd0c0d32729b357c8dabb6cd44680", - "version" : "1.4.2" + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" } }, { @@ -55,13 +55,22 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" + "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", + "version" : "1.6.0" } }, { @@ -69,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "515f79b522918f83483068d99c68daeb5116342d", - "version" : "600.0.0-prerelease-2024-08-20" + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" } }, { @@ -78,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "27d767d643fa2cf083d0a73d74fa84cacb53e85c", - "version" : "1.4.1" + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" } } ], diff --git a/Sources/AppKitNavigation/AppKitAnimation.swift b/Sources/AppKitNavigation/AppKitAnimation.swift index 096761ab34..22e729ae84 100644 --- a/Sources/AppKitNavigation/AppKitAnimation.swift +++ b/Sources/AppKitNavigation/AppKitAnimation.swift @@ -36,7 +36,7 @@ @MainActor func perform( _ body: () throws -> Result, - completion: ((Bool?) -> Void)? = nil + completion: (/*@Sendable*/ (Bool?) -> Void)? = nil ) rethrows -> Result { switch framework { case let .appKit(animation): diff --git a/Sources/AppKitNavigation/Bindings/NSControl.swift b/Sources/AppKitNavigation/Bindings/NSControl.swift index 33ae9d4348..1dc9a83188 100755 --- a/Sources/AppKitNavigation/Bindings/NSControl.swift +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -5,30 +5,6 @@ import AppKit extension NSControl: @retroactive Sendable {} extension NSControl: TargetActionProtocol {} -extension NSControl { - public convenience init(action: @escaping (Self) -> Void) { - self.init(frame: .zero) - createActionProxyIfNeeded().addAction { [weak self] _ in - guard let self else { return } - action(self) - } - } - - @discardableResult - public func addAction(_ action: @escaping (NSControl) -> Void) -> UUID { - createActionProxyIfNeeded().addAction { [weak self] _ in - guard let self else { return } - action(self) - } - } - - public func removeAction(for id: UUID) { - createActionProxyIfNeeded().removeAction(for: id) - } - - public func removeAllActions() { - createActionProxyIfNeeded().removeAllActions() - } -} +extension NSControl {} #endif diff --git a/Sources/AppKitNavigation/Bindings/NSFontManager.swift b/Sources/AppKitNavigation/Bindings/NSFontManager.swift index 01bc6b00ca..8bc2fef516 100755 --- a/Sources/AppKitNavigation/Bindings/NSFontManager.swift +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -7,168 +7,165 @@ import IdentifiedCollections @MainActor extension NSFontManager: @unchecked @retroactive Sendable { - - private static let appkitNavigationDelegateKey = malloc(1)! - - private var appkitNavigationDelegate: Delegate { - set { - objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - if let delegate = objc_getAssociatedObject(self, Self.appkitNavigationDelegateKey) as? Delegate { - return delegate - } else { - let delegate = Delegate() - target = delegate - self.appkitNavigationDelegate = delegate - return delegate - } - } - } - - private class Delegate: NSObject, NSFontChanging { - var target: AnyObject? - var action: Selector? - - func changeFont(_ sender: NSFontManager?) { - if let action { - NSApplication.shared.sendAction(action, to: target, from: sender) - } - } - } + private static let appkitNavigationDelegateKey = malloc(1)! + + private var appkitNavigationDelegate: Delegate { + set { + objc_setAssociatedObject(self, Self.appkitNavigationDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + if let delegate = objc_getAssociatedObject(self, Self.appkitNavigationDelegateKey) as? Delegate { + return delegate + } else { + let delegate = Delegate() + target = delegate + self.appkitNavigationDelegate = delegate + return delegate + } + } + } + + private class Delegate: NSObject, NSFontChanging { + var target: AnyObject? + var action: Selector? + + func changeFont(_ sender: NSFontManager?) { + if let action { + NSApplication.shared.sendAction(action, to: target, from: sender) + } + } + } } @MainActor extension NSFontManager { - /// Creates a new date picker with the specified frame and registers the binding against the - /// selected date. - /// - /// - Parameters: - /// - frame: The frame rectangle for the view, measured in points. - /// - date: The binding to read from for the selected date, and write to when the selected - /// date changes. - public convenience init(font: UIBinding) { - self.init() - bind(font: font) - } - - /// Establishes a two-way connection between a binding and the date picker's selected date. - /// - /// - Parameter date: The binding to read from for the selected date, and write to when the - /// selected date changes. - /// - Returns: A cancel token. - @discardableResult - public func bind(font: UIBinding) -> ObserveToken { - bind(font, to: \._selectedFont) - } - - @objc private var _selectedFont: NSFont { - set { setSelectedFont(newValue, isMultiple: false) } - get { convert(.systemFont(ofSize: 0)) } - } + /// Creates a new date picker with the specified frame and registers the binding against the + /// selected date. + /// + /// - Parameters: + /// - frame: The frame rectangle for the view, measured in points. + /// - date: The binding to read from for the selected date, and write to when the selected + /// date changes. + public convenience init(font: UIBinding) { + self.init() + bind(font: font) + } + + /// Establishes a two-way connection between a binding and the date picker's selected date. + /// + /// - Parameter date: The binding to read from for the selected date, and write to when the + /// selected date changes. + /// - Returns: A cancel token. + @discardableResult + public func bind(font: UIBinding) -> ObserveToken { + bind(font, to: \._selectedFont) + } + + @objc private var _selectedFont: NSFont { + set { setSelectedFont(newValue, isMultiple: false) } + get { convert(.systemFont(ofSize: 0)) } + } } - @MainActor extension NSFontManager { - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: ReferenceWritableKeyPath - ) -> ObserveToken { - bind(binding, to: keyPath) { control, newValue, _ in - control[keyPath: keyPath] = newValue - } - } - - private var actionProxy: FontManagerProxy? { - set { - objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - objc_getAssociatedObject(self, actionProxyKey) as? FontManagerProxy - } - } - - private func createActionProxyIfNeeded() -> FontManagerProxy { - if let actionProxy { - return actionProxy - } else { - let actionProxy = FontManagerProxy(owner: self) - self.actionProxy = actionProxy - return actionProxy - } - } - - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - set: A closure that is called when the binding's value changes with a weakly-captured - /// control, a new value that can be used to configure the control, and a transaction, which - /// can be used to determine how and if the change should be animated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: KeyPath, - set: @escaping (_ control: NSFontManager, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObserveToken { - unbind(keyPath) - let actionProxy = createActionProxyIfNeeded() - let actionID = actionProxy.addBindingAction { [weak self] _ in - guard let self else { return } - binding.wrappedValue = self[keyPath: keyPath] - } - - let isSetting = LockIsolated(false) - let token = observe { [weak self] transaction in - guard let self else { return } - isSetting.withValue { $0 = true } - defer { isSetting.withValue { $0 = false } } - set( - self, - binding.wrappedValue, - transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations - ? binding.transaction - : transaction - ) - } - // NB: This key path must only be accessed on the main actor - @UncheckedSendable var uncheckedKeyPath = keyPath - let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in - guard isSetting.withValue({ !$0 }) else { return } - MainActor._assumeIsolated { - binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] - } - } - let observationToken = ObserveToken { [weak self] in - MainActor._assumeIsolated { - self?.actionProxy?.removeAction(for: actionID) - } - token.cancel() - observation.invalidate() - } - observationTokens[keyPath] = observationToken - return observationToken - } - - public func unbind(_ keyPath: KeyPath) { - observationTokens[keyPath]?.cancel() - observationTokens[keyPath] = nil - } + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath + ) -> ObserveToken { + bind(binding, to: keyPath) { control, newValue, _ in + control[keyPath: keyPath] = newValue + } + } + + private var actionProxy: FontManagerProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + get { + objc_getAssociatedObject(self, actionProxyKey) as? FontManagerProxy + } + } + + private func createActionProxyIfNeeded() -> FontManagerProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = FontManagerProxy(owner: self) + self.actionProxy = actionProxy + return actionProxy + } + } + + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - set: A closure that is called when the binding's value changes with a weakly-captured + /// control, a new value that can be used to configure the control, and a transaction, which + /// can be used to determine how and if the change should be animated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: KeyPath, + set: @escaping (_ control: NSFontManager, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObserveToken { + unbind(keyPath) + let actionProxy = createActionProxyIfNeeded() + let actionID = actionProxy.addBindingAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } + + let isSetting = LockIsolated(false) + let token = observe { [weak self] transaction in + guard let self else { return } + isSetting.withValue { $0 = true } + defer { isSetting.withValue { $0 = false } } + set( + self, + binding.wrappedValue, + transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations + ? binding.transaction + : transaction + ) + } + // NB: This key path must only be accessed on the main actor + @UncheckedSendable var uncheckedKeyPath = keyPath + let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in + guard isSetting.withValue({ !$0 }) else { return } + MainActor._assumeIsolated { + binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + } + } + let observationToken = ObserveToken { [weak self] in + MainActor._assumeIsolated { + self?.actionProxy?.removeAction(for: actionID) + } + token.cancel() + observation.invalidate() + } + observationTokens[keyPath] = observationToken + return observationToken + } + + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } } - @MainActor private let observationTokensKey = malloc(1)! @MainActor @@ -176,81 +173,81 @@ private let actionProxyKey = malloc(1)! @MainActor private class FontManagerProxy: NSObject { - typealias ActionClosure = (Any?) -> Void + typealias ActionClosure = (Any?) -> Void - typealias ActionIdentifier = UUID + typealias ActionIdentifier = UUID - private struct Action: Identifiable { - let id = UUID() + private struct Action: Identifiable { + let id = UUID() - var closure: ActionClosure + var closure: ActionClosure - func invoke(_ sender: Any?) { - closure(sender) - } + func invoke(_ sender: Any?) { + closure(sender) } + } - private var bindingActions: IdentifiedArrayOf = [] + private var bindingActions: IdentifiedArrayOf = [] - private var actions: IdentifiedArrayOf = [] + private var actions: IdentifiedArrayOf = [] - private var originTarget: AnyObject? + private var originTarget: AnyObject? - private var originAction: Selector? + private var originAction: Selector? - weak var owner: NSFontManager? + weak var owner: NSFontManager? - required init(owner: NSFontManager) { - self.owner = owner - super.init() - self.originTarget = owner.target - self.originAction = owner.action - owner.target = self - owner.action = #selector(invokeAction(_:)) - } + required init(owner: NSFontManager) { + self.owner = owner + super.init() + self.originTarget = owner.target + self.originAction = owner.action + owner.target = self + owner.action = #selector(invokeAction(_:)) + } - @objc func controlTextDidChange(_ obj: Notification) { - bindingActions.forEach { $0.invoke(obj.object) } - actions.forEach { $0.invoke(obj.object) } - } + @objc func controlTextDidChange(_ obj: Notification) { + bindingActions.forEach { $0.invoke(obj.object) } + actions.forEach { $0.invoke(obj.object) } + } - @objc func invokeAction(_ sender: Any?) { - if let originTarget, let originAction { - NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) - } - bindingActions.forEach { $0.invoke(sender) } - actions.forEach { $0.invoke(sender) } + @objc func invokeAction(_ sender: Any?) { + if let originTarget, let originAction { + NSApplication.shared.sendAction(originAction, to: originTarget, from: sender) } + bindingActions.forEach { $0.invoke(sender) } + actions.forEach { $0.invoke(sender) } + } - @discardableResult - func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { - let action = Action(closure: actionClosure) - actions.append(action) - return action.id - } + @discardableResult + func addAction(_ actionClosure: @escaping ActionClosure) -> ActionIdentifier { + let action = Action(closure: actionClosure) + actions.append(action) + return action.id + } - func removeAction(for id: ActionIdentifier) { - actions.remove(id: id) - } + func removeAction(for id: ActionIdentifier) { + actions.remove(id: id) + } - func removeAllActions() { - actions.removeAll() - } + func removeAllActions() { + actions.removeAll() + } - @discardableResult - func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { - let bindingAction = Action(closure: bindingActionClosure) - bindingActions.append(bindingAction) - return bindingAction.id - } + @discardableResult + func addBindingAction(_ bindingActionClosure: @escaping ActionClosure) -> ActionIdentifier { + let bindingAction = Action(closure: bindingActionClosure) + bindingActions.append(bindingAction) + return bindingAction.id + } - func removeBindingAction(for id: ActionIdentifier) { - bindingActions.remove(id: id) - } + func removeBindingAction(for id: ActionIdentifier) { + bindingActions.remove(id: id) + } - func removeAllBindingActions() { - bindingActions.removeAll() - } + func removeAllBindingActions() { + bindingActions.removeAll() + } } #endif diff --git a/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift new file mode 100644 index 0000000000..1719443cb2 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSProgressIndicator.swift @@ -0,0 +1,32 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import SwiftNavigation + +extension NSProgressIndicator { + + public convenience init(frame: CGRect = .zero, isAnimated: UIBinding) { + self.init(frame: frame) + bind(isAnimated: isAnimated) + } + + @discardableResult + public func bind(isAnimated: UIBinding) -> ObserveToken { + let token = observe { [weak self] in + guard let self else { return } + let isAnimated = isAnimated.wrappedValue + if isAnimated { + startAnimation(nil) + } else { + stopAnimation(nil) + } + } + + let observeToken = ObserveToken { + token.cancel() + } + return observeToken + } + +} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift index c7bbedb5c6..a9ab01f311 100755 --- a/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift +++ b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift @@ -6,122 +6,137 @@ import AppKit /// A protocol used to extend `NSControl, NSMenuItem...`. @MainActor public protocol TargetActionProtocol: NSObject, Sendable { - var target: AnyObject? { set get } - var action: Selector? { set get } + var target: AnyObject? { set get } + var action: Selector? { set get } } extension TargetActionProtocol { - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: ReferenceWritableKeyPath - ) -> ObserveToken { - bind(binding, to: keyPath) { control, newValue, _ in - control[keyPath: keyPath] = newValue - } + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath + ) -> ObserveToken { + bind(binding, to: keyPath) { control, newValue, _ in + control[keyPath: keyPath] = newValue } + } - var actionProxy: TargetActionProxy? { - set { - objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - get { - objc_getAssociatedObject(self, actionProxyKey) as? TargetActionProxy - } + var actionProxy: TargetActionProxy? { + set { + objc_setAssociatedObject(self, actionProxyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + get { + objc_getAssociatedObject(self, actionProxyKey) as? TargetActionProxy + } + } - func createActionProxyIfNeeded() -> TargetActionProxy { - if let actionProxy { - return actionProxy - } else { - let actionProxy = TargetActionProxy(owner: self) - self.actionProxy = actionProxy - return actionProxy - } + func createActionProxyIfNeeded() -> TargetActionProxy { + if let actionProxy { + return actionProxy + } else { + let actionProxy = TargetActionProxy(owner: self) + self.actionProxy = actionProxy + return actionProxy } + } - /// Establishes a two-way connection between a source of truth and a property of this control. - /// - /// - Parameters: - /// - binding: A source of truth for the control's value. - /// - keyPath: A key path to the control's value. - /// - event: The control-specific events for which the binding is updated. - /// - set: A closure that is called when the binding's value changes with a weakly-captured - /// control, a new value that can be used to configure the control, and a transaction, which - /// can be used to determine how and if the change should be animated. - /// - Returns: A cancel token. - @discardableResult - public func bind( - _ binding: UIBinding, - to keyPath: KeyPath, - set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void - ) -> ObserveToken { - unbind(keyPath) - let actionProxy = createActionProxyIfNeeded() - let actionID = actionProxy.addBindingAction { [weak self] _ in - guard let self else { return } - binding.wrappedValue = self[keyPath: keyPath] - } + /// Establishes a two-way connection between a source of truth and a property of this control. + /// + /// - Parameters: + /// - binding: A source of truth for the control's value. + /// - keyPath: A key path to the control's value. + /// - event: The control-specific events for which the binding is updated. + /// - set: A closure that is called when the binding's value changes with a weakly-captured + /// control, a new value that can be used to configure the control, and a transaction, which + /// can be used to determine how and if the change should be animated. + /// - Returns: A cancel token. + @discardableResult + public func bind( + _ binding: UIBinding, + to keyPath: KeyPath, + set: @escaping (_ control: Self, _ newValue: Value, _ transaction: UITransaction) -> Void + ) -> ObserveToken { + unbind(keyPath) + let actionProxy = createActionProxyIfNeeded() + let actionID = actionProxy.addBindingAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + } - let isSetting = LockIsolated(false) - let token = observe { [weak self] transaction in - guard let self else { return } - isSetting.withValue { $0 = true } - defer { isSetting.withValue { $0 = false } } - set( - self, - binding.wrappedValue, - transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations - ? binding.transaction - : transaction - ) - } - // NB: This key path must only be accessed on the main actor - @UncheckedSendable var uncheckedKeyPath = keyPath - let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in - guard isSetting.withValue({ !$0 }) else { return } - MainActor._assumeIsolated { - binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] - } - } - let observationToken = ObserveToken { [weak self] in - MainActor._assumeIsolated { - self?.actionProxy?.removeAction(for: actionID) - } - token.cancel() - observation.invalidate() - } - observationTokens[keyPath] = observationToken - return observationToken + let isSetting = LockIsolated(false) + let token = observe { [weak self] transaction in + guard let self else { return } + isSetting.withValue { $0 = true } + defer { isSetting.withValue { $0 = false } } + set( + self, + binding.wrappedValue, + transaction.appKit.animation == nil && !transaction.appKit.disablesAnimations + ? binding.transaction + : transaction + ) + } + // NB: This key path must only be accessed on the main actor + @UncheckedSendable var uncheckedKeyPath = keyPath + let observation = observe(keyPath) { [$uncheckedKeyPath] control, _ in + guard isSetting.withValue({ !$0 }) else { return } + MainActor._assumeIsolated { + binding.wrappedValue = control[keyPath: $uncheckedKeyPath.wrappedValue] + } + } + let observationToken = ObserveToken { [weak self] in + MainActor._assumeIsolated { + self?.actionProxy?.removeAction(for: actionID) + } + token.cancel() + observation.invalidate() } + observationTokens[keyPath] = observationToken + return observationToken + } - public func unbind(_ keyPath: KeyPath) { - observationTokens[keyPath]?.cancel() - observationTokens[keyPath] = nil + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } + + @discardableResult + public func addAction(_ action: @escaping (Self) -> Void) -> UUID { + createActionProxyIfNeeded().addAction { [weak self] _ in + guard let self else { return } + action(self) } + } + + public func removeAction(for id: UUID) { + createActionProxyIfNeeded().removeAction(for: id) + } + public func removeAllActions() { + createActionProxyIfNeeded().removeAllActions() + } } @MainActor extension NSObject { - var observationTokens: [AnyKeyPath: ObserveToken] { - get { - objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObserveToken] - ?? [:] - } - set { - objc_setAssociatedObject( - self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } + var observationTokens: [AnyKeyPath: ObserveToken] { + get { + objc_getAssociatedObject(self, observationTokensKey) as? [AnyKeyPath: ObserveToken] + ?? [:] + } + set { + objc_setAssociatedObject( + self, observationTokensKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) } + } } @MainActor diff --git a/Sources/AppKitNavigation/Navigation/Sheet.swift b/Sources/AppKitNavigation/Navigation/Sheet.swift index 565c991704..93a36f1a3e 100644 --- a/Sources/AppKitNavigation/Navigation/Sheet.swift +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -157,6 +157,56 @@ extension NSWindow { } } +extension NSViewController { + @discardableResult + public func sheet( + isSheeted: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping () -> NSViewController + ) -> ObserveToken { + _sheet(isSheeted: isSheeted, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @_disfavoredOverload + @discardableResult + public func sheet( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } + + @discardableResult + public func sheet( + item: UIBinding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + content: @escaping (UIBinding) -> NSViewController + ) -> ObserveToken { + _sheet(item: item, id: id, onDismiss: onDismiss, content: content) + } +} + extension SheetContent { @discardableResult fileprivate func _sheet( @@ -209,8 +259,8 @@ extension SheetContent { content($item) } beginSheet: { [weak self] child, _ in guard let self else { return } - if let attachedSheetWindow = currentWindow?.attachedSheet { - self.endSheet(for: attachedSheetWindow) + if let attachedContent = attachedContent { + self.endSheet(for: attachedContent) onDismiss?() Task { @MainActor in await self.beginSheet(for: child) diff --git a/Sources/AppKitNavigation/Navigation/SheetContent.swift b/Sources/AppKitNavigation/Navigation/SheetContent.swift index b8ce7a2245..b8549782a5 100644 --- a/Sources/AppKitNavigation/Navigation/SheetContent.swift +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -5,11 +5,14 @@ import AppKit @MainActor protocol SheetContent: NavigationContent { var currentWindow: NSWindow? { get } + var attachedContent: SheetContent? { get } func beginSheet(for content: SheetContent) async func endSheet(for content: SheetContent) } extension SheetContent { + var attachedContent: (any SheetContent)? { currentWindow?.attachedSheet } + func beginSheet(for content: any SheetContent) async { guard let sheetedWindow = content.currentWindow else { return } await currentWindow?.beginSheet(sheetedWindow) @@ -49,4 +52,20 @@ extension NSSavePanel { } } +extension NSViewController: SheetContent { + var currentWindow: NSWindow? { view.window } + + var attachedContent: (any SheetContent)? { currentWindow?.attachedSheet?.contentViewController } + + func beginSheet(for content: any SheetContent) async { + guard let viewController = content as? NSViewController else { return } + presentAsSheet(viewController) + } + + func endSheet(for content: any SheetContent) { + guard let viewController = content as? NSViewController else { return } + dismiss(viewController) + } +} + #endif diff --git a/Sources/AppKitNavigationShim/shim.m b/Sources/AppKitNavigationShim/shim.m index e36845fde7..5f422637ab 100644 --- a/Sources/AppKitNavigationShim/shim.m +++ b/Sources/AppKitNavigationShim/shim.m @@ -28,7 +28,7 @@ + (void)load { class_getInstanceMethod(NSViewController.class, @selector(dismissViewController:)), class_getInstanceMethod(NSViewController.class, @selector(AppKitNavigation_dismissViewController:)) ); - method_exchangeImplementations( + method_exchangeImplementations( class_getInstanceMethod(NSSavePanel.class, NSSelectorFromString(@"setFinalURL:")), class_getInstanceMethod(NSSavePanel.class, @selector(AppKitNavigation_setFinalURL:)) ); diff --git a/Sources/UIKitNavigation/UIKitAnimation.swift b/Sources/UIKitNavigation/UIKitAnimation.swift index f0fef59eae..425cae34ef 100644 --- a/Sources/UIKitNavigation/UIKitAnimation.swift +++ b/Sources/UIKitNavigation/UIKitAnimation.swift @@ -43,7 +43,7 @@ if #available(iOS 18, macOS 15, tvOS 18, visionOS 2, watchOS 11, *) { var result: Swift.Result? UIView.animate( - with: animation, + animation, changes: { result = Swift.Result(catching: body) }, completion: completion.map { completion in { completion(true) } } ) diff --git a/SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme b/SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme new file mode 100644 index 0000000000..74e470a162 --- /dev/null +++ b/SwiftNavigation.xcworkspace/xcshareddata/xcschemes/AppKitNavigation.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + +