├── .github └── workflows │ ├── ci.yml │ └── format.yml ├── .gitignore ├── .spi.yml ├── .swift-version ├── .swiftformat ├── Examples ├── Demo │ ├── Demo.xcodeproj │ │ ├── project.pbxproj │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Demo.xcscheme │ └── Demo │ │ ├── AppDelegate.swift │ │ ├── AppState.swift │ │ ├── AppView.swift │ │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ │ ├── Custom Transitions │ │ ├── Flip.swift │ │ ├── Swing.swift │ │ └── Zoom.swift │ │ ├── Demo.entitlements │ │ ├── Info.plist │ │ ├── LaunchScreen.storyboard │ │ ├── PageView.swift │ │ ├── Pages.swift │ │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ ├── RootView.swift │ │ ├── SceneDelegate.swift │ │ └── SettingsView.swift └── Package.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── Package@swift-6.0.swift ├── README.md ├── Sources ├── Animation │ ├── Animation.swift │ ├── Default.swift │ ├── EaseIn.swift │ ├── EaseInOut.swift │ ├── EaseOut.swift │ ├── InterpolatingSpring.swift │ ├── Linear.swift │ └── TimingCurve.swift ├── Animator │ ├── Animator.swift │ ├── AnimatorTransientView.swift │ ├── AnimatorTransientViewProperties.swift │ ├── OptionalWithDefault.swift │ └── Transform.swift ├── AtomicTransition │ ├── AtomicTransition.swift │ ├── AtomicTransitionBuilder.swift │ ├── Combined.swift │ ├── Group.swift │ ├── Identity.swift │ ├── Mirror.swift │ ├── Move.swift │ ├── Offset.swift │ ├── On.swift │ ├── Opacity.swift │ ├── Rotate.swift │ ├── Rotate3D.swift │ ├── Scale.swift │ ├── ZPosition.swift │ └── _Exports.swift ├── NavigationTransition │ ├── AnyNavigationTransition.swift │ ├── Combined.swift │ ├── Default.swift │ ├── Fade.swift │ ├── Identity.swift │ ├── Mirror.swift │ ├── NavigationTransitionBuilder.swift │ ├── NavigationTransitionProtocol.swift │ ├── On.swift │ ├── Pick.swift │ ├── PrimitiveNavigationTransition.swift │ ├── Slide.swift │ └── _Exports.swift ├── RuntimeAssociation │ ├── RuntimeAssociation.swift │ └── RuntimeAssociationPolicy.swift ├── RuntimeSwizzling │ └── Swizzle.swift ├── SwiftUINavigationTransitions │ ├── Documentation.docc │ │ └── Articles │ │ │ └── Custom Transitions.md │ ├── SwiftUISupport.swift │ └── _Exports.swift ├── TestUtils │ ├── Animator+Mocks.swift │ ├── AnimatorTransientView+Mocks.swift │ ├── AtomicTransition+Mocks.swift │ ├── NavigationTransition+Mocks.swift │ ├── UIKitContext+Mocks.swift │ ├── UIView+Mocks.swift │ └── _Exports.swift └── UIKitNavigationTransitions │ ├── Delegate.swift │ ├── Interaction.swift │ ├── Interactivity.swift │ ├── UIKitSupport.swift │ └── _Exports.swift ├── SwiftUINavigationTransitions.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── swiftpm │ └── Package.resolved │ └── xcschemes │ └── SwiftUINavigationTransitions.xcscheme └── Tests ├── AnimatorTests ├── AnimatorTransientViewPropertiesTests.swift └── AnimatorTransientViewTests.swift ├── AtomicTransitionTests ├── CombinedTests.swift ├── GroupTests.swift ├── IdentityTests.swift ├── MoveTests.swift ├── OffsetTests.swift ├── OnTests.swift ├── OpacityTests.swift ├── RotateTests.swift ├── ScaleTests.swift └── ZPositionTests.swift └── TestPlans └── SwiftUINavigationTransitions.xctestplan /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - "**" 10 | schedule: 11 | - cron: "3 3 * * 2" # 3:03 AM, every Tuesday 12 | 13 | concurrency: 14 | group: ci-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | macOS: 19 | name: ${{ matrix.platform }} (Swift ${{ matrix.swift }}) 20 | runs-on: macos-15 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | platform: 25 | - iOS 26 | - mac-catalyst 27 | - tvOS 28 | swift: 29 | - "5.10" 30 | - "6.0" 31 | - "6.1" 32 | steps: 33 | - name: Git Checkout 34 | uses: actions/checkout@v4 35 | 36 | - name: Test Library 37 | uses: mxcl/xcodebuild@v3 38 | with: 39 | platform: ${{ matrix.platform }} 40 | swift: ~${{ matrix.swift }} 41 | action: test 42 | workspace: SwiftUINavigationTransitions.xcworkspace 43 | scheme: SwiftUINavigationTransitions 44 | 45 | - name: Build Examples/Demo 46 | uses: mxcl/xcodebuild@v3 47 | with: 48 | platform: ${{ matrix.platform }} 49 | swift: ~${{ matrix.swift }} 50 | action: build 51 | workspace: SwiftUINavigationTransitions.xcworkspace 52 | scheme: Demo 53 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: format-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | format: 14 | name: swiftformat 15 | runs-on: macos-15 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Format 19 | run: swiftformat . 20 | - uses: stefanzweifel/git-auto-commit-action@v5 21 | with: 22 | commit_message: Run SwiftFormat 23 | branch: main 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | xcuserdata/ 6 | DerivedData/ 7 | .netrc 8 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | scheme: SwiftUINavigationTransitions 6 | documentation_targets: [SwiftUINavigationTransitions] 7 | - platform: tvos 8 | scheme: SwiftUINavigationTransitions 9 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.10 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --disable redundantNilInit,redundantSelf,redundantType,unusedArguments 2 | 3 | --header strip 4 | --indent tab 5 | --ifdef no-indent 6 | --extensionacl on-declarations 7 | --patternlet inline 8 | -------------------------------------------------------------------------------- /Examples/Demo/Demo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | D52A95512DCEC08F00885069 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = D52A95502DCEC08F00885069 /* SwiftUINavigationTransitions */; }; 11 | D5535823290E9692009E5D72 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5535822290E9692009E5D72 /* Assets.xcassets */; }; 12 | D5535826290E9692009E5D72 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5535825290E9692009E5D72 /* Preview Assets.xcassets */; }; 13 | D5535834290E9718009E5D72 /* Swing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553582C290E9718009E5D72 /* Swing.swift */; }; 14 | D5535835290E9718009E5D72 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553582D290E9718009E5D72 /* RootView.swift */; }; 15 | D5535836290E9718009E5D72 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553582E290E9718009E5D72 /* SceneDelegate.swift */; }; 16 | D5535837290E9718009E5D72 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D553582F290E9718009E5D72 /* LaunchScreen.storyboard */; platformFilter = ios; }; 17 | D5535839290E9718009E5D72 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535831290E9718009E5D72 /* AppDelegate.swift */; }; 18 | D5535843290F4BEA009E5D72 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535842290F4BEA009E5D72 /* AppView.swift */; }; 19 | D5535845290F52F7009E5D72 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535844290F52F7009E5D72 /* SettingsView.swift */; }; 20 | D5535847290F5E6F009E5D72 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535846290F5E6F009E5D72 /* AppState.swift */; }; 21 | D5755A79291ADC00007F2201 /* Zoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5755A78291ADC00007F2201 /* Zoom.swift */; }; 22 | D58D803F292176D200D9FEAE /* Flip.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58D803E292176D200D9FEAE /* Flip.swift */; }; 23 | D5AAF4052911C59E009743D3 /* PageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF4042911C59E009743D3 /* PageView.swift */; }; 24 | D5AAF4072911C621009743D3 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF4062911C621009743D3 /* Pages.swift */; }; 25 | /* End PBXBuildFile section */ 26 | 27 | /* Begin PBXFileReference section */ 28 | D553581B290E9691009E5D72 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | D5535822290E9692009E5D72 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 30 | D5535825290E9692009E5D72 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 31 | D553582C290E9718009E5D72 /* Swing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Swing.swift; sourceTree = ""; }; 32 | D553582D290E9718009E5D72 /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 33 | D553582E290E9718009E5D72 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 34 | D553582F290E9718009E5D72 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 35 | D5535831290E9718009E5D72 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 36 | D553583C290E978C009E5D72 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 37 | D5535842290F4BEA009E5D72 /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; 38 | D5535844290F52F7009E5D72 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 39 | D5535846290F5E6F009E5D72 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 40 | D571826B291C9426003672F5 /* Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Demo.entitlements; sourceTree = ""; }; 41 | D5755A78291ADC00007F2201 /* Zoom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zoom.swift; sourceTree = ""; }; 42 | D58D803E292176D200D9FEAE /* Flip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Flip.swift; sourceTree = ""; }; 43 | D5AAF4042911C59E009743D3 /* PageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageView.swift; sourceTree = ""; }; 44 | D5AAF4062911C621009743D3 /* Pages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = ""; }; 45 | /* End PBXFileReference section */ 46 | 47 | /* Begin PBXFrameworksBuildPhase section */ 48 | D5535818290E9691009E5D72 /* Frameworks */ = { 49 | isa = PBXFrameworksBuildPhase; 50 | buildActionMask = 2147483647; 51 | files = ( 52 | D52A95512DCEC08F00885069 /* SwiftUINavigationTransitions in Frameworks */, 53 | ); 54 | runOnlyForDeploymentPostprocessing = 0; 55 | }; 56 | /* End PBXFrameworksBuildPhase section */ 57 | 58 | /* Begin PBXGroup section */ 59 | D5535812290E9691009E5D72 = { 60 | isa = PBXGroup; 61 | children = ( 62 | D553581D290E9691009E5D72 /* Demo */, 63 | D553581C290E9691009E5D72 /* Products */, 64 | D553583D290E97C5009E5D72 /* Frameworks */, 65 | ); 66 | sourceTree = ""; 67 | }; 68 | D553581C290E9691009E5D72 /* Products */ = { 69 | isa = PBXGroup; 70 | children = ( 71 | D553581B290E9691009E5D72 /* Demo.app */, 72 | ); 73 | name = Products; 74 | sourceTree = ""; 75 | }; 76 | D553581D290E9691009E5D72 /* Demo */ = { 77 | isa = PBXGroup; 78 | children = ( 79 | D571826B291C9426003672F5 /* Demo.entitlements */, 80 | D553583C290E978C009E5D72 /* Info.plist */, 81 | D553582F290E9718009E5D72 /* LaunchScreen.storyboard */, 82 | D5535831290E9718009E5D72 /* AppDelegate.swift */, 83 | D553582E290E9718009E5D72 /* SceneDelegate.swift */, 84 | D5535842290F4BEA009E5D72 /* AppView.swift */, 85 | D5535846290F5E6F009E5D72 /* AppState.swift */, 86 | D553582D290E9718009E5D72 /* RootView.swift */, 87 | D5AAF4062911C621009743D3 /* Pages.swift */, 88 | D5AAF4042911C59E009743D3 /* PageView.swift */, 89 | D5535844290F52F7009E5D72 /* SettingsView.swift */, 90 | D58D8040292176EE00D9FEAE /* Custom Transitions */, 91 | D5535822290E9692009E5D72 /* Assets.xcassets */, 92 | D5535824290E9692009E5D72 /* Preview Content */, 93 | ); 94 | path = Demo; 95 | sourceTree = ""; 96 | }; 97 | D5535824290E9692009E5D72 /* Preview Content */ = { 98 | isa = PBXGroup; 99 | children = ( 100 | D5535825290E9692009E5D72 /* Preview Assets.xcassets */, 101 | ); 102 | path = "Preview Content"; 103 | sourceTree = ""; 104 | }; 105 | D553583D290E97C5009E5D72 /* Frameworks */ = { 106 | isa = PBXGroup; 107 | children = ( 108 | ); 109 | name = Frameworks; 110 | sourceTree = ""; 111 | }; 112 | D58D8040292176EE00D9FEAE /* Custom Transitions */ = { 113 | isa = PBXGroup; 114 | children = ( 115 | D58D803E292176D200D9FEAE /* Flip.swift */, 116 | D553582C290E9718009E5D72 /* Swing.swift */, 117 | D5755A78291ADC00007F2201 /* Zoom.swift */, 118 | ); 119 | path = "Custom Transitions"; 120 | sourceTree = ""; 121 | }; 122 | /* End PBXGroup section */ 123 | 124 | /* Begin PBXNativeTarget section */ 125 | D553581A290E9691009E5D72 /* Demo */ = { 126 | isa = PBXNativeTarget; 127 | buildConfigurationList = D5535829290E9692009E5D72 /* Build configuration list for PBXNativeTarget "Demo" */; 128 | buildPhases = ( 129 | D5535817290E9691009E5D72 /* Sources */, 130 | D5535818290E9691009E5D72 /* Frameworks */, 131 | D5535819290E9691009E5D72 /* Resources */, 132 | ); 133 | buildRules = ( 134 | ); 135 | dependencies = ( 136 | ); 137 | name = Demo; 138 | packageProductDependencies = ( 139 | D52A95502DCEC08F00885069 /* SwiftUINavigationTransitions */, 140 | ); 141 | productName = Demo; 142 | productReference = D553581B290E9691009E5D72 /* Demo.app */; 143 | productType = "com.apple.product-type.application"; 144 | }; 145 | /* End PBXNativeTarget section */ 146 | 147 | /* Begin PBXProject section */ 148 | D5535813290E9691009E5D72 /* Project object */ = { 149 | isa = PBXProject; 150 | attributes = { 151 | BuildIndependentTargetsInParallel = 1; 152 | LastSwiftUpdateCheck = 1410; 153 | LastUpgradeCheck = 1640; 154 | TargetAttributes = { 155 | D553581A290E9691009E5D72 = { 156 | CreatedOnToolsVersion = 14.1; 157 | LastSwiftMigration = 1410; 158 | }; 159 | }; 160 | }; 161 | buildConfigurationList = D5535816290E9691009E5D72 /* Build configuration list for PBXProject "Demo" */; 162 | compatibilityVersion = "Xcode 14.0"; 163 | developmentRegion = en; 164 | hasScannedForEncodings = 0; 165 | knownRegions = ( 166 | en, 167 | Base, 168 | ); 169 | mainGroup = D5535812290E9691009E5D72; 170 | packageReferences = ( 171 | ); 172 | productRefGroup = D553581C290E9691009E5D72 /* Products */; 173 | projectDirPath = ""; 174 | projectRoot = ""; 175 | targets = ( 176 | D553581A290E9691009E5D72 /* Demo */, 177 | ); 178 | }; 179 | /* End PBXProject section */ 180 | 181 | /* Begin PBXResourcesBuildPhase section */ 182 | D5535819290E9691009E5D72 /* Resources */ = { 183 | isa = PBXResourcesBuildPhase; 184 | buildActionMask = 2147483647; 185 | files = ( 186 | D5535837290E9718009E5D72 /* LaunchScreen.storyboard in Resources */, 187 | D5535826290E9692009E5D72 /* Preview Assets.xcassets in Resources */, 188 | D5535823290E9692009E5D72 /* Assets.xcassets in Resources */, 189 | ); 190 | runOnlyForDeploymentPostprocessing = 0; 191 | }; 192 | /* End PBXResourcesBuildPhase section */ 193 | 194 | /* Begin PBXSourcesBuildPhase section */ 195 | D5535817290E9691009E5D72 /* Sources */ = { 196 | isa = PBXSourcesBuildPhase; 197 | buildActionMask = 2147483647; 198 | files = ( 199 | D5AAF4072911C621009743D3 /* Pages.swift in Sources */, 200 | D5535845290F52F7009E5D72 /* SettingsView.swift in Sources */, 201 | D5755A79291ADC00007F2201 /* Zoom.swift in Sources */, 202 | D58D803F292176D200D9FEAE /* Flip.swift in Sources */, 203 | D5AAF4052911C59E009743D3 /* PageView.swift in Sources */, 204 | D5535839290E9718009E5D72 /* AppDelegate.swift in Sources */, 205 | D5535847290F5E6F009E5D72 /* AppState.swift in Sources */, 206 | D5535843290F4BEA009E5D72 /* AppView.swift in Sources */, 207 | D5535835290E9718009E5D72 /* RootView.swift in Sources */, 208 | D5535834290E9718009E5D72 /* Swing.swift in Sources */, 209 | D5535836290E9718009E5D72 /* SceneDelegate.swift in Sources */, 210 | ); 211 | runOnlyForDeploymentPostprocessing = 0; 212 | }; 213 | /* End PBXSourcesBuildPhase section */ 214 | 215 | /* Begin XCBuildConfiguration section */ 216 | D5535827290E9692009E5D72 /* Debug */ = { 217 | isa = XCBuildConfiguration; 218 | buildSettings = { 219 | ALWAYS_SEARCH_USER_PATHS = NO; 220 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 221 | CLANG_ANALYZER_NONNULL = YES; 222 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 223 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 224 | CLANG_ENABLE_MODULES = YES; 225 | CLANG_ENABLE_OBJC_ARC = YES; 226 | CLANG_ENABLE_OBJC_WEAK = YES; 227 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 228 | CLANG_WARN_BOOL_CONVERSION = YES; 229 | CLANG_WARN_COMMA = YES; 230 | CLANG_WARN_CONSTANT_CONVERSION = YES; 231 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 232 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 233 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 234 | CLANG_WARN_EMPTY_BODY = YES; 235 | CLANG_WARN_ENUM_CONVERSION = YES; 236 | CLANG_WARN_INFINITE_RECURSION = YES; 237 | CLANG_WARN_INT_CONVERSION = YES; 238 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 239 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 240 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 241 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 242 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 243 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 244 | CLANG_WARN_STRICT_PROTOTYPES = YES; 245 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 246 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 247 | CLANG_WARN_UNREACHABLE_CODE = YES; 248 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 249 | COPY_PHASE_STRIP = NO; 250 | DEBUG_INFORMATION_FORMAT = dwarf; 251 | DEVELOPMENT_TEAM = 26CPNYHDUU; 252 | ENABLE_STRICT_OBJC_MSGSEND = YES; 253 | ENABLE_TESTABILITY = YES; 254 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 255 | GCC_C_LANGUAGE_STANDARD = gnu11; 256 | GCC_DYNAMIC_NO_PIC = NO; 257 | GCC_NO_COMMON_BLOCKS = YES; 258 | GCC_OPTIMIZATION_LEVEL = 0; 259 | GCC_PREPROCESSOR_DEFINITIONS = ( 260 | "DEBUG=1", 261 | "$(inherited)", 262 | ); 263 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 264 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 265 | GCC_WARN_UNDECLARED_SELECTOR = YES; 266 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 267 | GCC_WARN_UNUSED_FUNCTION = YES; 268 | GCC_WARN_UNUSED_VARIABLE = YES; 269 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 270 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 271 | MTL_FAST_MATH = YES; 272 | ONLY_ACTIVE_ARCH = YES; 273 | SDKROOT = iphoneos; 274 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 275 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 276 | TVOS_DEPLOYMENT_TARGET = 13.0; 277 | }; 278 | name = Debug; 279 | }; 280 | D5535828290E9692009E5D72 /* Release */ = { 281 | isa = XCBuildConfiguration; 282 | buildSettings = { 283 | ALWAYS_SEARCH_USER_PATHS = NO; 284 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 285 | CLANG_ANALYZER_NONNULL = YES; 286 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 287 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 288 | CLANG_ENABLE_MODULES = YES; 289 | CLANG_ENABLE_OBJC_ARC = YES; 290 | CLANG_ENABLE_OBJC_WEAK = YES; 291 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 292 | CLANG_WARN_BOOL_CONVERSION = YES; 293 | CLANG_WARN_COMMA = YES; 294 | CLANG_WARN_CONSTANT_CONVERSION = YES; 295 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 296 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 297 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 298 | CLANG_WARN_EMPTY_BODY = YES; 299 | CLANG_WARN_ENUM_CONVERSION = YES; 300 | CLANG_WARN_INFINITE_RECURSION = YES; 301 | CLANG_WARN_INT_CONVERSION = YES; 302 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 303 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 304 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 305 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 306 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 307 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 308 | CLANG_WARN_STRICT_PROTOTYPES = YES; 309 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 310 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 311 | CLANG_WARN_UNREACHABLE_CODE = YES; 312 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 313 | COPY_PHASE_STRIP = NO; 314 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 315 | DEVELOPMENT_TEAM = 26CPNYHDUU; 316 | ENABLE_NS_ASSERTIONS = NO; 317 | ENABLE_STRICT_OBJC_MSGSEND = YES; 318 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 319 | GCC_C_LANGUAGE_STANDARD = gnu11; 320 | GCC_NO_COMMON_BLOCKS = YES; 321 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 322 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 323 | GCC_WARN_UNDECLARED_SELECTOR = YES; 324 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 325 | GCC_WARN_UNUSED_FUNCTION = YES; 326 | GCC_WARN_UNUSED_VARIABLE = YES; 327 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 328 | MTL_ENABLE_DEBUG_INFO = NO; 329 | MTL_FAST_MATH = YES; 330 | SDKROOT = iphoneos; 331 | SWIFT_COMPILATION_MODE = wholemodule; 332 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 333 | TVOS_DEPLOYMENT_TARGET = 13.0; 334 | VALIDATE_PRODUCT = YES; 335 | }; 336 | name = Release; 337 | }; 338 | D553582A290E9692009E5D72 /* Debug */ = { 339 | isa = XCBuildConfiguration; 340 | buildSettings = { 341 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 342 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 343 | CLANG_ENABLE_MODULES = YES; 344 | CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; 345 | CODE_SIGN_STYLE = Automatic; 346 | CURRENT_PROJECT_VERSION = 1; 347 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 348 | ENABLE_PREVIEWS = YES; 349 | GENERATE_INFOPLIST_FILE = YES; 350 | INFOPLIST_FILE = Demo/Info.plist; 351 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 352 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 353 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; 354 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 355 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 356 | INFOPLIST_KEY_UIUserInterfaceStyle = Light; 357 | LD_RUNPATH_SEARCH_PATHS = ( 358 | "$(inherited)", 359 | "@executable_path/Frameworks", 360 | ); 361 | MARKETING_VERSION = 1.0; 362 | PRODUCT_BUNDLE_IDENTIFIER = mn.dro.Demo; 363 | PRODUCT_NAME = "$(TARGET_NAME)"; 364 | SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator xros xrsimulator"; 365 | SUPPORTS_MACCATALYST = YES; 366 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 367 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 368 | SWIFT_EMIT_LOC_STRINGS = YES; 369 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 370 | SWIFT_VERSION = 5.0; 371 | TARGETED_DEVICE_FAMILY = "1,2,3,7"; 372 | }; 373 | name = Debug; 374 | }; 375 | D553582B290E9692009E5D72 /* Release */ = { 376 | isa = XCBuildConfiguration; 377 | buildSettings = { 378 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 379 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 380 | CLANG_ENABLE_MODULES = YES; 381 | CODE_SIGN_ENTITLEMENTS = Demo/Demo.entitlements; 382 | CODE_SIGN_STYLE = Automatic; 383 | CURRENT_PROJECT_VERSION = 1; 384 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 385 | ENABLE_PREVIEWS = YES; 386 | GENERATE_INFOPLIST_FILE = YES; 387 | INFOPLIST_FILE = Demo/Info.plist; 388 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 389 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 390 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; 391 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 392 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 393 | INFOPLIST_KEY_UIUserInterfaceStyle = Light; 394 | LD_RUNPATH_SEARCH_PATHS = ( 395 | "$(inherited)", 396 | "@executable_path/Frameworks", 397 | ); 398 | MARKETING_VERSION = 1.0; 399 | PRODUCT_BUNDLE_IDENTIFIER = mn.dro.Demo; 400 | PRODUCT_NAME = "$(TARGET_NAME)"; 401 | SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator xros xrsimulator"; 402 | SUPPORTS_MACCATALYST = YES; 403 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 404 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 405 | SWIFT_EMIT_LOC_STRINGS = YES; 406 | SWIFT_VERSION = 5.0; 407 | TARGETED_DEVICE_FAMILY = "1,2,3,7"; 408 | }; 409 | name = Release; 410 | }; 411 | /* End XCBuildConfiguration section */ 412 | 413 | /* Begin XCConfigurationList section */ 414 | D5535816290E9691009E5D72 /* Build configuration list for PBXProject "Demo" */ = { 415 | isa = XCConfigurationList; 416 | buildConfigurations = ( 417 | D5535827290E9692009E5D72 /* Debug */, 418 | D5535828290E9692009E5D72 /* Release */, 419 | ); 420 | defaultConfigurationIsVisible = 0; 421 | defaultConfigurationName = Release; 422 | }; 423 | D5535829290E9692009E5D72 /* Build configuration list for PBXNativeTarget "Demo" */ = { 424 | isa = XCConfigurationList; 425 | buildConfigurations = ( 426 | D553582A290E9692009E5D72 /* Debug */, 427 | D553582B290E9692009E5D72 /* Release */, 428 | ); 429 | defaultConfigurationIsVisible = 0; 430 | defaultConfigurationName = Release; 431 | }; 432 | /* End XCConfigurationList section */ 433 | 434 | /* Begin XCSwiftPackageProductDependency section */ 435 | D52A95502DCEC08F00885069 /* SwiftUINavigationTransitions */ = { 436 | isa = XCSwiftPackageProductDependency; 437 | productName = SwiftUINavigationTransitions; 438 | }; 439 | /* End XCSwiftPackageProductDependency section */ 440 | }; 441 | rootObject = D5535813290E9691009E5D72 /* Project object */; 442 | } 443 | -------------------------------------------------------------------------------- /Examples/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | final class AppDelegate: UIResponder, UIApplicationDelegate { 5 | #if !os(tvOS) 6 | func applicationDidFinishLaunching(_ application: UIApplication) { 7 | customizeNavigationBarAppearance() 8 | customizeTabBarAppearance() 9 | } 10 | 11 | // https://developer.apple.com/documentation/technotes/tn3106-customizing-uinavigationbar-appearance 12 | func customizeNavigationBarAppearance() { 13 | let customAppearance = UINavigationBarAppearance() 14 | 15 | customAppearance.configureWithOpaqueBackground() 16 | customAppearance.backgroundColor = .systemBackground 17 | 18 | let proxy = UINavigationBar.appearance() 19 | proxy.scrollEdgeAppearance = customAppearance 20 | proxy.compactAppearance = customAppearance 21 | proxy.standardAppearance = customAppearance 22 | if #available(iOS 15.0, tvOS 15, *) { 23 | proxy.compactScrollEdgeAppearance = customAppearance 24 | } 25 | } 26 | 27 | func customizeTabBarAppearance() { 28 | let customAppearance = UITabBarAppearance() 29 | 30 | customAppearance.configureWithOpaqueBackground() 31 | customAppearance.backgroundColor = .systemBackground 32 | 33 | let proxy = UITabBar.appearance() 34 | proxy.standardAppearance = customAppearance 35 | if #available(iOS 15, tvOS 15, *) { 36 | proxy.scrollEdgeAppearance = customAppearance 37 | } 38 | } 39 | #endif 40 | 41 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 42 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/AppState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUINavigationTransitions 3 | 4 | final class AppState: ObservableObject { 5 | enum Transition: CaseIterable, CustomStringConvertible, Hashable { 6 | case `default` 7 | case crossFade 8 | case slide 9 | case slideVertically 10 | case slideAndFadeIn 11 | case slideAndFadeOut 12 | case flip 13 | case flipVertically 14 | case swing 15 | case zoom 16 | case zoomAndSlide 17 | 18 | var description: String { 19 | switch self { 20 | case .default: 21 | "Default" 22 | case .crossFade: 23 | "Fade" 24 | case .slide: 25 | "Slide" 26 | case .slideVertically: 27 | "Slide Vertically" 28 | case .slideAndFadeIn: 29 | "Slide + Fade In" 30 | case .slideAndFadeOut: 31 | "Slide + Fade Out" 32 | case .flip: 33 | "Flip" 34 | case .flipVertically: 35 | "Flip Vertically" 36 | case .swing: 37 | "Swing" 38 | case .zoom: 39 | "Zoom" 40 | case .zoomAndSlide: 41 | "Zoom + Slide" 42 | } 43 | } 44 | 45 | func callAsFunction() -> AnyNavigationTransition { 46 | switch self { 47 | case .default: 48 | .default 49 | case .crossFade: 50 | .fade(.cross) 51 | case .slide: 52 | .slide 53 | case .slideVertically: 54 | .slide(axis: .vertical) 55 | case .slideAndFadeIn: 56 | .slide.combined(with: .fade(.in)) 57 | case .slideAndFadeOut: 58 | .slide.combined(with: .fade(.out)) 59 | case .flip: 60 | .flip 61 | case .flipVertically: 62 | .flip(axis: .vertical) 63 | case .swing: 64 | .swing 65 | case .zoom: 66 | .zoom 67 | case .zoomAndSlide: 68 | .zoom.combined(with: .slide) 69 | } 70 | } 71 | } 72 | 73 | enum Animation: CaseIterable, CustomStringConvertible, Hashable { 74 | case none 75 | case linear 76 | case easeInOut 77 | case spring 78 | 79 | var description: String { 80 | switch self { 81 | case .none: 82 | "None" 83 | case .linear: 84 | "Linear" 85 | case .easeInOut: 86 | "Ease In Out" 87 | case .spring: 88 | "Spring" 89 | } 90 | } 91 | 92 | func callAsFunction( 93 | duration: Duration, 94 | stiffness: Stiffness, 95 | damping: Damping 96 | ) -> AnyNavigationTransition.Animation? { 97 | switch self { 98 | case .none: 99 | .none 100 | case .linear: 101 | .linear(duration: duration()) 102 | case .easeInOut: 103 | .easeInOut(duration: duration()) 104 | case .spring: 105 | .interpolatingSpring(stiffness: stiffness(), damping: damping()) 106 | } 107 | } 108 | } 109 | 110 | enum Duration: CaseIterable, CustomStringConvertible, Hashable { 111 | case slow 112 | case medium 113 | case fast 114 | 115 | var description: String { 116 | switch self { 117 | case .slow: 118 | "Slow" 119 | case .medium: 120 | "Medium" 121 | case .fast: 122 | "Fast" 123 | } 124 | } 125 | 126 | func callAsFunction() -> Double { 127 | switch self { 128 | case .slow: 129 | 1 130 | case .medium: 131 | 0.6 132 | case .fast: 133 | 0.35 134 | } 135 | } 136 | } 137 | 138 | enum Stiffness: CaseIterable, CustomStringConvertible, Hashable { 139 | case low 140 | case medium 141 | case high 142 | 143 | var description: String { 144 | switch self { 145 | case .low: 146 | "Low" 147 | case .medium: 148 | "Medium" 149 | case .high: 150 | "High" 151 | } 152 | } 153 | 154 | func callAsFunction() -> Double { 155 | switch self { 156 | case .low: 157 | 300 158 | case .medium: 159 | 120 160 | case .high: 161 | 50 162 | } 163 | } 164 | } 165 | 166 | enum Damping: CaseIterable, CustomStringConvertible, Hashable { 167 | case low 168 | case medium 169 | case high 170 | case veryHigh 171 | 172 | var description: String { 173 | switch self { 174 | case .low: 175 | "Low" 176 | case .medium: 177 | "Medium" 178 | case .high: 179 | "High" 180 | case .veryHigh: 181 | "Very High" 182 | } 183 | } 184 | 185 | func callAsFunction() -> Double { 186 | switch self { 187 | case .low: 188 | 20 189 | case .medium: 190 | 25 191 | case .high: 192 | 30 193 | case .veryHigh: 194 | 50 195 | } 196 | } 197 | } 198 | 199 | enum Interactivity: CaseIterable, CustomStringConvertible, Hashable { 200 | case disabled 201 | case edgePan 202 | case pan 203 | 204 | var description: String { 205 | switch self { 206 | case .disabled: 207 | "Disabled" 208 | case .edgePan: 209 | "Edge Pan" 210 | case .pan: 211 | "Pan" 212 | } 213 | } 214 | 215 | func callAsFunction() -> AnyNavigationTransition.Interactivity { 216 | switch self { 217 | case .disabled: 218 | .disabled 219 | case .edgePan: 220 | .edgePan 221 | case .pan: 222 | .pan 223 | } 224 | } 225 | } 226 | 227 | @Published var transition: Transition = .slide 228 | 229 | @Published var animation: Animation = .spring 230 | @Published var duration: Duration = .fast 231 | @Published var stiffness: Stiffness = .low 232 | @Published var damping: Damping = .veryHigh 233 | 234 | @Published var interactivity: Interactivity = .edgePan 235 | 236 | @Published var isPresentingSettings: Bool = false 237 | } 238 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/AppView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AppView: View { 4 | @ObservedObject var appState = AppState() 5 | 6 | var body: some View { 7 | RootView().environmentObject(appState) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Custom Transitions/Flip.swift: -------------------------------------------------------------------------------- 1 | import NavigationTransition 2 | import SwiftUI 3 | 4 | extension AnyNavigationTransition { 5 | static func flip(axis: Axis) -> Self { 6 | .init(Flip(axis: axis)) 7 | } 8 | 9 | static var flip: Self { 10 | .flip(axis: .horizontal) 11 | } 12 | } 13 | 14 | struct Flip: NavigationTransitionProtocol { 15 | var axis: Axis 16 | 17 | var body: some NavigationTransitionProtocol { 18 | MirrorPush { 19 | Rotate3D(.degrees(180), axis: axis == .horizontal ? (x: 1, y: 0, z: 0) : (x: 0, y: 1, z: 0)) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Custom Transitions/Swing.swift: -------------------------------------------------------------------------------- 1 | import NavigationTransition 2 | import SwiftUI 3 | 4 | extension AnyNavigationTransition { 5 | static var swing: Self { 6 | .init(Swing()) 7 | } 8 | } 9 | 10 | struct Swing: NavigationTransitionProtocol { 11 | var body: some NavigationTransitionProtocol { 12 | Slide(axis: .horizontal) 13 | MirrorPush { 14 | let angle = 70.0 15 | let offset = 150.0 16 | OnInsertion { 17 | ZPosition(1) 18 | Rotate(.degrees(-angle)) 19 | Offset(x: offset) 20 | Opacity() 21 | Scale(0.5) 22 | } 23 | OnRemoval { 24 | Rotate(.degrees(angle)) 25 | Offset(x: -offset) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Custom Transitions/Zoom.swift: -------------------------------------------------------------------------------- 1 | import NavigationTransition 2 | import SwiftUI 3 | 4 | extension AnyNavigationTransition { 5 | static var zoom: Self { 6 | .init(Zoom()) 7 | } 8 | } 9 | 10 | struct Zoom: NavigationTransitionProtocol { 11 | var body: some NavigationTransitionProtocol { 12 | MirrorPush { 13 | Scale(0.5) 14 | OnInsertion { 15 | ZPosition(1) 16 | Opacity() 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Demo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/PageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PageView: View { 4 | @EnvironmentObject var appState: AppState 5 | 6 | let number: Int 7 | let title: String 8 | let color: Color 9 | let content: Content 10 | let link: Link? 11 | let destination: Destination? 12 | 13 | var body: some View { 14 | ZStack { 15 | Rectangle() 16 | .modifier { 17 | if #available(iOS 16, tvOS 16, *) { 18 | $0.fill(color.gradient) 19 | } else { 20 | $0.fill(color) 21 | } 22 | } 23 | .frame(maxWidth: .infinity, maxHeight: .infinity) 24 | .edgesIgnoringSafeArea(.all) 25 | .opacity(0.45) 26 | .blendMode(.multiply) 27 | VStack { 28 | VStack(spacing: 20) { 29 | content 30 | } 31 | .font(.system(size: 20, design: .rounded)) 32 | .lineSpacing(4) 33 | .shadow(color: .white.opacity(0.25), radius: 1, x: 0, y: 1) 34 | .frame(maxWidth: .infinity, maxHeight: .infinity) 35 | .foregroundColor(Color(white: 0.14)) 36 | .frame(maxWidth: 1200) 37 | 38 | Group { 39 | if let link, let destination { 40 | if #available(iOS 16, tvOS 16, *) { 41 | NavigationLink(value: number + 1) { link } 42 | } else { 43 | NavigationLink(destination: destination) { link } 44 | } 45 | } 46 | } 47 | #if os(tvOS) 48 | .frame(maxWidth: 600) 49 | #else 50 | .frame(maxWidth: 300) 51 | #endif 52 | } 53 | .multilineTextAlignment(.center) 54 | .padding(.horizontal) 55 | .padding(.bottom, 30) 56 | } 57 | #if !os(tvOS) 58 | .navigationBarTitle(Text(title), displayMode: .inline) 59 | #endif 60 | .navigationBarItems( 61 | trailing: Button(action: { appState.isPresentingSettings = true }) { 62 | Group { 63 | if #available(iOS 14, tvOS 16, *) { 64 | Image(systemName: "gearshape") 65 | } else { 66 | Image(systemName: "gear") 67 | } 68 | } 69 | .font(.system(size: 16, weight: .semibold)) 70 | } 71 | ) 72 | } 73 | } 74 | 75 | extension PageView { 76 | init( 77 | number: Int, 78 | title: String, 79 | color: Color, 80 | @ViewBuilder content: () -> Content, 81 | @ViewBuilder link: () -> Link, 82 | @ViewBuilder destination: () -> Destination = { EmptyView() } 83 | ) { 84 | self.init( 85 | number: number, 86 | title: title, 87 | color: color, 88 | content: content(), 89 | link: link(), 90 | destination: destination() 91 | ) 92 | } 93 | } 94 | 95 | extension PageView where Link == EmptyView, Destination == EmptyView { 96 | static func final( 97 | number: Int, 98 | title: String, 99 | color: Color, 100 | @ViewBuilder content: () -> Content 101 | ) -> some View { 102 | Self( 103 | number: number, 104 | title: title, 105 | color: color, 106 | content: content(), 107 | link: nil, 108 | destination: nil 109 | ) 110 | } 111 | } 112 | 113 | extension View { 114 | /// Modify a view with a `ViewBuilder` closure. 115 | /// 116 | /// This represents a streamlining of the 117 | /// [`modifier`](https://developer.apple.com/documentation/swiftui/view/modifier(_:)) + 118 | /// [`ViewModifier`](https://developer.apple.com/documentation/swiftui/viewmodifier) pattern. 119 | /// 120 | /// - Note: Useful only when you don't need to reuse the closure. 121 | /// If you do, turn the closure into a proper modifier. 122 | public func modifier( 123 | @ViewBuilder _ modifier: (Self) -> ModifiedContent 124 | ) -> ModifiedContent { 125 | modifier(self) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Pages.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PageOne: View { 4 | var body: some View { 5 | let content = Group { 6 | Text("**SwiftUINavigationTransitions** is a library that integrates seamlessly with SwiftUI's **Navigation** views, allowing complete customization over **push and pop transitions**!") 7 | } 8 | 9 | PageView(number: 1, title: "Welcome", color: .orange) { 10 | content 11 | } link: { 12 | PageLink(title: "Show me!") 13 | } destination: { 14 | PageTwo() 15 | } 16 | .modifier { 17 | if #available(iOS 16, tvOS 16, *) { 18 | $0.navigationDestination(for: Int.self) { number in 19 | switch number { 20 | case 1: PageOne() 21 | case 2: PageTwo() 22 | case 3: PageThree() 23 | case 4: PageFour() 24 | case 5: PageFive() 25 | default: EmptyView() 26 | } 27 | } 28 | } else { 29 | $0 30 | } 31 | } 32 | } 33 | } 34 | 35 | struct PageTwo: View { 36 | var body: some View { 37 | let content = Group { 38 | Text("The library is fully compatible with **NavigationView** in iOS 13+, and the new **NavigationStack** in iOS 16.") 39 | Text("In fact, that entire transition you just saw can be implemented in **one line** of SwiftUI code:") 40 | Code(""" 41 | NavigationStack { 42 | ... 43 | } 44 | .navigationTransition(.slide) 45 | """ 46 | ) 47 | } 48 | 49 | PageView(number: 2, title: "Overview", color: .green) { 50 | content 51 | } link: { 52 | PageLink(title: "🤯") 53 | } destination: { 54 | PageThree() 55 | } 56 | } 57 | } 58 | 59 | struct PageThree: View { 60 | var body: some View { 61 | let content = Group { 62 | Text("The API is designed to resemble that of built-in SwiftUI Transitions for maximum **familiarity** and **ease of use**.") 63 | Text("You can apply **custom animations** just like with standard SwiftUI transitions:") 64 | Code(""" 65 | .navigationTransition( 66 | .fade(.in).animation( 67 | .easeInOut(duration: 0.3) 68 | ) 69 | ) 70 | """ 71 | ) 72 | Text("... and you can even **combine** them too:") 73 | Code(""" 74 | .navigationTransition( 75 | .slide.combined(with: .fade(.in)) 76 | ) 77 | """ 78 | ) 79 | } 80 | 81 | PageView(number: 3, title: "API Design", color: .red) { 82 | content 83 | } link: { 84 | PageLink(title: "Sweet!") 85 | } destination: { 86 | PageFour() 87 | } 88 | } 89 | } 90 | 91 | struct PageFour: View { 92 | var body: some View { 93 | let content = Group { 94 | Text("The library ships with some standard transitions out of the box, however you can create fully **custom transitions** in just a few lines of code.") 95 | Text("This demo features some presets to play with. You'll find them in the **settings** menu at the top.") 96 | } 97 | 98 | PageView(number: 4, title: "Customization", color: .blue) { 99 | content 100 | } link: { 101 | PageLink(title: "Awesome!") 102 | } destination: { 103 | PageFive() 104 | } 105 | } 106 | } 107 | 108 | struct PageFive: View { 109 | var body: some View { 110 | let content = Group { 111 | Text("The repository contains extensive [documentation](https://github.com/davdroman/swiftui-navigation-transitions/tree/main/Documentation) from how to get started to going fully custom, depending on your needs. 📖") 112 | Text("Feel free to **post questions**, **ideas**, or any **cool transitions** you build in the [Discussions](https://github.com/davdroman/swiftui-navigation-transitions/discussions) section! 💬") 113 | Text("I sincerely hope you enjoy using this library as much as I enjoyed building it.") 114 | Text("❤️") 115 | } 116 | 117 | PageView.final(number: 5, title: "Get Started", color: .purple) { 118 | content 119 | } 120 | } 121 | } 122 | 123 | struct PageLink: View { 124 | var title: String 125 | 126 | var body: some View { 127 | ZStack { 128 | RoundedRectangle(cornerRadius: 6, style: .continuous) 129 | #if !os(tvOS) && !os(visionOS) 130 | .fill(Color.blue.opacity(0.8)) 131 | #else 132 | .fill(Color.clear) 133 | #endif 134 | Text(title) 135 | #if !os(tvOS) 136 | .foregroundColor(.white) 137 | #endif 138 | .font(.system(size: 18, weight: .medium, design: .rounded)) 139 | } 140 | .frame(maxHeight: 50) 141 | } 142 | } 143 | 144 | struct Code: View { 145 | var content: Content 146 | 147 | init(_ content: Content) { 148 | self.content = content 149 | } 150 | 151 | var lineLimit: Int { 152 | content.split(separator: "\n").count 153 | } 154 | 155 | var body: some View { 156 | let shape = RoundedRectangle(cornerRadius: 4, style: .circular) 157 | 158 | Text(content) 159 | .frame(maxWidth: 500, alignment: .leading) 160 | .padding(10) 161 | .lineLimit(lineLimit) 162 | .multilineTextAlignment(.leading) 163 | .minimumScaleFactor(0.5) 164 | .font(.system(size: 14, design: .monospaced)) 165 | .background(shape.stroke(Color(white: 0.1).opacity(0.35), lineWidth: 1)) 166 | .background(Color(white: 0.94).opacity(0.6).clipShape(shape)) 167 | #if !os(tvOS) 168 | .modifier { 169 | if #available(iOS 15, *) { 170 | $0.textSelection(.enabled) 171 | } else { 172 | $0 173 | } 174 | } 175 | #endif 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigationTransitions 3 | 4 | struct RootView: View { 5 | @EnvironmentObject var appState: AppState 6 | 7 | var body: some View { 8 | Group { 9 | if #available(iOS 16, tvOS 16, *) { 10 | NavigationStack { 11 | PageOne() 12 | } 13 | } else { 14 | NavigationView { 15 | PageOne() 16 | } 17 | .navigationViewStyle(.stack) 18 | } 19 | } 20 | .navigationTransition(transition.animation(animation), interactivity: interactivity) 21 | .sheet(isPresented: $appState.isPresentingSettings) { 22 | SettingsView().environmentObject(appState) 23 | } 24 | } 25 | 26 | var transition: AnyNavigationTransition { 27 | appState.transition() 28 | } 29 | 30 | var animation: AnyNavigationTransition.Animation? { 31 | appState.animation( 32 | duration: appState.duration, 33 | stiffness: appState.stiffness, 34 | damping: appState.damping 35 | ) 36 | } 37 | 38 | var interactivity: AnyNavigationTransition.Interactivity { 39 | appState.interactivity() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | var window: UIWindow? 5 | 6 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 7 | guard let windowScene = (scene as? UIWindowScene) else { return } 8 | 9 | window = UIWindow(windowScene: windowScene) 10 | window?.rootViewController = UIHostingController(rootView: AppView()) 11 | window?.makeKeyAndVisible() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/Demo/Demo/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUINavigationTransitions 3 | 4 | struct SettingsView: View { 5 | @EnvironmentObject var appState: AppState 6 | 7 | var body: some View { 8 | NavigationView { 9 | Form { 10 | Section(header: Text("Transition")) { 11 | picker("Transition", $appState.transition) 12 | } 13 | 14 | Section(header: Text("Animation")) { 15 | picker("Animation", $appState.animation) 16 | switch appState.animation { 17 | case .none: 18 | EmptyView() 19 | case .linear, .easeInOut: 20 | picker("Duration", $appState.duration) 21 | case .spring: 22 | picker("Stiffness", $appState.stiffness) 23 | picker("Damping", $appState.damping) 24 | } 25 | } 26 | 27 | Section(header: Text("Interactivity"), footer: interactivityFooter) { 28 | picker("Interactivity", $appState.interactivity) 29 | } 30 | } 31 | #if !os(tvOS) 32 | .navigationBarTitle("Settings", displayMode: .inline) 33 | #endif 34 | .navigationBarItems( 35 | leading: Button("Shuffle", action: shuffle), 36 | trailing: Button(action: dismiss) { Text("Done").bold() } 37 | ) 38 | } 39 | .navigationViewStyle(.stack) 40 | } 41 | 42 | var interactivityFooter: some View { 43 | Text( 44 | """ 45 | You can choose the swipe-back gesture to be: 46 | 47 | • Disabled. 48 | • Edge Pan: recognized from the edge of the screen only. 49 | • Pan: recognized anywhere on the screen! ✨ 50 | """ 51 | ) 52 | } 53 | 54 | @ViewBuilder 55 | func picker( 56 | _ label: String, 57 | _ selection: Binding 58 | ) -> some View where Selection.AllCases: RandomAccessCollection { 59 | Picker( 60 | selection: selection, 61 | label: Text(label) 62 | ) { 63 | ForEach(Selection.allCases, id: \.self) { 64 | Text($0.description).tag($0) 65 | } 66 | } 67 | } 68 | 69 | func shuffle() { 70 | appState.transition = .allCases.randomElement()! 71 | 72 | appState.animation = .allCases.randomElement()! 73 | appState.duration = .allCases.randomElement()! 74 | appState.stiffness = .allCases.randomElement()! 75 | appState.damping = .allCases.randomElement()! 76 | 77 | appState.interactivity = .allCases.randomElement()! 78 | } 79 | 80 | func dismiss() { 81 | appState.isPresentingSettings = false 82 | } 83 | } 84 | 85 | struct SettingsViewPreview: PreviewProvider { 86 | static var previews: some View { 87 | SettingsView().environmentObject(AppState()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Examples/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Examples", 7 | products: [], 8 | targets: [] 9 | ) 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Roman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "6055238988eaf036fed98cc74283a630a84b8b7c28e6fa1948e8e7d489cf9dbe", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-custom-dump", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 8 | "state" : { 9 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 10 | "version" : "1.3.3" 11 | } 12 | }, 13 | { 14 | "identity" : "swiftui-introspect", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/siteline/swiftui-introspect", 17 | "state" : { 18 | "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", 19 | "version" : "1.3.0" 20 | } 21 | }, 22 | { 23 | "identity" : "xctest-dynamic-overlay", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 26 | "state" : { 27 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 28 | "version" : "1.5.2" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swiftui-navigation-transitions", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macCatalyst(.v13), 10 | .tvOS(.v13), 11 | ], 12 | products: [ 13 | .library(name: "SwiftUINavigationTransitions", targets: ["SwiftUINavigationTransitions"]), 14 | .library(name: "UIKitNavigationTransitions", targets: ["UIKitNavigationTransitions"]), 15 | ], 16 | targets: [ 17 | .target(name: "Animation"), 18 | 19 | .target(name: "Animator"), 20 | .testTarget(name: "AnimatorTests", dependencies: [ 21 | "Animator", 22 | "TestUtils", 23 | ]), 24 | 25 | .target(name: "AtomicTransition", dependencies: [ 26 | "Animator", 27 | ]), 28 | .testTarget(name: "AtomicTransitionTests", dependencies: [ 29 | "AtomicTransition", 30 | "TestUtils", 31 | ]), 32 | 33 | .target(name: "NavigationTransition", dependencies: [ 34 | "Animation", 35 | "AtomicTransition", 36 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 37 | ]), 38 | 39 | .target(name: "UIKitNavigationTransitions", dependencies: [ 40 | "NavigationTransition", 41 | "RuntimeAssociation", 42 | "RuntimeSwizzling", 43 | ]), 44 | 45 | .target(name: "SwiftUINavigationTransitions", dependencies: [ 46 | "NavigationTransition", 47 | "RuntimeAssociation", 48 | "RuntimeSwizzling", 49 | "UIKitNavigationTransitions", 50 | .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), 51 | ]), 52 | 53 | .target(name: "RuntimeAssociation"), 54 | .target(name: "RuntimeSwizzling"), 55 | 56 | .target(name: "TestUtils", dependencies: [ 57 | .product(name: "CustomDump", package: "swift-custom-dump"), 58 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 59 | "SwiftUINavigationTransitions", 60 | ]), 61 | ] 62 | ) 63 | 64 | // MARK: Dependencies 65 | 66 | package.dependencies = [ 67 | .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), // dev 68 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), 69 | .package(url: "https://github.com/siteline/swiftui-introspect", from: "1.0.0"), 70 | ] 71 | 72 | for target in package.targets { 73 | target.swiftSettings = target.swiftSettings ?? [] 74 | target.swiftSettings? += [ 75 | .enableExperimentalFeature("AccessLevelOnImport"), 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swiftui-navigation-transitions", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macCatalyst(.v13), 10 | .tvOS(.v13), 11 | ], 12 | products: [ 13 | .library(name: "SwiftUINavigationTransitions", targets: ["SwiftUINavigationTransitions"]), 14 | .library(name: "UIKitNavigationTransitions", targets: ["UIKitNavigationTransitions"]), 15 | ], 16 | targets: [ 17 | .target(name: "Animation"), 18 | 19 | .target(name: "Animator"), 20 | .testTarget(name: "AnimatorTests", dependencies: [ 21 | "Animator", 22 | "TestUtils", 23 | ]), 24 | 25 | .target(name: "AtomicTransition", dependencies: [ 26 | "Animator", 27 | ]), 28 | .testTarget(name: "AtomicTransitionTests", dependencies: [ 29 | "AtomicTransition", 30 | "TestUtils", 31 | ]), 32 | 33 | .target(name: "NavigationTransition", dependencies: [ 34 | "Animation", 35 | "AtomicTransition", 36 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 37 | ]), 38 | 39 | .target(name: "UIKitNavigationTransitions", dependencies: [ 40 | "NavigationTransition", 41 | "RuntimeAssociation", 42 | "RuntimeSwizzling", 43 | ]), 44 | 45 | .target(name: "SwiftUINavigationTransitions", dependencies: [ 46 | "NavigationTransition", 47 | "RuntimeAssociation", 48 | "RuntimeSwizzling", 49 | "UIKitNavigationTransitions", 50 | .product(name: "SwiftUIIntrospect", package: "swiftui-introspect"), 51 | ]), 52 | 53 | .target(name: "RuntimeAssociation"), 54 | .target(name: "RuntimeSwizzling"), 55 | 56 | .target(name: "TestUtils", dependencies: [ 57 | .product(name: "CustomDump", package: "swift-custom-dump"), 58 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 59 | "SwiftUINavigationTransitions", 60 | ]), 61 | ], 62 | swiftLanguageModes: [.v5] 63 | ) 64 | 65 | // MARK: Dependencies 66 | 67 | package.dependencies = [ 68 | .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), // dev 69 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"), 70 | .package(url: "https://github.com/siteline/swiftui-introspect", from: "1.0.0"), 71 | ] 72 | 73 | for target in package.targets { 74 | target.swiftSettings = target.swiftSettings ?? [] 75 | target.swiftSettings? += [ 76 | .enableUpcomingFeature("ExistentialAny"), 77 | .enableUpcomingFeature("InternalImportsByDefault"), 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUINavigationTransitions 2 | 3 | [![CI](https://github.com/davdroman/swiftui-navigation-transitions/actions/workflows/ci.yml/badge.svg)](https://github.com/davdroman/swiftui-navigation-transitions/actions/workflows/ci.yml) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdavdroman%2Fswiftui-navigation-transitions%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fdavdroman%2Fswiftui-navigation-transitions%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions) 6 | 7 |

