diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
new file mode 100644
index 0000000..2307f28
--- /dev/null
+++ b/.github/workflows/documentation.yml
@@ -0,0 +1,52 @@
+name: Deploy Documentation
+
+on:
+ push:
+ tags:
+ - '*'
+ workflow_dispatch:
+
+# Set permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages.
+permissions:
+ contents: read
+ id-token: write
+ pages: write
+
+# Allow one concurrent deployment. Do not cancel in-flight deployments because we don't want assets to be in a
+# a semi-deployed state.
+concurrency:
+ group: "documentation"
+ cancel-in-progress: false
+
+jobs:
+ deploy-documentation:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: macos-15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Set Up GitHub Pages
+ uses: actions/configure-pages@v3
+ - name: Build Documentation
+ run: |
+ xcodebuild docbuild \
+ -scheme HierarchyResponder \
+ -derivedDataPath ./build \
+ -destination 'generic/platform=iOS';
+ mkdir public;
+ $(xcrun --find docc) process-archive \
+ transform-for-static-hosting ./build/Build/Products/Debug-iphoneos/HierarchyResponder.doccarchive \
+ --hosting-base-path HierarchyResponder \
+ --output-path ./public;
+ - name: Create index.html
+ run: |
+ echo "" > ./public/index.html;
+ - name: Upload Documentation Artifact to GitHub Pages
+ uses: actions/upload-pages-artifact@v1
+ with:
+ path: public
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v2
\ No newline at end of file
diff --git a/.spi.yml b/.spi.yml
new file mode 100644
index 0000000..2930546
--- /dev/null
+++ b/.spi.yml
@@ -0,0 +1,3 @@
+version: 1
+external_links:
+ documentation: "https://emiliopelaez.github.io/HierarchyResponder/"
\ No newline at end of file
diff --git a/.swift-version b/.swift-version
index e5e7441..f0933d4 100644
--- a/.swift-version
+++ b/.swift-version
@@ -1 +1 @@
-5.5
\ No newline at end of file
+5.10
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c29d88e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Emilio Pelaez Romero
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Package.swift b/Package.swift
index 854230c..0121271 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version: 5.9
+// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
diff --git a/README.md b/README.md
index 5adbb7e..fae1e58 100644
--- a/README.md
+++ b/README.md
@@ -1,68 +1,21 @@
# Hierarchy Responder
+
+
+[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmilioPelaez%2FHierarchyResponder%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/EmilioPelaez/HierarchyResponder)
+[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmilioPelaez%2FHierarchyResponder%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/EmilioPelaez/HierarchyResponder)
+
[![tests](https://github.com/EmilioPelaez/HierarchyResponder/actions/workflows/tests.yml/badge.svg)](https://github.com/EmilioPelaez/HierarchyResponder/actions/workflows/tests.yml)
[![codecov](https://codecov.io/gh/EmilioPelaez/HierarchyResponder/branch/main/graph/badge.svg?token=05Y9RYF45B)](https://codecov.io/gh/EmilioPelaez/HierarchyResponder)
-[![Platforms](https://img.shields.io/badge/platforms-iOS%20|%20macOS%20|%20tvOS%20%20|%20watchOS-lightgray.svg)]()
-[![Swift 5.6](https://img.shields.io/badge/swift-5.6-orange.svg?style=flat)](https://developer.apple.com/swift)
+
[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://opensource.org/licenses/MIT)
-[![Twitter](https://img.shields.io/badge/twitter-@emiliopelaez-blue.svg)](http://twitter.com/emiliopelaez)
-`HierarchyResponder` is a framework designed to use the SwiftUI view hierarchy as a responder chain for event and error handling handling. By using the view hierarchy to report errors and trigger events, views can become more indepentent without sacrificing communication with other views.
+`HierarchyResponder` is a framework that allows you to use the SwiftUI view hierarchy as a responder chain for event and error handling. By using the view hierarchy to report errors and trigger events, views can become more indepentent without sacrificing communication with other views.
To report an error or trigger an event, a view reads a closure from the environment, and calls that closure with the event or error as a parameter.
Views above in the view hierarchy can register different responders that will be executed with the event or error as a parameter.
-
- Expand for a detailed explanation.
-
-A common pattern in SwiftUI is to pass action callbacks as closures that make their way down the view hierarchy as parameters in each view in the hierarchy. This, however, leads to views that receive these closures as parameters but make no use of them, besides passing them down.
-
-In the simplified example below, `GridView` receives `selectionAction` as a parameter but never calls it. In a real-world application, there could be a lot more intermediate views, all of them carrying multiple parameters they only transport and don't use.
-
-### Without HierarchyResponder
-
-```swift
-struct ParentView: View {
- @State var selection: Item?
- let items: [Item] = ...
-
- var body: some View {
- GridView(items: items, selectionAction: selectItem)
- }
-
- func selectItem(_ item: Item) {
- selection = item
- }
-}
-
-struct GridView: View {
- let items: [Item]
- let selectionAction: (Item) -> Void // <-
-
- var body: some View {
- ForEach(items) { item in
- ItemView(item: item, selectionAction: selectionAction)
- }
- }
-}
-
-struct ItemView: View {
- let item: Item
- let selectionAction: (Item) -> Void
-
- var body: some View {
- ItemPreview(_ item: Item)
- .onTapGesture {
- selectionAction(item)
- }
- }
-}
-```
-
-By using the view hierarchy as a responder chain, the triggering of the event (or error) and the responding to it are isolated to the views that are active participants.
-
-### With HierarchyResponder
```swift
struct ItemSelectionEvent: Event {
let item: Item
@@ -74,9 +27,12 @@ struct ParentView: View {
var body: some View {
GridView(items: items)
- .handleEvent(ItemSelectionEvent.self) {
- selection = $0.item
+ // The handleEvent modifier registers a responder closure to be executed when
+ // an ItemSelectionEvent is triggered in any descendant view
+ .handleEvent(ItemSelectionEvent.self) { event in
+ selection = event.item
}
+ .sheet(item: selection) { ItemDetail($0) }
}
}
@@ -95,25 +51,27 @@ struct ItemView: View {
let item: Item
var body: some View {
- ItemPreview(_ item: Item)
+ ItemPreview(item)
.onTapGesture {
+ // The triggerEvent object, called as a closure, triggers an event which
+ // will be received by any ancestor views that registered a responder
triggerEvent(ItemSelectionEvent(item: item))
}
}
}
```
-For a longer explanation of this functionality, you can read [this article](https://betterprogramming.pub/building-a-responder-chain-using-the-swiftui-view-hierarchy-2a08df23689c).
+## Getting Started
-
+The basics to understand how to use the hierarchy responder pattern.
-## Event Protocol
+### The Event Protocol
-`Event` is requirement-less protocol that identifies a type as an event that can be sent up the SwiftUI view hierarchy.
+`Event` is a requirement-less protocol that identifies a type as an event that can be sent up the SwiftUI view hierarchy.
-It can be of any type and contain any kind of additional information. It exists to avoid annotating the types used by methods in this framework as `Any`.
+It can be of any type and contain any kind of additional information. It exists to be able to easily identify a type as an object, and to avoid having to annotate the types used by methods in this framework as `Any`.
-## Triggering an Event
+### Triggering an Event
Events are triggered using the `triggerEvent` object that can be read from the `Environment`. Since this object implements `callAsFunction`, it can be called like closure.
@@ -131,7 +89,7 @@ struct TriggerView: View {
}
```
-## Reporting an Error
+### Reporting an Error
In a similar way to events, errors are triggered using the `reportError` closure. Since this object implements `callAsFunction`, it can be called like closure.
@@ -149,29 +107,43 @@ struct TriggerView: View {
}
```
-## What's a Responder
+### Handling an Event
-Responders are closures that "respond" in different ways to events or errors being triggered or reported by a view down in the view hierarchy.
-
-There's several kinds of responders, and each responder has two versions, one that will respond to any kind of event or error, and one that receives the type of an event or error as the first parameter and will only act on values of that type.
+Events and Errors are handled using one of the multiple responders. The `.handleEvent` responder below, for example, receives only events of the type `MyEvent`.
```swift
struct ContentView: View {
var body: some View {
TriggerView()
- .handleEvent(MyEvent.self) {
- // Only events of the type MyEvent will be handled
- }
- .handleEvent {
- // All event types will be handled here
+ .handleEvent(MyEvent.self) { event in
+ // Do something with event
}
}
}
```
-## Registering Responders
+## Understanding Responders
+
+Using responders to receive or handle events and errors.
-Registering a responder is done using the modifier syntax, and just like with any other modifier in SwiftUI, the order in which they are executed matters.
+### What are responders?
+
+Responders are closures that "respond" in different ways to events or errors being triggered or reported by a view down in the view hierarchy.
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .handleEvent(MyEvent.self) { event in
+ // Do something with event
+ }
+ }
+}
+```
+
+### Registering Responders
+
+Registering a responder is done using the modifier syntax, and just like with any other modifier in SwiftUI, the order in which they are executed **matters**.
In simple terms, responders will be called in the order they added to the view, which is inverse to their position in the view hierarchy.
@@ -182,18 +154,24 @@ struct ContentView: View {
var body: some View {
TriggerView()
.handleEvent(MyEvent.self) {
- // Will be called first
+ // Will be called first and absorb `MyEvent` objects
}
.handleEvent {
- // Will be called second
+ // Will be called second, will not receive any `MyEvent` objects
}
}
}
```
-### Receiving an Event or Error
+### Different Kinds of Responders
+
+There's several kinds of responders, and each responder has two versions, one that will respond to any kind of event or error, and one that receives the type of an event or error as the first parameter and will only act on values of that type.
+
+It is recommended to use explicit responders, aka responders that specify the type of the event or error they will receive. Doing this in combination with the safety modifiers will perform runtime checks and warn you when an event or error doesn't have an associated responder.
-When registering a receive responder, the handling closure can determine if the event or error was handled or not.
+#### Receiving an Event or Error
+
+When registering a receive responder, the handling closure will determine if the event or error was handled or not.
If the event or error was handled, the closure should return `.handled`, otherwise it should return `.unhandled`.
@@ -214,36 +192,29 @@ struct ContentView: View {
}
```
-### Handling an Event or Error
+#### Handling an Event or Error
Handle responders will consume the event or error they receive, which will stop it from propagating up the view hierarchy. This is equivalent to using a `receiveEvent` closure that always returns `.handled`.
-### Transforming an Event or Error
+#### Transforming an Event or Error
Transforming functions can be used to replace the received value with another.
-### Catching Errors
-
-Catching responders allow you to receive an error and convert it into an event that will be propagated instead.
-
```swift
-struct UnauthenticatedError: Error {}
-struct ShowSignInEvent: Event {}
-
struct ContentView: View {
var body: some View {
TriggerView()
- .catchError(UnauthenticatedError.self) {
- ShowSignInEvent()
+ .transformEvent(MyEvent.self) {
+ return AnotherEvent()
}
}
}
```
-### Failable Responders
+#### Failable Responders
-All event responders, as well as the catchError responders, receive a throwing closure. Any errors thrown inside this closure will be propagated up the view hierarchy as if it had been reported using the `reportError` closure.
+All event responders, as well as the `catchError` responders, receive a throwing closure. Any errors thrown inside this closure will be propagated up the view hierarchy as if it had been reported using the `reportError` closure.
```swift
struct ContentView: View {
@@ -259,19 +230,74 @@ struct ContentView: View {
}
```
-## Events originating outside the View Hierarchy
+#### Catching Errors
+
+Catching responders allow you to receive an error and convert it into an event that will be propagated instead.
+
+```swift
+struct UnauthenticatedError: Error {}
+struct ShowSignInEvent: Event {}
+
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .catchError(UnauthenticatedError.self) {
+ ShowSignInEvent()
+ }
+ }
+}
+```
+
+## External Events
+
+Handling events that are originated outside the View Hierarchy
+
The `triggerEvent` can be used to handle events that are originated within the view hierarchy, but some events, like menu bar actions, intents, deep linking, navigation events, shake events, etc. can originate from outside of the view hierarchy, and it can be tricky to make sure they're delivered to the right view.
The `.publisher` view modifier generates an `EventPublisher` object that can be used to publish an event that will traverse the view hierarchy "downwards", allowing us to, by default, find the last subscriber to the event.
For example, imagine you have multiple views listening to shake events via NotificationCenter, but as the user navigates through the app, some of these views may not be on screen but still be present in the view hierarchy. You could listen to the NotificationCenter event at the root of your app and publish an event that will only be delivered to the last subscriber, which would be the view that is currently on screen.
-## Other Goodies
+## Safety
+
+Best practices to avoid unhandled events and errors
+
+A trade-off of the flexibility offered by this pattern is that it can be easy to lose track of where events are generated and handled. A few modifiers are included to mitigate this.
+
+The `.triggers` and `.reports` modifiers can be used by views that contain complex hierarchies (like a screen view) to expose a kind of "API" to any views that utilize it.
+
+```swift
+struct FeedView: View {
+ var body: some View {
+ FeedList()
+ .triggers(ShowPostEvent.self, ShowProfileEvent.self)
+ .reports(AuthenticationError.self)
+ }
+}
+```
+
+These modifiers are not only useful for the developer consuming the `FeedView` object, they also act as a runtime check that will raise a warning if an event or error that is not on the list is utilized, and when explicit responders are required (enabled by default) it will raise a warning if there are no responders for these specific events and errors above in the hierarchy.
+
+The `.responderSafetyLevel` and `.requireExplicitResponders` modifiers allow customization of the behavior of the safety checks. The default responder safety level will throw console warnings, the strict safety level will trigger a `fatalError`, and the disabled safety level will ignore any infractions. All of these checks are disabled on release builds, to avoid causing crashes for users.
+
+## Goodies
+
+Small utilities to make things easier.
### EventButton
`EventButton` is essentially a wrapper for `Button` that receives, instead of an action closure, an `Event` that is triggered whenever the underlying Button's action would be called.
+```swift
+// Before:
+Button("Tap Me") {
+ // Perform action
+}
+
+// After:
+EventButton("Tap Me", event: MyEvent())
+```
+
### onTapGesture(trigger:)
The `onTapGesture(trigger:)` modifier works just like `onTapGesture(perform:)`, but instead of executing a closure it triggers an event.
diff --git a/SocialImage.png b/SocialImage.png
new file mode 100644
index 0000000..ad0ef56
Binary files /dev/null and b/SocialImage.png differ
diff --git a/SocialImage.pxd b/SocialImage.pxd
new file mode 100644
index 0000000..2f5edd8
Binary files /dev/null and b/SocialImage.pxd differ
diff --git a/Sources/HierarchyResponder/Documentation.docc/Documentation.md b/Sources/HierarchyResponder/Documentation.docc/Documentation.md
new file mode 100644
index 0000000..06c17f6
--- /dev/null
+++ b/Sources/HierarchyResponder/Documentation.docc/Documentation.md
@@ -0,0 +1,71 @@
+# ``HierarchyResponder``
+
+Use the SwiftUI View Hierarchy as a responder chain
+
+![Image Banner](SocialImage.png)
+
+`HierarchyResponder` is a framework that allows you to use the SwiftUI view hierarchy as a responder chain for event and error handling. By using the view hierarchy to report errors and trigger events, views can become more indepentent without sacrificing communication with other views.
+
+To report an error or trigger an event, a view reads a closure from the environment, and calls that closure with the event or error as a parameter.
+
+Views above in the view hierarchy can register different responders that will be executed with the event or error as a parameter.
+
+```swift
+struct ItemSelectionEvent: Event {
+ let item: Item
+}
+
+struct ParentView: View {
+ @State var selection: Item?
+ let items: [Item] = ...
+
+ var body: some View {
+ GridView(items: items)
+ // The handleEvent modifier registers a responder closure to be executed when
+ // an ItemSelectionEvent is triggered in any descendant view
+ .handleEvent(ItemSelectionEvent.self) { event in
+ selection = event.item
+ }
+ .sheet(item: selection) { ItemDetail($0) }
+ }
+}
+
+struct GridView: View {
+ let items: [Item]
+
+ var body: some View {
+ ForEach(items) { item in
+ ItemView(item: item)
+ }
+ }
+}
+
+struct ItemView: View {
+ @Environment(\.triggerEvent) var triggerEvent
+ let item: Item
+
+ var body: some View {
+ ItemPreview(item)
+ .onTapGesture {
+ // The triggerEvent object, called as a closure, triggers an event which
+ // will be received by any ancestor views that registered a responder
+ triggerEvent(ItemSelectionEvent(item: item))
+ }
+ }
+}
+```
+
+## Read Next
+
+@Links(visualStyle: detailedGrid) {
+ -
+}
+
+## Dig Deeper
+
+@Links(visualStyle: detailedGrid) {
+ -
+ -
+ -
+ -
+}
diff --git a/Sources/HierarchyResponder/Documentation.docc/GettingStarted.md b/Sources/HierarchyResponder/Documentation.docc/GettingStarted.md
new file mode 100644
index 0000000..98158bf
--- /dev/null
+++ b/Sources/HierarchyResponder/Documentation.docc/GettingStarted.md
@@ -0,0 +1,76 @@
+# Getting Started
+
+The basics to understand how to use the hierarchy responder pattern.
+
+## The Event Protocol
+
+`Event` is a requirement-less protocol that identifies a type as an event that can be sent up the SwiftUI view hierarchy.
+
+It can be of any type and contain any kind of additional information. It exists to be able to easily identify a type as an object, and to avoid having to annotate the types used by methods in this framework as `Any`.
+
+## Triggering an Event
+
+Events are triggered using the `triggerEvent` object that can be read from the `Environment`. Since this object implements `callAsFunction`, it can be called like closure.
+
+```swift
+struct MyEvent: Event {}
+
+struct TriggerView: View {
+ @Environment(\.triggerEvent) var triggerEvent
+
+ var body: some View {
+ Button("Trigger") {
+ triggerEvent(MyEvent())
+ }
+ }
+}
+```
+
+## Reporting an Error
+
+In a similar way to events, errors are triggered using the `reportError` closure. Since this object implements `callAsFunction`, it can be called like closure.
+
+```swift
+struct MyError: Error {}
+
+struct TriggerView: View {
+ @Environment(\.reportError) var reportError
+
+ var body: some View {
+ Button("Trigger") {
+ reportError(MyError())
+ }
+ }
+}
+```
+
+## Handling an Event
+
+Events and Errors are handled using one of the multiple responders. The `.handleEvent` responder below, for example, receives only events of the type `MyEvent`.
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .handleEvent(MyEvent.self) { event in
+ // Do something with event
+ }
+ }
+}
+```
+
+@Links(visualStyle: detailedGrid) {
+ -
+}
+
+## Topics
+
+### Events
+
+- ``SwiftUICore/EnvironmentValues/triggerEvent``
+- ``SwiftUICore/View/handleEvent(_:handler:)-2lm98``
+
+### Errors
+
+- ``SwiftUICore/EnvironmentValues/reportError``
+- ``SwiftUICore/View/handleError(_:handler:)-3n403``
diff --git a/Sources/HierarchyResponder/Documentation.docc/Goodies.md b/Sources/HierarchyResponder/Documentation.docc/Goodies.md
new file mode 100644
index 0000000..039de11
--- /dev/null
+++ b/Sources/HierarchyResponder/Documentation.docc/Goodies.md
@@ -0,0 +1,34 @@
+# Goodies
+
+Small utilities to make things easier.
+
+## EventButton
+
+`EventButton` is essentially a wrapper for `Button` that receives, instead of an action closure, an `Event` that is triggered whenever the underlying Button's action would be called.
+
+```swift
+// Before:
+Button("Tap Me") {
+ // Perform action
+}
+
+// After:
+EventButton("Tap Me", event: MyEvent())
+```
+
+## onTapGesture(trigger:)
+
+The `onTapGesture(trigger:)` modifier works just like `onTapGesture(perform:)`, but instead of executing a closure it triggers an event.
+
+## AlertableErrors
+
+`AlertableError` is a protocol that conforms to Error and represents a user-friendly error with a message and an optional title.
+
+By using the `.handleAlertErrors()` modifier, errors that conform to the `AlertableError` protocol will be handled by displaying an alert with the title and message provided by the error.
+
+## Topics
+
+- ``EventButton``
+- ``SwiftUICore/View/onTapGesture(count:trigger:)``
+- ``AlertableError``
+- ``SwiftUICore/View/handleAlertErrors()``
diff --git a/Sources/HierarchyResponder/Documentation.docc/PublishedEvents.md b/Sources/HierarchyResponder/Documentation.docc/PublishedEvents.md
new file mode 100644
index 0000000..08a1c82
--- /dev/null
+++ b/Sources/HierarchyResponder/Documentation.docc/PublishedEvents.md
@@ -0,0 +1,15 @@
+# External Events
+
+Handling events that are originated outside the View Hierarchy
+
+The `triggerEvent` can be used to handle events that are originated within the view hierarchy, but some events, like menu bar actions, intents, deep linking, navigation events, shake events, etc. can originate from outside of the view hierarchy, and it can be tricky to make sure they're delivered to the right view.
+
+The `.publisher` view modifier generates an `EventPublisher` object that can be used to publish an event that will traverse the view hierarchy "downwards", allowing us to, by default, find the last subscriber to the event.
+
+For example, imagine you have multiple views listening to shake events via NotificationCenter, but as the user navigates through the app, some of these views may not be on screen but still be present in the view hierarchy. You could listen to the NotificationCenter event at the root of your app and publish an event that will only be delivered to the last subscriber, which would be the view that is currently on screen.
+
+## Topics
+
+- ``EventPublisher``
+- ``SwiftUICore/View/publisher(for:destination:register:)``
+- ``SwiftUICore/View/subscribe(to:handler:)``
diff --git a/Sources/HierarchyResponder/Documentation.docc/Resources/SocialImage.png b/Sources/HierarchyResponder/Documentation.docc/Resources/SocialImage.png
new file mode 100644
index 0000000..ad0ef56
Binary files /dev/null and b/Sources/HierarchyResponder/Documentation.docc/Resources/SocialImage.png differ
diff --git a/Sources/HierarchyResponder/Documentation.docc/Safety.md b/Sources/HierarchyResponder/Documentation.docc/Safety.md
new file mode 100644
index 0000000..4c5be0e
--- /dev/null
+++ b/Sources/HierarchyResponder/Documentation.docc/Safety.md
@@ -0,0 +1,28 @@
+# Safety
+
+Best practices to avoid unhandled events and errors
+
+A trade-off of the flexibility offered by this pattern is that it can be easy to lose track of where events are generated and handled. A few modifiers are included to mitigate this.
+
+The `.triggers` and `.reports` modifiers can be used by views that contain complex hierarchies (like a screen view) to expose a kind of "API" to any views that utilize it.
+
+```swift
+struct FeedView: View {
+ var body: some View {
+ FeedList()
+ .triggers(ShowPostEvent.self, ShowProfileEvent.self)
+ .reports(AuthenticationError.self)
+ }
+}
+```
+
+These modifiers are not only useful for the developer consuming the `FeedView` object, they also act as a runtime check that will raise a warning if an event or error that is not on the list is utilized, and when explicit responders are required (enabled by default) it will raise a warning if there are no responders for these specific events and errors above in the hierarchy.
+
+The `.responderSafetyLevel` and `.requireExplicitResponders` modifiers allow customization of the behavior of the safety checks. The default responder safety level will throw console warnings, the strict safety level will trigger a `fatalError`, and the disabled safety level will ignore any infractions. All of these checks are disabled on release builds, to avoid causing crashes for users.
+
+## Topics
+
+- ``SwiftUICore/View/triggers(_:file:line:)``
+- ``SwiftUICore/View/reports(_:file:line:)``
+- ``SwiftUICore/View/responderSafetyLevel(_:)``
+- ``SwiftUICore/View/requireExplicitResponders(_:)``
diff --git a/Sources/HierarchyResponder/Documentation.docc/UnderstandingResponders.md b/Sources/HierarchyResponder/Documentation.docc/UnderstandingResponders.md
new file mode 100644
index 0000000..b7bf89c
--- /dev/null
+++ b/Sources/HierarchyResponder/Documentation.docc/UnderstandingResponders.md
@@ -0,0 +1,155 @@
+
+# Understanding Responders
+
+Using responders to receive or handle events and errors.
+
+## What are responders?
+
+Responders are closures that "respond" in different ways to events or errors being triggered or reported by a view down in the view hierarchy.
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .handleEvent(MyEvent.self) { event in
+ // Do something with event
+ }
+ }
+}
+```
+
+## Registering Responders
+
+Registering a responder is done using the modifier syntax, and just like with any other modifier in SwiftUI, the order in which they are executed **matters**.
+
+In simple terms, responders will be called in the order they added to the view, which is inverse to their position in the view hierarchy.
+
+For a better understanding of the view hierarchy you can read [this article](https://betterprogramming.pub/building-a-responder-chain-using-the-swiftui-view-hierarchy-2a08df23689c).
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .handleEvent(MyEvent.self) {
+ // Will be called first and absorb `MyEvent` objects
+ }
+ .handleEvent {
+ // Will be called second, will not receive any `MyEvent` objects
+ }
+ }
+}
+```
+
+## Different Kinds of Responders
+
+There's several kinds of responders, and each responder has two versions, one that will respond to any kind of event or error, and one that receives the type of an event or error as the first parameter and will only act on values of that type.
+
+It is recommended to use explicit responders, aka responders that specify the type of the event or error they will receive. Doing this in combination with the safety modifiers will perform runtime checks and warn you when an event or error doesn't have an associated responder.
+
+### Receiving an Event or Error
+
+When registering a receive responder, the handling closure will determine if the event or error was handled or not.
+
+If the event or error was handled, the closure should return `.handled`, otherwise it should return `.unhandled`.
+
+Unhandled events will continue to be propagated up the view hierarchy.
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .receiveEvent { event in
+ if canHandle(event) {
+ // Do something
+ return .handled
+ }
+ return .notHandled
+ }
+ }
+}
+```
+
+### Handling an Event or Error
+
+Handle responders will consume the event or error they receive, which will stop it from propagating up the view hierarchy. This is equivalent to using a `receiveEvent` closure that always returns `.handled`.
+
+
+### Transforming an Event or Error
+
+Transforming functions can be used to replace the received value with another.
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .transformEvent(MyEvent.self) {
+ return AnotherEvent()
+ }
+ }
+}
+```
+
+### Failable Responders
+
+All event responders, as well as the `catchError` responders, receive a throwing closure. Any errors thrown inside this closure will be propagated up the view hierarchy as if it had been reported using the `reportError` closure.
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .handleEvent { event in
+ guard canHandle(event) else {
+ throw AnError()
+ }
+ // Handle Event
+ }
+ }
+}
+```
+
+### Catching Errors
+
+Catching responders allow you to receive an error and convert it into an event that will be propagated instead.
+
+```swift
+struct UnauthenticatedError: Error {}
+struct ShowSignInEvent: Event {}
+
+struct ContentView: View {
+ var body: some View {
+ TriggerView()
+ .catchError(UnauthenticatedError.self) {
+ ShowSignInEvent()
+ }
+ }
+}
+```
+
+## Topics
+
+### Receiving Events and Errors
+
+- ``SwiftUICore/View/receiveEvent(_:)``
+- ``SwiftUICore/View/receiveEvent(_:closure:)-6l11u``
+- ``SwiftUICore/View/receiveError(_:)``
+- ``SwiftUICore/View/receiveError(_:closure:)-9lqgx``
+
+### Handling Events and Errors
+
+- ````
+- ``SwiftUICore/View/handleEvent(_:)``
+- ``SwiftUICore/View/handleEvent(_:handler:)-2lm98``
+- ``SwiftUICore/View/handleError(_:)``
+- ``SwiftUICore/View/handleError(_:handler:)-3n403``
+
+### Transforming Events and Errors
+
+- ``SwiftUICore/View/transformEvent(_:)``
+- ``SwiftUICore/View/transformEvent(_:transform:)-8qt1d``
+- ``SwiftUICore/View/transformError(_:)``
+- ``SwiftUICore/View/transformError(_:transform:)-22chc``
+
+### Recovering from Errors
+
+- ``SwiftUICore/View/catchError(_:)``
+- ``SwiftUICore/View/catchError(_:handler:)-8s21e``
diff --git a/Sources/HierarchyResponder/Errors/ErrorEnvironmentValues.swift b/Sources/HierarchyResponder/Errors/ErrorEnvironmentValues.swift
index 46ca3f3..6fb28ba 100644
--- a/Sources/HierarchyResponder/Errors/ErrorEnvironmentValues.swift
+++ b/Sources/HierarchyResponder/Errors/ErrorEnvironmentValues.swift
@@ -4,6 +4,17 @@
import SwiftUI
+/**
+ This object, which can be used as a closure, can be used when an `Error`
+ that can't be handled by the current view is generated. The `Error` will be
+ sent up the view hierarchy until it is handled by another view.
+
+ Views can register a closure to handle these `Errors` using the
+ `receiveError` and `handleError` view modifiers.
+
+ If no view has registered an action that handles the `Error`, an
+ `assertionFailure` will be triggered.
+ */
public struct ReportError {
let function: (Error) -> Void
@@ -14,9 +25,9 @@ public struct ReportError {
public extension EnvironmentValues {
/**
- This closure can be used when an `Error` that can't be handled by the
- current view is generated. The `Error` will be sent up the view hierarchy
- until it is handled by another view.
+ This object, which can be used as a closure, can be used when an `Error`
+ that can't be handled by the current view is generated. The `Error` will be
+ sent up the view hierarchy until it is handled by another view.
Views can register a closure to handle these `Errors` using the
`receiveError` and `handleError` view modifiers.
diff --git a/Sources/HierarchyResponder/Events/EventEnvironmentValues.swift b/Sources/HierarchyResponder/Events/EventEnvironmentValues.swift
index 459a1aa..db5baa6 100644
--- a/Sources/HierarchyResponder/Events/EventEnvironmentValues.swift
+++ b/Sources/HierarchyResponder/Events/EventEnvironmentValues.swift
@@ -4,6 +4,17 @@
import SwiftUI
+/**
+ This object, which can be used as a closure, can be used when an `Event`
+ that can't be handled by the current view is generated. The `Event` will be
+ sent up the view hierarchy until it is handled by another view.
+
+ Views can register a closure to handle these `Events` using the
+ `receiveEvent` and `handleEvent` view modifiers, among others.
+
+ If no view has registered an event that handles the `Event`, an
+ `assertionFailure` will be triggered unless the view is being previewed.
+*/
public struct TriggerEvent {
let function: (Event) -> Void
@@ -14,15 +25,15 @@ public struct TriggerEvent {
public extension EnvironmentValues {
/**
- This closure can be used when an `Event` that can't be handled by the
- current view is generated. The `Event` will be sent up the view hierarchy
- until it is handled by another view.
+ This object, which can be used as a closure, can be used when an `Event`
+ that can't be handled by the current view is generated. The `Event` will be
+ sent up the view hierarchy until it is handled by another view.
Views can register a closure to handle these `Events` using the
- `receiveEvent` and `handleEvent` view modifiers.
+ `receiveEvent` and `handleEvent` view modifiers, among others.
If no view has registered an event that handles the `Event`, an
- `assertionFailure` will be triggered.
+ `assertionFailure` will be triggered unless the view is being previewed.
*/
var triggerEvent: TriggerEvent {
get { self[EventClosureEnvironmentKey.self] }
diff --git a/Sources/HierarchyResponder/Internal/EnvironmentValues.swift b/Sources/HierarchyResponder/Internal/EnvironmentValues.swift
index 79606c2..aee2bbc 100644
--- a/Sources/HierarchyResponder/Internal/EnvironmentValues.swift
+++ b/Sources/HierarchyResponder/Internal/EnvironmentValues.swift
@@ -11,7 +11,12 @@ struct ErrorClosureEnvironmentKey: EnvironmentKey {
}
struct EventClosureEnvironmentKey: EnvironmentKey {
- static var defaultValue: TriggerEvent = .init { _ in }
+ static var defaultValue: TriggerEvent = .init { event in
+ guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else {
+ return
+ }
+ assertionFailure("Unhandled Event \(event)")
+ }
}
struct ResponderSafetyLevelKey: EnvironmentKey {
diff --git a/Sources/HierarchyResponder/Internal/View+Safety.swift b/Sources/HierarchyResponder/Internal/View+Safety.swift
new file mode 100644
index 0000000..808d486
--- /dev/null
+++ b/Sources/HierarchyResponder/Internal/View+Safety.swift
@@ -0,0 +1,18 @@
+//
+// View+Safety.swift
+// HierarchyResponder
+//
+// Created by Emilio Peláez on 12/24/24.
+//
+
+import SwiftUI
+
+extension View {
+ func registerHandler(for event: any Event.Type) -> some View {
+ transformEnvironment(\.handledEvents) { $0.append(event) }
+ }
+
+ func registerHandler(for error: any Error.Type) -> some View {
+ transformEnvironment(\.handledErrors) { $0.append(error) }
+ }
+}
diff --git a/Sources/HierarchyResponder/Safety/Safety.swift b/Sources/HierarchyResponder/Safety/Safety.swift
index 5612189..c3f5b60 100644
--- a/Sources/HierarchyResponder/Safety/Safety.swift
+++ b/Sources/HierarchyResponder/Safety/Safety.swift
@@ -4,16 +4,32 @@
import SwiftUI
+/**
+ The safety level that will be used when using the `triggers` and `reports`
+ view modifiers.
+ */
public enum ResponderSafetyLevel {
+ /// `strict` will trigger a `fatalError` when safety conditions are not met
case strict
+ /// `relax` will show a console warning when safety conditions are not met
case relaxed
+ /// `disabled` will have no actions when safety confitions are not met
case disabled
public static let `default` = ResponderSafetyLevel.relaxed
}
public extension View {
-
+ /**
+ The `triggers` modifier declares the types of the events that are expected to
+ be triggered by the descendants of the modified view.
+
+ It is used to document the behavior of the view, and also acts as a runtime
+ check by raising a warning when an event that was not declared is detected,
+ and when explicit responders are required (enabled by default) by raising a
+ warning if there are no responders for these specific events above in the
+ hierarchy.
+ */
func triggers(_ events: any Event.Type..., file: String = #file, line: Int = #line) -> some View {
#if DEBUG
modifier(EventSafetyModifier(events: events, location: "\(file):\(line)"))
@@ -22,6 +38,16 @@ public extension View {
#endif
}
+ /**
+ The `reports` modifier declares the types of the errors that are expected to
+ be triggered by the descendants of the modified view.
+
+ It is used to document the behavior of the view, and also acts as a runtime
+ check by raising a warning when an event that was not declared is detected,
+ and when explicit responders are required (enabled by default) by raising a
+ warning if there are no responders for these specific errors above in the
+ hierarchy.
+ */
func reports(_ errors: any Error.Type..., file: String = #file, line: Int = #line) -> some View {
#if DEBUG
modifier(ErrorSafetyModifier(errors: errors, location: "\(file):\(line)"))
@@ -30,19 +56,32 @@ public extension View {
#endif
}
+ /**
+ Sets the responder safety level for all the descendants in the view hierarchy.
+
+ The default, `relaxed`, will only log console warnings, while `strict` will
+ trigger a `fatalError`.
+
+ This will only work when the `triggers` and `reports` modifiers are used.
+ */
func responderSafetyLevel(_ level: ResponderSafetyLevel) -> some View {
environment(\.responderSafetyLevel, level)
}
+ /**
+ Require explicit responders is enabled by default, and when using the
+ `triggers` and `reports` modifiers, it will throw raise an error when an
+ event or error is declared that doesn't have a matching explicit responder in
+ the view hierarchy.
+
+ Explicit responders are all the responders that receive a type as their
+ first argument:
+
+ ```
+ .handleEvent(MyEvent.self) { ... }
+ ```
+ */
func requireExplicitResponders(_ flag: Bool) -> some View {
environment(\.requiresExplicitResponders, flag)
}
-
- func registerHandler(for event: any Event.Type) -> some View {
- transformEnvironment(\.handledEvents) { $0.append(event) }
- }
-
- func registerHandler(for error: any Error.Type) -> some View {
- transformEnvironment(\.handledErrors) { $0.append(error) }
- }
}