Skip to content

Mirror Transitions #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Nov 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Demo/Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
D5535843290F4BEA009E5D72 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535842290F4BEA009E5D72 /* AppView.swift */; };
D5535845290F52F7009E5D72 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535844290F52F7009E5D72 /* SettingsView.swift */; };
D5535847290F5E6F009E5D72 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535846290F5E6F009E5D72 /* AppState.swift */; };
D5755A79291ADC00007F2201 /* Zoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5755A78291ADC00007F2201 /* Zoom.swift */; };
D5AAF4052911C59E009743D3 /* PageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF4042911C59E009743D3 /* PageView.swift */; };
D5AAF4072911C621009743D3 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF4062911C621009743D3 /* Pages.swift */; };
/* End PBXBuildFile section */
Expand All @@ -35,6 +36,7 @@
D5535842290F4BEA009E5D72 /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = "<group>"; };
D5535844290F52F7009E5D72 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
D5535846290F5E6F009E5D72 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
D5755A78291ADC00007F2201 /* Zoom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zoom.swift; sourceTree = "<group>"; };
D5AAF4042911C59E009743D3 /* PageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageView.swift; sourceTree = "<group>"; };
D5AAF4062911C621009743D3 /* Pages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -82,6 +84,7 @@
D5AAF4042911C59E009743D3 /* PageView.swift */,
D5535844290F52F7009E5D72 /* SettingsView.swift */,
D553582C290E9718009E5D72 /* Swing.swift */,
D5755A78291ADC00007F2201 /* Zoom.swift */,
D5535822290E9692009E5D72 /* Assets.xcassets */,
D5535824290E9692009E5D72 /* Preview Content */,
);
Expand Down Expand Up @@ -180,6 +183,7 @@
files = (
D5AAF4072911C621009743D3 /* Pages.swift in Sources */,
D5535845290F52F7009E5D72 /* SettingsView.swift in Sources */,
D5755A79291ADC00007F2201 /* Zoom.swift in Sources */,
D5AAF4052911C59E009743D3 /* PageView.swift in Sources */,
D5535839290E9718009E5D72 /* AppDelegate.swift in Sources */,
D5535847290F5E6F009E5D72 /* AppState.swift in Sources */,
Expand Down
5 changes: 5 additions & 0 deletions Demo/Demo/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ final class AppState: ObservableObject {
case slideAndFade
case moveVertically
case swing
case zoom

var description: String {
switch self {
Expand All @@ -24,6 +25,8 @@ final class AppState: ObservableObject {
return "Slide Vertically"
case .swing:
return "Swing"
case .zoom:
return "Zoom"
}
}

Expand All @@ -41,6 +44,8 @@ final class AppState: ObservableObject {
return .slide(axis: .vertical)
case .swing:
return .swing
case .zoom:
return .zoom
}
}
}
Expand Down
30 changes: 7 additions & 23 deletions Demo/Demo/Swing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,41 +9,25 @@ extension AnyNavigationTransition {

struct Swing: NavigationTransition {
var body: some NavigationTransition {
let angle = Angle(degrees: 70)
let offset: CGFloat = 150
let scale: CGFloat = 0.5

Slide(axis: .horizontal)
OnPush {
MirrorPush {
let angle = 70.0
let offset = 150.0
OnInsertion {
Rotate(-angle)
Rotate(.degrees(-angle))
Offset(x: offset)
Opacity()
Scale(scale)
Scale(0.5)
}
OnRemoval {
Rotate(angle)
Rotate(.degrees(angle))
Offset(x: -offset)
}
}
OnPop {
OnInsertion {
Rotate(angle)
Offset(x: -offset)
Opacity()
Scale(scale)
BringToFront()
}
OnRemoval {
Rotate(-angle)
Offset(x: offset)
SendToBack()
}
}
}
}

extension Angle {
static prefix func - (_ rhs: Self) -> Self {
.init(degrees: -rhs.degrees)
}
}
17 changes: 17 additions & 0 deletions Demo/Demo/Zoom.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import NavigationTransition
import SwiftUI

extension AnyNavigationTransition {
static var zoom: Self {
.init(Zoom())
}
}

struct Zoom: NavigationTransition {
var body: some NavigationTransition {
Slide(axis: .horizontal)
MirrorPush {
Scale(0.5)
}
}
}
32 changes: 11 additions & 21 deletions Documentation/Custom-Transitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,39 +47,23 @@ public struct Slide: NavigationTransition {
public var body: some NavigationTransition {
switch axis {
case .horizontal:
OnPush {
MirrorPush {
OnInsertion {
Move(edge: .trailing)
}
OnRemoval {
Move(edge: .leading)
}
}
OnPop {
OnInsertion {
Move(edge: .leading)
}
OnRemoval {
Move(edge: .trailing)
}
}
case .vertical:
OnPush {
MirrorPush {
OnInsertion {
Move(edge: .bottom)
}
OnRemoval {
Move(edge: .top)
}
}
OnPop {
OnInsertion {
Move(edge: .top)
}
OnRemoval {
Move(edge: .bottom)
}
}
}
}
}
Expand Down Expand Up @@ -227,9 +211,15 @@ All types conforming to `AtomicTransition` must implement what's known as a "tra

Next up, let's explore two ways of conforming to `NavigationTransition`.

The simplest (and most recommended) way happens by declaring our atomic transitions (if needed), and composing them via `var body: some NavigationTransition { ... }` like we saw [previously with `Slide`](#NavigationTransition).
The simplest (and most recommended) way is by declaring our atomic transitions (if needed), and composing them via `var body: some NavigationTransition { ... }` like we saw [previously with `Slide`](#NavigationTransition).

Check out the [documentation](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.2.0/documentation/navigationtransitions/navigationtransition) to learn about the different `NavigationTransition` types and how they compose.

The Demo project in the repo is also a great source of learning about different types of custom transitions and the way to implement them.

---

But there's actually an **alternative** option for those who'd like to reach for a more wholistic API. `NavigationTransition` declares this other function that can be implemented instead of `body`:
Finally, let's explore an alternative option for those who'd like to reach for a more wholistic API. `NavigationTransition` declares a `transition` function that can be implemented instead of `body`:

```swift
func transition(from fromView: TransientView, to toView: TransientView, for operation: TransitionOperation, in container: Container)
Expand All @@ -241,7 +231,7 @@ Whilst `body` helps composing other transitions, this transition handler helps u
- `Operation` defines whether the operation being performed is a `push` or a `pop`. The concept of insertions or removals is entirely irrelevant to this function, since you can directly modify the property values for the views without needing atomic transitions.
- `Container` is the container view of type `UIView` where `fromView` and `toView` are added during the transition. There's no need to add either view 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.

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.
This approach is a less cumbersome 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.

### UIKit

Expand Down
24 changes: 7 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,34 +96,24 @@ In addition to these, you can create fully [**custom**](Demo/Demo/Swing.swift) t
```swift
struct Swing: NavigationTransition {
var body: some NavigationTransition {
let angle = Angle(degrees: 70)
let offset: CGFloat = 150
let scale: CGFloat = 0.5

Slide(axis: .horizontal)
OnPush {
MirrorPush {
let angle = 70.0
let offset = 150.0
OnInsertion {
Rotate(-angle)
Rotate(.degrees(-angle))
Offset(x: offset)
Opacity()
Scale(scale)
Scale(0.5)
}
OnRemoval {
Rotate(angle)
Rotate(.degrees(angle))
Offset(x: -offset)
}
}
OnPop {
OnInsertion {
Rotate(angle)
Offset(x: -offset)
Opacity()
Scale(scale)
BringToFront()
}
OnRemoval {
Rotate(-angle)
Offset(x: offset)
SendToBack()
}
}
}
Expand Down
40 changes: 35 additions & 5 deletions Sources/AtomicTransition/Asymmetric.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import class UIKit.UIView

/// A composite transition that uses a different transition for insertion versus removal.
/// A composite transition that uses a different transition for push versus pop.
public struct Asymmetric<InsertionTransition: AtomicTransition, RemovalTransition: AtomicTransition>: AtomicTransition {
private let insertion: InsertionTransition
private let removal: RemovalTransition

private init(insertion: InsertionTransition, removal: RemovalTransition) {
self.insertion = insertion
self.removal = removal
}

public init(
@AtomicTransitionBuilder insertion: () -> InsertionTransition,
@AtomicTransitionBuilder removal: () -> RemovalTransition
) {
self.insertion = insertion()
self.removal = removal()
self.init(insertion: insertion(), removal: removal())
}

public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) {
Expand All @@ -23,15 +27,25 @@ public struct Asymmetric<InsertionTransition: AtomicTransition, RemovalTransitio
}
}

extension Asymmetric: MirrorableAtomicTransition where InsertionTransition: MirrorableAtomicTransition, RemovalTransition: MirrorableAtomicTransition {
public func mirrored() -> Asymmetric<InsertionTransition.Mirrored, RemovalTransition.Mirrored> {
return .init(insertion: insertion.mirrored(), removal: removal.mirrored())
}
}

extension Asymmetric: Equatable where InsertionTransition: Equatable, RemovalTransition: Equatable {}
extension Asymmetric: Hashable where InsertionTransition: Hashable, RemovalTransition: Hashable {}

/// A transition that executes only on insertion.
public struct OnInsertion<Transition: AtomicTransition>: AtomicTransition {
private let transition: Transition

private init(_ transition: Transition) {
self.transition = transition
}

public init(@AtomicTransitionBuilder transition: () -> Transition) {
self.transition = transition()
self.init(transition())
}

public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) {
Expand All @@ -44,15 +58,25 @@ public struct OnInsertion<Transition: AtomicTransition>: AtomicTransition {
}
}

extension OnInsertion: MirrorableAtomicTransition where Transition: MirrorableAtomicTransition {
public func mirrored() -> OnInsertion<Transition.Mirrored> {
.init(transition.mirrored())
}
}

extension OnInsertion: Equatable where Transition: Equatable {}
extension OnInsertion: Hashable where Transition: Hashable {}

/// A transition that executes only on removal.
public struct OnRemoval<Transition: AtomicTransition>: AtomicTransition {
private let transition: Transition

init(_ transition: Transition) {
self.transition = transition
}

public init(@AtomicTransitionBuilder transition: () -> Transition) {
self.transition = transition()
self.init(transition())
}

public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) {
Expand All @@ -65,5 +89,11 @@ public struct OnRemoval<Transition: AtomicTransition>: AtomicTransition {
}
}

extension OnRemoval: MirrorableAtomicTransition where Transition: MirrorableAtomicTransition {
public func mirrored() -> OnRemoval<Transition.Mirrored> {
.init(transition.mirrored())
}
}

extension OnRemoval: Equatable where Transition: Equatable {}
extension OnRemoval: Hashable where Transition: Hashable {}
18 changes: 18 additions & 0 deletions Sources/AtomicTransition/AtomicTransition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,21 @@ public enum AtomicTransitionOperation {
case insertion
case removal
}

/// Defines an `AtomicTransition` that can be mirrored. It is a specialized building block of `NavigationTransition`.
///
/// A transition that conform to these protocol expose a `Mirrored` associated type expressing the type resulting
/// from mirroring the transition.
public protocol MirrorableAtomicTransition: AtomicTransition {
associatedtype Mirrored: AtomicTransition

/// The mirrored transition.
///
/// > Note: A good indicator of a proper implementation for this function is that it should round-trip
/// > to its original value when called twice:
/// >
/// > ```swift
/// > Offset(x: 10).mirrored().mirrored() == Offset(x: 10)
/// > ```
func mirrored() -> Mirrored
}
6 changes: 6 additions & 0 deletions Sources/AtomicTransition/Combined.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,11 @@ public struct Combined<TransitionA: AtomicTransition, TransitionB: AtomicTransit
}
}

extension Combined: MirrorableAtomicTransition where TransitionA: MirrorableAtomicTransition, TransitionB: MirrorableAtomicTransition {
public func mirrored() -> Combined<TransitionA.Mirrored, TransitionB.Mirrored> {
.init(transitionA.mirrored(), transitionB.mirrored())
}
}

extension Combined: Equatable where TransitionA: Equatable, TransitionB: Equatable {}
extension Combined: Hashable where TransitionA: Hashable, TransitionB: Hashable {}
12 changes: 11 additions & 1 deletion Sources/AtomicTransition/Group.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,24 @@ import class UIKit.UIView
public struct Group<Transitions: AtomicTransition>: AtomicTransition {
private let transitions: Transitions

private init(_ transitions: Transitions) {
self.transitions = transitions
}

public init(@AtomicTransitionBuilder _ transitions: () -> Transitions) {
self.transitions = transitions()
self.init(transitions())
}

public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) {
transitions.transition(view, for: operation, in: container)
}
}

extension Group: MirrorableAtomicTransition where Transitions: MirrorableAtomicTransition {
public func mirrored() -> Group<Transitions.Mirrored> {
.init(transitions.mirrored())
}
}

extension Group: Equatable where Transitions: Equatable {}
extension Group: Hashable where Transitions: Hashable {}
Loading