From 75cb92d5dbd8dd4a5c3e093b61dcb71ff54da13f Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Thu, 28 May 2026 14:10:45 +0200 Subject: [PATCH 1/2] Add documentation, increase swift version, modernize --- MIGRATING.md | 102 +++++++ Package.swift | 4 +- README.md | 268 ++++++++++-------- Sources/XUI/Binding/Binding+Change.swift | 18 ++ Sources/XUI/Binding/Binding+Element.swift | 22 +- Sources/XUI/Binding/Binding+Force.swift | 9 +- Sources/XUI/Combine/CancellableBuilder.swift | 29 +- Sources/XUI/Combine/Result+Combine.swift | 36 ++- .../XUI/DeepLink/DeepLinkable+Receiver.swift | 17 +- Sources/XUI/DeepLink/DeepLinkable.swift | 30 +- .../XUI/DeepLink/DeepLinkableBuilder.swift | 5 +- Sources/XUI/Store/AnyObservableObject.swift | 30 ++ Sources/XUI/Store/Store.swift | 43 ++- Sources/XUI/Store/ViewModel.swift | 15 + .../ViewModifiers/NavigationModifier.swift | 10 + .../XUI/ViewModifiers/PopoverModifier.swift | 14 +- Sources/XUI/ViewModifiers/SheetModifier.swift | 14 +- Sources/XUI/Views/NavigationLink.swift | 7 + Sources/XUI/Views/View+Navigation.swift | 17 ++ .../Views/View+NavigationDestination.swift | 38 +++ Sources/XUI/Views/View+Popover.swift | 17 +- Sources/XUI/Views/View+Sheet.swift | 17 +- Tests/LinuxMain.swift | 7 - Tests/XUITests/XCTestManifests.swift | 9 - Tests/XUITests/XUITests.swift | 4 - 25 files changed, 590 insertions(+), 192 deletions(-) create mode 100644 MIGRATING.md create mode 100644 Sources/XUI/Views/View+NavigationDestination.swift delete mode 100644 Tests/LinuxMain.swift delete mode 100644 Tests/XUITests/XCTestManifests.swift diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..4f45ced --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,102 @@ +# Migrating to XUI 2.0 + +XUI 2.0 raises the platform floor, fixes several API quirks that surfaced in long-term use, and modernises the codebase for Swift 5.9+. This guide enumerates every breaking change. + +## Platform floor + +| | 1.x | 2.0 | +|---|---|---| +| iOS | 13.0 | **15.0** | +| macOS | 10.15 | **12.0** | +| watchOS | 6.0 | **8.0** | +| tvOS | 13.0 | **15.0** | +| Swift tools | 5.3 | **5.9** | + +If you cannot raise your deployment target, stay on XUI 1.x. + +## `firstReceiver` returns `Receiver?` (was `Receiver!`) + +The implicitly-unwrapped optional return type silently produced crashes at unexpected call sites. The signature is now an honest `Receiver?`. + +**Before:** +```swift +let detail: DetailViewModel = root.firstReceiver(as: DetailViewModel.self) +detail.open() +``` + +**After:** +```swift +guard let detail = root.firstReceiver(as: DetailViewModel.self) else { return } +detail.open() +``` + +## `popover(model:)` / `sheet(model:)` argument label + +The closure argument label is now `destination:` (was `content:`), matching `navigation(model:)`. The same rename applies to `PopoverModifier` and `SheetModifier`. + +**Before:** +```swift +.sheet(model: $viewModel.modal, content: { vm in ModalView(viewModel: vm) }) +.popover(model: $viewModel.popover, content: { vm in PopoverView(viewModel: vm) }) +``` + +**After:** +```swift +.sheet(model: $viewModel.modal) { vm in ModalView(viewModel: vm) } +.popover(model: $viewModel.popover) { vm in PopoverView(viewModel: vm) } +``` + +## `sheet(model:)` / `popover(model:)` — value-type model footgun documented + +Both modifiers identify the presented item by object identity. Passing a `struct`-typed `Model` silently re-boxes on every render (via `object as AnyObject`), producing a fresh `ObjectIdentifier` each time and causing the sheet/popover to re-present continuously. This was undocumented in 1.x; in 2.0 it's called out in the doc comments. + +The constraint was **not** raised to `Model: AnyObject` because doing so would reject `any MyViewModel` even when `MyViewModel: AnyObject` (protocols don't self-conform to `AnyObject`), which would break the protocol-typed view model use case that is XUI's headline feature. The generic stays unconstrained; pass a reference type or a class-constrained protocol existential. + +## `navigation(model:)` / `NavigationLink(model:)` / `onNavigation(_:)` / `NavigationModifier` are deprecated + +These are built on `NavigationLink(isActive:)`, which Apple deprecated in iOS 16. They still function on iOS 15, but emit a deprecation warning. + +**On iOS 16+, replace `.navigation(model:)` with `.navigationDestination(model:)`:** + +```swift +// Before +ContentView() + .navigation(model: $viewModel.detail) { vm in DetailView(viewModel: vm) } + +// After +NavigationStack { + ContentView() + .navigationDestination(model: $viewModel.detail) { vm in DetailView(viewModel: vm) } +} +``` + +For `NavigationLink(model:destination:label:)`, prefer `NavigationLink(value:)` plus `.navigationDestination(for:)`. + +## `Result+Combine` operators are deprecated + +`Publisher.asResult()`, `Publisher.mapResult(success:failure:)`, and `Publisher.tryMapResult(...)` are deprecated. They will be removed in a future major release. Prefer structured concurrency: + +```swift +// Before +publisher.tryMapResult( + success: { $0.uppercased() }, + failure: { _ in "fallback" } +) + +// After +do { + let value = try await asyncCall() + return value.uppercased() +} catch { + return "fallback" +} +``` + +## Newly public `Binding.first(equalTo:)` / `Binding.first(as:)` + +These overloads were documented in the README but accidentally non-public in 1.x. They are now `public` and available to callers. + +## Internal cleanups + +- `@_functionBuilder` was migrated to `@resultBuilder` (`CancellableBuilder`, `CollectionBuilder`). Both attributes are functionally equivalent — no caller change required. +- `Tests/LinuxMain.swift` and `Tests/XUITests/XCTestManifests.swift` were deleted (SwiftPM auto-discovers tests on Linux since 5.4). diff --git a/Package.swift b/Package.swift index 62f9003..d8df393 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.3 +// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "XUI", - platforms: [.iOS(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v13)], + platforms: [.iOS(.v15), .macOS(.v12), .watchOS(.v8), .tvOS(.v15)], products: [ .library( name: "XUI", diff --git a/README.md b/README.md index e5564b0..fede85d 100644 --- a/README.md +++ b/README.md @@ -1,216 +1,242 @@ ![XUI Logo](README_Assets/Logo.jpeg) -**X**UI is a toolbox for creating modular, reusable, testable app architectures with **SwiftUI**. With extensions to tackle common issues, **X**UI makes working with SwiftUI and Combine a lot easier! +![Swift](https://img.shields.io/badge/Swift-5.9+-orange.svg) +![Platforms](https://img.shields.io/badge/platforms-iOS%2015%20%7C%20macOS%2012%20%7C%20watchOS%208%20%7C%20tvOS%2015-lightgrey.svg) +[![SwiftPM](https://img.shields.io/badge/SwiftPM-compatible-brightgreen.svg)](https://swift.org/package-manager/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -* Easily keep your apps clean, maintainable and with a consistent app state -* Abstract view models with protocols -* Make more use of common _SwiftUI_ and _Combine_ components -* Find any object in deep hierarchies +**XUI** lets your SwiftUI views depend on **view-model protocols** instead of concrete classes, and ships the tools you need to build a coordinator-pattern app around that — deep-link search across your view-model tree, model-driven sheet/popover/navigation modifiers, ergonomic `Binding` combinators, and a few Combine helpers. -In our blog articles -* ["SwiftUI Architectures: Model-View, Redux & MVVM"](https://quickbirdstudios.com/blog/swiftui-architecture-redux-mvvm/), -* ["How to Use the Coordinator Pattern in SwiftUI"](https://quickbirdstudios.com/blog/coordinator-pattern-in-swiftui/) and -* ["Handling Navigation in large SwiftUI projects"](https://quickbirdstudios.com/blog/swiftui-navigation-deep-links/), +```swift +struct RecipeListView: View { + @Store var viewModel: RecipeListViewModel // ← a protocol, not a concrete class + var body: some View { /* ... */ } +} +``` -we have already had a look at how to organize views and view models in SwiftUI. With all this knowledge, we have combined and summarized the most important and useful components in this library. +Swap in a `DefaultRecipeListViewModel` for production, a `PreviewRecipeListViewModel` for `#Preview`, and a `MockRecipeListViewModel` for tests — all without touching the view. -## 🔥 Features +## 🤔 Why XUI in 2026? -- Abstraction of view models with protocols through the use of the `@Store` property wrapper -- Deep Linking made easy → simply find any coordinator or view model in your app with a single call! -- Useful extensions to make the use of SwiftUI and Combine simpler! +SwiftUI has evolved a lot since XUI shipped in 2021. Apple's Observation framework (`@Observable`, `@Bindable`, iOS 17+) and `NavigationStack` (iOS 16+) now cover some of what XUI originally provided. XUI is still useful when you want: -## 🏃‍♂️Getting Started +- **Protocol-typed view models.** `@Bindable` requires a concrete `@Observable` type — passing a protocol existential produces the compiler error `Referencing subscript 'subscript(dynamicMember:)' on 'Bindable' requires that 'any P' be a class type`. If you want your views to depend on a protocol with multiple production/preview/test implementations, `@Store` remains the most ergonomic path. +- **A coordinator-first architecture.** `DeepLinkable.firstReceiver(as:where:)` walks your view-model tree to resolve a deep link to a specific receiver. Pair it with the model-driven presentation modifiers (`navigation(model:)`, `sheet(model:)`, `popover(model:)`) and the parent coordinator owns the transition logic — leaf views stay unaware. +- **`Binding` ergonomics SwiftUI doesn't provide.** `Binding.first(where:)`, `Binding.ensure`, `Binding.willSet`/`didSet`, `Binding.map` — small, focused extensions that pay off across a real codebase. -#### Store +On iOS 16+, prefer the new `navigationDestination(model:destination:)` to the deprecated `navigation(model:)`; on iOS 17+, `@Observable` types remain compatible with `@Store` as long as you also conform them to `AnyObservableObject`. + +## 🛠 Installation -One of the integral parts of **X**UI is the [`Store`](https://github.com/quickbirdstudios/XUI/blob/main/Sources/XUI/Store/Store.swift) property wrapper. It makes it possible to define SwiftUI view models with protocols. +XUI is distributed via **Swift Package Manager**. -Let me guide you through the process: First, we create a protocol for our view model and make that conform to `ViewModel`. +**In Xcode:** File → Add Package Dependencies… and paste `https://github.com/quickbirdstudios/XUI.git`. Select a version rule (we recommend "Up to Next Major") and add the `XUI` library to your target. + +**In `Package.swift`:** + +```swift +dependencies: [ + .package(url: "https://github.com/quickbirdstudios/XUI.git", from: "2.0.0"), +], +targets: [ + .target(name: "MyApp", dependencies: ["XUI"]), +] +``` + +Then `import XUI` in any file. XUI re-exports `SwiftUI`, `Combine`, and `Foundation`, so a single import covers most files. + +**Requirements:** iOS 15+, macOS 12+, watchOS 8+, tvOS 15+, Swift 5.9+. + +## 🏃‍♂️ Getting Started + +### `@Store` — protocol-typed view models + +First, declare your view-model protocol. Refine `ViewModel` (a composition of `AnyObservableObject` and `DeepLinkable`): ```swift import XUI protocol MyViewModel: ViewModel { - - // You can specify properties and methods as you like - // This is just an example - var text: String { get set } - func open() - } ``` -Secondly, we create an implementation for that protocol. Our implementation needs to be a class conforming to `ObservableObject` and our protocol. +Then provide an implementation. It must be a class. Conforming to `ObservableObject` automatically satisfies `AnyObservableObject` (Swift synthesises a matching `objectWillChange` from your `@Published` properties), so the machinery you already know just works: ```swift import XUI -class DefaultMyViewModel: MyViewModel, ObservableObject { - - @Published var text: String - - func open() { - // ... - } +final class DefaultMyViewModel: MyViewModel, ObservableObject { + @Published var text: String = "" + func open() { /* … */ } } ``` -Last but not least, we use the `Store` property wrapper to use a protocol as view model in our view. +Finally, use the `@Store` property wrapper in your view, declaring the protocol — not the concrete class — as the property type: ```swift import XUI struct MyView: View { @Store var viewModel: MyViewModel - + var body: some View { TextField("Text", text: $viewModel.text) } } ``` -As you can see, you can use your view model as you would with the `@ObservedObject` property wrapper in SwiftUI. Instead of being constrained to a concrete type, you can specify a protocol instead. This way, we can write different implementations of the `MyViewModel` protocol and use them in `MyView` as well. +**Lifetime:** `@Store` has `@ObservedObject` semantics — it does **not** own the model. Hold root view models in a parent coordinator (or wrap them in a `@StateObject` at the top of your view hierarchy) so they survive view re-creation. -#### Deep Links +## 🧭 Coordinator Pattern -For deep links, we provide a search algorithm throughout your view model / coordinator hierarchy. You can use the `DeepLinkable` protocol to provide access to your immediate children. To find a specific child in that hierarchy, you can use the `firstReceiver` method on `DeepLinkable`. - -You can find a more extensive explanation in [this blog article](https://quickbirdstudios.com/blog/swiftui-navigation-deep-links/). - -## 🤸‍♂️ Extensions - -**X**UI makes working with Combine and SwiftUI a lot easier! - -#### Cancellable - -When working with Combine extensively, there might be many occurences of `.store(in: &cancellables)` in your code. To minimize code size and make code a bit more readable, we offer a function builder to insert multiple `Cancellables` in a collection at once. Let's see it in action: +XUI's `Binding`-driven modifiers and `DeepLinkable` tree work together to keep navigation logic in coordinators and out of leaf views. ```swift -var cancellables = Set() - -cancellables.insert { - $myViewModel.title - .sink { print("MyViewModel title changed to", $0) } +final class AppCoordinator: ViewModel, ObservableObject { + @Published var detail: DetailViewModel? + @Published var settings: SettingsViewModel? + @Published var recipes = RecipesCoordinator() + + var children: [DeepLinkable] { + recipes + if let detail { detail } + if let settings { settings } + } - $myViewModel.text - .sink { print("MyViewModel text changed to", $0) } + func open(_ recipe: Recipe) { + detail = DetailViewModel(recipe: recipe) + } } ``` -#### Publisher +### Presentation: `navigation(model:)`, `sheet(model:)`, `popover(model:)` -With Publishers, you often work with singles or simply publishers that will only emit a single value or an error. To make working with these publishers easier (and since the `Result` type is part of Swift now), we can simply build the following extensions: +Each modifier presents when its `Binding` is non-nil and dismisses (resetting the binding to nil) when the user dismisses interactively: ```swift -var publisher: AnyPublisher - -publisher.asResult() // AnyPublisher, Never> -publisher.mapResult(success: { $0 }, failure: { _ in "Error occured." }) // AnyPublisher -publisher.tryMapResult(success: { $0 }, failure: { throw $0 }) // AnyPublisher +struct RootView: View { + @Store var viewModel: AppCoordinator + var body: some View { + NavigationStack { + RecipesView(viewModel: viewModel.recipes) + .sheet(model: $viewModel.settings) { vm in + SettingsView(viewModel: vm) + } + .navigationDestination(model: $viewModel.detail) { vm in + DetailView(viewModel: vm) + } + } + } +} ``` -#### ViewModifiers +On iOS 16+, prefer `navigationDestination(model:destination:)` over the deprecated `navigation(model:destination:)`. The `sheet(model:)` / `popover(model:)` modifiers identify the presented item by object identity, so `Model` should be a reference type (class or class-constrained protocol). -When using the Coordinator Pattern in SwiftUI (as discussed in [this blog article](https://quickbirdstudios.com/blog/coordinator-pattern-in-swiftui/)), we need to inject a view modifier into a child view, so that transition logic is fully specified by the coordinator view rather than being distributed across views. +### Deep links: `firstReceiver(as:where:)` -`NavigationModifier`, `PopoverModifier` and `SheetModifier` are provided, with a similar interface to the actual modifiers. +`DeepLinkable.firstReceiver` does a breadth-first walk of the coordinator/view-model tree, falling back to Mirror reflection over stored properties if `children` doesn't yield a match. Use it to route an external URL or notification to the right receiver: -#### View +```swift +func handle(_ url: URL) { + guard let id = url.recipeID, + let detail = appCoordinator.firstReceiver(as: RecipeDetailViewModel.self, where: { $0.recipeID == id }) else { + return + } + detail.focus() +} +``` -To make working with `NavigationView` simpler in SwiftUI, we provide a `onNavigation` method that can be used, when you would like a closure to be performed, when a `NavigationLink` is performed. Simply put it around your view, it will add a `NavigationLink` itself. +## 🔁 Combine helpers -Further, we add methods to your views for handling `sheet`, `popover` and `navigation` with view model protocols. +### `CancellableBuilder` -Example: +A result builder for storing multiple `Cancellable`s in a collection without writing `.store(in:)` at each call site. `if` / `switch` / `for` / `#available` are supported inside the block: ```swift -struct MyView: View { - - @Store var viewModel: MyViewModel - - var body: some View { - NavigationView { - Text("Example") - .navigation(model: $viewModel.detailViewModel) { viewModel in - DetailView(viewModel: viewModel) - } - .sheet(model: $viewModel.sheetViewModel) { viewModel in - SheetView(viewModel: viewModel) - } - } +var cancellables = Set() + +cancellables.insert { + viewModel.$title.sink { print("title:", $0) } + viewModel.$count.sink { print("count:", $0) } + + if FeatureFlags.tracingEnabled { + viewModel.$state.sink { tracer.record($0) } } - } ``` -#### Binding - -Working with bindings, especially when it concerns collections is hard - but no longer! We have written a few extensions to easily work with elements of collections using bindings. +### `Result` interop (deprecated) -```swift -var binding: Binding<[String]> +`asResult()`, `mapResult(success:failure:)`, and `tryMapResult(...)` bridge publisher errors into `Result` values. Deprecated in XUI 2.0 — prefer `async/await` with `do/try/catch`. -binding.first(equalTo: "example") // Binding -binding.first(where: { $0.count < 5 }) // Binding, this is not a practical example though +## 🧰 `Binding` combinators -binding.first(equalTo: "example").forceUnwrap() // Binding -binding.first(equalTo: "example").force(as: CustomStringConvertible.self) // Binding +### Validation +```swift +binding.ensure { !$0.isEmpty } // silently drops writes that fail the predicate +binding.assert { !$0.isEmpty } // asserts on read AND write (debug-only) ``` -Further, one would possibly like to alter or observe the values being used through a binding. +### Observation ```swift -var binding: Binding - -binding.willSet { print("will set", $0) } -// Binding, will print whenever a new value is set by the binding, before it is forwarded to the initial binding +binding.willSet { print("about to set:", $0) } +binding.didSet { print("did set:", $0) } +``` -binding.didSet { print("did set", $0) } -// Binding, will print whenever a new value is set by the binding, after it is forwarded to the initial binding +### Transformation -binding.ensure { !$0.isEmpty } -// Binding, will only set the initial binding, when the condition is fulfilled +```swift +binding.map(get: { $0.uppercased() }, set: { $0.lowercased() }) // bidirectional +binding.alterGet { $0.uppercased() } // read-side only +binding.alterSet { $0.lowercased() } // write-side only +``` -binding.assert { !$0.isEmpty } -// Binding, will assert on get and set, that a condition is fulfilled +### Collection element bindings -binding.map(get: { $0.first! }, set: { String($0) }) -// Binding, will map the binding's value to a different type +```swift +var binding: Binding<[Recipe]> -binding.alterGet { $0.prefix(1) } -// Binding, will forward the altered value on get +binding.first(where: { $0.id == openedID }) // Binding +binding.first(where: \.id, equals: openedID) // Binding +binding.first(equalTo: someRecipe) // Binding -binding.alterSet { $0.prefix(1) } -// Binding, will forward the altered value on set to the underlying binding +binding.first(equalTo: someRecipe).forceUnwrap() // Binding ⚠️ traps on nil +binding.first(equalTo: someRecipe).force(as: Any.self) // Binding ⚠️ traps on type mismatch ``` +Write semantics for the `first(...)` family: setting a non-nil value when no element matches **appends** the value; setting a non-nil value when an element matches **replaces** it in place; setting `nil` **removes** the matching element. + ## 📚 Example -As an example on how to use **X**UI in your application, we have written a [Recipes App](https://github.com/quickbirdstudios/SwiftUI-Coordinators-Example/tree/xui) with the help of **X**UI. +A full sample app built with XUI lives at [SwiftUI-Coordinators-Example (`xui` branch)](https://github.com/quickbirdstudios/SwiftUI-Coordinators-Example/tree/xui). -## 🛠 Installation +## 🤝 Contributing -#### Swift Package Manager +Issues and PRs welcome. -See [this WWDC presentation](https://developer.apple.com/videos/play/wwdc2019/408/) about more information how to use Swift packages in your app. -Specify `https://github.com/quickbirdstudios/XUI.git` as the `XUI` package link. +- Run tests with `swift test` from the repo root, or open `Package.swift` in Xcode and ⌘U. +- Please add a test for any new public API. +- For larger changes, open an issue first so we can align on design. -## 👨‍💻 Author +## 📃 License -This framework is created with ❤️ by [QuickBird Studios](https://quickbirdstudios.com). +XUI is released under an MIT license. See [LICENSE](LICENSE) for more information. -## 🤝 Contributing +## 📖 Background reading -Open an issue if you need help, if you found a bug, or if you want to discuss a feature request. -Open a PR if you want to make changes to **X**UI. +The design of XUI is explored in three QuickBird Studios blog posts: -## 📃 License +- ["SwiftUI Architectures: Model-View, Redux & MVVM"](https://quickbirdstudios.com/blog/swiftui-architecture-redux-mvvm/) +- ["How to Use the Coordinator Pattern in SwiftUI"](https://quickbirdstudios.com/blog/coordinator-pattern-in-swiftui/) +- ["Handling Navigation in large SwiftUI projects"](https://quickbirdstudios.com/blog/swiftui-navigation-deep-links/) + +## 👨‍💻 Author -**X**UI is released under an MIT license. See [License.md](https://github.com/quickbirdstudios/XUI/blob/master/LICENSE) for more information. +Maintained by [QuickBird Studios](https://quickbirdstudios.com). diff --git a/Sources/XUI/Binding/Binding+Change.swift b/Sources/XUI/Binding/Binding+Change.swift index 1ac1455..7e2ffef 100644 --- a/Sources/XUI/Binding/Binding+Change.swift +++ b/Sources/XUI/Binding/Binding+Change.swift @@ -6,8 +6,13 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +// Each of the methods below returns a *new* derived `Binding`; they do not mutate the +// original. The original binding remains usable and unchanged. + extension Binding { + /// Returns a derived `Binding` that invokes `willSet` immediately **before** writing + /// the new value through to the original binding. public func willSet(_ willSet: @escaping (Value) -> Void) -> Binding { .init( get: { self.wrappedValue }, @@ -18,6 +23,8 @@ extension Binding { ) } + /// Returns a derived `Binding` that invokes `didSet` immediately **after** writing the + /// new value through to the original binding. public func didSet(_ didSet: @escaping (Value) -> Void) -> Binding { .init( get: { self.wrappedValue }, @@ -28,6 +35,8 @@ extension Binding { ) } + /// Returns a derived `Binding` that **silently drops** the write if `ensure` returns + /// `false`. The original binding's setter is only invoked when the predicate succeeds. public func ensure(_ ensure: @escaping (Value) -> Bool) -> Binding { .init( get: { self.wrappedValue }, @@ -40,6 +49,9 @@ extension Binding { ) } + /// Returns a derived `Binding` that asserts `assertion` on **both** read and write. + /// The assertion is compiled out in release builds — use this to catch invariant + /// violations during development without affecting shipping behaviour. public func assert(_ assertion: @escaping (Value) -> Bool) -> Binding { .init( get: { @@ -54,6 +66,8 @@ extension Binding { ) } + /// Returns a `Binding` that is a bidirectional projection of the underlying binding. + /// `get` converts `Value → V` on read; `set` converts `V → Value` on write. public func map(get: @escaping (Value) -> V, set: @escaping (V) -> Value) -> Binding { .init( get: { get(self.wrappedValue) }, @@ -61,6 +75,8 @@ extension Binding { ) } + /// Returns a derived `Binding` that applies `map` to values on read only; writes pass + /// through unchanged. public func alterGet(_ map: @escaping (Value) -> Value) -> Binding { .init( get: { map(self.wrappedValue) }, @@ -68,6 +84,8 @@ extension Binding { ) } + /// Returns a derived `Binding` that applies `map` to values on write only; reads pass + /// through unchanged. public func alterSet(_ map: @escaping (Value) -> Value) -> Binding { .init( get: { self.wrappedValue }, diff --git a/Sources/XUI/Binding/Binding+Element.swift b/Sources/XUI/Binding/Binding+Element.swift index 6458926..3d16313 100644 --- a/Sources/XUI/Binding/Binding+Element.swift +++ b/Sources/XUI/Binding/Binding+Element.swift @@ -1,13 +1,21 @@ // // Binding+Element.swift // XUI -// +// // Created by Paul Kraft on 01.03.21. // Copyright © 2021 QuickBird Studios. All rights reserved. // extension Binding where Value: RangeReplaceableCollection { + /// Returns a `Binding` projecting the first element of the underlying + /// collection that matches `condition`. + /// + /// Write semantics: + /// - Setting a non-`nil` value while no element matches **appends** the new element. + /// - Setting a non-`nil` value while an element matches **replaces** the matching element + /// in place. + /// - Setting `nil` **removes** the matching element (if any). public func first( where condition: @escaping (Value.Element) -> Bool ) -> Binding { @@ -29,6 +37,8 @@ extension Binding where Value: RangeReplaceableCollection { ) } + /// Returns a `Binding` projecting the first element whose value at `keyPath` + /// equals `value`. Same append/replace/remove write semantics as `first(where:)`. public func first( where keyPath: KeyPath, equals value: E @@ -37,14 +47,20 @@ extension Binding where Value: RangeReplaceableCollection { first { $0[keyPath: keyPath] == value } } - func first( + /// Returns a `Binding` projecting the first element equal to `value`. Same + /// append/replace/remove write semantics as `first(where:)`. + public func first( equalTo value: Value.Element ) -> Binding where Value.Element: Equatable { first { $0 == value } } - func first(as: T.Type) -> Binding { + /// Returns a `Binding` projecting the first element that is castable to `T`. + /// + /// The cast is performed unsafely (via `force(as:)`) — if the matching element's actual + /// type does not match `T` exactly on a write-through, the program will trap. + public func first(as: T.Type) -> Binding { first(where: { $0 is T }) .force(as: T?.self) diff --git a/Sources/XUI/Binding/Binding+Force.swift b/Sources/XUI/Binding/Binding+Force.swift index 5807832..0ef21bf 100644 --- a/Sources/XUI/Binding/Binding+Force.swift +++ b/Sources/XUI/Binding/Binding+Force.swift @@ -6,13 +6,21 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +// The operations in this file are **unsafe in both directions** — reads trap on `nil` +// or type mismatch, and writes trap on type mismatch. Prefer safe alternatives (`if let`, +// `as?`, `Binding.map`) wherever possible. + extension Binding { + /// Returns a `Binding` that force-unwraps the underlying optional binding. + /// Reading the binding when the underlying value is `nil` will trap. public func forceUnwrap() -> Binding where Value == Wrapped? { .init(get: { self.wrappedValue! }, set: { self.wrappedValue = $0 }) } + /// Returns a `Binding` that force-casts the underlying binding to `T`. Both reads + /// (`Value as! T`) and writes (`T as! Value`) trap on type mismatch. public func force(as type: T.Type) -> Binding { .init( get: { self.wrappedValue as! T }, @@ -21,4 +29,3 @@ extension Binding { } } - diff --git a/Sources/XUI/Combine/CancellableBuilder.swift b/Sources/XUI/Combine/CancellableBuilder.swift index e23a310..63c5a6c 100644 --- a/Sources/XUI/Combine/CancellableBuilder.swift +++ b/Sources/XUI/Combine/CancellableBuilder.swift @@ -6,12 +6,22 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // -/// `CancellableBuilder` is a function builder to combine `Cancellable` -/// objects into one `AnyCancellable`. +/// A result builder that collects multiple `Cancellable` values produced by sink/assign +/// subscriptions, so they can be stored in a collection without writing `.store(in:)` at +/// each call site. /// -/// Possible use case: Storing multiple cancellables in a collection -/// without writing `.store(in:)` for each subscription separately. -@_functionBuilder +/// Entry points: `Set.insert(_:)` and `RangeReplaceableCollection.insert(_:)` +/// (the variants defined in this file). Both accept any `Cancellable`-producing block, with +/// `if` / `switch` / `for` / `#available` supported inside the block. +/// +/// ```swift +/// var cancellables = Set() +/// cancellables.insert { +/// viewModel.$title.sink { print("title:", $0) } +/// viewModel.$count.sink { print("count:", $0) } +/// } +/// ``` +@resultBuilder public struct CancellableBuilder { public static func buildBlock(_ components: [Cancellable]...) -> [Cancellable] { @@ -70,8 +80,8 @@ extension Cancellable { extension RangeReplaceableCollection where Element == AnyCancellable { - /// This method can be used to store multiple `Cancellable` objects - /// from different subscriptions in a collection of `AnyCancellable`. + /// Stores the cancellables produced by `builder` in the collection. Each cancellable is + /// erased to `AnyCancellable` before storage. public mutating func insert(@CancellableBuilder _ builder: () -> [AnyCancellable]) { builder().forEach { $0.store(in: &self) } } @@ -80,11 +90,10 @@ extension RangeReplaceableCollection where Element == AnyCancellable { extension Set where Element == AnyCancellable { - /// This method can be used to store multiple `Cancellable` objects - /// from different subscriptions in a set of `AnyCancellable`. + /// Stores the cancellables produced by `builder` in the set. Each cancellable is erased + /// to `AnyCancellable` before storage. public mutating func insert(@CancellableBuilder _ builder: () -> [AnyCancellable]) { builder().forEach { $0.store(in: &self) } } } - diff --git a/Sources/XUI/Combine/Result+Combine.swift b/Sources/XUI/Combine/Result+Combine.swift index df687f5..ac2677e 100644 --- a/Sources/XUI/Combine/Result+Combine.swift +++ b/Sources/XUI/Combine/Result+Combine.swift @@ -8,23 +8,26 @@ extension Publisher { + /// Converts the publisher into one that emits each output as `.success` and any failure + /// as a `.failure` value. /// - /// Maps the output and/or failure of a publisher to a `Result` - /// output without completing the publisher when receiving a failure. - /// + /// On upstream failure, the failure is delivered as a value (`.failure(...)`) and the + /// subscription then completes normally — i.e. the downstream is not torn down by a + /// Combine completion failure, but it still receives at most one trailing value before + /// completion. + @available(*, deprecated, message: "Prefer async/await with do/try/catch.") public func asResult() -> AnyPublisher, Never> { map(Result.success) .catch { Just(.failure($0)) } .eraseToAnyPublisher() } + /// Reduces each output and any failure to values of a common type `Value`, using + /// separate closures for the success and failure cases. /// - /// Maps a given `Result` output to values of the same type with separate - /// closures for the success and failure case. - /// - /// Since errors are transformed into values, the publisher will no longer complete - /// when errors occur, but instead continue the subscription. - /// + /// On upstream failure, the failure is mapped through `failure` and emitted as a value; + /// the subscription then completes normally. + @available(*, deprecated, message: "Prefer async/await with do/try/catch.") public func mapResult( success: @escaping (Output) -> Value, failure: @escaping (Failure) -> Value) -> AnyPublisher { @@ -34,14 +37,9 @@ extension Publisher { .eraseToAnyPublisher() } - /// - /// Maps a given `Result` output to values of the same type with separate - /// closures for the success and failure case. - /// - /// Since errors are transformed into values, the publisher will no longer complete - /// when errors occur, but instead continue the subscription, unless the given closures - /// are throwing an error. - /// + /// Throwing variant of `mapResult(success:failure:)`. If either closure throws, the + /// resulting publisher fails with the thrown error. + @available(*, deprecated, message: "Prefer async/await with do/try/catch.") public func tryMapResult( success: @escaping (Output) throws -> Value, failure: @escaping (Failure) throws -> Value) -> AnyPublisher { @@ -55,8 +53,8 @@ extension Publisher { extension Result { - /// Maps a given `Result` to values of the same type with separate closures - /// for the success and failure case. + /// Reduces the `Result` to a single value of type `T` by applying either `successClosure` + /// or `failureClosure`. public func map(success successClosure: (Success) throws -> T, failure failureClosure: (Failure) throws -> T) rethrows -> T { switch self { diff --git a/Sources/XUI/DeepLink/DeepLinkable+Receiver.swift b/Sources/XUI/DeepLink/DeepLinkable+Receiver.swift index 6c2b8b3..a9899a4 100644 --- a/Sources/XUI/DeepLink/DeepLinkable+Receiver.swift +++ b/Sources/XUI/DeepLink/DeepLinkable+Receiver.swift @@ -8,10 +8,25 @@ extension DeepLinkable { + /// Searches the tree rooted at `self` for the first node of type `Receiver` that matches + /// `filter`, returning it or `nil` if no match is found. + /// + /// The search is breadth-first over `children`, with cycle protection. If the search via + /// `children` yields no match, a **second pass** uses Mirror reflection over each node's + /// stored properties to discover any `DeepLinkable`-conforming fields that were not + /// explicitly listed in `children`. This makes deep-link lookups work out of the box for + /// types that haven't bothered to override `children`, at the cost of also reaching + /// fields the author may not have intended to expose. + /// + /// - Parameters: + /// - type: The target type to search for (often a view-model protocol). + /// - filter: An optional predicate to further narrow the match. Defaults to accepting + /// any value of `Receiver`. + /// - Returns: The first matching descendant, or `nil`. public func firstReceiver( as type: Receiver.Type, where filter: (Receiver) -> Bool = { _ in true } - ) -> Receiver! { + ) -> Receiver? { firstReceiver(ofType: type, where: filter) { $0.children } ?? firstReceiver(ofType: type, where: filter) { Mirror(reflecting: $0) diff --git a/Sources/XUI/DeepLink/DeepLinkable.swift b/Sources/XUI/DeepLink/DeepLinkable.swift index cb1a266..f22fa25 100644 --- a/Sources/XUI/DeepLink/DeepLinkable.swift +++ b/Sources/XUI/DeepLink/DeepLinkable.swift @@ -2,11 +2,39 @@ // DeepLinkable.swift // XUI // -// Created by Paul Kraft on 01.03.21. +// Created by Paul Kraft by 01.03.21. // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// A node in a tree of coordinators / view models that can be searched by +/// `firstReceiver(as:where:)` for deep-linking purposes. +/// +/// Conformers may expose their immediate children via the `children` property. If `children` +/// is not overridden, the default returns an empty array — in that case, `firstReceiver` +/// falls back to Mirror-based reflection over the conformer's stored properties to discover +/// nested `DeepLinkable`s automatically. +/// +/// ```swift +/// final class AppCoordinator: ViewModel, ObservableObject { +/// @Published var recipes: RecipesCoordinator +/// @Published var settings: SettingsCoordinator? +/// +/// var children: [DeepLinkable] { +/// recipes +/// if let settings { settings } +/// } +/// } +/// ``` +/// +/// The `children` property is built with `@DeepLinkableBuilder`, so `if`/`switch`/optional +/// syntax is supported. public protocol DeepLinkable: AnyObject { + + /// The immediate children of this node, used to drive `firstReceiver(as:where:)` searches. + /// + /// Built via `@DeepLinkableBuilder`, supporting `if`/`switch`/optional inside the block. + /// Defaults to an empty array; in that case `firstReceiver` falls back to Mirror + /// reflection over stored properties. @DeepLinkableBuilder var children: [DeepLinkable] { get } } diff --git a/Sources/XUI/DeepLink/DeepLinkableBuilder.swift b/Sources/XUI/DeepLink/DeepLinkableBuilder.swift index bf61fc1..e4c68ac 100644 --- a/Sources/XUI/DeepLink/DeepLinkableBuilder.swift +++ b/Sources/XUI/DeepLink/DeepLinkableBuilder.swift @@ -6,9 +6,12 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// Result builder used to assemble the `children` array of a `DeepLinkable`. public typealias DeepLinkableBuilder = CollectionBuilder -@_functionBuilder +/// A generic result builder that collects `Component` values into an array, supporting +/// `if`, `switch`, `for`, optional, and `#available` expressions inside the block. +@resultBuilder public struct CollectionBuilder { public static func buildBlock(_ components: [Component]...) -> [Component] { diff --git a/Sources/XUI/Store/AnyObservableObject.swift b/Sources/XUI/Store/AnyObservableObject.swift index 98a1a5e..f2e10c3 100644 --- a/Sources/XUI/Store/AnyObservableObject.swift +++ b/Sources/XUI/Store/AnyObservableObject.swift @@ -6,6 +6,36 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// A non-generic counterpart to SwiftUI's `ObservableObject` that can be used as a +/// protocol existential. +/// +/// SwiftUI's `ObservableObject` has an associated `ObjectWillChangePublisher` type, which +/// prevents it from being used as the type of a view's view-model property (you can't write +/// `var viewModel: any ObservableObject` and feed it to `@ObservedObject`). `AnyObservableObject` +/// fixes the publisher to `ObservableObjectPublisher`, which removes the associated-type +/// constraint and lets you abstract view models behind protocols. +/// +/// Most conformers already inherit `ObservableObject` for the `@Published` machinery — in that +/// case, conforming to `AnyObservableObject` requires no additional code, because +/// `ObservableObject` synthesises a matching `objectWillChange` for free: +/// +/// ```swift +/// protocol MyViewModel: AnyObservableObject { +/// var title: String { get set } +/// } +/// +/// final class DefaultMyViewModel: MyViewModel, ObservableObject { +/// @Published var title: String = "" +/// } +/// ``` +/// +/// Used in combination with the `@Store` property wrapper to make view models protocol-typed. public protocol AnyObservableObject: AnyObject { + + /// A publisher that emits before the object has changed. + /// + /// Conformers that already conform to `ObservableObject` get this for free — Swift + /// synthesises an `ObservableObjectPublisher` from the `@Published` properties of the + /// class. var objectWillChange: ObservableObjectPublisher { get } } diff --git a/Sources/XUI/Store/Store.swift b/Sources/XUI/Store/Store.swift index c535129..1fb58a6 100644 --- a/Sources/XUI/Store/Store.swift +++ b/Sources/XUI/Store/Store.swift @@ -6,16 +6,41 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// A SwiftUI property wrapper that observes a **protocol-typed** view model. +/// +/// `@ObservedObject` requires the wrapped property to have a concrete `ObservableObject` type, +/// which prevents views from depending on a view-model protocol (e.g. for swapping in mocks +/// during previews or tests). `@Store` accepts any value that conforms to +/// `AnyObservableObject` — including a protocol existential — and re-publishes its +/// `objectWillChange` events to SwiftUI: +/// +/// ```swift +/// struct RecipeListView: View { +/// @Store var viewModel: RecipeListViewModel // a protocol +/// var body: some View { +/// List(viewModel.recipes, id: \.id) { recipe in +/// Text(recipe.title) +/// } +/// } +/// } +/// ``` +/// +/// Lifetime: like `@ObservedObject`, `@Store` does not own its model. The model must be held +/// by a longer-lived owner (a coordinator stored in a parent's `@StateObject`, an app-level +/// dependency container, etc.) — otherwise it will be deallocated when the view is recreated. @propertyWrapper public struct Store: DynamicProperty { // MARK: Nested types + /// The `projectedValue` of `@Store`, vended as `$viewModel`. Uses `@dynamicMemberLookup` + /// to produce a two-way `Binding` to any reference-writable key path on `Model`. @dynamicMemberLookup public struct Wrapper { fileprivate var store: Store + /// Returns a two-way `Binding` to the given key path on the underlying model. public subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> Binding { Binding(get: { self.store.wrappedValue[keyPath: keyPath] }, set: { self.store.wrappedValue[keyPath: keyPath] = $0 }) @@ -25,6 +50,8 @@ public struct Store: DynamicProperty { // MARK: Stored properties + /// The underlying model. Typically a protocol existential whose concrete implementation + /// conforms to both the protocol and `AnyObservableObject`. public let wrappedValue: Model @ObservedObject @@ -32,25 +59,39 @@ public struct Store: DynamicProperty { // MARK: Computed Properties + /// Returns a `Wrapper` exposing `Binding`s into the model via `@dynamicMemberLookup`. + /// Accessed via `$viewModel` in a SwiftUI view. public var projectedValue: Wrapper { Wrapper(store: self) } // MARK: Initialization + /// Wraps `wrappedValue` and subscribes to its `objectWillChange` publisher. + /// + /// `wrappedValue` **must** conform to `AnyObservableObject`. In debug builds, passing a + /// value that does not conform raises an `assertionFailure`; in release builds the + /// `Store` is still created but will not propagate any change notifications, so SwiftUI + /// will not re-render the view when the model changes. public init(wrappedValue: Model) { self.wrappedValue = wrappedValue if let objectWillChange = (wrappedValue as? AnyObservableObject)?.objectWillChange { self.observableObject = .init(objectWillChange: objectWillChange.eraseToAnyPublisher()) } else { - assertionFailure("Only use the `Store` property wrapper with instances conforming to `AnyObservableObject`.") + assertionFailure(""" + @Store requires the wrapped value to conform to `AnyObservableObject`. \ + If the wrapped value is a class, conform it to `AnyObservableObject` — this is free \ + if it already conforms to `ObservableObject`. If it is a value type, use `@State` \ + or `@Binding` instead. + """) self.observableObject = .empty() } } // MARK: Methods + /// SwiftUI's `DynamicProperty` hook. Not intended to be called directly. public mutating func update() { _observableObject.update() } diff --git a/Sources/XUI/Store/ViewModel.swift b/Sources/XUI/Store/ViewModel.swift index 075c19a..5c66944 100644 --- a/Sources/XUI/Store/ViewModel.swift +++ b/Sources/XUI/Store/ViewModel.swift @@ -6,4 +6,19 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// A composition of `AnyObservableObject` (for use with the `@Store` property wrapper) and +/// `DeepLinkable` (for `firstReceiver(as:where:)` lookup across a view-model hierarchy). +/// +/// Most view-model protocols in an XUI-based app refine this: +/// +/// ```swift +/// protocol RecipeListViewModel: ViewModel { +/// var recipes: [Recipe] { get } +/// func open(_ recipe: Recipe) +/// } +/// ``` +/// +/// Lifetime note: `@Store` observes its model with `@ObservedObject` semantics — it does +/// **not** own the model. Keep your root view models in a parent coordinator (or a +/// `@StateObject` wrapper) so they survive view re-creation. public protocol ViewModel: AnyObservableObject, DeepLinkable {} diff --git a/Sources/XUI/ViewModifiers/NavigationModifier.swift b/Sources/XUI/ViewModifiers/NavigationModifier.swift index d3c2811..224190f 100644 --- a/Sources/XUI/ViewModifiers/NavigationModifier.swift +++ b/Sources/XUI/ViewModifiers/NavigationModifier.swift @@ -6,6 +6,16 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// A coordinator-friendly `ViewModifier` that drives navigation off of a `Binding`. +/// +/// When `model.wrappedValue` is non-nil the destination is pushed; when it becomes nil +/// (either externally or via the back button) the navigation is dismissed. This isolates +/// transition logic in the coordinator and keeps leaf views unaware of how presentation +/// happens. +/// +/// Built on `NavigationLink(isActive:)`, which is deprecated in iOS 16+. On those platforms +/// prefer `View.navigationDestination(model:destination:)`. +@available(*, deprecated, message: "Use `.navigationDestination(model:destination:)` on iOS 16+.") public struct NavigationModifier: ViewModifier { // MARK: Stored Properties diff --git a/Sources/XUI/ViewModifiers/PopoverModifier.swift b/Sources/XUI/ViewModifiers/PopoverModifier.swift index 9ec97d1..e68bc9a 100644 --- a/Sources/XUI/ViewModifiers/PopoverModifier.swift +++ b/Sources/XUI/ViewModifiers/PopoverModifier.swift @@ -6,6 +6,14 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// A coordinator-friendly `ViewModifier` that drives a popover presentation off of a +/// `Binding`. +/// +/// Non-nil `model` presents the popover; setting `model` to nil (or the user dismissing +/// interactively) dismisses it. The model should be a reference type — XUI identifies the +/// presented item by object identity. The generic is unconstrained so that class-constrained +/// protocol existentials can be passed; see `View.popover(model:destination:)` for the +/// detailed rationale. public struct PopoverModifier: ViewModifier { // MARK: Stored Properties @@ -16,16 +24,16 @@ public struct PopoverModifier: ViewModifier { // MARK: Initialization public init(model: Binding, - @ViewBuilder content: @escaping (Model) -> Destination) { + @ViewBuilder destination: @escaping (Model) -> Destination) { self.model = model - self.destination = content + self.destination = destination } // MARK: Methods public func body(content: Content) -> some View { - content.popover(model: model, content: destination) + content.popover(model: model, destination: destination) } } diff --git a/Sources/XUI/ViewModifiers/SheetModifier.swift b/Sources/XUI/ViewModifiers/SheetModifier.swift index 7108709..c405e82 100644 --- a/Sources/XUI/ViewModifiers/SheetModifier.swift +++ b/Sources/XUI/ViewModifiers/SheetModifier.swift @@ -6,6 +6,14 @@ // Copyright © 2021 QuickBird Studios. All rights reserved. // +/// A coordinator-friendly `ViewModifier` that drives a sheet presentation off of a +/// `Binding`. +/// +/// Non-nil `model` presents the sheet; setting `model` to nil (or the user dismissing the +/// sheet interactively) dismisses it. The model should be a reference type — XUI identifies +/// the presented item by object identity. The generic is unconstrained so that +/// class-constrained protocol existentials can be passed; see `View.sheet(model:destination:)` +/// for the detailed rationale. public struct SheetModifier: ViewModifier { // MARK: Stored Properties @@ -16,16 +24,16 @@ public struct SheetModifier: ViewModifier { // MARK: Initialization public init(model: Binding, - @ViewBuilder content: @escaping (Model) -> Destination) { + @ViewBuilder destination: @escaping (Model) -> Destination) { self.model = model - self.destination = content + self.destination = destination } // MARK: Methods public func body(content: Content) -> some View { - content.sheet(model: model, content: destination) + content.sheet(model: model, destination: destination) } } diff --git a/Sources/XUI/Views/NavigationLink.swift b/Sources/XUI/Views/NavigationLink.swift index 63e50a1..18c7b23 100644 --- a/Sources/XUI/Views/NavigationLink.swift +++ b/Sources/XUI/Views/NavigationLink.swift @@ -8,6 +8,13 @@ extension NavigationLink { + /// Creates a `NavigationLink` whose destination is derived from an optional model + /// binding. When `model` is non-nil, tapping the link presents `destination(model)`; + /// when the destination is popped, `model` is reset to nil. + /// + /// Built on `NavigationLink(isActive:)`, which is deprecated in iOS 16+. Prefer + /// `NavigationLink(value:)` plus `.navigationDestination(for:)` on newer platforms. + @available(*, deprecated, message: "Use `NavigationLink(value:)` plus `.navigationDestination(for:)` on iOS 16+.") public init( model: Binding, @ViewBuilder destination: (Model) -> _Destination, diff --git a/Sources/XUI/Views/View+Navigation.swift b/Sources/XUI/Views/View+Navigation.swift index 833fd1f..8005626 100644 --- a/Sources/XUI/Views/View+Navigation.swift +++ b/Sources/XUI/Views/View+Navigation.swift @@ -8,6 +8,12 @@ extension View { + /// Wraps the view in a hidden `NavigationLink` that fires `action` when the user taps it. + /// + /// The link is never visually active — its `isActive` getter always returns `false` — + /// it exists only to translate a tap into a side-effecting closure. Useful for + /// coordinator-pattern flows where the parent decides what navigation should happen. + @available(*, deprecated, message: "Use `NavigationLink(value:)` plus `.navigationDestination(for:)` on iOS 16+.") public func onNavigation(_ action: @escaping () -> Void) -> some View { let isActive = Binding( get: { false }, @@ -25,6 +31,9 @@ extension View { } } + /// Convenience overload of `navigation(model:destination:)` that takes an + /// `Identifiable` item binding. + @available(*, deprecated, message: "Use `.navigationDestination(model:destination:)` on iOS 16+.") public func navigation( item: Binding, @ViewBuilder destination: (Item) -> Destination @@ -33,6 +42,12 @@ extension View { navigation(model: item, destination: destination) } + /// Pushes `destination(model)` onto the navigation stack when `model.wrappedValue` is + /// non-nil; pops it (and resets the binding to nil) when navigation is dismissed. + /// + /// Implemented on top of `NavigationLink(isActive:)`, which is deprecated in iOS 16+. + /// Prefer `navigationDestination(model:destination:)` on iOS 16 and later. + @available(*, deprecated, message: "Use `.navigationDestination(model:destination:)` on iOS 16+.") public func navigation( model: Binding, @ViewBuilder destination: (Model) -> Destination @@ -50,6 +65,8 @@ extension View { } } + /// Overlays a hidden `NavigationLink` that activates whenever `isActive` becomes `true`. + @available(*, deprecated, message: "Use `.navigationDestination(isPresented:)` on iOS 16+.") public func navigation( isActive: Binding, @ViewBuilder destination: () -> Destination diff --git a/Sources/XUI/Views/View+NavigationDestination.swift b/Sources/XUI/Views/View+NavigationDestination.swift new file mode 100644 index 0000000..de964b0 --- /dev/null +++ b/Sources/XUI/Views/View+NavigationDestination.swift @@ -0,0 +1,38 @@ +// +// View+NavigationDestination.swift +// XUI +// +// Created by Paul Kraft on 01.03.21. +// Copyright © 2021 QuickBird Studios. All rights reserved. +// + +@available(iOS 16, macOS 13, watchOS 9, tvOS 16, *) +extension View { + + /// Pushes `destination(model)` onto the enclosing `NavigationStack` when + /// `model.wrappedValue` is non-nil; pops it (and resets the binding to nil) when the + /// navigation is dismissed. + /// + /// The `NavigationStack` equivalent of `View.navigation(model:destination:)`. Use this on + /// iOS 16+ wherever you previously used `navigation(model:)`. + public func navigationDestination( + model: Binding, + @ViewBuilder destination: @escaping (Model) -> Destination + ) -> some View { + + let isPresented = Binding( + get: { model.wrappedValue != nil }, + set: { value in + if !value { + model.wrappedValue = nil + } + } + ) + return navigationDestination(isPresented: isPresented) { + if let value = model.wrappedValue { + destination(value) + } + } + } + +} diff --git a/Sources/XUI/Views/View+Popover.swift b/Sources/XUI/Views/View+Popover.swift index fe7a519..3626c4a 100644 --- a/Sources/XUI/Views/View+Popover.swift +++ b/Sources/XUI/Views/View+Popover.swift @@ -8,13 +8,24 @@ extension View { - public func popover( + /// Presents a popover when `model.wrappedValue` is non-nil. Setting it to nil dismisses + /// the popover; an interactive dismissal sets it back to nil. + /// + /// `Model` should be a reference type (class, or class-constrained protocol) — XUI + /// identifies the presented item by object identity. The generic is intentionally + /// unconstrained so that protocol-typed view models (`any MyViewModel` where + /// `MyViewModel: AnyObject`) can be passed through; Swift's generic system would + /// otherwise reject those at the call site because protocols don't self-conform to + /// `AnyObject`. Passing a value-type `Model` will misbehave: SwiftUI's `Identifiable` + /// item identity is computed from a freshly-boxed `ObjectIdentifier` on every read, + /// causing the popover to re-present continuously. + public func popover( model: Binding, - @ViewBuilder content: @escaping (Model) -> Content + @ViewBuilder destination: @escaping (Model) -> Destination ) -> some View { popover(item: model.objectIdentifiable()) { _ in - model.wrappedValue.map(content) + model.wrappedValue.map(destination) } } diff --git a/Sources/XUI/Views/View+Sheet.swift b/Sources/XUI/Views/View+Sheet.swift index a7bd4a5..917a209 100644 --- a/Sources/XUI/Views/View+Sheet.swift +++ b/Sources/XUI/Views/View+Sheet.swift @@ -8,13 +8,24 @@ extension View { - public func sheet( + /// Presents a sheet when `model.wrappedValue` is non-nil. Setting it to nil dismisses + /// the sheet; an interactive dismissal sets it back to nil. + /// + /// `Model` should be a reference type (class, or class-constrained protocol) — XUI + /// identifies the presented item by object identity. The generic is intentionally + /// unconstrained so that protocol-typed view models (`any MyViewModel` where + /// `MyViewModel: AnyObject`) can be passed through; Swift's generic system would + /// otherwise reject those at the call site because protocols don't self-conform to + /// `AnyObject`. Passing a value-type `Model` will misbehave: SwiftUI's `Identifiable` + /// item identity is computed from a freshly-boxed `ObjectIdentifier` on every read, + /// causing the sheet to re-present continuously. + public func sheet( model: Binding, - @ViewBuilder content: @escaping (Model) -> Content + @ViewBuilder destination: @escaping (Model) -> Destination ) -> some View { sheet(item: model.objectIdentifiable()) { _ in - model.wrappedValue.map(content) + model.wrappedValue.map(destination) } } diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index be683f3..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import XUITests - -var tests = [XCTestCaseEntry]() -tests += XUITests.allTests() -XCTMain(tests) diff --git a/Tests/XUITests/XCTestManifests.swift b/Tests/XUITests/XCTestManifests.swift deleted file mode 100644 index 0710550..0000000 --- a/Tests/XUITests/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(XUITests.allTests), - ] -} -#endif diff --git a/Tests/XUITests/XUITests.swift b/Tests/XUITests/XUITests.swift index 9c95edc..58ffbb4 100644 --- a/Tests/XUITests/XUITests.swift +++ b/Tests/XUITests/XUITests.swift @@ -7,8 +7,4 @@ final class XUITests: XCTestCase { // Use XCTAssert and related functions to verify your tests produce the correct // results. } - - static var allTests = [ - ("testExample", testExample), - ] } From d91cf7bb822f017acf3eab80d6305032fc3f8907 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 28 May 2026 14:15:24 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/XUI/DeepLink/DeepLinkable.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/XUI/DeepLink/DeepLinkable.swift b/Sources/XUI/DeepLink/DeepLinkable.swift index f22fa25..113266b 100644 --- a/Sources/XUI/DeepLink/DeepLinkable.swift +++ b/Sources/XUI/DeepLink/DeepLinkable.swift @@ -2,7 +2,7 @@ // DeepLinkable.swift // XUI // -// Created by Paul Kraft by 01.03.21. +// Created by Paul Kraft on 01.03.21. // Copyright © 2021 QuickBird Studios. All rights reserved. //