diff --git a/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift new file mode 100644 index 0000000000..c7a71253f3 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+BasicsNavigationViewController.swift @@ -0,0 +1,96 @@ +#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) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self?.model.sheet = nil + } + } + + 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 } + + 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 +// 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 + 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) + ) + } + } + + @Observable + class Model { + var alert: String? + var isSheetPresented = false + var sheet: Int? + var url: URL? + } +} + +#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..8abcdec54c --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+ConciseEnumNavigationViewController.swift @@ -0,0 +1,108 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +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/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 new file mode 100644 index 0000000000..f9b51ffca1 --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+MinimalObservationViewController.swift @@ -0,0 +1,74 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import AppKitNavigation +import ConcurrencyExtras + +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 \ + 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 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/AppKit+NSControlBindingsViewController.swift b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift new file mode 100644 index 0000000000..75fe38967b --- /dev/null +++ b/Examples/CaseStudies/AppKit/AppKit+NSControlBindingsViewController.swift @@ -0,0 +1,133 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import AppKitNavigation + +class XiblessViewController: NSViewController { + lazy var contentView = View() + + 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 { + 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/AppKit/AppKitCaseStudies.swift b/Examples/CaseStudies/AppKit/AppKitCaseStudies.swift new file mode 100644 index 0000000000..5fb7bee5c3 --- /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 2f2a60cffe..c55a82c934 100644 --- a/Examples/CaseStudies/Internal/CaseStudy.swift +++ b/Examples/CaseStudies/Internal/CaseStudy.swift @@ -1,220 +1,285 @@ import SwiftUI -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) +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 } +} +#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 } -} -extension UIKitCaseStudy { - var usesOwnLayout: Bool { true } + 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) - } - @ViewBuilder - static func buildExpression(_ caseStudy: some UIKitCaseStudy) -> some View { - UIKitCaseStudyButton(caseStudy: caseStudy) - } - static func buildPartialBlock(first: some View) -> some View { - first - } - @ViewBuilder - static func buildPartialBlock(accumulated: some View, next: some View) -> some View { - accumulated - next - } + @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 + } } 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) + #if os(macOS) + Button("Close") { + isAboutPresented = false + } + #endif + } + .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! """ } +#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..cb466a2879 100644 --- a/Examples/CaseStudies/RootView.swift +++ b/Examples/CaseStudies/RootView.swift @@ -2,23 +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") } - NavigationLink("UIKit") { - UIKitCaseStudiesView() - } - } - .navigationTitle("Case studies") } - } } struct RootView_Previews: PreviewProvider { - static var previews: some View { - RootView() - } + static var previews: some View { + RootView() + } } 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 531f38a4b4..aa3b2ed62e 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 @@ -171,3 +172,4 @@ private class FeatureViewController: UIViewController { #Preview { StaticNavigationStackController(model: StaticNavigationStackController.Model()) } +#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 e6e86b697a..bbf55f7762 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 deleted file mode 100644 index fee0ef6472..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/ConnectToNetworkFeature.swift +++ /dev/null @@ -1,111 +0,0 @@ -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") - ) - ) - ) - } -} diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift deleted file mode 100644 index 3255e01d04..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/NetworkDetailFeature.swift +++ /dev/null @@ -1,99 +0,0 @@ -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() - ) - ) - ) - } -} diff --git a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift b/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift deleted file mode 100644 index 3fb9c068c7..0000000000 --- a/Examples/CaseStudies/UIKit/WiFiFeature/WiFiSettingsFeature.swift +++ /dev/null @@ -1,311 +0,0 @@ -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..9f72cd5b61 --- /dev/null +++ b/Examples/CaseStudies/WiFiFeature/WiFiSettingsFeature/AppKit+WiFiSettingsViewController.swift @@ -0,0 +1,355 @@ +#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 + ) { [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 + 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 { [weak self] _ in + guard let self else { return } + 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 { [weak self] _ in + guard let self else { return } + 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.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) + 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/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 e533c10c0b..4fde934316 100644 --- a/Examples/CaseStudiesTests/NavigationStackTests.swift +++ b/Examples/CaseStudiesTests/NavigationStackTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import UIKitNavigationShim import XCTest @@ -317,3 +318,4 @@ private final class ChildViewController: UIViewController { } } } +#endif diff --git a/Examples/CaseStudiesTests/PresentationTests.swift b/Examples/CaseStudiesTests/PresentationTests.swift index 33b28506a5..2861c95fba 100644 --- a/Examples/CaseStudiesTests/PresentationTests.swift +++ b/Examples/CaseStudiesTests/PresentationTests.swift @@ -1,3 +1,4 @@ +#if canImport(UIKit) && !os(watchOS) import UIKitNavigation import XCTest @@ -528,3 +529,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 47561d7f05..ac06aec67b 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 */; }; CA456DA82CB810BA00F04E02 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = CA456DA72CB810BA00F04E02 /* IdentifiedCollections */; }; CA4737CF272F09600012CAC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA4737CE272F09600012CAC3 /* Assets.xcassets */; }; CA4737F4272F09780012CAC3 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA4737F3272F09780012CAC3 /* SwiftUINavigation */; }; @@ -25,7 +25,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 */; }; @@ -58,6 +58,21 @@ 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; 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 */; }; + 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 */ @@ -108,10 +123,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 = ""; }; @@ -119,6 +134,20 @@ 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 /* 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 = ""; }; + 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 */ @@ -140,6 +169,7 @@ CA48F2FA2C49645100BE2C3C /* SwiftUINavigation in Frameworks */, CA48F3052C49650F00BE2C3C /* Tagged in Frameworks */, CA48F2FC2C49645100BE2C3C /* UIKitNavigation in Frameworks */, + E9836D942C6CA07300FC6EFD /* AppKitNavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -217,6 +247,8 @@ CA9D70492C1F642A003B672A /* Internal */, CADCA3642C1CE80B00DE645F /* SwiftUI */, CA9D704A2C1F6431003B672A /* UIKit */, + E9836D902C6C9E3000FC6EFD /* AppKit */, + DC5EB9BC2C0525980034B757 /* WiFiFeature */, ); path = CaseStudies; sourceTree = ""; @@ -258,7 +290,6 @@ CA9D704F2C1FB431003B672A /* StaticNavigationStackController.swift */, CA9D70592C208977003B672A /* UIControlBindingsViewController.swift */, CA9D70442C1F6001003B672A /* UIKitCaseStudies.swift */, - DC5EB9BC2C0525980034B757 /* WiFiFeature */, ); path = UIKit; sourceTree = ""; @@ -288,10 +319,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 = ""; @@ -306,6 +337,51 @@ path = Internal; sourceTree = ""; }; + E9836D902C6C9E3000FC6EFD /* AppKit */ = { + isa = PBXGroup; + children = ( + E9CD5B7D2C6D14DC00CE7947 /* AppKitCaseStudies.swift */, + E9836D912C6CA03E00FC6EFD /* AppKit+MinimalObservationViewController.swift */, + E9836D952C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.swift */, + E9CD5B752C6CD7A200CE7947 /* AppKit+EnumControlsViewController.swift */, + E9CD5B772C6CDEA500CE7947 /* AppKit+FocusViewController.swift */, + E9CD5B792C6CE1BE00CE7947 /* AppKit+BasicsNavigationViewController.swift */, + E9CD5B7A2C6CE1BE00CE7947 /* AppKit+ConciseEnumNavigationViewController.swift */, + ); + 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 PBXHeadersBuildPhase section */ @@ -359,6 +435,7 @@ CA48F3012C49650100BE2C3C /* IdentifiedCollections */, CA48F3042C49650F00BE2C3C /* Tagged */, CA48F3072C49651700BE2C3C /* ConcurrencyExtras */, + E9836D932C6CA07300FC6EFD /* AppKitNavigation */, ); productName = CaseStudies; productReference = CA473804272F0D330012CAC3 /* CaseStudies.app */; @@ -511,30 +588,44 @@ buildActionMask = 2147483647; files = ( 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 */, 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 */, - CA3D0E332C20B27B00CCF7CD /* NetworkDetailFeature.swift in Sources */, + CA3D0E322C20B27B00CCF7CD /* UIKit+ConnectToNetworkViewController.swift in Sources */, + E9836D962C6CADBB00FC6EFD /* AppKit+NSControlBindingsViewController.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 */, + 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; @@ -770,6 +861,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; @@ -800,6 +893,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"; @@ -812,6 +907,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; @@ -821,6 +917,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; @@ -835,6 +933,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; @@ -844,6 +943,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"; @@ -1059,13 +1160,13 @@ package = CA48F3062C49651700BE2C3C /* XCRemoteSwiftPackageReference "swift-concurrency-extras" */; productName = ConcurrencyExtras; }; - CAAFA9D22C8B422800BB2BA1 /* UIKitNavigation */ = { + E9836D932C6CA07300FC6EFD /* AppKitNavigation */ = { isa = XCSwiftPackageProductDependency; - productName = UIKitNavigation; + productName = AppKitNavigation; }; - CAAFA9D62C8B468000BB2BA1 /* AppKitNavigation */ = { + CAAFA9D22C8B422800BB2BA1 /* UIKitNavigation */ = { isa = XCSwiftPackageProductDependency; - productName = AppKitNavigation; + productName = UIKitNavigation; }; CAAFA9D82C8B468000BB2BA1 /* SwiftNavigation */ = { isa = XCSwiftPackageProductDependency; diff --git a/Package.swift b/Package.swift index 2194c6fcfc..d8e88c6c6d 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..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( @@ -83,8 +84,13 @@ let package = Package( name: "AppKitNavigation", dependencies: [ "SwiftNavigation", + "AppKitNavigationShim", + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ] ), + .target( + name: "AppKitNavigationShim" + ), .testTarget( name: "UIKitNavigationTests", dependencies: [ diff --git a/Sources/AppKitNavigation/Bindings/NSAlert.swift b/Sources/AppKitNavigation/Bindings/NSAlert.swift new file mode 100644 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..79adfd1851 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSColorPanel.swift @@ -0,0 +1,42 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSColorPanel: @retroactive Sendable {} +extension NSColorPanel: TargetActionProtocol { + public var target: AnyObject? { + set { setTarget(newValue) } + get { value(forKeyPath: "target") as? AnyObject } + } + + public var action: 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..33ae9d4348 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSControl.swift @@ -0,0 +1,34 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +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() + } +} + +#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..01bc6b00ca --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSFontManager.swift @@ -0,0 +1,256 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import ConcurrencyExtras +@_spi(Internals) import SwiftNavigation +import AppKit +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) + } + } + } +} + +@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)) } + } +} + + +@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 new file mode 100755 index 0000000000..663c1302e9 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSMenuItem.swift @@ -0,0 +1,7 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSMenuItem: TargetActionProtocol, @unchecked @retroactive Sendable {} + +#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/NSTextField.swift b/Sources/AppKitNavigation/Bindings/NSTextField.swift new file mode 100755 index 0000000000..8b9182aa08 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSTextField.swift @@ -0,0 +1,343 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +import Combine +import SwiftNavigation + +@MainActor +extension NSTextField: @retroactive NSTextDelegate {} +extension NSTextField: @retroactive 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 = ObserveToken { [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 = ObserveToken { + 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 @retroactive Sendable {} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift new file mode 100644 index 0000000000..e90fec934d --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/NSToolbarItem.swift @@ -0,0 +1,8 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +extension NSToolbarItem: @retroactive Sendable {} +extension NSToolbarItem: TargetActionProtocol {} + +#endif diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift new file mode 100755 index 0000000000..c7bbedb5c6 --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/TargetActionProtocol.swift @@ -0,0 +1,132 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import ConcurrencyExtras +@_spi(Internals) import SwiftNavigation +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 } +} + +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 + } + } + + 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 + } + } + + /// 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 + } + + public func unbind(_ keyPath: KeyPath) { + observationTokens[keyPath]?.cancel() + observationTokens[keyPath] = nil + } + +} + +@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 + ) + } + } +} + +@MainActor +private let observationTokensKey = malloc(1)! +@MainActor +private let actionProxyKey = malloc(1)! + +#endif diff --git a/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift new file mode 100755 index 0000000000..961235ff1e --- /dev/null +++ b/Sources/AppKitNavigation/Bindings/TargetActionProxy.swift @@ -0,0 +1,88 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit +import IdentifiedCollections + +@MainActor +class TargetActionProxy: 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: 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) + } + } + + @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/Documentation.docc/Extensions/UIColorWell.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIColorWell.md new file mode 100644 index 0000000000..33ca0c8aa8 --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIColorWell.md @@ -0,0 +1,8 @@ +# ``UIKit/UIColorWell`` + +## Topics + +### Binding to observable state + +- ``UIKit/UIColorWell/init(frame:selectedColor:)`` +- ``UIKit/UIColorWell/bind(selectedColor:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UIControlProtocol.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIControlProtocol.md new file mode 100644 index 0000000000..669a617e88 --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIControlProtocol.md @@ -0,0 +1,8 @@ +# ``UIKitNavigation/UIControlProtocol`` + +## Topics + +### Binding to observable state + +- ``bind(_:to:for:)`` +- ``bind(_:to:for:set:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UIDatePicker.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIDatePicker.md new file mode 100644 index 0000000000..dceb86bc9b --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIDatePicker.md @@ -0,0 +1,8 @@ +# ``UIKit/UIDatePicker`` + +## Topics + +### Binding to observable state + +- ``UIKit/UIDatePicker/init(frame:date:)`` +- ``UIKit/UIDatePicker/bind(date:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UIKitAnimation.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIKitAnimation.md new file mode 100644 index 0000000000..fee55a5dbf --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIKitAnimation.md @@ -0,0 +1,35 @@ +# ``UIKitNavigation/UIKitAnimation`` + +## Topics + +### Getting the default animation + +- ``default`` + +### Getting linear animations + +- ``linear`` +- ``linear(duration:)`` + +### Getting eased animations + +- ``easeIn`` +- ``easeIn(duration:)`` +- ``easeOut`` +- ``easeOut(duration:)`` +- ``easeInOut`` +- ``easeInOut(duration:)`` + +### Creating custom animations + +- ``animate(withDuration:delay:options:)`` +- ``animate(withDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:)`` +- ``animate(springDuration:bounce:initialSpringVelocity:delay:options:)`` +- ``init(_:)`` + +### Configuring an animation + +- ``delay(_:)`` +- ``repeatCount(_:autoreverses:)`` +- ``repeatForever(autoreverses:)`` +- ``speed(_:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UIPageControl.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIPageControl.md new file mode 100644 index 0000000000..94fd9d8cf6 --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIPageControl.md @@ -0,0 +1,8 @@ +# ``UIKit/UIPageControl`` + +## Topics + +### Binding to observable state + +- ``UIKit/UIPageControl/init(frame:currentPage:)`` +- ``UIKit/UIPageControl/bind(currentPage:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UISlider.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UISlider.md new file mode 100644 index 0000000000..833cfa9d96 --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UISlider.md @@ -0,0 +1,8 @@ +# ``UIKit/UISlider`` + +## Topics + +### Binding to observable state + +- ``UIKit/UISlider/init(frame:value:)`` +- ``UIKit/UISlider/bind(value:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UIStepper.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIStepper.md new file mode 100644 index 0000000000..b716f43afc --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIStepper.md @@ -0,0 +1,8 @@ +# ``UIKit/UIStepper`` + +## Topics + +### Binding to observable state + +- ``UIKit/UIStepper/init(frame:value:)`` +- ``UIKit/UIStepper/bind(value:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UISwitch.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UISwitch.md new file mode 100644 index 0000000000..630ef43f03 --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UISwitch.md @@ -0,0 +1,8 @@ +# ``UIKit/UISwitch`` + +## Topics + +### Binding to observable state + +- ``UIKit/UISwitch/init(frame:isOn:)`` +- ``UIKit/UISwitch/bind(isOn:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UITextField.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UITextField.md new file mode 100644 index 0000000000..64484959fa --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UITextField.md @@ -0,0 +1,22 @@ +# ``UIKit/UITextField`` + +## Topics + +### Binding to observable text + +- ``UIKit/UITextField/init(frame:text:)`` +- ``UIKit/UITextField/bind(text:)`` + +### Binding to attributed text + +- ``UIKit/UITextField/init(frame:attributedText:)`` +- ``UIKit/UITextField/bind(attributedText:)`` + +### Binding to focus state + +- ``UIKit/UITextField/bind(focus:)`` +- ``UIKit/UITextField/bind(focus:equals:)`` + +### Binding to text selection + +- ``UIKit/UITextField/bind(selection:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/UIViewController.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIViewController.md new file mode 100644 index 0000000000..23737ce72d --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/UIViewController.md @@ -0,0 +1,26 @@ +# ``UIKit/UIViewController`` + +## Topics + +### Tree-based navigation + +- ``UIKit/UIViewController/present(isPresented:onDismiss:content:)`` +- ``UIKit/UIViewController/present(item:onDismiss:content:)-34iup`` +- ``UIKit/UIViewController/present(item:onDismiss:content:)-86u3v`` +- ``UIKit/UIViewController/present(item:id:onDismiss:content:)-8grlt`` +- ``UIKit/UIViewController/present(item:id:onDismiss:content:)-2fmzj`` +- ``UIKit/UIViewController/navigationDestination(isPresented:content:)`` +- ``UIKit/UIViewController/navigationDestination(item:content:)-367r6`` +- ``UIKit/UIViewController/navigationDestination(item:content:)-4pmzx`` + +### Custom tree-based navigation + +- ``UIKit/UIViewController/destination(isPresented:content:present:dismiss:)`` +- ``UIKit/UIViewController/destination(item:content:present:dismiss:)`` +- ``UIKit/UIViewController/destination(item:id:content:present:dismiss:)`` + +### Stack-based navigation + +- ``NavigationStackController`` +- ``UIKit/UIViewController/navigationDestination(for:destination:)`` +- ``UIKit/UIViewController/push(value:)`` diff --git a/Sources/AppKitNavigation/Documentation.docc/Extensions/observe.md b/Sources/AppKitNavigation/Documentation.docc/Extensions/observe.md new file mode 100644 index 0000000000..c5b82f2319 --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/Extensions/observe.md @@ -0,0 +1,7 @@ +# ``UIKitNavigation/ObjectiveC/NSObject/observe(_:)-1trvu`` + +## Topics + +### Observing with a transaction + +- ``UIKitNavigation/ObjectiveC/NSObject/observe(_:)-1r7fl`` diff --git a/Sources/AppKitNavigation/Documentation.docc/UIKitNavigation.md b/Sources/AppKitNavigation/Documentation.docc/UIKitNavigation.md new file mode 100644 index 0000000000..b54525f892 --- /dev/null +++ b/Sources/AppKitNavigation/Documentation.docc/UIKitNavigation.md @@ -0,0 +1,165 @@ +# ``UIKitNavigation`` + +Tools for making SwiftUI navigation simpler, more ergonomic and more precise. + +## Additional Resources + +- [GitHub Repo](https://github.com/pointfreeco/swift-navigation) +- [Discussions](https://github.com/pointfreeco/swift-navigation/discussions) +- [Point-Free Videos](https://www.pointfree.co/collections/ukit) + +## Overview + +UIKit provides a few simple tools for navigation, but none of them are state-driven. Its navigation +tools are what is known as "fire-and-forget", which means you simply invoke a method to trigger +a navigation, but there is no representation of that event in your feature's state. + + +For example, to present a sheet from a button press one can simply do: + +```swift +let button = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + present(SettingsViewController(), animated: true) +}) +``` + +This makes it easy to get started with navigation, but there are a few problems with this: + +* It is difficult to determine from your feature's logic what child features are currently +presented. You can check the `presentedViewController` property on `UIViewController` directly, +but then that logic must exist in the view (and so hard to test), and you have to do extra work +to inspect the type-erased controller to truly see what is being presented. +* It is difficult to perform deep-linking to any feature of your app. You must duplicate the +logic that invokes `present` or `pushViewController` in another part of your app in order to +deep-link into child features. + +SwiftUI has taught us, it is incredibly powerful to be able to drive navigation from state. It +allows you to encapsulate more of your feature's logic in an isolated and testable domain, and it +unlocks deep linking for free since one just needs to construct a piece of state that represents +where you want to navigate to, hand it to SwiftUI, and let SwiftUI do the rest. + +The UIKitNavigation library brings a powerful suite of navigation tools to UIKit that are heavily +inspired by SwiftUI. For example, if you have a feature that can navigate to 3 different screens, +you can model that as an enum with 3 cases and some optional state: + +```swift +@Observable +class FeatureModel { + var destination: Destination? + + enum Destination { + case addItem(AddItemModel) + case deleteItemAlert + case editItem(EditItemModel) + } +} +``` + +This allows us to prove that at most one of these destinations can be active at a time. And it +would be great if we could present and dismiss these child features based on the value of +`destination`. + +This is possible, but first we have to make one small change to the `Destination` enum by annotating +it with the `@CasePathable` macro: + +```diff ++@CasePathable + enum Destination { + // ... + } +``` + +This allows us to derive key paths and properties for each case of an enum, which is not currently +possible in native Swift. + +With that done one can drive navigation in a _view controller_ using tools in the library: + +```swift +class FeatureViewController: UIViewController { + @UIBindable var model: FeatureModel + + func viewDidLoad() { + super.viewDidLoad() + + // Set up view hierarchy + + present(item: $model.destination.addItem) { addItemModel in + AddItemViewController(model: addItemModel) + } + present(isPresented: Binding($model.destination.deleteItemAlert)) { + let alert = UIAlertController(title: "Delete?", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Yes", style: .destructive)) + alert.addAction(UIAlertAction(title: "No", style: .cancel)) + return alert + } + navigationDestination(item: $model.destination.editItem) { editItemModel in + EditItemViewController(model: editItemModel) + } + } +} +``` + +By using the libraries navigation tools we can be guaranteed that the model will be kept in sync +with the view. When the state becomes non-`nil` the corresponding form of navigation will be +triggered, and when the presented view is dismissed, the state will be `nil`'d out. + +Another powerful aspect of SwiftUI is its ability to update its UI whenever state in an observable +model changes. And thanks to Swift's observation tools this can be done done implicitly and +minimally: whichever fields are accessed in the `body` of the view are automatically tracked +so that when they change the view updates. + +Our UIKitNavigation library comes with a tool that brings this power to UIKit, and it's called +``observe(isolation:_:)-93yzu``: + +```swift +observe { [weak self] in + guard let self else { return } + + countLabel.text = "Count: \(model.count)" + factLabel.isHidden = model.fact == nil + if let fact = model.fact { + factLabel.text = fact + } + activityIndicator.isHidden = !model.isLoadingFact +} +``` + +Whichever fields are accessed inside `observe` (such as `count`, `fact` and `isLoadingFact` above) +are automatically tracked, so that whenever they are mutated the trailing closure of `observe` +will be invoked again, allowing us to update the UI with the freshest data. + +All of these tools are built on top of Swift's powerful Observation framework. However, that +framework only works on newer versions of Apple's platforms: iOS 17+, macOS 14+, tvOS 17+ and +watchOS 10+. However, thanks to our back-port of Swift's observation tools (see +[Perception](http://github.com/pointfreeco/swift-perception)), you can make use of our tools +right away, going all the way back to the iOS 13 era of platforms. + + +## Topics + +### Observing changes to state + +- ``ObjectiveC/NSObject/observe(_:)-1trvu`` + +### Animations + +- ``withUIKitAnimation(_:_:completion:)`` +- ``UIKitAnimation`` + +### Controls + +- ``UIControlProtocol`` +- ``UIKit/UIColorWell`` +- ``UIKit/UIDatePicker`` +- ``UIKit/UIPageControl`` +- ``UIKit/UISegmentedControl`` +- ``UIKit/UISlider`` +- ``UIKit/UIStepper`` +- ``UIKit/UISwitch`` +- ``UIKit/UITextField`` + +### Navigation + +- ``UIKit/UIViewController`` +- ``UIKit/UIAlertController`` +- ``UIKit/UITraitCollection`` diff --git a/Sources/AppKitNavigation/Internal/AssociatedKeys.swift b/Sources/AppKitNavigation/Internal/AssociatedKeys.swift new file mode 100644 index 0000000000..f1398d8c8c --- /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/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/Navigation/Modal.swift b/Sources/AppKitNavigation/Navigation/Modal.swift new file mode 100644 index 0000000000..6ac702ac14 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Modal.swift @@ -0,0 +1,416 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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( + 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?() + } + } + + // 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?() + } + } + } + + 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..3098e6a589 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/ModalContent.swift @@ -0,0 +1,46 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +protocol ModalContent: NavigationContent { + @discardableResult func appKitNavigationRunModal() -> NSApplication.ModalResponse + var window: NSWindow { get } +} + +@MainActor +protocol ModalSessionContent: ModalContent { + func appKitNavigationBeginModalSession() -> NSApplication.ModalSession +} + +extension NSWindow: ModalContent { + var window: NSWindow { self } + + func appKitNavigationRunModal() -> NSApplication.ModalResponse { + __appKitNavigationRunModal() + } + + @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() + } +} + +extension NSAlert: ModalContent { + func appKitNavigationRunModal() -> NSApplication.ModalResponse { + runModal() + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationContent.swift b/Sources/AppKitNavigation/Navigation/NavigationContent.swift new file mode 100644 index 0000000000..8c10f8c664 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/NavigationContent.swift @@ -0,0 +1,37 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import Foundation + +@MainActor +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 { + 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 + } + } +} + +#endif diff --git a/Sources/AppKitNavigation/Navigation/NavigationObserver.swift b/Sources/AppKitNavigation/Navigation/NavigationObserver.swift new file mode 100644 index 0000000000..ce952a080a --- /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..f90ec118a3 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Presentation.swift @@ -0,0 +1,174 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import SwiftNavigation +import AppKit +import AppKitNavigationShim + +class PresentationObserver: NavigationObserver { + 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( + 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) + } + + @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?() + } + } + + @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, + 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 + } + } +} + +extension NavigationContent where Self: NSViewController { + var _onEndNavigation: (() -> Void)? { + set { + _AppKitNavigation_onDismiss = newValue + } + get { + _AppKitNavigation_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..907536422c --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/PresentationContent.swift @@ -0,0 +1,41 @@ +#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) + } + + 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..565c991704 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/Sheet.swift @@ -0,0 +1,274 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +private typealias SheetObserver = NavigationObserver + +@MainActor +private var sheetObserverKeys = AssociatedKeys() + +extension NSWindow { + @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) + } + + @_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 + 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) + } + + @_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 (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 Navigated where Content: SheetContent { + 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 new file mode 100644 index 0000000000..b8ce7a2245 --- /dev/null +++ b/Sources/AppKitNavigation/Navigation/SheetContent.swift @@ -0,0 +1,52 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + +import AppKit + +@MainActor +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 { + 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 } +} + +extension NSAlert: SheetContent { + var currentWindow: NSWindow? { 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) + } +} + +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) + } +} + +#endif diff --git a/Sources/AppKitNavigationShim/include/shim.h b/Sources/AppKitNavigationShim/include/shim.h new file mode 100644 index 0000000000..9dc496b6da --- /dev/null +++ b/Sources/AppKitNavigationShim/include/shim.h @@ -0,0 +1,24 @@ +#if __has_include() +#include + +#if __has_include() && !TARGET_OS_MACCATALYST +@import AppKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface NSViewController (AppKitNavigation) + +@property BOOL _AppKitNavigation_hasViewAppeared; +@property (nullable) void (^ _AppKitNavigation_onDismiss)(); +@property NSArray *_AppKitNavigation_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..e36845fde7 --- /dev/null +++ b/Sources/AppKitNavigationShim/shim.m @@ -0,0 +1,153 @@ +#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 + +static void *hasViewAppearedKey = &hasViewAppearedKey; +static void *onDismissKey = &onDismissKey; +static void *onViewAppearKey = &onViewAppearKey; + +@implementation NSViewController (AppKitNavigation) + +- (void)AppKitNavigation_viewDidAppear { + [self AppKitNavigation_viewDidAppear]; + + if (self._AppKitNavigation_hasViewAppeared) { + return; + } + + self._AppKitNavigation_hasViewAppeared = YES; + + for (void (^work)() in self._AppKitNavigation_onViewAppear) { + work(); + } + + self._AppKitNavigation_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._AppKitNavigation_onDismiss != NULL) { + self._AppKitNavigation_onDismiss(); + self._AppKitNavigation_onDismiss = nil; + [self setBeingDismissed:NO]; + } +} + +- (void)AppKitNavigation_dismissViewController:(NSViewController *)sender { + [self AppKitNavigation_dismissViewController:sender]; + [self setBeingDismissed:YES]; +} + +- (BOOL)_AppKitNavigation_hasViewAppeared { + return [objc_getAssociatedObject(self, hasViewAppearedKey) boolValue]; +} + +- (void)set_AppKitNavigation_hasViewAppeared:(BOOL)_AppKitNavigation_hasViewAppeared { + objc_setAssociatedObject( + self, hasViewAppearedKey, @(_AppKitNavigation_hasViewAppeared), OBJC_ASSOCIATION_COPY_NONATOMIC + ); +} + +- (void (^)())_AppKitNavigation_onDismiss { + return objc_getAssociatedObject(self, onDismissKey); +} + +- (void)set_AppKitNavigation_onDismiss:(void (^)())_AppKitNavigation_onDismiss { + objc_setAssociatedObject(self, onDismissKey, [_AppKitNavigation_onDismiss copy], OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (NSMutableArray *)_AppKitNavigation_onViewAppear { + id onViewAppear = objc_getAssociatedObject(self, onViewAppearKey); + + return onViewAppear == nil ? @[] : onViewAppear; +} + +- (void)set_AppKitNavigation_onViewAppear:(NSMutableArray *)onViewAppear { + objc_setAssociatedObject(self, onViewAppearKey, onViewAppear, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +@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..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" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", - "version" : "1.5.4" + "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", + "version" : "1.6.1" } }, { @@ -23,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "3581e280bf0d90c3fb9236fb23e75a5d8c46b533", - "version" : "1.0.4" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -32,17 +33,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { "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" : "d7472be6b3c89251ce4c0db07d32405b43426781", - "version" : "1.3.7" + "revision" : "52b5e1a09dc016e64ce253e19ab3124b7fae9ac9", + "version" : "1.7.0" } }, { @@ -68,14 +69,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" @@ -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" : "1552c8f722ac256cc0b8daaf1a7073217d4fcdfb", - "version" : "1.3.4" + "revision" : "21811d6230a625fa0f2e6ffa85be857075cc02c4", + "version" : "1.5.0" } }, { @@ -104,8 +105,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,10 +123,10 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", - "version" : "1.2.2" + "revision" : "b444594f79844b0d6d76d70fbfb3f7f71728f938", + "version" : "1.5.1" } } ], - "version" : 2 + "version" : 3 }