8 | 9 | 10 | 11 | 12 |

13 | 14 | **SwiftUINavigationTransitions** is a library that integrates seamlessly with SwiftUI's `NavigationView` and `NavigationStack`, allowing complete customization over **push and pop transitions**! 15 | 16 | ## Overview 17 | 18 | Instead of reinventing the entire navigation stack just to control its transitions, this library ships with a **simple modifier** that can be applied directly to SwiftUI's very own **first-party navigation** components. 19 | 20 | ### The Basics 21 | 22 | #### iOS 13+ 23 | 24 | ```swift 25 | NavigationView { 26 | // ... 27 | } 28 | .navigationViewStyle(.stack) 29 | .navigationTransition(.slide) 30 | ``` 31 | 32 | #### iOS 16+ 33 | 34 | ```swift 35 | NavigationStack { 36 | // ... 37 | } 38 | .navigationTransition(.slide) 39 | ``` 40 | 41 | --- 42 | 43 | The API is designed to resemble that of built-in SwiftUI Transitions for maximum **familiarity** and **ease of use**. 44 | 45 | You can apply **custom animations** just like with standard SwiftUI transitions: 46 | 47 | ```swift 48 | .navigationTransition( 49 | .fade(.in).animation(.easeInOut(duration: 0.3)) 50 | ) 51 | ``` 52 | 53 | You can **combine** them: 54 | 55 | ```swift 56 | .navigationTransition( 57 | .slide.combined(with: .fade(.in)) 58 | ) 59 | ``` 60 | 61 | And you can **dynamically** choose between transitions based on logic: 62 | 63 | ```swift 64 | .navigationTransition( 65 | reduceMotion ? .fade(.in).animation(.linear) : .slide(.vertical) 66 | ) 67 | ``` 68 | 69 | ### Transitions 70 | 71 | The library ships with some **standard transitions** out of the box: 72 | 73 | - [`default`](Sources/NavigationTransition/Default.swift) 74 | - [`fade(_:)`](Sources/NavigationTransition/Fade.swift) 75 | - [`slide(axis:)`](Sources/NavigationTransition/Slide.swift) 76 | 77 | In addition to these, you can create fully [**custom transitions**](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/main/documentation/navigationtransitions/custom-transitions) in just a few lines of SwiftUI-like code! 78 | 79 | ```swift 80 | struct Swing: NavigationTransitionProtocol { 81 | var body: some NavigationTransitionProtocol { 82 | Slide(axis: .horizontal) 83 | MirrorPush { 84 | let angle = 70.0 85 | let offset = 150.0 86 | OnInsertion { 87 | ZPosition(1) 88 | Rotate(.degrees(-angle)) 89 | Offset(x: offset) 90 | Opacity() 91 | Scale(0.5) 92 | } 93 | OnRemoval { 94 | Rotate(.degrees(angle)) 95 | Offset(x: -offset) 96 | } 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | The [**Demo**](Examples/Demo) app showcases some of these transitions in action. 103 | 104 | ### Interactivity 105 | 106 | A sweet additional feature is the ability to override the behavior of the **pop gesture** on the navigation view: 107 | 108 | ```swift 109 | .navigationTransition(.slide, interactivity: .pan) // full-pan screen gestures! 110 | ``` 111 | 112 | This even works to override its behavior while maintaining the **default system transition** in iOS: 113 | 114 | ```swift 115 | .navigationTransition(.default, interactivity: .pan) // ✨ 116 | ``` 117 | 118 | ## Installation 119 | 120 | Add the package via Swift Package Manager: 121 | 122 | ``` swift 123 | dependencies: [ 124 | .package(url: "https://github.com/davdroman/swiftui-navigation-transitions", from: "0.15.0"), 125 | ] 126 | ``` 127 | 128 | ```swift 129 | .product(name: "SwiftUINavigationTransitions", package: "swiftui-navigation-transitions"), 130 | ``` 131 | 132 | ## Documentation 133 | 134 | The documentation for releases and `main` are available here: 135 | 136 | - [`main`](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/main/documentation/swiftuinavigationtransitions) 137 | - [0.15.1](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.15.1/documentation/swiftuinavigationtransitions) 138 | 139 |
140 | 141 | Other versions 142 | 143 | 144 | - [0.9.3](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.9.3/documentation/navigationtransitions) 145 | - [0.9.2](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.9.2/documentation/navigationtransitions) 146 | - [0.9.1](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.9.1/documentation/navigationtransitions) 147 | - [0.9.0](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.9.0/documentation/navigationtransitions) 148 | - [0.8.1](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.8.1/documentation/navigationtransitions) 149 | - [0.8.0](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.8.0/documentation/navigationtransitions) 150 | - [0.7.4](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.7.4/documentation/navigationtransitions) 151 | - [0.7.3](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.7.3/documentation/navigationtransitions) 152 | - [0.7.2](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.7.2/documentation/navigationtransitions) 153 | - [0.7.1](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.7.1/documentation/navigationtransitions) 154 | - [0.7.0](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.7.0/documentation/navigationtransitions) 155 | - [0.6.0](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.6.0/documentation/navigationtransitions) 156 | - [0.5.1](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/0.5.1/documentation/navigationtransitions) 157 |
158 | -------------------------------------------------------------------------------- /Sources/Animation/Animation.swift: -------------------------------------------------------------------------------- 1 | package import UIKit 2 | 3 | public struct Animation { 4 | static var defaultDuration: Double { 0.35 } 5 | 6 | package var duration: Double 7 | package let timingParameters: any UITimingCurveProvider 8 | 9 | init(duration: Double, timingParameters: any UITimingCurveProvider) { 10 | self.duration = duration 11 | self.timingParameters = timingParameters 12 | } 13 | 14 | init(duration: Double, curve: UIView.AnimationCurve) { 15 | self.init(duration: duration, timingParameters: UICubicTimingParameters(animationCurve: curve)) 16 | } 17 | } 18 | 19 | extension Animation { 20 | public func speed(_ speed: Double) -> Self { 21 | var copy = self 22 | copy.duration /= speed 23 | return copy 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Animation/Default.swift: -------------------------------------------------------------------------------- 1 | extension Animation { 2 | public static var `default`: Self { 3 | .init(duration: defaultDuration, curve: .easeInOut) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Animation/EaseIn.swift: -------------------------------------------------------------------------------- 1 | extension Animation { 2 | public static func easeIn(duration: Double) -> Self { 3 | .init(duration: duration, curve: .easeIn) 4 | } 5 | 6 | public static var easeIn: Self { 7 | .easeIn(duration: defaultDuration) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Animation/EaseInOut.swift: -------------------------------------------------------------------------------- 1 | extension Animation { 2 | public static func easeInOut(duration: Double) -> Self { 3 | .init(duration: duration, curve: .easeInOut) 4 | } 5 | 6 | public static var easeInOut: Self { 7 | .easeInOut(duration: defaultDuration) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Animation/EaseOut.swift: -------------------------------------------------------------------------------- 1 | extension Animation { 2 | public static func easeOut(duration: Double) -> Self { 3 | .init(duration: duration, curve: .easeOut) 4 | } 5 | 6 | public static var easeOut: Self { 7 | .easeOut(duration: defaultDuration) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Animation/InterpolatingSpring.swift: -------------------------------------------------------------------------------- 1 | internal import UIKit // TODO: remove internal from all imports when Swift 5.10 is dropped 2 | 3 | extension Animation { 4 | public static func interpolatingSpring( 5 | mass: Double = 1.0, 6 | stiffness: Double, 7 | damping: Double, 8 | initialVelocity: Double = 0.0 9 | ) -> Self { 10 | .init( 11 | duration: defaultDuration, 12 | timingParameters: UISpringTimingParameters( 13 | mass: mass, 14 | stiffness: stiffness, 15 | damping: damping, 16 | initialVelocity: CGVector(dx: initialVelocity, dy: initialVelocity) 17 | ) 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Animation/Linear.swift: -------------------------------------------------------------------------------- 1 | extension Animation { 2 | public static func linear(duration: Double) -> Self { 3 | .init(duration: duration, curve: .linear) 4 | } 5 | 6 | public static var linear: Self { 7 | .linear(duration: defaultDuration) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Animation/TimingCurve.swift: -------------------------------------------------------------------------------- 1 | internal import UIKit // TODO: remove internal from all imports when Swift 5.10 is dropped 2 | 3 | extension Animation { 4 | public static func timingCurve( 5 | _ c0x: Double, 6 | _ c0y: Double, 7 | _ c1x: Double, 8 | _ c1y: Double, 9 | duration: Double 10 | ) -> Self { 11 | .init( 12 | duration: duration, 13 | timingParameters: UICubicTimingParameters( 14 | controlPoint1: CGPoint(x: c0x, y: c0y), 15 | controlPoint2: CGPoint(x: c1x, y: c1y) 16 | ) 17 | ) 18 | } 19 | 20 | public static func timingCurve( 21 | _ c0x: Double, 22 | _ c0y: Double, 23 | _ c1x: Double, 24 | _ c1y: Double 25 | ) -> Self { 26 | .timingCurve(c0x, c0y, c1x, c1y, duration: defaultDuration) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Animator/Animator.swift: -------------------------------------------------------------------------------- 1 | public import SwiftUI 2 | 3 | /// Typealias for `Animator`. Useful for disambiguation. 4 | public typealias _Animator = Animator 5 | 6 | /// A protocol representing an abstract view animator, whose sole 7 | /// responsibility is receiving animation and completion blocks 8 | /// as a means to define end-to-end animations as the sum of said blocks. 9 | /// 10 | /// Its interface is a subset of the interface of `UIViewImplicitlyAnimating`. 11 | @objc public protocol Animator { 12 | /// Adds the specified animation block to the animator. 13 | /// 14 | /// Use this method to add new animation blocks to the animator. The animations in the new block run alongside 15 | /// any previously configured animations. 16 | /// 17 | /// If the animation block modifies a property that’s being modified by a different property animator, then the 18 | /// animators combine their changes in the most appropriate way. For many properties, the changes from each 19 | /// animator are added together to yield a new intermediate value. If a property can’t be modified in this 20 | /// additive manner, the new animations take over as if the beginFromCurrentState option had been specified 21 | /// for a view-based animation. 22 | /// 23 | /// You can call this method multiple times to add multiple blocks to the animator. 24 | func addAnimations(_ animation: @escaping () -> Void) 25 | 26 | /// Adds the specified completion block to the animator. 27 | /// 28 | /// Completion blocks are executed after the animations finish normally. 29 | /// 30 | /// - Parameters: 31 | /// - completion: A block to execute when the animations finish. This block has no return value and takes 32 | /// the following parameter: 33 | /// 34 | /// finalPosition 35 | /// 36 | /// The ending position of the animations. Use this value to determine whether the animations stopped at 37 | /// the beginning, end, or somewhere in the middle. 38 | func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) 39 | } 40 | 41 | extension Animator where Self: UIViewImplicitlyAnimating { 42 | public func addAnimations(_ animation: @escaping () -> Void) { 43 | addAnimations?(animation) 44 | } 45 | 46 | public func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) { 47 | addCompletion?(completion) 48 | } 49 | } 50 | 51 | extension UIViewPropertyAnimator: Animator {} 52 | -------------------------------------------------------------------------------- /Sources/Animator/AnimatorTransientView.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | 3 | /// An animation-transient view. 4 | /// 5 | /// This view's interface vaguely resembles that of a `UIView`, 6 | /// however it's scoped to a subset of animatable properties exclusively. 7 | /// 8 | /// It also acts as a property container rather than an actual 9 | /// view being animated, which helps compound mutating values across 10 | /// different defined transitions before actually submitting them 11 | /// to the animator. This helps ensure no jumpy behavior in animations occurs. 12 | @dynamicMemberLookup 13 | public class AnimatorTransientView { 14 | /// Typealias for `AnimatorTransientViewProperties`. 15 | public typealias Properties = AnimatorTransientViewProperties 16 | 17 | /// The initial set of properties that sets up the animation's initial state. 18 | /// 19 | /// Use this to prepare the view before the animation starts. 20 | public var initial: Properties 21 | 22 | /// The set of property changes that get submitted to the animator. 23 | /// 24 | /// Use this to define the desired animation. 25 | public var animation: Properties 26 | 27 | /// The set of property changes that occur after the animation finishes. 28 | /// 29 | /// Use this to clean up any messy view state or to make any final view 30 | /// alterations before presentation. 31 | /// 32 | /// Note: these changes are *not* animated. 33 | public var completion: Properties 34 | 35 | package let uiView: UIView 36 | 37 | /// Read-only proxy to underlying `UIView` properties. 38 | public subscript(dynamicMember keyPath: KeyPath) -> T { 39 | uiView[keyPath: keyPath] 40 | } 41 | 42 | package init(_ uiView: UIView) { 43 | self.initial = Properties(of: uiView) 44 | self.animation = Properties(of: uiView) 45 | self.completion = Properties(of: uiView) 46 | 47 | self.uiView = uiView 48 | } 49 | 50 | package func setUIViewProperties( 51 | to properties: KeyPath, 52 | force: Bool = false 53 | ) { 54 | self[keyPath: properties].assignToUIView(uiView, force: force) 55 | } 56 | 57 | package func resetUIViewProperties() { 58 | Properties.default.assignToUIView(uiView, force: true) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Animator/AnimatorTransientViewProperties.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | 3 | /// Defines the allowed mutable properties in a transient view throughout each stage of the transition. 4 | public struct AnimatorTransientViewProperties: Equatable { 5 | /// A proxy for `UIView.alpha`. 6 | @OptionalWithDefault 7 | public var alpha: CGFloat 8 | 9 | /// A proxy for `UIView.transform` or `UIView.transform3D`. 10 | @OptionalWithDefault 11 | public var transform: Transform 12 | 13 | /// A proxy for `UIView.layer.zPosition`. 14 | @OptionalWithDefault 15 | public var zPosition: CGFloat 16 | } 17 | 18 | extension AnimatorTransientViewProperties { 19 | static let `default` = Self( 20 | alpha: 1, 21 | transform: .identity, 22 | zPosition: 0 23 | ) 24 | 25 | init(of uiView: UIView) { 26 | self.init( 27 | alpha: uiView.alpha, 28 | transform: .init(uiView.transform3D), 29 | zPosition: uiView.layer.zPosition 30 | ) 31 | } 32 | 33 | func assignToUIView(_ uiView: UIView, force: Bool) { 34 | $alpha.assign(to: uiView, \.alpha, force: force) 35 | $transform.assign(to: uiView, force: force) 36 | $zPosition.assign(to: uiView, \.layer.zPosition, force: force) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Animator/OptionalWithDefault.swift: -------------------------------------------------------------------------------- 1 | @propertyWrapper 2 | public struct OptionalWithDefault { 3 | public var projectedValue: Self { self } 4 | 5 | public private(set) var value: Value? = nil 6 | public private(set) var defaultValue: Value 7 | 8 | public var wrappedValue: Value { 9 | get { value ?? defaultValue } 10 | set { value = newValue } 11 | } 12 | 13 | public init(wrappedValue: Value) { 14 | self.defaultValue = wrappedValue 15 | } 16 | } 17 | 18 | extension OptionalWithDefault: Equatable where Value: Equatable {} 19 | 20 | extension OptionalWithDefault { 21 | func assign(to root: Root, _ valueKeyPath: ReferenceWritableKeyPath, force: Bool) { 22 | assign(force: force) { 23 | root[keyPath: valueKeyPath] = $0 24 | } 25 | } 26 | 27 | func assign(force: Bool, handler: (Value) -> Void) { 28 | if let value = force ? wrappedValue : value { 29 | handler(value) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Animator/Transform.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | 3 | @dynamicMemberLookup 4 | public struct Transform: Equatable { 5 | fileprivate var transform: CATransform3D 6 | 7 | public subscript(dynamicMember keyPath: WritableKeyPath) -> T { 8 | get { transform[keyPath: keyPath] } 9 | set { transform[keyPath: keyPath] = newValue } 10 | } 11 | 12 | init(_ transform: CATransform3D) { 13 | self.transform = transform 14 | } 15 | } 16 | 17 | extension OptionalWithDefault where Value == Transform { 18 | func assign(to uiView: UIView, force: Bool) { 19 | self.assign(force: force) { 20 | if let transform = $0.transform.affineTransform { 21 | uiView.transform = transform 22 | } else { 23 | uiView.transform3D = $0.transform 24 | } 25 | } 26 | } 27 | } 28 | 29 | extension CATransform3D { 30 | var affineTransform: CGAffineTransform? { 31 | guard CATransform3DIsAffine(self) else { 32 | return nil 33 | } 34 | return CATransform3DGetAffineTransform(self) 35 | } 36 | } 37 | 38 | extension CATransform3D: Equatable { 39 | @inlinable 40 | public static func == (lhs: Self, rhs: Self) -> Bool { 41 | CATransform3DEqualToTransform(lhs, rhs) 42 | } 43 | } 44 | 45 | extension Transform { 46 | public static var identity: Self { 47 | .init(.identity) 48 | } 49 | 50 | public mutating func translate(x: CGFloat = 0, y: CGFloat = 0, z: CGFloat = 0) { 51 | transform = transform.translated(x: x, y: y, z: z) 52 | } 53 | 54 | public mutating func scale(x: CGFloat = 1, y: CGFloat = 1, z: CGFloat = 1) { 55 | transform = transform.scaled(x: x, y: y, z: z) 56 | } 57 | 58 | public mutating func scale(_ s: CGFloat) { 59 | transform = transform.scaled(x: s, y: s, z: s) 60 | } 61 | 62 | public mutating func rotate(by angle: CGFloat, x: CGFloat = 0, y: CGFloat = 0, z: CGFloat = 0) { 63 | transform = transform.rotated(by: angle, x: x, y: y, z: z) 64 | } 65 | 66 | public func concatenated(with other: Self) -> Self { 67 | .init(transform.concatenated(with: other.transform)) 68 | } 69 | } 70 | 71 | extension CATransform3D { 72 | @inlinable 73 | static var identity: Self { 74 | CATransform3DIdentity 75 | } 76 | 77 | @inlinable 78 | func translated(x: CGFloat, y: CGFloat, z: CGFloat) -> CATransform3D { 79 | CATransform3DTranslate(self, x, y, z) 80 | } 81 | 82 | @inlinable 83 | func scaled(x: CGFloat, y: CGFloat, z: CGFloat) -> CATransform3D { 84 | CATransform3DScale(self, x, y, z) 85 | } 86 | 87 | @inlinable 88 | func scaled(_ s: CGFloat) -> CATransform3D { 89 | CATransform3DScale(self, s, s, s) 90 | } 91 | 92 | @inlinable 93 | func rotated(by angle: CGFloat, x: CGFloat, y: CGFloat, z: CGFloat) -> CATransform3D { 94 | CATransform3DRotate(self, angle, x, y, z) 95 | } 96 | 97 | @inlinable 98 | func concatenated(with other: CATransform3D) -> CATransform3D { 99 | CATransform3DConcat(self, other) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/AtomicTransition.swift: -------------------------------------------------------------------------------- 1 | public import class Animator.AnimatorTransientView 2 | public import class UIKit.UIView 3 | 4 | /// Defines an atomic transition which applies to a single view. It is the core building block of 5 | /// `NavigationTransition`. 6 | /// 7 | /// Similarly to SwiftUI's `AnyTransition`, `AtomicTransition` is designed to handle both the insertion and the removal 8 | /// of a view, and is agnostic as to what the overarching operation (push vs pop) is. This design allows great 9 | /// composability when defining complex navigation transitions. 10 | public protocol AtomicTransition { 11 | /// Typealias for `AtomicTransitionOperation`. 12 | typealias TransitionOperation = AtomicTransitionOperation 13 | /// Typealias for `AnimatorTransientView`. 14 | typealias TransientView = AnimatorTransientView 15 | /// Typealias for `UIView`. 16 | typealias Container = UIView 17 | 18 | /// Set up a custom atomic transition within this function. 19 | /// 20 | /// - Parameters: 21 | /// - view: The ``TransientView`` instance being animated. Apply animations directly to this instance 22 | /// by modifying specific sub-properties of its `initial`, `animation`, or `completion` properties. 23 | /// - operation: The ``TransitionOperation``. Possible values are `insertion` or `removal`. 24 | /// It's recommended that you customize the behavior of your transition based on this parameter. 25 | /// - container: The raw `UIView` containing the transitioning views. 26 | func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) 27 | } 28 | 29 | public enum AtomicTransitionOperation { 30 | case insertion 31 | case removal 32 | } 33 | 34 | /// Defines an `AtomicTransition` that can be mirrored. It is a specialized building block of `NavigationTransition`. 35 | /// 36 | /// A transition that conform to these protocol expose a `Mirrored` associated type expressing the type resulting 37 | /// from mirroring the transition. 38 | public protocol MirrorableAtomicTransition: AtomicTransition { 39 | associatedtype Mirrored: AtomicTransition 40 | 41 | /// The mirrored transition. 42 | /// 43 | /// > Note: A good indicator of a proper implementation for this function is that it should round-trip 44 | /// > to its original value when called twice: 45 | /// > 46 | /// > ```swift 47 | /// > Offset(x: 10).mirrored().mirrored() == Offset(x: 10) 48 | /// > ``` 49 | func mirrored() -> Mirrored 50 | } 51 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/AtomicTransitionBuilder.swift: -------------------------------------------------------------------------------- 1 | @resultBuilder 2 | public enum AtomicTransitionBuilder { 3 | public static func buildBlock() -> Identity { 4 | Identity() 5 | } 6 | 7 | public static func buildPartialBlock(first: T1) -> T1 { 8 | first 9 | } 10 | 11 | public static func buildPartialBlock(accumulated: T1, next: T2) -> Combined { 12 | Combined(accumulated, next) 13 | } 14 | 15 | public static func buildOptional(_ component: T?) -> _OptionalTransition { 16 | if let component { 17 | _OptionalTransition(component) 18 | } else { 19 | _OptionalTransition(nil) 20 | } 21 | } 22 | 23 | public static func buildEither(first component: TrueTransition) -> _ConditionalTransition { 24 | _ConditionalTransition(trueTransition: component) 25 | } 26 | 27 | public static func buildEither(second component: FalseTransition) -> _ConditionalTransition { 28 | _ConditionalTransition(falseTransition: component) 29 | } 30 | } 31 | 32 | public struct _OptionalTransition: AtomicTransition { 33 | private let transition: Transition? 34 | 35 | init(_ transition: Transition?) { 36 | self.transition = transition 37 | } 38 | 39 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 40 | transition?.transition(view, for: operation, in: container) 41 | } 42 | } 43 | 44 | extension _OptionalTransition: MirrorableAtomicTransition where Transition: MirrorableAtomicTransition { 45 | public func mirrored() -> _OptionalTransition { 46 | .init(transition?.mirrored()) 47 | } 48 | } 49 | 50 | extension _OptionalTransition: Equatable where Transition: Equatable {} 51 | extension _OptionalTransition: Hashable where Transition: Hashable {} 52 | 53 | public struct _ConditionalTransition: AtomicTransition { 54 | private typealias Transition = _Either 55 | private let transition: Transition 56 | 57 | init(trueTransition: TrueTransition) { 58 | self.transition = .left(trueTransition) 59 | } 60 | 61 | init(falseTransition: FalseTransition) { 62 | self.transition = .right(falseTransition) 63 | } 64 | 65 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 66 | switch transition { 67 | case .left(let trueTransition): 68 | trueTransition.transition(view, for: operation, in: container) 69 | case .right(let falseTransition): 70 | falseTransition.transition(view, for: operation, in: container) 71 | } 72 | } 73 | } 74 | 75 | extension _ConditionalTransition: MirrorableAtomicTransition where TrueTransition: MirrorableAtomicTransition, FalseTransition: MirrorableAtomicTransition { 76 | public func mirrored() -> _ConditionalTransition { 77 | switch transition { 78 | case .left(let trueTransition): 79 | .init(trueTransition: trueTransition.mirrored()) 80 | case .right(let falseTransition): 81 | .init(falseTransition: falseTransition.mirrored()) 82 | } 83 | } 84 | } 85 | 86 | extension _ConditionalTransition: Equatable where TrueTransition: Equatable, FalseTransition: Equatable {} 87 | extension _ConditionalTransition: Hashable where TrueTransition: Hashable, FalseTransition: Hashable {} 88 | 89 | private enum _Either { 90 | case left(Left) 91 | case right(Right) 92 | } 93 | 94 | extension _Either: Equatable where Left: Equatable, Right: Equatable {} 95 | extension _Either: Hashable where Left: Hashable, Right: Hashable {} 96 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Combined.swift: -------------------------------------------------------------------------------- 1 | public import class UIKit.UIView 2 | 3 | /// A composite transition that is the result of two or more transitions being applied. 4 | public struct Combined: AtomicTransition { 5 | private let transitionA: TransitionA 6 | private let transitionB: TransitionB 7 | 8 | init(_ transitionA: TransitionA, _ transitionB: TransitionB) { 9 | self.transitionA = transitionA 10 | self.transitionB = transitionB 11 | } 12 | 13 | public init(@AtomicTransitionBuilder transitions: () -> Self) { 14 | self = transitions() 15 | } 16 | 17 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 18 | transitionA.transition(view, for: operation, in: container) 19 | transitionB.transition(view, for: operation, in: container) 20 | } 21 | } 22 | 23 | extension Combined: MirrorableAtomicTransition where TransitionA: MirrorableAtomicTransition, TransitionB: MirrorableAtomicTransition { 24 | public func mirrored() -> Combined { 25 | .init(transitionA.mirrored(), transitionB.mirrored()) 26 | } 27 | } 28 | 29 | extension Combined: Equatable where TransitionA: Equatable, TransitionB: Equatable {} 30 | extension Combined: Hashable where TransitionA: Hashable, TransitionB: Hashable {} 31 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Group.swift: -------------------------------------------------------------------------------- 1 | public import class UIKit.UIView 2 | 3 | /// A composite transition that is the result of all the specified transitions being applied. 4 | public struct Group: AtomicTransition { 5 | private let transitions: Transitions 6 | 7 | private init(_ transitions: Transitions) { 8 | self.transitions = transitions 9 | } 10 | 11 | public init(@AtomicTransitionBuilder _ transitions: () -> Transitions) { 12 | self.init(transitions()) 13 | } 14 | 15 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 16 | transitions.transition(view, for: operation, in: container) 17 | } 18 | } 19 | 20 | extension Group: MirrorableAtomicTransition where Transitions: MirrorableAtomicTransition { 21 | public func mirrored() -> Group { 22 | .init(transitions.mirrored()) 23 | } 24 | } 25 | 26 | extension Group: Equatable where Transitions: Equatable {} 27 | extension Group: Hashable where Transitions: Hashable {} 28 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Identity.swift: -------------------------------------------------------------------------------- 1 | public import class UIKit.UIView 2 | 3 | /// A transition that returns the input view, unmodified, as the output view. 4 | public struct Identity: AtomicTransition, MirrorableAtomicTransition { 5 | public init() {} 6 | 7 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 8 | // NO-OP 9 | } 10 | 11 | @inlinable 12 | public func mirrored() -> Self { 13 | self 14 | } 15 | } 16 | 17 | extension Identity: Hashable {} 18 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Mirror.swift: -------------------------------------------------------------------------------- 1 | /// A transition that executes only on insertion, but executes only on removal when mirrored. 2 | public struct MirrorInsertion: AtomicTransition { 3 | private let transition: Transition 4 | 5 | fileprivate init(_ transition: Transition) { 6 | self.transition = transition 7 | } 8 | 9 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 10 | self.init(transition()) 11 | } 12 | 13 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 14 | switch operation { 15 | case .insertion: 16 | transition.transition(view, for: operation, in: container) 17 | case .removal: 18 | return 19 | } 20 | } 21 | } 22 | 23 | extension MirrorInsertion: MirrorableAtomicTransition where Transition: MirrorableAtomicTransition { 24 | public func mirrored() -> MirrorRemoval { 25 | .init(transition) 26 | } 27 | } 28 | 29 | extension MirrorInsertion: Equatable where Transition: Equatable {} 30 | extension MirrorInsertion: Hashable where Transition: Hashable {} 31 | 32 | /// A transition that executes only on removal, but executes only on insertion when mirrored. 33 | public struct MirrorRemoval: AtomicTransition { 34 | private let transition: Transition 35 | 36 | fileprivate init(_ transition: Transition) { 37 | self.transition = transition 38 | } 39 | 40 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 41 | self.init(transition()) 42 | } 43 | 44 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 45 | switch operation { 46 | case .insertion: 47 | return 48 | case .removal: 49 | transition.transition(view, for: operation, in: container) 50 | } 51 | } 52 | } 53 | 54 | extension MirrorRemoval: MirrorableAtomicTransition where Transition: MirrorableAtomicTransition { 55 | public func mirrored() -> MirrorInsertion { 56 | .init(transition) 57 | } 58 | } 59 | 60 | extension MirrorRemoval: Equatable where Transition: Equatable {} 61 | extension MirrorRemoval: Hashable where Transition: Hashable {} 62 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Move.swift: -------------------------------------------------------------------------------- 1 | public import SwiftUI 2 | 3 | /// A transition entering from `edge` on insertion, and exiting towards `edge` on removal. 4 | public struct Move: MirrorableAtomicTransition { 5 | private let edge: Edge 6 | 7 | public init(edge: Edge) { 8 | self.edge = edge 9 | } 10 | 11 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 12 | switch (edge, operation) { 13 | case (.top, .insertion): 14 | view.initial.transform.translate(y: -container.frame.height) 15 | view.animation.transform = .identity 16 | 17 | case (.leading, .insertion): 18 | view.initial.transform.translate(x: -container.frame.width) 19 | view.animation.transform = .identity 20 | 21 | case (.trailing, .insertion): 22 | view.initial.transform.translate(x: container.frame.width) 23 | view.animation.transform = .identity 24 | 25 | case (.bottom, .insertion): 26 | view.initial.transform.translate(y: container.frame.height) 27 | view.animation.transform = .identity 28 | 29 | case (.top, .removal): 30 | view.animation.transform.translate(y: -container.frame.height) 31 | view.completion.transform = .identity 32 | 33 | case (.leading, .removal): 34 | view.animation.transform.translate(x: -container.frame.width) 35 | view.completion.transform = .identity 36 | 37 | case (.trailing, .removal): 38 | view.animation.transform.translate(x: container.frame.width) 39 | view.completion.transform = .identity 40 | 41 | case (.bottom, .removal): 42 | view.animation.transform.translate(y: container.frame.height) 43 | view.completion.transform = .identity 44 | } 45 | } 46 | 47 | public func mirrored() -> Move { 48 | switch edge { 49 | case .top: 50 | .init(edge: .bottom) 51 | case .leading: 52 | .init(edge: .trailing) 53 | case .bottom: 54 | .init(edge: .top) 55 | case .trailing: 56 | .init(edge: .leading) 57 | } 58 | } 59 | } 60 | 61 | extension Move: Hashable {} 62 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Offset.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | 3 | /// A transition that translates the view from offset to zero on insertion, and from zero to offset on removal. 4 | public struct Offset: MirrorableAtomicTransition { 5 | private let x: CGFloat 6 | private let y: CGFloat 7 | 8 | public init(x: CGFloat, y: CGFloat) { 9 | self.x = x 10 | self.y = y 11 | } 12 | 13 | public init(x: CGFloat) { 14 | self.init(x: x, y: 0) 15 | } 16 | 17 | public init(y: CGFloat) { 18 | self.init(x: 0, y: y) 19 | } 20 | 21 | public init(_ offset: CGSize) { 22 | self.init(x: offset.width, y: offset.height) 23 | } 24 | 25 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 26 | switch operation { 27 | case .insertion: 28 | view.initial.transform.translate(x: x, y: y) 29 | view.animation.transform = .identity 30 | case .removal: 31 | view.animation.transform.translate(x: x, y: y) 32 | view.completion.transform = .identity 33 | } 34 | } 35 | 36 | public func mirrored() -> Offset { 37 | .init(x: -x, y: -y) 38 | } 39 | } 40 | 41 | extension Offset: Hashable {} 42 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/On.swift: -------------------------------------------------------------------------------- 1 | public import class UIKit.UIView 2 | 3 | /// A transition that executes only on insertion. 4 | public struct OnInsertion: AtomicTransition { 5 | private let transition: Transition 6 | 7 | private init(_ transition: Transition) { 8 | self.transition = transition 9 | } 10 | 11 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 12 | self.init(transition()) 13 | } 14 | 15 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 16 | switch operation { 17 | case .insertion: 18 | transition.transition(view, for: operation, in: container) 19 | case .removal: 20 | return 21 | } 22 | } 23 | } 24 | 25 | extension OnInsertion: MirrorableAtomicTransition where Transition: MirrorableAtomicTransition { 26 | public func mirrored() -> OnInsertion { 27 | .init(transition.mirrored()) 28 | } 29 | } 30 | 31 | extension OnInsertion: Equatable where Transition: Equatable {} 32 | extension OnInsertion: Hashable where Transition: Hashable {} 33 | 34 | /// A transition that executes only on removal. 35 | public struct OnRemoval: AtomicTransition { 36 | private let transition: Transition 37 | 38 | init(_ transition: Transition) { 39 | self.transition = transition 40 | } 41 | 42 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 43 | self.init(transition()) 44 | } 45 | 46 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 47 | switch operation { 48 | case .insertion: 49 | return 50 | case .removal: 51 | transition.transition(view, for: operation, in: container) 52 | } 53 | } 54 | } 55 | 56 | extension OnRemoval: MirrorableAtomicTransition where Transition: MirrorableAtomicTransition { 57 | public func mirrored() -> OnRemoval { 58 | .init(transition.mirrored()) 59 | } 60 | } 61 | 62 | extension OnRemoval: Equatable where Transition: Equatable {} 63 | extension OnRemoval: Hashable where Transition: Hashable {} 64 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Opacity.swift: -------------------------------------------------------------------------------- 1 | public import class UIKit.UIView 2 | 3 | /// A transition from transparent to opaque on insertion, and from opaque to transparent on removal. 4 | public struct Opacity: MirrorableAtomicTransition { 5 | public init() {} 6 | 7 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 8 | switch operation { 9 | case .insertion: 10 | view.initial.alpha = 0 11 | view.animation.alpha = 1 12 | case .removal: 13 | view.animation.alpha = 0 14 | view.completion.alpha = 1 15 | } 16 | } 17 | 18 | @inlinable 19 | public func mirrored() -> Self { 20 | self 21 | } 22 | } 23 | 24 | extension Opacity: Hashable {} 25 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Rotate.swift: -------------------------------------------------------------------------------- 1 | public import SwiftUI 2 | 3 | /// A transition that rotates the view from `angle` to zero on insertion, and from zero to `angle` on removal. 4 | public struct Rotate: MirrorableAtomicTransition { 5 | private let angle: Angle 6 | 7 | public init(_ angle: Angle) { 8 | self.angle = angle 9 | } 10 | 11 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 12 | switch operation { 13 | case .insertion: 14 | view.initial.transform.rotate(by: angle.radians, z: 1) 15 | view.animation.transform = .identity 16 | case .removal: 17 | view.animation.transform.rotate(by: angle.radians, z: 1) 18 | view.completion.transform = .identity 19 | } 20 | } 21 | 22 | public func mirrored() -> Rotate { 23 | .init(.radians(-angle.radians)) 24 | } 25 | } 26 | 27 | extension Rotate: Hashable {} 28 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Rotate3D.swift: -------------------------------------------------------------------------------- 1 | public import Animator 2 | public import SwiftUI 3 | 4 | /// A transition that rotates the view from `angle` to zero on insertion, and from zero to `angle` on removal. 5 | public struct Rotate3D: MirrorableAtomicTransition { 6 | private let angle: Angle 7 | private let axis: (x: CGFloat, y: CGFloat, z: CGFloat) 8 | private let perspective: CGFloat 9 | 10 | public init(_ angle: Angle, axis: (x: CGFloat, y: CGFloat, z: CGFloat), perspective: CGFloat = 1) { 11 | self.angle = angle 12 | self.axis = axis 13 | self.perspective = perspective 14 | } 15 | 16 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 17 | let m34 = perspective / max(view.frame.width, view.frame.height) 18 | switch operation { 19 | case .insertion: 20 | view.uiView.layer.isDoubleSided = false 21 | view.initial.transform.m34 = m34 22 | view.initial.transform.rotate(by: angle.radians, x: axis.x, y: axis.y, z: axis.z) 23 | view.animation.transform = .identity 24 | case .removal: 25 | view.uiView.layer.isDoubleSided = false 26 | view.animation.transform.m34 = -m34 27 | view.animation.transform.rotate(by: angle.radians, x: axis.x, y: axis.y, z: axis.z) 28 | view.completion.transform = .identity 29 | } 30 | } 31 | 32 | public func mirrored() -> Rotate3D { 33 | .init(.degrees(angle.degrees), axis: axis, perspective: -perspective) 34 | } 35 | } 36 | 37 | extension Rotate3D: Hashable { 38 | public static func == (lhs: Rotate3D, rhs: Rotate3D) -> Bool { 39 | lhs.angle == rhs.angle 40 | && lhs.axis == rhs.axis 41 | } 42 | 43 | public func hash(into hasher: inout Hasher) { 44 | hasher.combine(angle) 45 | hasher.combine(axis.x) 46 | hasher.combine(axis.y) 47 | hasher.combine(axis.z) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/Scale.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | 3 | /// A transition that scales the view from `scale` to `1` on insertion, and from `1` to `scale` on removal. 4 | public struct Scale: MirrorableAtomicTransition { 5 | private let scale: CGFloat 6 | 7 | /// Returns a transition that scales the view from `scale` to 1.0 on insertion, and from 1.0 to `scale` on removal. 8 | /// 9 | /// - Parameters: 10 | /// - scale: The scale of the view, ranging from `0` to `1`. 11 | public init(_ scale: CGFloat) { 12 | self.scale = scale 13 | } 14 | 15 | /// Returns a transition that scales the view from 0 to 1.0 on insertion, and from 1.0 to 0 on removal. 16 | public init() { 17 | self.init(.leastNonzeroMagnitude) 18 | } 19 | 20 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 21 | switch operation { 22 | case .insertion: 23 | view.initial.transform.scale(scale) 24 | view.animation.transform = .identity 25 | case .removal: 26 | view.animation.transform.scale(scale) 27 | view.completion.transform = .identity 28 | } 29 | } 30 | 31 | @inlinable 32 | public func mirrored() -> Self { 33 | self 34 | } 35 | } 36 | 37 | extension Scale: Hashable {} 38 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/ZPosition.swift: -------------------------------------------------------------------------------- 1 | public import Animator 2 | public import UIKit 3 | 4 | /// A transition that changes the view layer’s position on the z axis. 5 | public struct ZPosition: MirrorableAtomicTransition { 6 | private var zPosition: CGFloat 7 | 8 | public init(_ zPosition: CGFloat) { 9 | self.zPosition = zPosition 10 | } 11 | 12 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 13 | view.animation.zPosition = zPosition 14 | view.completion.zPosition = 0 15 | } 16 | 17 | @inlinable 18 | public func mirrored() -> Self { 19 | self 20 | } 21 | } 22 | 23 | /// A transition that brings the view to the front, regardless of insertion or removal. 24 | public struct BringToFront: AtomicTransition { 25 | public init() {} 26 | 27 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 28 | container.bringSubviewToFront(view.uiView) 29 | } 30 | } 31 | 32 | extension BringToFront: Hashable {} 33 | 34 | /// A transition that sends the view to the back, regardless of insertion or removal. 35 | public struct SendToBack: AtomicTransition { 36 | public init() {} 37 | 38 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 39 | container.sendSubviewToBack(view.uiView) 40 | } 41 | } 42 | 43 | extension SendToBack: Hashable {} 44 | -------------------------------------------------------------------------------- /Sources/AtomicTransition/_Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported public import Animator 2 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/AnyNavigationTransition.swift: -------------------------------------------------------------------------------- 1 | public import Animation 2 | package import UIKit 3 | 4 | public struct AnyNavigationTransition { 5 | package typealias TransientHandler = ( 6 | AnimatorTransientView, 7 | AnimatorTransientView, 8 | NavigationTransitionOperation, 9 | UIView 10 | ) -> Void 11 | 12 | package typealias PrimitiveHandler = ( 13 | any Animator, 14 | NavigationTransitionOperation, 15 | any UIViewControllerContextTransitioning 16 | ) -> Void 17 | 18 | package enum Handler { 19 | case transient(TransientHandler) 20 | case primitive(PrimitiveHandler) 21 | } 22 | 23 | package let isDefault: Bool 24 | package let handler: Handler 25 | package var animation: Animation? = .default 26 | 27 | public init(_ transition: some NavigationTransitionProtocol) { 28 | self.isDefault = false 29 | self.handler = .transient(transition.transition(from:to:for:in:)) 30 | } 31 | 32 | public init(_ transition: some PrimitiveNavigationTransition) { 33 | self.isDefault = transition is Default 34 | self.handler = .primitive(transition.transition(with:for:in:)) 35 | } 36 | } 37 | 38 | public typealias _Animation = Animation 39 | 40 | extension AnyNavigationTransition { 41 | /// Typealias for `Animation`. 42 | public typealias Animation = _Animation 43 | 44 | /// Attaches an animation to this transition. 45 | public func animation(_ animation: Animation?) -> Self { 46 | var copy = self 47 | copy.animation = animation 48 | return copy 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/Combined.swift: -------------------------------------------------------------------------------- 1 | import IssueReporting 2 | 3 | extension AnyNavigationTransition { 4 | /// Combines this transition with another, returning a new transition that is the result of both transitions 5 | /// being applied. 6 | public func combined(with other: Self) -> Self { 7 | switch (self.handler, other.handler) { 8 | case (.transient(let lhsHandler), .transient(let rhsHandler)): 9 | struct Erased: NavigationTransitionProtocol { 10 | let handler: AnyNavigationTransition.TransientHandler 11 | 12 | @inlinable 13 | func transition(from fromView: TransientView, to toView: TransientView, for operation: TransitionOperation, in container: Container) { 14 | handler(fromView, toView, operation, container) 15 | } 16 | } 17 | return AnyNavigationTransition( 18 | Combined(Erased(handler: lhsHandler), Erased(handler: rhsHandler)) 19 | ) 20 | case (.transient, .primitive), 21 | (.primitive, .transient), 22 | (.primitive, .primitive): 23 | reportIssue( 24 | """ 25 | Combining primitive and non-primitive or two primitive transitions via 'combine(with:)' is not allowed. 26 | 27 | The left-hand side transition will be left unmodified and the right-hand side transition will be discarded. 28 | """ 29 | ) 30 | return self 31 | } 32 | } 33 | } 34 | 35 | public struct Combined: NavigationTransitionProtocol { 36 | private let transitionA: TransitionA 37 | private let transitionB: TransitionB 38 | 39 | init(_ transitionA: TransitionA, _ transitionB: TransitionB) { 40 | self.transitionA = transitionA 41 | self.transitionB = transitionB 42 | } 43 | 44 | public init(@NavigationTransitionBuilder transitions: () -> Self) { 45 | self = transitions() 46 | } 47 | 48 | public func transition( 49 | from fromView: TransientView, 50 | to toView: TransientView, 51 | for operation: TransitionOperation, 52 | in container: Container 53 | ) { 54 | transitionA.transition(from: fromView, to: toView, for: operation, in: container) 55 | transitionB.transition(from: fromView, to: toView, for: operation, in: container) 56 | } 57 | } 58 | 59 | extension Combined: Equatable where TransitionA: Equatable, TransitionB: Equatable {} 60 | extension Combined: Hashable where TransitionA: Hashable, TransitionB: Hashable {} 61 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/Default.swift: -------------------------------------------------------------------------------- 1 | extension AnyNavigationTransition { 2 | /// The system-default transition. 3 | /// 4 | /// Use this transition if you wish to modify the interactivity of the transition without altering the 5 | /// system-provided transition itself. For example: 6 | /// 7 | /// ```swift 8 | /// NavigationStack { 9 | /// // ... 10 | /// } 11 | /// .navigationStackTransition(.default, interactivity: .pan) // enables full-screen panning for system-provided pop 12 | /// ``` 13 | /// 14 | /// - Note: The animation for `default` cannot be customized via ``animation(_:)``. 15 | public static var `default`: Self { 16 | .init(Default()) 17 | } 18 | } 19 | 20 | package struct Default: PrimitiveNavigationTransition { 21 | init() {} 22 | 23 | package func transition(with animator: any Animator, for operation: TransitionOperation, in context: any Context) { 24 | // NO-OP 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/Fade.swift: -------------------------------------------------------------------------------- 1 | internal import AtomicTransition 2 | 3 | extension AnyNavigationTransition { 4 | /// A transition that fades the pushed view in, fades the popped view out, or cross-fades both views. 5 | public static func fade(_ style: Fade.Style) -> Self { 6 | .init(Fade(style)) 7 | } 8 | } 9 | 10 | /// A transition that fades the pushed view in, fades the popped view out, or cross-fades both views. 11 | public struct Fade: NavigationTransitionProtocol { 12 | public enum Style { 13 | case `in` 14 | case out 15 | case cross 16 | } 17 | 18 | private let style: Style 19 | 20 | public init(_ style: Style) { 21 | self.style = style 22 | } 23 | 24 | public var body: some NavigationTransitionProtocol { 25 | switch style { 26 | case .in: 27 | MirrorPush { 28 | OnInsertion { 29 | ZPosition(1) 30 | Opacity() 31 | } 32 | } 33 | case .out: 34 | MirrorPush { 35 | OnRemoval { 36 | ZPosition(1) 37 | Opacity() 38 | } 39 | } 40 | case .cross: 41 | MirrorPush { 42 | Opacity() 43 | } 44 | } 45 | } 46 | } 47 | 48 | extension Fade: Hashable {} 49 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/Identity.swift: -------------------------------------------------------------------------------- 1 | // For internal use only. 2 | struct Identity: NavigationTransitionProtocol { 3 | init() {} 4 | 5 | func transition( 6 | from fromView: TransientView, 7 | to toView: TransientView, 8 | for operation: TransitionOperation, 9 | in container: Container 10 | ) { 11 | // NO-OP 12 | } 13 | } 14 | 15 | extension Identity: Hashable {} 16 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/Mirror.swift: -------------------------------------------------------------------------------- 1 | public import AtomicTransition 2 | 3 | /// Used to define a transition that executes on push, and executes the mirrored version of said transition on pop. 4 | public struct MirrorPush: NavigationTransitionProtocol { 5 | private let transition: Transition 6 | 7 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 8 | self.transition = transition() 9 | } 10 | 11 | public var body: some NavigationTransitionProtocol { 12 | OnPush { 13 | transition 14 | } 15 | OnPop { 16 | transition.mirrored() 17 | } 18 | } 19 | } 20 | 21 | extension MirrorPush: Equatable where Transition: Equatable {} 22 | extension MirrorPush: Hashable where Transition: Hashable {} 23 | 24 | /// Used to define a transition that executes on pop, and executes the mirrored version of said transition on push. 25 | public struct MirrorPop: NavigationTransitionProtocol { 26 | private let transition: Transition 27 | 28 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 29 | self.transition = transition() 30 | } 31 | 32 | public var body: some NavigationTransitionProtocol { 33 | OnPush { 34 | transition.mirrored() 35 | } 36 | OnPop { 37 | transition 38 | } 39 | } 40 | } 41 | 42 | extension MirrorPop: Equatable where Transition: Equatable {} 43 | extension MirrorPop: Hashable where Transition: Hashable {} 44 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/NavigationTransitionBuilder.swift: -------------------------------------------------------------------------------- 1 | @resultBuilder 2 | public enum NavigationTransitionBuilder { 3 | public static func buildPartialBlock(first: T1) -> T1 { 4 | first 5 | } 6 | 7 | public static func buildPartialBlock(accumulated: T1, next: T2) -> Combined { 8 | Combined(accumulated, next) 9 | } 10 | 11 | public static func buildOptional(_ component: T?) -> _OptionalTransition { 12 | if let component { 13 | _OptionalTransition(component) 14 | } else { 15 | _OptionalTransition(nil) 16 | } 17 | } 18 | 19 | public static func buildEither(first component: TrueTransition) -> _ConditionalTransition { 20 | _ConditionalTransition(trueTransition: component) 21 | } 22 | 23 | public static func buildEither(second component: FalseTransition) -> _ConditionalTransition { 24 | _ConditionalTransition(falseTransition: component) 25 | } 26 | } 27 | 28 | public struct _OptionalTransition: NavigationTransitionProtocol { 29 | private let transition: Transition? 30 | 31 | init(_ transition: Transition?) { 32 | self.transition = transition 33 | } 34 | 35 | public func transition( 36 | from fromView: TransientView, 37 | to toView: TransientView, 38 | for operation: TransitionOperation, 39 | in container: Container 40 | ) { 41 | transition?.transition(from: fromView, to: toView, for: operation, in: container) 42 | } 43 | } 44 | 45 | public struct _ConditionalTransition: NavigationTransitionProtocol { 46 | private typealias Transition = _Either 47 | private let transition: Transition 48 | 49 | init(trueTransition: TrueTransition) { 50 | self.transition = .left(trueTransition) 51 | } 52 | 53 | init(falseTransition: FalseTransition) { 54 | self.transition = .right(falseTransition) 55 | } 56 | 57 | public func transition( 58 | from fromView: TransientView, 59 | to toView: TransientView, 60 | for operation: TransitionOperation, 61 | in container: Container 62 | ) { 63 | switch transition { 64 | case .left(let trueTransition): 65 | trueTransition.transition(from: fromView, to: toView, for: operation, in: container) 66 | case .right(let falseTransition): 67 | falseTransition.transition(from: fromView, to: toView, for: operation, in: container) 68 | } 69 | } 70 | } 71 | 72 | private enum _Either { 73 | case left(Left) 74 | case right(Right) 75 | } 76 | 77 | extension _Either: Equatable where Left: Equatable, Right: Equatable {} 78 | extension _Either: Hashable where Left: Hashable, Right: Hashable {} 79 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/NavigationTransitionProtocol.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | 3 | /// Defines a transition which applies to two views: an origin ("from") view and a destination ("to") view. 4 | /// 5 | /// It is designed to handle both push and pop operations for a pair of views in a given navigation stack transition, 6 | /// and is usually composed of smaller isolated transitions of type `AtomicTransition`, which act as building blocks. 7 | /// 8 | /// Although the library ships with a set of predefined transitions (e.g. ``Slide``, one can also create 9 | /// entirely new, fully customizable transitions by conforming to this protocol. 10 | public protocol NavigationTransitionProtocol { 11 | /// Typealias for `AnimatorTransientView`. 12 | typealias TransientView = AnimatorTransientView 13 | /// Typealias for `NavigationTransitionOperation`. 14 | typealias TransitionOperation = NavigationTransitionOperation 15 | /// Typealias for `UIView`. 16 | typealias Container = UIView 17 | 18 | // NB: for Xcode to favor autocompleting `var body: Body` over `var body: Never` we must use a type alias. 19 | associatedtype _Body 20 | 21 | /// A type representing the body of this transition. 22 | /// 23 | /// If you create a custom transition by implementing ``transition(from:to:for:in:)-211yh``, Swift 24 | /// infers this type to be `Never`. 25 | typealias Body = _Body 26 | 27 | /// Used to implement a custom navigation transition. 28 | /// 29 | /// - Parameters: 30 | /// - fromView: A `TransientView` abstracting over the origin view. Apply animations directly to this instance 31 | /// by modifying specific sub-properties of its `initial`, `animation`, or `completion` properties. 32 | /// - toView: A `TransientView` abstracting over the destination view. Apply animations directly to this instance 33 | /// by modifying specific sub-properties of its `initial`, `animation`, or `completion` properties. 34 | /// - operation: The ``TransitionOperation``. Possible values are `push` or `pop`. It's recommended that you 35 | /// customize the behavior of your transition based on this parameter. 36 | /// - container: The raw `UIView` containing the transitioning views. 37 | func transition( 38 | from fromView: TransientView, 39 | to toView: TransientView, 40 | for operation: TransitionOperation, 41 | in container: Container 42 | ) 43 | 44 | /// The content of a navigation transition that is composed from other transitions. 45 | /// 46 | /// Implement this requirement when you want to combine the behavior of other transitions 47 | /// together. 48 | /// 49 | /// Do not invoke this property directly. 50 | /// 51 | /// - Important: If your transition implements the ``transition(from:to:for:in:)-22zdm`` method, it will take precedence 52 | /// over this property, and only ``transition(from:to:for:in:)-22zdm`` will be called by the animator. 53 | @NavigationTransitionBuilder 54 | var body: Body { get } 55 | } 56 | 57 | extension NavigationTransitionProtocol where Body: NavigationTransitionProtocol { 58 | /// Invokes ``body``'s implementation of ``transition(from:to:for:in:)-211yh``. 59 | @inlinable 60 | public func transition( 61 | from fromView: TransientView, 62 | to toView: TransientView, 63 | for operation: TransitionOperation, 64 | in container: Container 65 | ) { 66 | self.body.transition(from: fromView, to: toView, for: operation, in: container) 67 | } 68 | } 69 | 70 | extension NavigationTransitionProtocol where Body == Never { 71 | /// A non-existent body. 72 | /// 73 | /// > Warning: Do not invoke this property directly. It will trigger a fatal error at runtime. 74 | @_transparent 75 | public var body: Body { 76 | fatalError( 77 | """ 78 | '\(Self.self)' has no body. … 79 | Do not access a transition's 'body' property directly, as it may not exist. 80 | """ 81 | ) 82 | } 83 | } 84 | 85 | public enum NavigationTransitionOperation: Hashable { 86 | case push 87 | case pop 88 | 89 | package init?(_ operation: UINavigationController.Operation) { 90 | switch operation { 91 | case .push: 92 | self = .push 93 | case .pop: 94 | self = .pop 95 | case .none: 96 | return nil 97 | @unknown default: 98 | return nil 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/On.swift: -------------------------------------------------------------------------------- 1 | public import AtomicTransition 2 | 3 | /// Used to define a transition that executes only on push. 4 | public struct OnPush: NavigationTransitionProtocol { 5 | private let transition: Transition 6 | 7 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 8 | self.transition = transition() 9 | } 10 | 11 | public func transition( 12 | from fromView: TransientView, 13 | to toView: TransientView, 14 | for operation: TransitionOperation, 15 | in container: Container 16 | ) { 17 | switch operation { 18 | case .push: 19 | transition.transition(fromView, for: .removal, in: container) 20 | transition.transition(toView, for: .insertion, in: container) 21 | case .pop: 22 | return 23 | } 24 | } 25 | } 26 | 27 | extension OnPush: Equatable where Transition: Equatable {} 28 | extension OnPush: Hashable where Transition: Hashable {} 29 | 30 | /// Used to define a transition that executes only on pop. 31 | public struct OnPop: NavigationTransitionProtocol { 32 | private let transition: Transition 33 | 34 | public init(@AtomicTransitionBuilder transition: () -> Transition) { 35 | self.transition = transition() 36 | } 37 | 38 | public func transition( 39 | from fromView: TransientView, 40 | to toView: TransientView, 41 | for operation: TransitionOperation, 42 | in container: Container 43 | ) { 44 | switch operation { 45 | case .push: 46 | return 47 | case .pop: 48 | transition.transition(fromView, for: .removal, in: container) 49 | transition.transition(toView, for: .insertion, in: container) 50 | } 51 | } 52 | } 53 | 54 | extension OnPop: Equatable where Transition: Equatable {} 55 | extension OnPop: Hashable where Transition: Hashable {} 56 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/Pick.swift: -------------------------------------------------------------------------------- 1 | /// Used to isolate the push portion of a full `NavigationTransitionProtocol` and execute it on push, ignoring the pop portion. 2 | public struct PickPush: NavigationTransitionProtocol { 3 | private let transition: Transition 4 | 5 | public init(@NavigationTransitionBuilder transition: () -> Transition) { 6 | self.transition = transition() 7 | } 8 | 9 | public func transition( 10 | from fromView: TransientView, 11 | to toView: TransientView, 12 | for operation: TransitionOperation, 13 | in container: Container 14 | ) { 15 | switch operation { 16 | case .push: 17 | transition.transition(from: fromView, to: toView, for: operation, in: container) 18 | case .pop: 19 | return 20 | } 21 | } 22 | } 23 | 24 | extension PickPush: Equatable where Transition: Equatable {} 25 | extension PickPush: Hashable where Transition: Hashable {} 26 | 27 | /// Used to isolate the pop portion of a full `NavigationTransitionProtocol` and execute it on pop, ignoring the push portion. 28 | public struct PickPop: NavigationTransitionProtocol { 29 | private let transition: Transition 30 | 31 | public init(@NavigationTransitionBuilder transition: () -> Transition) { 32 | self.transition = transition() 33 | } 34 | 35 | public func transition( 36 | from fromView: TransientView, 37 | to toView: TransientView, 38 | for operation: TransitionOperation, 39 | in container: Container 40 | ) { 41 | switch operation { 42 | case .push: 43 | return 44 | case .pop: 45 | transition.transition(from: fromView, to: toView, for: operation, in: container) 46 | } 47 | } 48 | } 49 | 50 | extension PickPop: Equatable where Transition: Equatable {} 51 | extension PickPop: Hashable where Transition: Hashable {} 52 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/PrimitiveNavigationTransition.swift: -------------------------------------------------------------------------------- 1 | public import Animator 2 | public import UIKit 3 | 4 | /// Defines a transition which applies to two views: an origin ("from") view and a destination ("to") view. 5 | /// 6 | /// This protocol variant is used to implement transitions that need to interact with raw UIKit transitioning entities. 7 | /// 8 | /// - Warning: Usage of this initializer is highly discouraged unless you know what you're doing. 9 | /// Conform to ``NavigationTransitionProtocol`` instead to ensure correct transition behavior. 10 | public protocol PrimitiveNavigationTransition { 11 | /// Typealias for `NavigationTransitionOperation`. 12 | typealias TransitionOperation = NavigationTransitionOperation 13 | /// Typealias for `UIViewControllerContextTransitioning`. 14 | typealias Context = UIViewControllerContextTransitioning 15 | 16 | /// Used to implement a custom navigation transition. 17 | /// 18 | /// - Parameters: 19 | /// - Animator: The `Animator` object used for the transition. Attach animations or completion blocks to it. 20 | /// - Operation: The ``TransitionOperation``. Possible values are `push` or `pop`. It's recommended that you 21 | /// customize the behavior of your transition based on this parameter. 22 | /// - Context: The raw `UIViewControllerContextTransitioning` instance of the transition coordinator. 23 | func transition(with animator: any Animator, for operation: TransitionOperation, in context: any Context) 24 | } 25 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/Slide.swift: -------------------------------------------------------------------------------- 1 | internal import AtomicTransition 2 | public import SwiftUI 3 | 4 | extension AnyNavigationTransition { 5 | /// A transition that moves both views in and out along the specified axis. 6 | /// 7 | /// This transition: 8 | /// - Pushes views right-to-left and pops views left-to-right when `axis` is `horizontal`. 9 | /// - Pushes views bottom-to-top and pops views top-to-bottom when `axis` is `vertical`. 10 | public static func slide(axis: Axis) -> Self { 11 | .init(Slide(axis: axis)) 12 | } 13 | } 14 | 15 | extension AnyNavigationTransition { 16 | /// Equivalent to `slide(axis: .horizontal)`. 17 | @inlinable 18 | public static var slide: Self { 19 | .slide(axis: .horizontal) 20 | } 21 | } 22 | 23 | /// A transition that moves both views in and out along the specified axis. 24 | /// 25 | /// This transition: 26 | /// - Pushes views right-to-left and pops views left-to-right when `axis` is `horizontal`. 27 | /// - Pushes views bottom-to-top and pops views top-to-bottom when `axis` is `vertical`. 28 | public struct Slide: NavigationTransitionProtocol { 29 | private let axis: Axis 30 | 31 | public init(axis: Axis) { 32 | self.axis = axis 33 | } 34 | 35 | /// Equivalent to `Move(axis: .horizontal)`. 36 | @inlinable 37 | public init() { 38 | self.init(axis: .horizontal) 39 | } 40 | 41 | public var body: some NavigationTransitionProtocol { 42 | switch axis { 43 | case .horizontal: 44 | MirrorPush { 45 | OnInsertion { 46 | Move(edge: .trailing) 47 | } 48 | OnRemoval { 49 | Move(edge: .leading) 50 | } 51 | } 52 | case .vertical: 53 | MirrorPush { 54 | OnInsertion { 55 | Move(edge: .bottom) 56 | } 57 | OnRemoval { 58 | Move(edge: .top) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | extension Slide: Hashable {} 66 | -------------------------------------------------------------------------------- /Sources/NavigationTransition/_Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported public import Animation 2 | @_exported public import AtomicTransition 3 | -------------------------------------------------------------------------------- /Sources/RuntimeAssociation/RuntimeAssociation.swift: -------------------------------------------------------------------------------- 1 | import ObjectiveC 2 | 3 | public protocol RuntimeAssociation: AnyObject { 4 | subscript(forKey key: String, policy: RuntimeAssociationPolicy) -> T? { get set } 5 | } 6 | 7 | extension RuntimeAssociation { 8 | public subscript(forKey key: String = #function, policy: RuntimeAssociationPolicy = .retain(.nonatomic)) -> T? { 9 | get { 10 | let key = unsafeBitCast(Selector(key), to: UnsafeRawPointer.self) 11 | return objc_getAssociatedObject(self, key) as? T 12 | } 13 | set { 14 | let key = unsafeBitCast(Selector(key), to: UnsafeRawPointer.self) 15 | objc_setAssociatedObject(self, key, newValue, .init(policy)) 16 | } 17 | } 18 | } 19 | 20 | extension NSObject: RuntimeAssociation {} 21 | -------------------------------------------------------------------------------- /Sources/RuntimeAssociation/RuntimeAssociationPolicy.swift: -------------------------------------------------------------------------------- 1 | import ObjectiveC 2 | 3 | public enum RuntimeAssociationPolicy { 4 | public enum Atomicity { 5 | case atomic 6 | case nonatomic 7 | } 8 | 9 | case assign 10 | case copy(Atomicity) 11 | case retain(Atomicity) 12 | } 13 | 14 | extension objc_AssociationPolicy { 15 | init(_ policy: RuntimeAssociationPolicy) { 16 | switch policy { 17 | case .assign: 18 | self = .OBJC_ASSOCIATION_ASSIGN 19 | case .copy(.atomic): 20 | self = .OBJC_ASSOCIATION_COPY 21 | case .copy(.nonatomic): 22 | self = .OBJC_ASSOCIATION_COPY_NONATOMIC 23 | case .retain(.atomic): 24 | self = .OBJC_ASSOCIATION_RETAIN 25 | case .retain(.nonatomic): 26 | self = .OBJC_ASSOCIATION_RETAIN_NONATOMIC 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/RuntimeSwizzling/Swizzle.swift: -------------------------------------------------------------------------------- 1 | public import ObjectiveC 2 | 3 | public var swizzleLogs = false 4 | 5 | public func swizzle(_ type: AnyObject.Type, _ original: Selector, _ swizzled: Selector) { 6 | guard !swizzlingHistory.contains(type, original, swizzled) else { 7 | return 8 | } 9 | 10 | swizzlingHistory.add(type, original, swizzled) 11 | 12 | guard let originalMethod = class_getInstanceMethod(type, original) else { 13 | assertionFailure("[Swizzling] Instance method \(type).\(original) not found.") 14 | return 15 | } 16 | 17 | guard let swizzledMethod = class_getInstanceMethod(type, swizzled) else { 18 | assertionFailure("[Swizzling] Instance method \(type).\(swizzled) not found.") 19 | return 20 | } 21 | 22 | if swizzleLogs { 23 | print("[Swizzling] [\(type) \(original) <~> \(swizzled)]") 24 | } 25 | 26 | method_exchangeImplementations(originalMethod, swizzledMethod) 27 | } 28 | 29 | private struct SwizzlingHistory { 30 | private var map: [Int: Void] = [:] 31 | 32 | func contains(_ type: AnyObject.Type, _ original: Selector, _ swizzled: Selector) -> Bool { 33 | map[hash(type, original, swizzled)] != nil 34 | } 35 | 36 | mutating func add(_ type: AnyObject.Type, _ original: Selector, _ swizzled: Selector) { 37 | map[hash(type, original, swizzled)] = () 38 | } 39 | 40 | private func hash(_ type: AnyObject.Type, _ original: Selector, _ swizzled: Selector) -> Int { 41 | var hasher = Hasher() 42 | hasher.combine(ObjectIdentifier(type)) 43 | hasher.combine(original) 44 | hasher.combine(swizzled) 45 | return hasher.finalize() 46 | } 47 | } 48 | 49 | private var swizzlingHistory = SwizzlingHistory() 50 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationTransitions/Documentation.docc/Articles/Custom Transitions.md: -------------------------------------------------------------------------------- 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 | - [**Basic**](#Basic) 13 | - [**Recommended**](#Recommended) 14 | - [**UIKit**](#UIKit) 15 | 16 | ## Core Concepts 17 | 18 | ### `NavigationTransitionProtocol` 19 | 20 | The main construct the library leverages is called `AnyNavigationTransition`. You may have seen some instances of this type in the README's code samples (e.g. `.slide`). 21 | 22 | `AnyNavigationTransition` instances describe both `push` and `pop` transitions for both *origin* and *destination* views. 23 | 24 | If we dive into the implementation of `AnyNavigationTransition.slide`, we'll find this: 25 | 26 | ```swift 27 | extension AnyNavigationTransition { 28 | /// [...] 29 | public static func slide(axis: Axis) -> Self { 30 | .init(Slide(axis: axis)) 31 | } 32 | } 33 | ``` 34 | 35 | As you can see, there's not much going on here. The reason is that `AnyNavigationTransition` is actually just a type erasing wrapper around the real meat and potatoes: the protocol `NavigationTransitionProtocol`. 36 | 37 | Let's take a look at what (capital "S") `Slide` is: 38 | 39 | ```swift 40 | public struct Slide: NavigationTransitionProtocol { 41 | private let axis: Axis 42 | 43 | public init(axis: Axis) { 44 | self.axis = axis 45 | } 46 | 47 | public var body: some NavigationTransitionProtocol { 48 | switch axis { 49 | case .horizontal: 50 | MirrorPush { 51 | OnInsertion { 52 | Move(edge: .trailing) 53 | } 54 | OnRemoval { 55 | Move(edge: .leading) 56 | } 57 | } 58 | case .vertical: 59 | MirrorPush { 60 | OnInsertion { 61 | Move(edge: .bottom) 62 | } 63 | OnRemoval { 64 | Move(edge: .top) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | ``` 71 | 72 | This is more like it! 73 | 74 | As you can see, `NavigationTransitionProtocol` leverages result builder syntax to define "what" transitions do, not "how" they do it. Notice how the entire transition is implemented concisely, 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 at its core: `Move(edge: ...)`. 75 | 76 | If you've used SwiftUI's `transition` modifier before, it's easy to draw a comparison to `AnyTransition.move(edge:)`. And in fact, whilst the API is slightly different, the intent behind it is the same indeed! `Move` is a type that conforms to the building block of the library: `AtomicTransition`. 77 | 78 | ### `AtomicTransition` 79 | 80 | `AtomicTransition` is a SwiftUI `AnyTransition`-inspired type which acts very much in the same manner. It can describe a specific set of view changes on an individual ("atomic") basis, for both **insertion** and **removal** of said view. 81 | 82 | Contrary to `NavigationTransitionProtocol` 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** `NavigationTransitionProtocol`. 83 | 84 | If we dive even deeper into `Move`, this is what we find: 85 | 86 | ```swift 87 | public struct Move: AtomicTransition { 88 | private let edge: Edge 89 | 90 | public init(edge: Edge) { 91 | self.edge = edge 92 | } 93 | 94 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 95 | switch (edge, operation) { 96 | case (.top, .insertion): 97 | view.initial.translation.dy = -container.frame.height 98 | view.animation.translation.dy = 0 99 | 100 | case (.leading, .insertion): 101 | view.initial.translation.dx = -container.frame.width 102 | view.animation.translation.dx = 0 103 | 104 | case (.trailing, .insertion): 105 | view.initial.translation.dx = container.frame.width 106 | view.animation.translation.dx = 0 107 | 108 | case (.bottom, .insertion): 109 | view.initial.translation.dy = container.frame.height 110 | view.animation.translation.dy = 0 111 | 112 | case (.top, .removal): 113 | view.animation.translation.dy = -container.frame.height 114 | view.completion.translation.dy = 0 115 | 116 | case (.leading, .removal): 117 | view.animation.translation.dx = -container.frame.width 118 | view.completion.translation.dx = 0 119 | 120 | case (.trailing, .removal): 121 | view.animation.translation.dx = container.frame.width 122 | view.completion.translation.dx = 0 123 | 124 | case (.bottom, .removal): 125 | view.animation.translation.dy = container.frame.height 126 | view.completion.translation.dy = 0 127 | } 128 | } 129 | } 130 | ``` 131 | 132 | Now we're talking! There's some basic math and value assignments happening, but nothing resembling a typical `UIView` animation block even at this point. Although there are some references to `animation` and `completion`, which are very familiar concepts in UIKit world. 133 | 134 | 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, easily maintainable, non-glitchy transitions down the road. 135 | 136 | ## Implementation 137 | 138 | ### Basic 139 | 140 | #### `AnyNavigationTransition.combined(with:)` 141 | 142 | You can create a custom `AnyNavigationTransition` by combining two existing transitions: 143 | 144 | ```swift 145 | .slide.combined(with: .fade(.in)) 146 | ``` 147 | 148 | It is rarely the case where you'd want to combine `AnyNavigationTransition`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. 149 | 150 | Instead, most combinations should happen at lowers level, in `NavigationTransitionProtocol` and `AtomicTransition` conformances. 151 | 152 | 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. 153 | 154 | ### Recommended 155 | 156 | Let's delve into how `AtomicTransition` actually works, by taking another look at the implementation of `Move`: 157 | 158 | ```swift 159 | public struct Move: AtomicTransition { 160 | private let edge: Edge 161 | 162 | public init(edge: Edge) { 163 | self.edge = edge 164 | } 165 | 166 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 167 | switch (edge, operation) { 168 | case (.top, .insertion): 169 | view.initial.translation.dy = -container.frame.height 170 | view.animation.translation.dy = 0 171 | 172 | case (.leading, .insertion): 173 | view.initial.translation.dx = -container.frame.width 174 | view.animation.translation.dx = 0 175 | 176 | case (.trailing, .insertion): 177 | view.initial.translation.dx = container.frame.width 178 | view.animation.translation.dx = 0 179 | 180 | case (.bottom, .insertion): 181 | view.initial.translation.dy = container.frame.height 182 | view.animation.translation.dy = 0 183 | 184 | case (.top, .removal): 185 | view.animation.translation.dy = -container.frame.height 186 | view.completion.translation.dy = 0 187 | 188 | case (.leading, .removal): 189 | view.animation.translation.dx = -container.frame.width 190 | view.completion.translation.dx = 0 191 | 192 | case (.trailing, .removal): 193 | view.animation.translation.dx = container.frame.width 194 | view.completion.translation.dx = 0 195 | 196 | case (.bottom, .removal): 197 | view.animation.translation.dy = container.frame.height 198 | view.completion.translation.dy = 0 199 | } 200 | } 201 | } 202 | ``` 203 | 204 | All types conforming to `AtomicTransition` must implement what's known as a "transition handler". This transition handler hands over several things for us to work with: 205 | 206 | - A `TransientView` instance, which is actually an abstraction over the `UIView` being inserted or removed under the hood by UIKit (and thus SwiftUI) as part of a push or a pop. The reason it exists is because it helps abstract away all of the UIKit animation logic and instead allows you 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 atomic transitions affecting the same property don't accidentally cause glitchy UI behavior. 207 | - `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. 208 | - `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. 209 | 210 | --- 211 | 212 | Next up, let's explore two ways of conforming to `NavigationTransitionProtocol`. 213 | 214 | The simplest (and most recommended) way is by declaring our atomic transitions (if needed), and composing them via `var body: some NavigationTransitionProtocol { ... }` like we saw [previously with `Slide`](#NavigationTransitionProtocol). 215 | 216 | Check out the [documentation](https://swiftpackageindex.com/davdroman/swiftui-navigation-transitions/main/documentation/swiftuinavigationtransitions/NavigationTransitionProtocol) to learn about the different `NavigationTransitionProtocol` types and how they compose. 217 | 218 | 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. 219 | 220 | --- 221 | 222 | Finally, let's explore an alternative option for those who'd like to reach for a more wholistic API. `NavigationTransitionProtocol` declares a `transition` function that can be implemented instead of `body`: 223 | 224 | ```swift 225 | func transition(from fromView: TransientView, to toView: TransientView, for operation: TransitionOperation, in container: Container) 226 | ``` 227 | 228 | Whilst `body` helps composing other transitions, this transition handler helps us define a completely custom transition without reaching down to atomic transitions as building blocks. You can roll your own logic as you would with `AtomicTransition` earlier, but with full context of the transition: 229 | 230 | - `fromView` and `toView` are `TransientView`s corresponding to the *origin* and *destination* views involved in the transition. They work just like with `AtomicTransition`. 231 | - `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. 232 | - `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. 233 | 234 | 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. 235 | 236 | ### UIKit 237 | 238 | 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: 239 | 240 | - You intend to migrate one of your existing [`UIViewControllerAnimatedTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrolleranimatedtransitioning) implementations over to SwiftUI. 241 | - 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. 242 | 243 | 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. 244 | 245 | Let's delve into the final customization entry point, which as mentioned interacts with UIKit abstractions directly. 246 | 247 | 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). 248 | 249 | The interface looks as follows: 250 | 251 | ```swift 252 | @objc public protocol Animator { 253 | func addAnimations(_ animation: @escaping () -> Void) 254 | func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) 255 | } 256 | ``` 257 | 258 | In order to adopt this, you must declare a type conforming to `PrimitiveNavigationTransition`, and implement its transition handler: 259 | 260 | ```swift 261 | struct MyTransition: PrimitiveNavigationTransition { 262 | func transition(with animator: Animator, for operation: TransitionOperation, in context: Context) { 263 | // ... 264 | } 265 | } 266 | ``` 267 | 268 | - `Animator` is used to setup animations and completion logic. 269 | - `Operation` describes the `push` or `pop` operation being performed. 270 | - `Context` ([`UIViewControllerContextTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrollercontexttransitioning)) gives you access to the views being animated and more. 271 | 272 | This function can be thought of as the equivalent of [`UIViewControllerAnimatedTransitioning.animateTransition(using:)`](https://developer.apple.com/documentation/uikit/uiviewcontrolleranimatedtransitioning/1622061-animatetransition), except it also handles interactive pops automatically for you (as long as you use the provided animator). 273 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationTransitions/SwiftUISupport.swift: -------------------------------------------------------------------------------- 1 | public import UIKitNavigationTransitions 2 | public import SwiftUI 3 | @_spi(Advanced) internal import SwiftUIIntrospect 4 | 5 | extension View { 6 | @MainActor 7 | public func navigationTransition( 8 | _ transition: AnyNavigationTransition, 9 | interactivity: AnyNavigationTransition.Interactivity = .default 10 | ) -> some View { 11 | self.introspect( 12 | .navigationView(style: .stack), 13 | on: .iOS(.v13...), .tvOS(.v13...), .visionOS(.v1...), 14 | scope: [.receiver, .ancestor] 15 | ) { controller in 16 | controller.setNavigationTransition(transition, interactivity: interactivity) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftUINavigationTransitions/_Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported public import Animation 2 | @_exported public import Animator 3 | @_exported public import AtomicTransition 4 | @_exported public import NavigationTransition 5 | -------------------------------------------------------------------------------- /Sources/TestUtils/Animator+Mocks.swift: -------------------------------------------------------------------------------- 1 | public import Animator 2 | public import UIKit 3 | import IssueReporting 4 | 5 | public final class UnimplementedAnimator: Animator { 6 | public init() {} 7 | 8 | public func addAnimations(_ animation: @escaping () -> Void) { 9 | reportIssue("\(Self.self).\(#function) is unimplemented") 10 | } 11 | 12 | public func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) { 13 | reportIssue("\(Self.self).\(#function) is unimplemented") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/TestUtils/AnimatorTransientView+Mocks.swift: -------------------------------------------------------------------------------- 1 | @testable public import Animator 2 | internal import UIKit 3 | import IssueReporting 4 | 5 | extension AnimatorTransientView { 6 | public static var unimplemented: AnimatorTransientView { 7 | UnimplementedAnimatorTransientView() 8 | } 9 | } 10 | 11 | final class UnimplementedAnimatorTransientView: AnimatorTransientView { 12 | override public var initial: AnimatorTransientView.Properties { 13 | get { 14 | reportIssue("\(Self.self).\(#function) is unimplemented") 15 | return .noop 16 | } 17 | set { 18 | reportIssue("\(Self.self).\(#function) is unimplemented") 19 | } 20 | } 21 | 22 | override public var animation: AnimatorTransientView.Properties { 23 | get { 24 | reportIssue("\(Self.self).\(#function) is unimplemented") 25 | return .noop 26 | } 27 | set { 28 | reportIssue("\(Self.self).\(#function) is unimplemented") 29 | } 30 | } 31 | 32 | override public var completion: AnimatorTransientView.Properties { 33 | get { 34 | reportIssue("\(Self.self).\(#function) is unimplemented") 35 | return .noop 36 | } 37 | set { 38 | reportIssue("\(Self.self).\(#function) is unimplemented") 39 | } 40 | } 41 | 42 | override public subscript(dynamicMember keyPath: KeyPath) -> T { 43 | reportIssue("\(Self.self).\(#function) is unimplemented") 44 | return uiView[keyPath: keyPath] 45 | } 46 | 47 | public init() { 48 | super.init(UIView()) 49 | } 50 | 51 | override public func setUIViewProperties( 52 | to properties: KeyPath, 53 | force: Bool 54 | ) { 55 | reportIssue("\(Self.self).\(#function) is unimplemented") 56 | } 57 | } 58 | 59 | extension AnimatorTransientView.Properties { 60 | fileprivate static let noop = Self( 61 | alpha: 0, 62 | transform: .init(.init()), 63 | zPosition: 0 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /Sources/TestUtils/AtomicTransition+Mocks.swift: -------------------------------------------------------------------------------- 1 | @testable public import AtomicTransition 2 | 3 | public struct Spy: AtomicTransition { 4 | public typealias Handler = (TransientView, TransitionOperation, Container) -> Void 5 | 6 | private let handler: Handler 7 | 8 | public init(handler: @escaping Handler) { 9 | self.handler = handler 10 | } 11 | 12 | public init(handler: @escaping () -> Void) { 13 | self.handler = { _, _, _ in handler() } 14 | } 15 | 16 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 17 | handler(view, operation, container) 18 | } 19 | } 20 | 21 | public struct Noop: AtomicTransition { 22 | public init() {} 23 | 24 | public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) { 25 | // NO-OP 26 | } 27 | } 28 | 29 | extension Noop: Hashable {} 30 | 31 | extension AtomicTransitionOperation { 32 | public static func random() -> Self { 33 | [.insertion, .removal].randomElement()! 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/TestUtils/NavigationTransition+Mocks.swift: -------------------------------------------------------------------------------- 1 | // @testable import NavigationTransition 2 | // 3 | // extension NavigationTransition { 4 | // public static func spy(_ handler: @escaping () -> Void) -> Self { 5 | // .init { _, _, _ in 6 | // handler() 7 | // } 8 | // } 9 | // 10 | // public static func spy(_ handler: @escaping _Handler) -> Self { 11 | // .init(handler: handler) 12 | // } 13 | // } 14 | // 15 | // extension NavigationTransition { 16 | // public static var noop: Self { 17 | // .init { _, _, _ in } 18 | // } 19 | // } 20 | // 21 | // extension NavigationTransition.Operation { 22 | // public static func random() -> Self { 23 | // [.push, .pop].randomElement()! 24 | // } 25 | // } 26 | -------------------------------------------------------------------------------- /Sources/TestUtils/UIKitContext+Mocks.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | import IssueReporting 3 | 4 | public class UnimplementedUIKitContext: NSObject, UIViewControllerContextTransitioning { 5 | public var containerView: UIView { 6 | reportIssue("\(Self.self).\(#function) is unimplemented") 7 | return .init() 8 | } 9 | 10 | public var isAnimated: Bool { 11 | reportIssue("\(Self.self).\(#function) is unimplemented") 12 | return false 13 | } 14 | 15 | public var isInteractive: Bool { 16 | reportIssue("\(Self.self).\(#function) is unimplemented") 17 | return false 18 | } 19 | 20 | public var transitionWasCancelled: Bool { 21 | reportIssue("\(Self.self).\(#function) is unimplemented") 22 | return false 23 | } 24 | 25 | public var presentationStyle: UIModalPresentationStyle { 26 | reportIssue("\(Self.self).\(#function) is unimplemented") 27 | return .none 28 | } 29 | 30 | public func updateInteractiveTransition(_ percentComplete: CGFloat) { 31 | reportIssue("\(Self.self).\(#function) is unimplemented") 32 | } 33 | 34 | public func finishInteractiveTransition() { 35 | reportIssue("\(Self.self).\(#function) is unimplemented") 36 | } 37 | 38 | public func cancelInteractiveTransition() { 39 | reportIssue("\(Self.self).\(#function) is unimplemented") 40 | } 41 | 42 | public func pauseInteractiveTransition() { 43 | reportIssue("\(Self.self).\(#function) is unimplemented") 44 | } 45 | 46 | public func completeTransition(_ didComplete: Bool) { 47 | reportIssue("\(Self.self).\(#function) is unimplemented") 48 | } 49 | 50 | public func viewController(forKey key: UITransitionContextViewControllerKey) -> UIViewController? { 51 | reportIssue("\(Self.self).\(#function) is unimplemented") 52 | return nil 53 | } 54 | 55 | public func view(forKey key: UITransitionContextViewKey) -> UIView? { 56 | reportIssue("\(Self.self).\(#function) is unimplemented") 57 | return nil 58 | } 59 | 60 | public var targetTransform: CGAffineTransform { 61 | reportIssue("\(Self.self).\(#function) is unimplemented") 62 | return .init() 63 | } 64 | 65 | public func initialFrame(for vc: UIViewController) -> CGRect { 66 | reportIssue("\(Self.self).\(#function) is unimplemented") 67 | return .zero 68 | } 69 | 70 | public func finalFrame(for vc: UIViewController) -> CGRect { 71 | reportIssue("\(Self.self).\(#function) is unimplemented") 72 | return .zero 73 | } 74 | } 75 | 76 | public final class MockedUIKitContext: UnimplementedUIKitContext { 77 | public init(containerView: UIView) { 78 | self._containerView = containerView 79 | } 80 | 81 | private var _containerView: UIView 82 | override public var containerView: UIView { 83 | _containerView 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/TestUtils/UIView+Mocks.swift: -------------------------------------------------------------------------------- 1 | public import UIKit 2 | 3 | extension UIView { 4 | public static var unimplemented: UIView { 5 | UnimplementedUIView() 6 | } 7 | } 8 | 9 | final class UnimplementedUIView: UIView { 10 | // TODO: unimplement some stuff... 11 | } 12 | -------------------------------------------------------------------------------- /Sources/TestUtils/_Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported public import CustomDump 2 | @_exported public import UIKit 3 | @_exported public import XCTest 4 | -------------------------------------------------------------------------------- /Sources/UIKitNavigationTransitions/Delegate.swift: -------------------------------------------------------------------------------- 1 | internal import Animation 2 | internal import Animator 3 | internal import NavigationTransition 4 | internal import UIKit 5 | 6 | final class NavigationTransitionDelegate: NSObject, UINavigationControllerDelegate { 7 | var transition: AnyNavigationTransition 8 | private weak var baseDelegate: (any UINavigationControllerDelegate)? 9 | var interactionController: UIPercentDrivenInteractiveTransition? 10 | 11 | init(transition: AnyNavigationTransition, baseDelegate: (any UINavigationControllerDelegate)?) { 12 | self.transition = transition 13 | self.baseDelegate = baseDelegate 14 | } 15 | 16 | func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { 17 | baseDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated) 18 | } 19 | 20 | func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { 21 | baseDelegate?.navigationController?(navigationController, didShow: viewController, animated: animated) 22 | } 23 | 24 | func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: any UIViewControllerAnimatedTransitioning) -> (any UIViewControllerInteractiveTransitioning)? { 25 | if !transition.isDefault { 26 | interactionController 27 | } else { 28 | nil 29 | } 30 | } 31 | 32 | func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? { 33 | if 34 | !transition.isDefault, 35 | let animation = transition.animation, 36 | let operation = NavigationTransitionOperation(operation) 37 | { 38 | NavigationTransitionAnimatorProvider( 39 | transition: transition, 40 | animation: animation, 41 | operation: operation 42 | ) 43 | } else { 44 | nil 45 | } 46 | } 47 | } 48 | 49 | final class NavigationTransitionAnimatorProvider: NSObject, UIViewControllerAnimatedTransitioning { 50 | let transition: AnyNavigationTransition 51 | let animation: Animation 52 | let operation: NavigationTransitionOperation 53 | 54 | init(transition: AnyNavigationTransition, animation: Animation, operation: NavigationTransitionOperation) { 55 | self.transition = transition 56 | self.animation = animation 57 | self.operation = operation 58 | } 59 | 60 | func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval { 61 | animation.duration 62 | } 63 | 64 | func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) { 65 | transitionAnimator(for: transitionContext).startAnimation() 66 | } 67 | 68 | func interruptibleAnimator(using transitionContext: any UIViewControllerContextTransitioning) -> any UIViewImplicitlyAnimating { 69 | transitionAnimator(for: transitionContext) 70 | } 71 | 72 | func animationEnded(_ transitionCompleted: Bool) { 73 | cachedAnimators.removeAll(keepingCapacity: true) 74 | } 75 | 76 | private var cachedAnimators: [ObjectIdentifier: UIViewPropertyAnimator] = .init(minimumCapacity: 1) 77 | 78 | private func transitionAnimator(for transitionContext: any UIViewControllerContextTransitioning) -> UIViewPropertyAnimator { 79 | if let cached = cachedAnimators[ObjectIdentifier(transitionContext)] { 80 | return cached 81 | } 82 | let animator = UIViewPropertyAnimator( 83 | duration: transitionDuration(using: transitionContext), 84 | timingParameters: animation.timingParameters 85 | ) 86 | cachedAnimators[ObjectIdentifier(transitionContext)] = animator 87 | 88 | let container = transitionContext.containerView 89 | guard 90 | let fromUIView = transitionContext.view(forKey: .from), 91 | let toUIView = transitionContext.view(forKey: .to) 92 | else { 93 | return animator 94 | } 95 | 96 | fromUIView.isUserInteractionEnabled = false 97 | toUIView.isUserInteractionEnabled = false 98 | 99 | switch transition.handler { 100 | case .transient(let handler): 101 | if let (fromView, toView) = transientViews( 102 | for: handler, 103 | animator: animator, 104 | context: (container, fromUIView, toUIView) 105 | ) { 106 | for view in [fromView, toView] { 107 | view.setUIViewProperties(to: \.initial) 108 | animator.addAnimations { view.setUIViewProperties(to: \.animation) } 109 | animator.addCompletion { _ in 110 | if transitionContext.transitionWasCancelled { 111 | view.resetUIViewProperties() 112 | } else { 113 | view.setUIViewProperties(to: \.completion) 114 | } 115 | } 116 | } 117 | } 118 | case .primitive(let handler): 119 | handler(animator, operation, transitionContext) 120 | } 121 | 122 | animator.addCompletion { _ in 123 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled) 124 | 125 | fromUIView.isUserInteractionEnabled = true 126 | toUIView.isUserInteractionEnabled = true 127 | 128 | // iOS 16 workaround to nudge views into becoming responsive after transition 129 | if transitionContext.transitionWasCancelled { 130 | fromUIView.removeFromSuperview() 131 | container.addSubview(fromUIView) 132 | } else { 133 | toUIView.removeFromSuperview() 134 | container.addSubview(toUIView) 135 | } 136 | } 137 | 138 | return animator 139 | } 140 | 141 | private func transientViews( 142 | for handler: AnyNavigationTransition.TransientHandler, 143 | animator: any Animator, 144 | context: (container: UIView, fromUIView: UIView, toUIView: UIView) 145 | ) -> (fromView: AnimatorTransientView, toView: AnimatorTransientView)? { 146 | let (container, fromUIView, toUIView) = context 147 | 148 | switch operation { 149 | case .push: 150 | container.insertSubview(toUIView, aboveSubview: fromUIView) 151 | case .pop: 152 | container.insertSubview(toUIView, belowSubview: fromUIView) 153 | } 154 | 155 | let fromView = AnimatorTransientView(fromUIView) 156 | let toView = AnimatorTransientView(toUIView) 157 | 158 | handler(fromView, toView, operation, container) 159 | 160 | return (fromView, toView) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Sources/UIKitNavigationTransitions/Interaction.swift: -------------------------------------------------------------------------------- 1 | internal import UIKit 2 | 3 | extension UINavigationController { 4 | @objc func handleInteraction(_ gestureRecognizer: UIPanGestureRecognizer) { 5 | guard let delegate = customDelegate else { 6 | return 7 | } 8 | guard let gestureRecognizerView = gestureRecognizer.view else { 9 | delegate.interactionController = nil 10 | return 11 | } 12 | 13 | let translation = gestureRecognizer.translation(in: gestureRecognizerView).x 14 | let width = gestureRecognizerView.bounds.size.width 15 | let percent = translation / width 16 | 17 | switch gestureRecognizer.state { 18 | case .possible: 19 | break 20 | 21 | case .began: 22 | delegate.interactionController = UIPercentDrivenInteractiveTransition() 23 | popViewController(animated: true) 24 | delegate.interactionController?.update(percent) 25 | 26 | case .changed: 27 | delegate.interactionController?.update(percent) 28 | 29 | case .ended: 30 | let velocity = gestureRecognizer.velocity(in: gestureRecognizerView).x 31 | 32 | if velocity > 675 || (percent >= 0.2 && velocity > -200) { 33 | let resistance: Double = 800 34 | let maxSpeed: Double = 2.25 35 | let nominalSpeed = max(0.99, velocity / resistance) 36 | let speed = min(nominalSpeed, maxSpeed) 37 | 38 | delegate.interactionController?.completionSpeed = speed 39 | delegate.interactionController?.finish() 40 | } else { 41 | delegate.interactionController?.cancel() 42 | } 43 | 44 | delegate.interactionController = nil 45 | 46 | case .failed, .cancelled: 47 | delegate.interactionController?.cancel() 48 | delegate.interactionController = nil 49 | 50 | @unknown default: 51 | break 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/UIKitNavigationTransitions/Interactivity.swift: -------------------------------------------------------------------------------- 1 | public import NavigationTransition 2 | 3 | extension AnyNavigationTransition { 4 | public enum Interactivity { 5 | case disabled 6 | case edgePan 7 | case pan 8 | 9 | @inlinable 10 | public static var `default`: Self { 11 | .edgePan 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/UIKitNavigationTransitions/UIKitSupport.swift: -------------------------------------------------------------------------------- 1 | public import NavigationTransition 2 | import RuntimeAssociation 3 | import RuntimeSwizzling 4 | public import UIKit 5 | 6 | public struct UISplitViewControllerColumns: OptionSet { 7 | public static let primary = Self(rawValue: 1) 8 | public static let supplementary = Self(rawValue: 1 << 1) 9 | public static let secondary = Self(rawValue: 1 << 2) 10 | 11 | public static let compact = Self(rawValue: 1 << 3) 12 | 13 | public static let all: Self = [compact, primary, supplementary, secondary] 14 | 15 | public let rawValue: Int8 16 | 17 | public init(rawValue: Int8) { 18 | self.rawValue = rawValue 19 | } 20 | } 21 | 22 | extension UISplitViewController { 23 | public func setNavigationTransition( 24 | _ transition: AnyNavigationTransition, 25 | forColumns columns: UISplitViewControllerColumns, 26 | interactivity: AnyNavigationTransition.Interactivity = .default 27 | ) { 28 | if columns.contains(.compact), let compact = compactViewController as? UINavigationController { 29 | compact.setNavigationTransition(transition, interactivity: interactivity) 30 | } 31 | if columns.contains(.primary), let primary = primaryViewController as? UINavigationController { 32 | primary.setNavigationTransition(transition, interactivity: interactivity) 33 | } 34 | if columns.contains(.supplementary), let supplementary = supplementaryViewController as? UINavigationController { 35 | supplementary.setNavigationTransition(transition, interactivity: interactivity) 36 | } 37 | if columns.contains(.secondary), let secondary = secondaryViewController as? UINavigationController { 38 | secondary.setNavigationTransition(transition, interactivity: interactivity) 39 | } 40 | } 41 | } 42 | 43 | extension UISplitViewController { 44 | var compactViewController: UIViewController? { 45 | if #available(iOS 14, tvOS 14, *) { 46 | viewController(for: .compact) 47 | } else { 48 | if isCollapsed { 49 | viewControllers.first 50 | } else { 51 | nil 52 | } 53 | } 54 | } 55 | 56 | var primaryViewController: UIViewController? { 57 | if #available(iOS 14, tvOS 14, *) { 58 | viewController(for: .primary) 59 | } else { 60 | if !isCollapsed { 61 | viewControllers.first 62 | } else { 63 | nil 64 | } 65 | } 66 | } 67 | 68 | var supplementaryViewController: UIViewController? { 69 | if #available(iOS 14, tvOS 14, *) { 70 | viewController(for: .supplementary) 71 | } else { 72 | if !isCollapsed { 73 | if viewControllers.count >= 3 { 74 | viewControllers[safe: 1] 75 | } else { 76 | nil 77 | } 78 | } else { 79 | nil 80 | } 81 | } 82 | } 83 | 84 | var secondaryViewController: UIViewController? { 85 | if #available(iOS 14, tvOS 14, *) { 86 | viewController(for: .secondary) 87 | } else { 88 | if !isCollapsed { 89 | if viewControllers.count >= 3 { 90 | viewControllers[safe: 2] 91 | } else { 92 | viewControllers[safe: 1] 93 | } 94 | } else { 95 | nil 96 | } 97 | } 98 | } 99 | } 100 | 101 | extension RandomAccessCollection where Index == Int { 102 | subscript(safe index: Index) -> Element? { 103 | self.dropFirst(index).first 104 | } 105 | } 106 | 107 | extension UINavigationController { 108 | private var defaultDelegate: (any UINavigationControllerDelegate)! { 109 | get { self[] } 110 | set { self[] = newValue } 111 | } 112 | 113 | var customDelegate: NavigationTransitionDelegate! { 114 | get { self[] } 115 | set { 116 | self[] = newValue 117 | delegate = newValue 118 | } 119 | } 120 | 121 | public func setNavigationTransition( 122 | _ transition: AnyNavigationTransition, 123 | interactivity: AnyNavigationTransition.Interactivity = .default 124 | ) { 125 | if defaultDelegate == nil { 126 | defaultDelegate = delegate 127 | } 128 | 129 | if customDelegate == nil { 130 | customDelegate = NavigationTransitionDelegate(transition: transition, baseDelegate: defaultDelegate) 131 | } else { 132 | customDelegate.transition = transition 133 | } 134 | 135 | swizzle( 136 | UINavigationController.self, 137 | #selector(UINavigationController.setViewControllers), 138 | #selector(UINavigationController.setViewControllers_animateIfNeeded) 139 | ) 140 | 141 | swizzle( 142 | UINavigationController.self, 143 | #selector(UINavigationController.pushViewController), 144 | #selector(UINavigationController.pushViewController_animateIfNeeded) 145 | ) 146 | 147 | swizzle( 148 | UINavigationController.self, 149 | #selector(UINavigationController.popViewController), 150 | #selector(UINavigationController.popViewController_animateIfNeeded) 151 | ) 152 | 153 | swizzle( 154 | UINavigationController.self, 155 | #selector(UINavigationController.popToViewController), 156 | #selector(UINavigationController.popToViewController_animateIfNeeded) 157 | ) 158 | 159 | swizzle( 160 | UINavigationController.self, 161 | #selector(UINavigationController.popToRootViewController), 162 | #selector(UINavigationController.popToRootViewController_animateIfNeeded) 163 | ) 164 | 165 | #if !os(tvOS) && !os(visionOS) 166 | if defaultEdgePanRecognizer.strongDelegate == nil { 167 | defaultEdgePanRecognizer.strongDelegate = NavigationGestureRecognizerDelegate(controller: self) 168 | } 169 | 170 | if defaultPanRecognizer == nil { 171 | defaultPanRecognizer = UIPanGestureRecognizer() 172 | defaultPanRecognizer.targets = defaultEdgePanRecognizer.targets // https://stackoverflow.com/a/60526328/1922543 173 | defaultPanRecognizer.strongDelegate = NavigationGestureRecognizerDelegate(controller: self) 174 | view.addGestureRecognizer(defaultPanRecognizer) 175 | } 176 | 177 | if edgePanRecognizer == nil { 178 | edgePanRecognizer = UIScreenEdgePanGestureRecognizer() 179 | edgePanRecognizer.edges = .left 180 | edgePanRecognizer.addTarget(self, action: #selector(handleInteraction)) 181 | edgePanRecognizer.strongDelegate = NavigationGestureRecognizerDelegate(controller: self) 182 | view.addGestureRecognizer(edgePanRecognizer) 183 | } 184 | 185 | if panRecognizer == nil { 186 | panRecognizer = UIPanGestureRecognizer() 187 | panRecognizer.addTarget(self, action: #selector(handleInteraction)) 188 | panRecognizer.strongDelegate = NavigationGestureRecognizerDelegate(controller: self) 189 | view.addGestureRecognizer(panRecognizer) 190 | } 191 | 192 | if transition.isDefault { 193 | switch interactivity { 194 | case .disabled: 195 | exclusivelyEnableGestureRecognizer(.none) 196 | case .edgePan: 197 | exclusivelyEnableGestureRecognizer(defaultEdgePanRecognizer) 198 | case .pan: 199 | exclusivelyEnableGestureRecognizer(defaultPanRecognizer) 200 | } 201 | } else { 202 | switch interactivity { 203 | case .disabled: 204 | exclusivelyEnableGestureRecognizer(.none) 205 | case .edgePan: 206 | exclusivelyEnableGestureRecognizer(edgePanRecognizer) 207 | case .pan: 208 | exclusivelyEnableGestureRecognizer(panRecognizer) 209 | } 210 | } 211 | #endif 212 | } 213 | 214 | @available(tvOS, unavailable) 215 | @available(visionOS, unavailable) 216 | private func exclusivelyEnableGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer?) { 217 | for recognizer in [defaultEdgePanRecognizer!, defaultPanRecognizer!, edgePanRecognizer!, panRecognizer!] { 218 | if let gestureRecognizer, recognizer === gestureRecognizer { 219 | recognizer.isEnabled = true 220 | } else { 221 | recognizer.isEnabled = false 222 | } 223 | } 224 | } 225 | } 226 | 227 | extension UINavigationController { 228 | @objc private func setViewControllers_animateIfNeeded(_ viewControllers: [UIViewController], animated: Bool) { 229 | if let transitionDelegate = customDelegate { 230 | setViewControllers_animateIfNeeded(viewControllers, animated: transitionDelegate.transition.animation != nil) 231 | } else { 232 | setViewControllers_animateIfNeeded(viewControllers, animated: animated) 233 | } 234 | } 235 | 236 | @objc private func pushViewController_animateIfNeeded(_ viewController: UIViewController, animated: Bool) { 237 | if let transitionDelegate = customDelegate { 238 | pushViewController_animateIfNeeded(viewController, animated: transitionDelegate.transition.animation != nil) 239 | } else { 240 | pushViewController_animateIfNeeded(viewController, animated: animated) 241 | } 242 | } 243 | 244 | @objc private func popViewController_animateIfNeeded(animated: Bool) -> UIViewController? { 245 | if let transitionDelegate = customDelegate { 246 | popViewController_animateIfNeeded(animated: transitionDelegate.transition.animation != nil) 247 | } else { 248 | popViewController_animateIfNeeded(animated: animated) 249 | } 250 | } 251 | 252 | @objc private func popToViewController_animateIfNeeded(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? { 253 | if let transitionDelegate = customDelegate { 254 | popToViewController_animateIfNeeded(viewController, animated: transitionDelegate.transition.animation != nil) 255 | } else { 256 | popToViewController_animateIfNeeded(viewController, animated: animated) 257 | } 258 | } 259 | 260 | @objc private func popToRootViewController_animateIfNeeded(animated: Bool) -> UIViewController? { 261 | if let transitionDelegate = customDelegate { 262 | popToRootViewController_animateIfNeeded(animated: transitionDelegate.transition.animation != nil) 263 | } else { 264 | popToRootViewController_animateIfNeeded(animated: animated) 265 | } 266 | } 267 | } 268 | 269 | @available(tvOS, unavailable) 270 | @available(visionOS, unavailable) 271 | extension UINavigationController { 272 | var defaultEdgePanRecognizer: UIScreenEdgePanGestureRecognizer! { 273 | interactivePopGestureRecognizer as? UIScreenEdgePanGestureRecognizer 274 | } 275 | 276 | var defaultPanRecognizer: UIPanGestureRecognizer! { 277 | get { self[] } 278 | set { self[] = newValue } 279 | } 280 | 281 | var edgePanRecognizer: UIScreenEdgePanGestureRecognizer! { 282 | get { self[] } 283 | set { self[] = newValue } 284 | } 285 | 286 | var panRecognizer: UIPanGestureRecognizer! { 287 | get { self[] } 288 | set { self[] = newValue } 289 | } 290 | } 291 | 292 | @available(tvOS, unavailable) 293 | extension UIGestureRecognizer { 294 | var strongDelegate: (any UIGestureRecognizerDelegate)? { 295 | get { self[] } 296 | set { 297 | self[] = newValue 298 | delegate = newValue 299 | } 300 | } 301 | 302 | var targets: Any? { 303 | get { 304 | value(forKey: #function) 305 | } 306 | set { 307 | if let newValue { 308 | setValue(newValue, forKey: #function) 309 | } else { 310 | setValue(NSMutableArray(), forKey: #function) 311 | } 312 | } 313 | } 314 | } 315 | 316 | @available(tvOS, unavailable) 317 | final class NavigationGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate { 318 | private unowned let navigationController: UINavigationController 319 | 320 | init(controller: UINavigationController) { 321 | self.navigationController = controller 322 | } 323 | 324 | // TODO: swizzle instead 325 | func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 326 | let isNotOnRoot = navigationController.viewControllers.count > 1 327 | let noModalIsPresented = navigationController.presentedViewController == nil // TODO: check if this check is still needed after iOS 17 public release 328 | return isNotOnRoot && noModalIsPresented 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /Sources/UIKitNavigationTransitions/_Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported public import Animation 2 | @_exported public import Animator 3 | @_exported public import AtomicTransition 4 | @_exported public import NavigationTransition 5 | -------------------------------------------------------------------------------- /SwiftUINavigationTransitions.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /SwiftUINavigationTransitions.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SwiftUINavigationTransitions.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | ../../../Package.resolved -------------------------------------------------------------------------------- /SwiftUINavigationTransitions.xcworkspace/xcshareddata/xcschemes/SwiftUINavigationTransitions.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 48 | 49 | 55 | 56 | 62 | 63 | 64 | 65 | 67 | 68 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Tests/AnimatorTests/AnimatorTransientViewPropertiesTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import TestUtils 3 | 4 | final class AnimatorTransientViewPropertiesTests: XCTestCase {} 5 | 6 | extension AnimatorTransientViewPropertiesTests { 7 | func testAssignToUIView() { 8 | let view = UIView() 9 | XCTAssertEqual(view.alpha, 1) 10 | XCTAssertEqual(view.transform3D, .identity) 11 | XCTAssertEqual(view.layer.zPosition, 0) 12 | 13 | var sut = AnimatorTransientView.Properties(of: view) 14 | 15 | sut.alpha = 0.5 16 | sut.transform = .init(.identity.scaled(5)) 17 | sut.zPosition = 15 18 | 19 | sut.assignToUIView(view, force: false) 20 | XCTAssertEqual(view.alpha, 0.5) 21 | XCTAssertEqual(view.transform3D, .identity.scaled(5)) 22 | XCTAssertEqual(view.layer.zPosition, 15) 23 | } 24 | 25 | func testForceAssignToUIView() { 26 | let view = UIView() 27 | view.transform = .identity.scaledBy(x: 5, y: 5) 28 | view.layer.zPosition = 15 29 | XCTAssertEqual(view.alpha, 1) 30 | XCTAssertEqual(view.transform, .identity.scaledBy(x: 5, y: 5)) 31 | XCTAssertEqual(view.layer.zPosition, 15) 32 | 33 | var sut = AnimatorTransientView.Properties.default 34 | sut.alpha = 0.5 35 | 36 | sut.assignToUIView(view, force: true) 37 | XCTAssertEqual(view.alpha, 0.5) 38 | XCTAssertEqual(view.transform3D, .identity) 39 | XCTAssertEqual(view.layer.zPosition, 0) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/AnimatorTests/AnimatorTransientViewTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import TestUtils 3 | 4 | final class AnimatorTransientViewTests: XCTestCase {} 5 | 6 | extension AnimatorTransientViewTests { 7 | func testInit() { 8 | let uiView = UIView() 9 | uiView.alpha = 0.5 10 | uiView.frame = .init(x: 10, y: 20, width: 30, height: 40) 11 | uiView.transform3D = .identity.translated(x: 50, y: 60, z: 0).scaled(4).rotated(by: .pi, x: 0, y: 0, z: 1) 12 | uiView.layer.zPosition = 15 13 | 14 | let sut = AnimatorTransientView(uiView) 15 | 16 | XCTAssertEqual(sut.alpha, 0.5) 17 | XCTAssertEqual(sut.bounds, .init(x: 0, y: 0, width: 30, height: 40)) 18 | XCTAssertEqual(sut.frame.origin.x, 15, accuracy: 0.000001) 19 | XCTAssertEqual(sut.frame.origin.y, 20, accuracy: 0.000001) 20 | XCTAssertEqual(sut.frame.size.width, 120, accuracy: 0.000001) 21 | XCTAssertEqual(sut.frame.size.height, 160, accuracy: 0.000001) 22 | XCTAssertEqual(sut.transform3D, .identity.translated(x: 50, y: 60, z: 0).scaled(4).rotated(by: .pi, x: 0, y: 0, z: 1)) 23 | 24 | let expectedProperties = AnimatorTransientView.Properties( 25 | alpha: 0.5, 26 | transform: .init(.identity.translated(x: 50, y: 60, z: 0).scaled(4).rotated(by: .pi, x: 0, y: 0, z: 1)), 27 | zPosition: 15 28 | ) 29 | 30 | XCTAssertEqual(sut.initial, expectedProperties) 31 | XCTAssertEqual(sut.animation, expectedProperties) 32 | XCTAssertEqual(sut.completion, expectedProperties) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/CombinedTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AtomicTransition 2 | import TestUtils 3 | 4 | final class CombinedTests: XCTestCase { 5 | func testTwo() { 6 | enum A {}; enum B {} 7 | XCTAssert(Combined { Noop(); Noop() } is Combined, Noop>) 8 | } 9 | 10 | func testEquality() { 11 | enum A {}; enum B {} 12 | XCTAssertEqual(Combined { Noop(); Noop() }, Combined(Noop(), Noop())) 13 | } 14 | 15 | func testExecutionOrder() { 16 | let expectation1 = expectation(description: "Transition 1") 17 | let expectation2 = expectation(description: "Transition 2") 18 | let expectation3 = expectation(description: "Transition 3") 19 | let expectation4 = expectation(description: "Transition 4") 20 | 21 | let sut = Combined { 22 | Spy { expectation1.fulfill() } 23 | Spy { expectation2.fulfill() } 24 | Spy { expectation3.fulfill() } 25 | Spy { expectation4.fulfill() } 26 | } 27 | 28 | sut.transition(.unimplemented, for: .random(), in: .unimplemented) 29 | 30 | wait(for: [expectation1, expectation2, expectation3, expectation4], timeout: 0, enforceOrder: true) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/GroupTests.swift: -------------------------------------------------------------------------------- 1 | import AtomicTransition 2 | import TestUtils 3 | 4 | final class GroupTests: XCTestCase { 5 | func testEmpty() { 6 | XCTAssert(Group {} is Group) 7 | } 8 | 9 | func testOne() { 10 | enum A {} 11 | XCTAssert(Group { Noop() } is Group>) 12 | } 13 | 14 | func testEquality() { 15 | XCTAssertEqual(Group {}, Group { Identity() }) 16 | enum A {} 17 | XCTAssertEqual(Group { Noop() }, Group { Noop() }) 18 | } 19 | 20 | func testExecutionOrder() { 21 | let expectation1 = expectation(description: "Transition 1") 22 | let expectation2 = expectation(description: "Transition 2") 23 | let expectation3 = expectation(description: "Transition 3") 24 | let expectation4 = expectation(description: "Transition 4") 25 | 26 | let sut = Group { 27 | Spy { expectation1.fulfill() } 28 | Spy { expectation2.fulfill() } 29 | Spy { expectation3.fulfill() } 30 | Spy { expectation4.fulfill() } 31 | } 32 | 33 | sut.transition(.unimplemented, for: .random(), in: .unimplemented) 34 | 35 | wait(for: [expectation1, expectation2, expectation3, expectation4], timeout: 0, enforceOrder: true) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/IdentityTests.swift: -------------------------------------------------------------------------------- 1 | import AtomicTransition 2 | import TestUtils 3 | 4 | final class IdentityTests: XCTestCase { 5 | func testInsertion() { 6 | Identity().transition(.unimplemented, for: .insertion, in: .unimplemented) 7 | } 8 | 9 | func testRemoval() { 10 | Identity().transition(.unimplemented, for: .removal, in: .unimplemented) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/MoveTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import AtomicTransition 3 | import TestUtils 4 | 5 | final class MoveTests: XCTestCase { 6 | let viewUsed = AnimatorTransientView(UIView()) 7 | let properties = AnimatorTransientViewProperties( 8 | alpha: 1, 9 | transform: .identity, 10 | zPosition: 0 11 | ) 12 | let containerUsed: UIView = { 13 | let _containerUsed = UIView() 14 | _containerUsed.frame.size = .init(width: 100, height: 200) 15 | return _containerUsed 16 | }() 17 | } 18 | 19 | extension MoveTests { 20 | func testInitialState() { 21 | XCTAssertEqual(viewUsed.initial, properties) 22 | XCTAssertEqual(viewUsed.animation, properties) 23 | XCTAssertEqual(viewUsed.completion, properties) 24 | } 25 | } 26 | 27 | extension MoveTests { 28 | func testTopInsertion() { 29 | Move(edge: .top).transition(viewUsed, for: .insertion, in: containerUsed) 30 | 31 | var initial = properties 32 | initial.transform.translate(x: 0, y: -200, z: 0) 33 | expectNoDifference(viewUsed.initial, initial) 34 | 35 | var animation = properties 36 | animation.transform.translate(x: 0, y: 0, z: 0) 37 | expectNoDifference(viewUsed.animation, animation) 38 | 39 | let completion = properties 40 | expectNoDifference(viewUsed.completion, completion) 41 | } 42 | 43 | func testLeadingInsertion() { 44 | Move(edge: .leading).transition(viewUsed, for: .insertion, in: containerUsed) 45 | 46 | var initial = properties 47 | initial.transform.translate(x: -100, y: 0, z: 0) 48 | expectNoDifference(viewUsed.initial, initial) 49 | 50 | var animation = properties 51 | animation.transform.translate(x: 0, y: 0, z: 0) 52 | expectNoDifference(viewUsed.animation, animation) 53 | 54 | let completion = properties 55 | expectNoDifference(viewUsed.completion, completion) 56 | } 57 | 58 | func testTrailingInsertion() { 59 | Move(edge: .trailing).transition(viewUsed, for: .insertion, in: containerUsed) 60 | 61 | var initial = properties 62 | initial.transform.translate(x: 100, y: 0, z: 0) 63 | expectNoDifference(viewUsed.initial, initial) 64 | 65 | var animation = properties 66 | animation.transform.translate(x: 0, y: 0, z: 0) 67 | expectNoDifference(viewUsed.animation, animation) 68 | 69 | let completion = properties 70 | expectNoDifference(viewUsed.completion, completion) 71 | } 72 | 73 | func testBottomInsertion() { 74 | Move(edge: .bottom).transition(viewUsed, for: .insertion, in: containerUsed) 75 | 76 | var initial = properties 77 | initial.transform.translate(x: 0, y: 200, z: 0) 78 | expectNoDifference(viewUsed.initial, initial) 79 | 80 | var animation = properties 81 | animation.transform.translate(x: 0, y: 0, z: 0) 82 | expectNoDifference(viewUsed.animation, animation) 83 | 84 | let completion = properties 85 | expectNoDifference(viewUsed.completion, completion) 86 | } 87 | } 88 | 89 | extension MoveTests { 90 | func testTopRemoval() { 91 | Move(edge: .top).transition(viewUsed, for: .removal, in: containerUsed) 92 | 93 | let initial = properties 94 | expectNoDifference(viewUsed.initial, initial) 95 | 96 | var animation = properties 97 | animation.transform.translate(x: 0, y: -200) 98 | expectNoDifference(viewUsed.animation, animation) 99 | 100 | var completion = properties 101 | completion.transform.translate(x: 0, y: 0) 102 | expectNoDifference(viewUsed.completion, completion) 103 | } 104 | 105 | func testLeadingRemoval() { 106 | Move(edge: .leading).transition(viewUsed, for: .removal, in: containerUsed) 107 | 108 | let initial = properties 109 | expectNoDifference(viewUsed.initial, initial) 110 | 111 | var animation = properties 112 | animation.transform.translate(x: -100, y: 0) 113 | expectNoDifference(viewUsed.animation, animation) 114 | 115 | var completion = properties 116 | completion.transform.translate(x: 0, y: 0) 117 | expectNoDifference(viewUsed.completion, completion) 118 | } 119 | 120 | func testTrailingRemoval() { 121 | Move(edge: .trailing).transition(viewUsed, for: .removal, in: containerUsed) 122 | 123 | let initial = properties 124 | expectNoDifference(viewUsed.initial, initial) 125 | 126 | var animation = properties 127 | animation.transform.translate(x: 100, y: 0) 128 | expectNoDifference(viewUsed.animation, animation) 129 | 130 | var completion = properties 131 | completion.transform.translate(x: 0, y: 0) 132 | expectNoDifference(viewUsed.completion, completion) 133 | } 134 | 135 | func testBottomRemoval() { 136 | Move(edge: .bottom).transition(viewUsed, for: .removal, in: containerUsed) 137 | 138 | let initial = properties 139 | expectNoDifference(viewUsed.initial, initial) 140 | 141 | var animation = properties 142 | animation.transform.translate(x: 0, y: 200) 143 | expectNoDifference(viewUsed.animation, animation) 144 | 145 | var completion = properties 146 | completion.transform.translate(x: 0, y: 0) 147 | expectNoDifference(viewUsed.completion, completion) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/OffsetTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import AtomicTransition 3 | import TestUtils 4 | 5 | final class OffsetTests: XCTestCase { 6 | let viewUsed = AnimatorTransientView(UIView()) 7 | let properties = AnimatorTransientViewProperties( 8 | alpha: 1, 9 | transform: .identity, 10 | zPosition: 0 11 | ) 12 | let containerUsed = UIView() 13 | 14 | func testInsertion() { 15 | Offset(x: 100, y: 200).transition(viewUsed, for: .insertion, in: containerUsed) 16 | 17 | var initial = properties 18 | initial.transform.translate(x: 100, y: 200) 19 | expectNoDifference(viewUsed.initial, initial) 20 | 21 | var animation = properties 22 | animation.transform.translate(x: 0, y: 0) 23 | expectNoDifference(viewUsed.animation, animation) 24 | 25 | let completion = properties 26 | expectNoDifference(viewUsed.completion, completion) 27 | } 28 | 29 | func testRemoval() { 30 | Offset(x: 100, y: 200).transition(viewUsed, for: .removal, in: containerUsed) 31 | 32 | let initial = properties 33 | expectNoDifference(viewUsed.initial, initial) 34 | 35 | var animation = properties 36 | animation.transform.translate(x: 100, y: 200) 37 | expectNoDifference(viewUsed.animation, animation) 38 | 39 | var completion = properties 40 | completion.transform.translate(x: 0, y: 0) 41 | expectNoDifference(viewUsed.completion, completion) 42 | } 43 | 44 | func testConveniences() { 45 | XCTAssertEqual(Offset(x: 1), Offset(x: 1, y: 0)) 46 | XCTAssertEqual(Offset(y: 1), Offset(x: 0, y: 1)) 47 | XCTAssertEqual(Offset(.init(width: 1, height: 2)), Offset(x: 1, y: 2)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/OnTests.swift: -------------------------------------------------------------------------------- 1 | import AtomicTransition 2 | import TestUtils 3 | 4 | final class OnInsertionTests: XCTestCase { 5 | func testInsertion() { 6 | let expectation = expectation(description: "Handler called") 7 | let sut = OnInsertion { Spy { expectation.fulfill() } } 8 | sut.transition(.unimplemented, for: .insertion, in: .unimplemented) 9 | wait(for: [expectation], timeout: 0) 10 | } 11 | 12 | func testRemoval() { 13 | let sut = OnInsertion { Spy { XCTFail() } } 14 | sut.transition(.unimplemented, for: .removal, in: .unimplemented) 15 | } 16 | } 17 | 18 | final class OnRemovalTests: XCTestCase { 19 | func testInsertion() { 20 | let sut = OnRemoval { Spy { XCTFail() } } 21 | sut.transition(.unimplemented, for: .insertion, in: .unimplemented) 22 | } 23 | 24 | func testRemoval() { 25 | let expectation = expectation(description: "Handler called") 26 | let sut = OnRemoval { Spy { expectation.fulfill() } } 27 | sut.transition(.unimplemented, for: .removal, in: .unimplemented) 28 | wait(for: [expectation], timeout: 0) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/OpacityTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import AtomicTransition 3 | import TestUtils 4 | 5 | final class OpacityTests: XCTestCase { 6 | let viewUsed = AnimatorTransientView(UIView()) 7 | let properties = AnimatorTransientViewProperties( 8 | alpha: 1, 9 | transform: .identity, 10 | zPosition: 0 11 | ) 12 | let containerUsed = UIView() 13 | 14 | func testInsertion() { 15 | Opacity().transition(viewUsed, for: .insertion, in: containerUsed) 16 | 17 | var initial = properties 18 | initial.alpha = 0 19 | expectNoDifference(viewUsed.initial, initial) 20 | 21 | var animation = properties 22 | animation.alpha = 1 23 | expectNoDifference(viewUsed.animation, animation) 24 | 25 | let completion = properties 26 | expectNoDifference(viewUsed.completion, completion) 27 | } 28 | 29 | func testRemoval() { 30 | Opacity().transition(viewUsed, for: .removal, in: containerUsed) 31 | 32 | let initial = properties 33 | expectNoDifference(viewUsed.initial, initial) 34 | 35 | var animation = properties 36 | animation.alpha = 0 37 | expectNoDifference(viewUsed.animation, animation) 38 | 39 | var completion = properties 40 | completion.alpha = 1 41 | expectNoDifference(viewUsed.completion, completion) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/RotateTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import AtomicTransition 3 | import TestUtils 4 | 5 | final class RotateTests: XCTestCase { 6 | let viewUsed = AnimatorTransientView(UIView()) 7 | let properties = AnimatorTransientViewProperties( 8 | alpha: 1, 9 | transform: .identity, 10 | zPosition: 0 11 | ) 12 | let containerUsed = UIView() 13 | 14 | func testInsertion() { 15 | Rotate(.radians(.pi)).transition(viewUsed, for: .insertion, in: containerUsed) 16 | 17 | var initial = properties 18 | initial.transform.rotate(by: .pi, z: 1) 19 | expectNoDifference(viewUsed.initial, initial) 20 | 21 | var animation = properties 22 | animation.transform.rotate(by: 0) 23 | expectNoDifference(viewUsed.animation, animation) 24 | 25 | let completion = properties 26 | expectNoDifference(viewUsed.completion, completion) 27 | } 28 | 29 | func testRemoval() { 30 | Rotate(.radians(.pi)).transition(viewUsed, for: .removal, in: containerUsed) 31 | 32 | let initial = properties 33 | expectNoDifference(viewUsed.initial, initial) 34 | 35 | var animation = properties 36 | animation.transform.rotate(by: .pi, z: 1) 37 | expectNoDifference(viewUsed.animation, animation) 38 | 39 | var completion = properties 40 | completion.transform.rotate(by: 0) 41 | expectNoDifference(viewUsed.completion, completion) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/ScaleTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import AtomicTransition 3 | import TestUtils 4 | 5 | final class ScaleTests: XCTestCase { 6 | let viewUsed = AnimatorTransientView(UIView()) 7 | let properties = AnimatorTransientViewProperties( 8 | alpha: 1, 9 | transform: .identity, 10 | zPosition: 0 11 | ) 12 | let containerUsed = UIView() 13 | 14 | func testInsertion() { 15 | Scale(0.5).transition(viewUsed, for: .insertion, in: containerUsed) 16 | 17 | var initial = properties 18 | initial.transform.scale(0.5) 19 | expectNoDifference(viewUsed.initial, initial) 20 | 21 | var animation = properties 22 | animation.transform.rotate(by: 0, z: 1) 23 | expectNoDifference(viewUsed.animation, animation) 24 | 25 | let completion = properties 26 | expectNoDifference(viewUsed.completion, completion) 27 | } 28 | 29 | func testRemoval() { 30 | Scale(0.5).transition(viewUsed, for: .removal, in: containerUsed) 31 | 32 | let initial = properties 33 | expectNoDifference(viewUsed.initial, initial) 34 | 35 | var animation = properties 36 | animation.transform.scale(0.5) 37 | expectNoDifference(viewUsed.animation, animation) 38 | 39 | var completion = properties 40 | completion.transform.rotate(by: 0, z: 1) 41 | expectNoDifference(viewUsed.completion, completion) 42 | } 43 | 44 | func testConveniences() { 45 | XCTAssertEqual(Scale(), Scale(.leastNonzeroMagnitude)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/AtomicTransitionTests/ZPositionTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Animator 2 | import AtomicTransition 3 | import TestUtils 4 | 5 | final class ZPositionTests: XCTestCase { 6 | let viewUsed = AnimatorTransientView(UIView()) 7 | let properties = AnimatorTransientViewProperties( 8 | alpha: 1, 9 | transform: .identity, 10 | zPosition: 0 11 | ) 12 | let containerUsed = UIView() 13 | 14 | func testInsertion() { 15 | ZPosition(2).transition(viewUsed, for: .insertion, in: containerUsed) 16 | 17 | let initial = properties 18 | expectNoDifference(viewUsed.initial, initial) 19 | 20 | var animation = properties 21 | animation.zPosition = 2 22 | expectNoDifference(viewUsed.animation, animation) 23 | 24 | var completion = properties 25 | completion.zPosition = 0 26 | expectNoDifference(viewUsed.completion, completion) 27 | } 28 | 29 | func testRemoval() { 30 | ZPosition(2).transition(viewUsed, for: .insertion, in: containerUsed) 31 | 32 | let initial = properties 33 | expectNoDifference(viewUsed.initial, initial) 34 | 35 | var animation = properties 36 | animation.zPosition = 2 37 | expectNoDifference(viewUsed.animation, animation) 38 | 39 | var completion = properties 40 | completion.zPosition = 0 41 | expectNoDifference(viewUsed.completion, completion) 42 | } 43 | } 44 | 45 | final class BringToFrontAndSendToBackTests: XCTestCase { 46 | let uiViewUsed = UIView() 47 | lazy var viewUsed = AnimatorTransientView(uiViewUsed) 48 | let properties = AnimatorTransientViewProperties( 49 | alpha: 1, 50 | transform: .identity, 51 | zPosition: 0 52 | ) 53 | let anotherUIViewA = UIView() 54 | let anotherUIViewB = UIView() 55 | lazy var containerView: UIView = { 56 | let _containerView = UIView() 57 | _containerView.addSubview(anotherUIViewA) 58 | _containerView.addSubview(uiViewUsed) 59 | _containerView.addSubview(anotherUIViewB) 60 | return _containerView 61 | }() 62 | 63 | func testInitialState() { 64 | XCTAssertIdentical(containerView.subviews[0], anotherUIViewA) 65 | XCTAssertIdentical(containerView.subviews[1], uiViewUsed) 66 | XCTAssertIdentical(containerView.subviews[2], anotherUIViewB) 67 | } 68 | 69 | func testFront() { 70 | BringToFront().transition(viewUsed, for: .random(), in: containerView) 71 | 72 | XCTAssertIdentical(containerView.subviews[0], anotherUIViewA) 73 | XCTAssertIdentical(containerView.subviews[1], anotherUIViewB) 74 | XCTAssertIdentical(containerView.subviews[2], uiViewUsed) 75 | } 76 | 77 | func testBack() { 78 | SendToBack().transition(viewUsed, for: .random(), in: containerView) 79 | 80 | XCTAssertIdentical(containerView.subviews[0], uiViewUsed) 81 | XCTAssertIdentical(containerView.subviews[1], anotherUIViewA) 82 | XCTAssertIdentical(containerView.subviews[2], anotherUIViewB) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/TestPlans/SwiftUINavigationTransitions.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "8C608A98-3C43-44EC-9508-ECA482696F28", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false, 13 | "testExecutionOrdering" : "random" 14 | }, 15 | "testTargets" : [ 16 | { 17 | "target" : { 18 | "containerPath" : "container:", 19 | "identifier" : "AnimatorTests", 20 | "name" : "AnimatorTests" 21 | } 22 | }, 23 | { 24 | "target" : { 25 | "containerPath" : "container:", 26 | "identifier" : "AtomicTransitionTests", 27 | "name" : "AtomicTransitionTests" 28 | } 29 | } 30 | ], 31 | "version" : 1 32 | } 33 | --------------------------------------------------------------------------------