Skip to content

Commit d79caae

Browse files
authored
Enable SPI Documentation (#16)
This will hopefully enable docs on [SPI](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions). Closes #5.
1 parent 65f168d commit d79caae

File tree

3 files changed

+338
-1
lines changed

3 files changed

+338
-1
lines changed

.spi.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ builder:
33
configs:
44
- platform: ios
55
scheme: NavigationTransitions
6+
documentation_targets: [NavigationTransitions]
67
- platform: macos-xcodebuild
78
scheme: NavigationTransitions
89
- platform: tvos
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
# Custom Transitions
2+
3+
This document will guide you through the entire technical explanation on how to build custom navigation transitions.
4+
5+
As a first time reader, it is highly recommended that you read **Core Concepts** first before jumping onto one of the implementation sections, in order to understand the base abstractions you'll be working with.
6+
7+
- [**Core Concepts**](#Core-Concepts)
8+
- [**`NavigationTransition`**](#NavigationTransition)
9+
- [**`AtomicTransition`**](#AtomicTransition)
10+
11+
- [**Implementation**](#Implementation)
12+
13+
- [**Basic**](#Basic)
14+
- [**Intermediate**](#Intermediate)
15+
- [**Advanced**](#Advanced)
16+
17+
## Core Concepts
18+
19+
### `NavigationTransition`
20+
21+
The main construct the library leverages is called `NavigationTransition`. You may have seen some instances of this type in the code samples (e.g. `.slide`).
22+
23+
`NavigationTransition` instances describe both `push` and `pop` transitions for both *origin* and *destination* views.
24+
25+
Drawing from this previous example, if we dive into the implementation of `NavigationTransition.slide`, we'll find this:
26+
27+
```swift
28+
extension NavigationTransition {
29+
/// Equivalent to `move(axis: .horizontal)`.
30+
@inlinable
31+
public static var slide: Self {
32+
.move(axis: .horizontal)
33+
}
34+
}
35+
```
36+
37+
As the comment rightly documents, this statement is in fact **equivalent** to another transition called `.move`, which accepts a parameter `axis` to modify the direction of the movement.
38+
39+
This first glance at the type teaches us two things:
40+
41+
1. Transitions are **simple static extensions** of the type `NavigationTransition`.
42+
2. Transitions can be declared to **simplify access to more complex transitions**.
43+
44+
Let's go ahead and dive even deeper, into the implementation of `NavigationTransition.move(axis:)`:
45+
46+
```swift
47+
extension NavigationTransition {
48+
/// A transition that moves both views in and out along the specified axis.
49+
///
50+
/// This transition:
51+
/// - Pushes views right-to-left and pops views left-to-right when `axis` is `horizontal`.
52+
/// - Pushes views bottom-to-top and pops views top-to-bottom when `axis` is `vertical`.
53+
public static func move(axis: Axis) -> Self {
54+
switch axis {
55+
case .horizontal:
56+
return .asymmetric(
57+
push: .asymmetric(
58+
insertion: .move(edge: .trailing),
59+
removal: .move(edge: .leading)
60+
),
61+
pop: .asymmetric(
62+
insertion: .move(edge: .leading),
63+
removal: .move(edge: .trailing)
64+
)
65+
)
66+
case .vertical:
67+
return .asymmetric(
68+
push: .asymmetric(
69+
insertion: .move(edge: .bottom),
70+
removal: .move(edge: .top)
71+
),
72+
pop: .asymmetric(
73+
insertion: .move(edge: .top),
74+
removal: .move(edge: .bottom)
75+
)
76+
)
77+
}
78+
}
79+
}
80+
```
81+
82+
Ah, this is a lot more interesting!
83+
84+
Observe how the implementation body follows a very specific symmetrical shape. Funnily enough however, transitions themselves are built by using the term `asymmetric`, which we'll get to know in depth later in this read.
85+
86+
Notice how the entire transition is implemented concisely in around 25 lines of code, yet there's **no explicit `UIView` animation** code to be seen anywhere at this point. I'd like to direct your attention instead to what's actually describing the transition on each `.asymmetric` call: `.move(edge: ...)`.
87+
88+
If you've used the SwiftUI `transition` modifier before, you could easily mistake this for `AnyTransition.move(edge:)`, but this is in fact not the case. `.move(edge:)` actually belongs to another type that ships with this library: `AtomicTransition`.
89+
90+
### `AtomicTransition`
91+
92+
`AtomicTransition` is a SwiftUI `AnyTransition`-inspired type which acts very much in the same manner. It can describe a specific set of mutations to view properties on an individual ("atomic") basis, for both **insertion** and **removal** of said view.
93+
94+
Contrary to `NavigationTransition` and as the name indicates, `AtomicTransition` applies to only a **single view** out of the two, and is **agnostic** as to the **intent** (push or pop) of its **parent** `NavigationTransition`.
95+
96+
If we dive even deeper into `AtomicTransition.move(edge:)`, this is what we find:
97+
98+
```swift
99+
extension AtomicTransition {
100+
/// A transition entering from `edge` on insertion, and exiting towards `edge` on removal.
101+
public static func move(edge: Edge) -> Self {
102+
.custom { view, operation, container in
103+
switch (edge, operation) {
104+
case (.top, .insertion):
105+
view.initial.translation.dy = -container.frame.height
106+
view.animation.translation.dy = 0
107+
108+
case (.leading, .insertion):
109+
view.initial.translation.dx = -container.frame.width
110+
view.animation.translation.dx = 0
111+
112+
case (.trailing, .insertion):
113+
view.initial.translation.dx = container.frame.width
114+
view.animation.translation.dx = 0
115+
116+
case (.bottom, .insertion):
117+
view.initial.translation.dy = container.frame.height
118+
view.animation.translation.dy = 0
119+
120+
case (.top, .removal):
121+
view.animation.translation.dy = -container.frame.height
122+
view.completion.translation.dy = 0
123+
124+
case (.leading, .removal):
125+
view.animation.translation.dx = -container.frame.width
126+
view.completion.translation.dx = 0
127+
128+
case (.trailing, .removal):
129+
view.animation.translation.dx = container.frame.width
130+
view.completion.translation.dx = 0
131+
132+
case (.bottom, .removal):
133+
view.animation.translation.dy = container.frame.height
134+
view.completion.translation.dy = 0
135+
}
136+
}
137+
}
138+
}
139+
```
140+
141+
Now we're talking! There's some basic math and value assignments happening, but nothing resembling a typical `UIView` animation block just yet. Although there are some references to `animation` and `completion`, which are very familiar concepts in UIKit world.
142+
143+
We'll be covering what these are in just a moment, but as a closing thought before we jump onto the nitty gritty of the implementation, take a moment to acknowledge the inherent **layered approach** this library uses to describe transitions. This design philosophy is the basis for building great, non-glitchy transitions down the line.
144+
145+
## Implementation
146+
147+
### Basic
148+
149+
There are 2 main entry points for building a `NavigationTransition`:
150+
151+
#### `NavigationTransition.combined(with:)`
152+
153+
You can create a custom `NavigationTransition` by combining two existing transitions:
154+
155+
```swift
156+
.slide.combined(with: .fade(.in))
157+
```
158+
159+
It is rarely the case where you'd want to combine `NavigationTransition`s in this manner due to their nature as high level abstractions. In fact, most of the time they won't combine very well at all, and will produce glitchy or weird effects. This is because two or more fully-fledged transitions tend to override the same view properties with different values, producing unexpected outcomes.
160+
161+
Instead, most combinations should happen with the lower level abstraction `AtomicTransition`.
162+
163+
Regardless, it's still allowed for cases like `slide` + `fade(in:)`, which affect completely different properties of the view. Separatedly, `slide` only moves the views horizontally, and `.fade(.in)` fades views in. When combined, both occur at the same time without interfering with each other.
164+
165+
#### `NavigationTransition.asymmetric(push:pop:)`
166+
167+
```swift
168+
.asymmetric(push: .fade(.cross), pop: .slide)
169+
```
170+
171+
This second, more interesting entry point is one reminiscent of SwiftUI's asymmetric transition API. As the name suggest, this transition splits the `push` transition from the `pop` transition, to make them as different as you wish.
172+
173+
You can use this method with a pair of `NavigationTransition` values or, more importantly, a pair of `AtomicTransition` values. Most transitions will utilize the latter due to its superior granularity.
174+
175+
---
176+
177+
There are 2 main entry points for building an `AtomicTransition`:
178+
179+
#### `AtomicTransition.combined(with:)`
180+
181+
```swift
182+
.move(edge: .trailing).combined(with: .scale(0.5))
183+
```
184+
185+
The API is remarkably similar to `AnyTransition` on purpose, and acts on a single view in the same way you'd expect the first-party API to behave.
186+
187+
It's important to understand the **nuance** this entails: regardless of whether its parent transition is `push` or `pop`, this transition will insert the incoming view from the trailing edge and scale it from an initial value of 0.5 to a final value of 1. In the same manner, the outgoing view will be removed by moving away towards the same trailing edge and scaling down from 1 to 0.5. In order to actually apply a different edge movement for insertion vs removal you'll need to use the `.asymmetric` transition described below.
188+
189+
#### `AtomicTransition.asymmetric(insertion:removal:)`
190+
191+
```swift
192+
.asymmetric(
193+
insertion: .move(edge: .trailing).combined(with: .scale(0.5)),
194+
removal: .move(edge: .leading).combined(with: .scale(0.5))
195+
)
196+
```
197+
198+
Just like `AnyTransition.asymmetric`, this transition uses a different transition for insertion vs removal, and acts as a cornerstone for custom transitions along with its `NavigationTransition.asymmetric(push:pop:)` counterpart.
199+
200+
Now that you understand the 4 basic customization entry points the library has to offer, you should be able to refer back to the earlier [**example**](#NavigationTransition) and understand a bit more about how the entire implementation works.
201+
202+
### Intermediate
203+
204+
In addition to the basic entry points to customization described in the previous section, let's delve even deeper into how primitive transitions actually work. By "primitive transitions" I'm referring to standalone transitions which are not the result of composing other transitions together, but which rather define what the transition actually does to a view in animation terms.
205+
206+
The primitive transitions which currently ship with this library are:
207+
208+
- `AtomicTransition.identity`
209+
- `AtomicTransition.move(edge:)`
210+
- `AtomicTransition.offset(x:y:)`
211+
- `AtomicTransition.opacity(_:)`
212+
- `AtomicTransition.rotate(_:)`
213+
- `AtomicTransition.scale(_:)`
214+
- `AtomicTransition.zPosition(_:)`
215+
216+
Let's take another look at the implementation of `.move(edge:)`:
217+
218+
```swift
219+
extension AtomicTransition {
220+
/// A transition entering from `edge` on insertion, and exiting towards `edge` on removal.
221+
public static func move(edge: Edge) -> Self {
222+
.custom { view, operation, container in
223+
switch (edge, operation) {
224+
case (.top, .insertion):
225+
view.initial.translation.dy = -container.frame.height
226+
view.animation.translation.dy = 0
227+
228+
case (.leading, .insertion):
229+
view.initial.translation.dx = -container.frame.width
230+
view.animation.translation.dx = 0
231+
232+
case (.trailing, .insertion):
233+
view.initial.translation.dx = container.frame.width
234+
view.animation.translation.dx = 0
235+
236+
case (.bottom, .insertion):
237+
view.initial.translation.dy = container.frame.height
238+
view.animation.translation.dy = 0
239+
240+
case (.top, .removal):
241+
view.animation.translation.dy = -container.frame.height
242+
view.completion.translation.dy = 0
243+
244+
case (.leading, .removal):
245+
view.animation.translation.dx = -container.frame.width
246+
view.completion.translation.dx = 0
247+
248+
case (.trailing, .removal):
249+
view.animation.translation.dx = container.frame.width
250+
view.completion.translation.dx = 0
251+
252+
case (.bottom, .removal):
253+
view.animation.translation.dy = container.frame.height
254+
view.completion.translation.dy = 0
255+
}
256+
}
257+
}
258+
}
259+
```
260+
261+
Here we find neither `.asymmetric` nor `.combined` are used for this primitive transition. Instead, we find a `.custom` initializer with the following signature:
262+
263+
```swift
264+
// AtomicTransition.swift
265+
public static func custom(withTransientView handler: @escaping TransientViewHandler) -> Self
266+
```
267+
268+
... where `TransientViewHandler` is a typealias for `(TransientView, Operation, Container) -> Void`.
269+
270+
`TransientView` is actually an abstraction over the `UIView` which is being inserted or removed under the hood by UIKit (and thus SwiftUI) as part of a push or a pop. The reason this abstraction exists is because it helps abstract away all of the UIKit animation logic and instead allows one to focus on assigning the desired values for each stage of the transition (`initial`, `animation`, and `completion`). It also helps the transition engine with merging transition states under the hood, making sure two primitive transitions affecting the same property don't accidentally cause glitchy UI behavior.
271+
272+
Alongside `TransientView`, `Operation` defines whether the operation being performed is an `insertion` or a `removal` of the view, which should help you differentiate and set up your property values accordingly.
273+
274+
Finally, container is a direct typealias to `UIView`, and it represents the container in which the transition is ocurring. There's no need to add `TransientView` to this container as the library does this for you. Even better, there's no way to even accidentally do it because `TransientView` is not a `UIView` subclass.
275+
276+
---
277+
278+
Whilst composing `AtomicTransition`s is the recommended way of building up to a `NavigationTransition`, there is actually an **alternative** option for those who'd like to reach for a more wholistic API:
279+
280+
```swift
281+
// NavigationTransition.swift
282+
public static func custom(withTransientViews handler: @escaping TransientViewsHandler) -> Self
283+
```
284+
285+
... where `TransientViewsHandler` is a typealias for `(FromView, ToView, Operation, Container) -> Void`.
286+
287+
`FromView` and `ToView` are typealiases for the `TransientView`s corresponding to the origin and destination views involved in the transition.
288+
289+
Alongside them, `Operation` defines whether the operation being performed is a `push` or a `pop`. The concept of insertions or removals is entirely removed from this abstraction, since you can directly modify the property values for the views without needing atomic transitions.
290+
291+
This approach is often a simple one to take in case you're working on an app that only requires one custom navigation transition. However, if you're working on an app that features multiple custom transitions, it is recommended that you model your navigation transitions via atomic transitions as described earlier. In the long term, this will be beneficial to your development and iteration speed, by promoting code reusability amongst your team.
292+
293+
### Advanced
294+
295+
We're now exploring the edges of the API surface of this library. Anything past this point entails a level of granularity that should be rarely needed in any team, unless:
296+
297+
- You intend to migrate one of your existing [`UIViewControllerAnimatedTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrolleranimatedtransitioning) implementations over to SwiftUI.
298+
- You're well versed in *Custom UINavigationController Transitions* and are willing to dive straight into raw UIKit territory, including view snapshotting, hierarchy set-up, lifecycle management, and animator configuration. Even then, I highly encourage you to consider using one of the formerly discussed abstractions in order to accomplish the desired effect instead.
299+
300+
Before we get started, I'd like to ask that if you're reaching for these abstractions because there's something missing in the previously discussed customization mechanisms that you believe should be there to build your transition the way you need, **please** [**open an issue**](https://github.com/davdroman/swiftui-navigation-transitions/issues/new) in order to let me know, so I can close the capability gap between abstractions and make everyone's development experience richer.
301+
302+
Let's delve into the two final customization entry points, which as mentioned interact with UIKit abstractions directly.
303+
304+
The entire concept of advanced custom transitions revolves around an `Animator` object. This `Animator` is a protocol which exposes a subset of functions in the UIKit protocol [`UIViewImplicitlyAnimating`](https://developer.apple.com/documentation/uikit/uiviewimplicitlyanimating).
305+
306+
The interface looks as follows:
307+
308+
```swift
309+
@objc public protocol Animator {
310+
func addAnimations(_ animation: @escaping () -> Void)
311+
func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void)
312+
}
313+
```
314+
315+
The `AtomicTransition` API for utilising this mechanism is:
316+
317+
```swift
318+
// AtomicTransition.swift
319+
public static func custom(withAnimator handler: @escaping AnimatorHandler) -> Self
320+
```
321+
322+
... where `AnimatorHandler` is a typealias for `(Animator, UIView, Operation, Context) -> Void`.
323+
324+
In this case, the `Animator` is utilized to setup animations and completion logic for the inserted or removed `UIView` with access to a `Context` (typealias for [`UIViewControllerContextTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrollercontexttransitioning)).
325+
326+
---
327+
328+
The same principle applies to `NavigationTransition` as well:
329+
330+
```swift
331+
public static func custom(withAnimator handler: @escaping AnimatorHandler) -> Self
332+
```
333+
334+
... where `AnimatorHandler` is a typealias for `(Animator, Operation, Context) -> Void`.
335+
336+
In this case, the `Animator` is utilized to setup animations and completion logic for the pushed or popped views which you must extract from `Context` (typealias for [`UIViewControllerContextTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrollercontexttransitioning)).

Sources/NavigationTransitions/NavigationTransition+SwiftUI.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Introspect
1+
@_implementationOnly import Introspect
22
import SwiftUI
33

44
// MARK: iOS 16

0 commit comments

Comments
 (0)