diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c88c8f2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - "**" + schedule: + - cron: '3 3 * * 2' # 3:03 AM, every Tuesday + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + macOS: + name: ${{ matrix.platform }} (Swift ${{ matrix.swift }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-12 + platform: + - iOS + swift: + - 5.5 + - 5.6 + - 5.7 + steps: + - uses: actions/checkout@v2 + - name: Test NavigationTransitions + uses: mxcl/xcodebuild@v1 + with: + platform: ${{ matrix.platform }} + swift: ~${{ matrix.swift }} + action: test + scheme: NavigationTransitions + - if: ${{ matrix.swift >= 5.7 }} + name: Build Demo + uses: mxcl/xcodebuild@v1 + with: + platform: ${{ matrix.platform }} + swift: ~${{ matrix.swift }} + action: build + scheme: Demo diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fb2382a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/.swiftpm +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.netrc diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 00000000..53201be1 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,11 @@ +version: 1 +builder: + configs: + - platform: ios + scheme: NavigationTransitions + - platform: macos-xcodebuild + scheme: NavigationTransitions + - platform: tvos + scheme: NavigationTransitions + - platform: watchos + scheme: NavigationTransitions diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj new file mode 100644 index 00000000..a2e533bc --- /dev/null +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -0,0 +1,406 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + D5535823290E9692009E5D72 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5535822290E9692009E5D72 /* Assets.xcassets */; }; + D5535826290E9692009E5D72 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5535825290E9692009E5D72 /* Preview Assets.xcassets */; }; + D5535834290E9718009E5D72 /* Swing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553582C290E9718009E5D72 /* Swing.swift */; }; + D5535835290E9718009E5D72 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553582D290E9718009E5D72 /* RootView.swift */; }; + D5535836290E9718009E5D72 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553582E290E9718009E5D72 /* SceneDelegate.swift */; }; + D5535837290E9718009E5D72 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D553582F290E9718009E5D72 /* LaunchScreen.storyboard */; }; + D5535839290E9718009E5D72 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535831290E9718009E5D72 /* AppDelegate.swift */; }; + D553583F290E97C5009E5D72 /* NavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = D553583E290E97C5009E5D72 /* NavigationTransitions */; }; + D5535843290F4BEA009E5D72 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535842290F4BEA009E5D72 /* AppView.swift */; }; + D5535845290F52F7009E5D72 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535844290F52F7009E5D72 /* SettingsView.swift */; }; + D5535847290F5E6F009E5D72 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5535846290F5E6F009E5D72 /* AppState.swift */; }; + D5AAF4052911C59E009743D3 /* PageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF4042911C59E009743D3 /* PageView.swift */; }; + D5AAF4072911C621009743D3 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF4062911C621009743D3 /* Pages.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + D553581B290E9691009E5D72 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D5535822290E9692009E5D72 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D5535825290E9692009E5D72 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + D553582C290E9718009E5D72 /* Swing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Swing.swift; sourceTree = ""; }; + D553582D290E9718009E5D72 /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + D553582E290E9718009E5D72 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + D553582F290E9718009E5D72 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + D5535831290E9718009E5D72 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + D553583C290E978C009E5D72 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + D5535842290F4BEA009E5D72 /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; + D5535844290F52F7009E5D72 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + D5535846290F5E6F009E5D72 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + D5AAF4042911C59E009743D3 /* PageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageView.swift; sourceTree = ""; }; + D5AAF4062911C621009743D3 /* Pages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + D5535818290E9691009E5D72 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D553583F290E97C5009E5D72 /* NavigationTransitions in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D5535812290E9691009E5D72 = { + isa = PBXGroup; + children = ( + D553581D290E9691009E5D72 /* Demo */, + D553581C290E9691009E5D72 /* Products */, + D553583D290E97C5009E5D72 /* Frameworks */, + ); + sourceTree = ""; + }; + D553581C290E9691009E5D72 /* Products */ = { + isa = PBXGroup; + children = ( + D553581B290E9691009E5D72 /* Demo.app */, + ); + name = Products; + sourceTree = ""; + }; + D553581D290E9691009E5D72 /* Demo */ = { + isa = PBXGroup; + children = ( + D553583C290E978C009E5D72 /* Info.plist */, + D553582F290E9718009E5D72 /* LaunchScreen.storyboard */, + D5535831290E9718009E5D72 /* AppDelegate.swift */, + D553582E290E9718009E5D72 /* SceneDelegate.swift */, + D5535842290F4BEA009E5D72 /* AppView.swift */, + D5535846290F5E6F009E5D72 /* AppState.swift */, + D553582D290E9718009E5D72 /* RootView.swift */, + D5AAF4062911C621009743D3 /* Pages.swift */, + D5AAF4042911C59E009743D3 /* PageView.swift */, + D5535844290F52F7009E5D72 /* SettingsView.swift */, + D553582C290E9718009E5D72 /* Swing.swift */, + D5535822290E9692009E5D72 /* Assets.xcassets */, + D5535824290E9692009E5D72 /* Preview Content */, + ); + path = Demo; + sourceTree = ""; + }; + D5535824290E9692009E5D72 /* Preview Content */ = { + isa = PBXGroup; + children = ( + D5535825290E9692009E5D72 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + D553583D290E97C5009E5D72 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D553581A290E9691009E5D72 /* Demo */ = { + isa = PBXNativeTarget; + buildConfigurationList = D5535829290E9692009E5D72 /* Build configuration list for PBXNativeTarget "Demo" */; + buildPhases = ( + D5535817290E9691009E5D72 /* Sources */, + D5535818290E9691009E5D72 /* Frameworks */, + D5535819290E9691009E5D72 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Demo; + packageProductDependencies = ( + D553583E290E97C5009E5D72 /* NavigationTransitions */, + ); + productName = Demo; + productReference = D553581B290E9691009E5D72 /* Demo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + D5535813290E9691009E5D72 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1410; + LastUpgradeCheck = 1410; + TargetAttributes = { + D553581A290E9691009E5D72 = { + CreatedOnToolsVersion = 14.1; + LastSwiftMigration = 1410; + }; + }; + }; + buildConfigurationList = D5535816290E9691009E5D72 /* Build configuration list for PBXProject "Demo" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = D5535812290E9691009E5D72; + productRefGroup = D553581C290E9691009E5D72 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D553581A290E9691009E5D72 /* Demo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D5535819290E9691009E5D72 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D5535837290E9718009E5D72 /* LaunchScreen.storyboard in Resources */, + D5535826290E9692009E5D72 /* Preview Assets.xcassets in Resources */, + D5535823290E9692009E5D72 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D5535817290E9691009E5D72 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D5AAF4072911C621009743D3 /* Pages.swift in Sources */, + D5535845290F52F7009E5D72 /* SettingsView.swift in Sources */, + D5AAF4052911C59E009743D3 /* PageView.swift in Sources */, + D5535839290E9718009E5D72 /* AppDelegate.swift in Sources */, + D5535847290F5E6F009E5D72 /* AppState.swift in Sources */, + D5535843290F4BEA009E5D72 /* AppView.swift in Sources */, + D5535835290E9718009E5D72 /* RootView.swift in Sources */, + D5535834290E9718009E5D72 /* Swing.swift in Sources */, + D5535836290E9718009E5D72 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + D5535827290E9692009E5D72 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + D5535828290E9692009E5D72 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + D553582A290E9692009E5D72 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; + DEVELOPMENT_TEAM = 895A67RTTU; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Demo/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = mn.dro.Demo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D553582B290E9692009E5D72 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; + DEVELOPMENT_TEAM = 895A67RTTU; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Demo/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = mn.dro.Demo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + D5535816290E9691009E5D72 /* Build configuration list for PBXProject "Demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D5535827290E9692009E5D72 /* Debug */, + D5535828290E9692009E5D72 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D5535829290E9692009E5D72 /* Build configuration list for PBXNativeTarget "Demo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D553582A290E9692009E5D72 /* Debug */, + D553582B290E9692009E5D72 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + D553583E290E97C5009E5D72 /* NavigationTransitions */ = { + isa = XCSwiftPackageProductDependency; + productName = NavigationTransitions; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = D5535813290E9691009E5D72 /* Project object */; +} diff --git a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme new file mode 100644 index 00000000..35c5af6d --- /dev/null +++ b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Demo/Demo/AppDelegate.swift b/Demo/Demo/AppDelegate.swift new file mode 100644 index 00000000..85da7d1c --- /dev/null +++ b/Demo/Demo/AppDelegate.swift @@ -0,0 +1,42 @@ +import SwiftUI + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func applicationDidFinishLaunching(_ application: UIApplication) { + customizeNavigationBarAppearance() + customizeTabBarAppearance() + } + + // https://developer.apple.com/documentation/technotes/tn3106-customizing-uinavigationbar-appearance + func customizeNavigationBarAppearance() { + let customAppearance = UINavigationBarAppearance() + + customAppearance.configureWithOpaqueBackground() + customAppearance.backgroundColor = .systemBackground + + let proxy = UINavigationBar.appearance() + proxy.scrollEdgeAppearance = customAppearance + proxy.compactAppearance = customAppearance + proxy.standardAppearance = customAppearance + if #available(iOS 15.0, *) { + proxy.compactScrollEdgeAppearance = customAppearance + } + } + + func customizeTabBarAppearance() { + let customAppearance = UITabBarAppearance() + + customAppearance.configureWithOpaqueBackground() + customAppearance.backgroundColor = .systemBackground + + let proxy = UITabBar.appearance() + proxy.standardAppearance = customAppearance + if #available(iOS 15, *) { + proxy.scrollEdgeAppearance = customAppearance + } + } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } +} diff --git a/Demo/Demo/AppState.swift b/Demo/Demo/AppState.swift new file mode 100644 index 00000000..adf23712 --- /dev/null +++ b/Demo/Demo/AppState.swift @@ -0,0 +1,137 @@ +import Foundation +import NavigationTransitions + +final class AppState: ObservableObject { + enum Transition: CaseIterable, CustomStringConvertible, Hashable { + case slide + case crossFade + case slideAndFade + case moveVertically + case swing + + var description: String { + switch self { + case .slide: + return "Slide" + case .crossFade: + return "Fade" + case .slideAndFade: + return "Slide + Fade" + case .moveVertically: + return "Move Vertically" + case .swing: + return "Swing" + } + } + + func callAsFunction() -> NavigationTransition { + switch self { + case .slide: + return .slide + case .crossFade: + return .fade(.cross) + case .slideAndFade: + return .slide.combined(with: .fade(.in)) + case .moveVertically: + return .move(axis: .vertical) + case .swing: + return .swing + } + } + } + + struct Animation { + enum Curve: CaseIterable, CustomStringConvertible, Hashable { + case linear + case easeInOut + case spring + + var description: String { + switch self { + case .linear: + return "Linear" + case .easeInOut: + return "Ease In Out" + case .spring: + return "Spring" + } + } + } + + enum Duration: CaseIterable, CustomStringConvertible, Hashable { + case slow + case medium + case fast + + var description: String { + switch self { + case .slow: + return "Slow" + case .medium: + return "Medium" + case .fast: + return "Fast" + } + } + + func callAsFunction() -> Double { + switch self { + case .slow: + return 1 + case .medium: + return 0.6 + case .fast: + return 0.35 + } + } + } + + var curve: Curve + var duration: Duration + + func callAsFunction() -> NavigationTransition.Animation { + switch curve { + case .linear: + return .linear(duration: duration()) + case .easeInOut: + return .easeInOut(duration: duration()) + case .spring: + return .interpolatingSpring(stiffness: 120, damping: 50) + } + } + } + + enum Interactivity: CaseIterable, CustomStringConvertible, Hashable { + case disabled + case edgePan + case pan + + var description: String { + switch self { + case .disabled: + return "Disabled" + case .edgePan: + return "Edge Pan" + case .pan: + return "Pan" + } + } + + func callAsFunction() -> NavigationTransition.Interactivity { + switch self { + case .disabled: + return .disabled + case .edgePan: + return .edgePan + case .pan: + return .pan + } + } + } + + @Published var transition: Transition = .slide + @Published var animation: Animation = .init(curve: .easeInOut, duration: .medium) + @Published var interactivity: Interactivity = .edgePan + + @Published var isPresentingSettings: Bool = false +} diff --git a/Demo/Demo/AppView.swift b/Demo/Demo/AppView.swift new file mode 100644 index 00000000..30f88f58 --- /dev/null +++ b/Demo/Demo/AppView.swift @@ -0,0 +1,9 @@ +import SwiftUI + +struct AppView: View { + @ObservedObject var appState = AppState() + + var body: some View { + RootView().environmentObject(appState) + } +} diff --git a/Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json b/Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/Demo/Assets.xcassets/Contents.json b/Demo/Demo/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Demo/Demo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/Demo/Info.plist b/Demo/Demo/Info.plist new file mode 100644 index 00000000..0b052173 --- /dev/null +++ b/Demo/Demo/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Demo/Demo/LaunchScreen.storyboard b/Demo/Demo/LaunchScreen.storyboard new file mode 100644 index 00000000..5e6f8fb2 --- /dev/null +++ b/Demo/Demo/LaunchScreen.storyboard @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Demo/Demo/PageView.swift b/Demo/Demo/PageView.swift new file mode 100644 index 00000000..34488634 --- /dev/null +++ b/Demo/Demo/PageView.swift @@ -0,0 +1,108 @@ +import SwiftUI + +struct PageView: View { + @EnvironmentObject var appState: AppState + + let number: Int + let title: String + let color: Color + let content: Content + let link: Link? + let destination: Destination? + + var body: some View { + ZStack { + Rectangle() + .do { + if #available(iOS 16, *) { + $0.fill(color.gradient) + } else { + $0.fill(color) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + .opacity(0.45) + .blendMode(.multiply) + VStack { + VStack(spacing: 20) { + content + } + .font(.system(size: 20, design: .rounded)) + .lineSpacing(4) + .shadow(color: .white.opacity(0.25), radius: 1, x: 0, y: 1) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(Color(white: 0.14)) + if let link = link, let destination = destination { + if #available(iOS 16, *) { + NavigationLink(value: number + 1) { link } + } else { + NavigationLink(destination: destination) { link } + } + } + } + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.bottom, 30) + } + .navigationBarTitle(Text(title), displayMode: .inline) + .navigationBarItems( + trailing: Button(action: { appState.isPresentingSettings = true }) { + Group { + if #available(iOS 14, *) { + Image(systemName: "gearshape") + } else { + Image(systemName: "gear") + } + } + .font(.system(size: 16, weight: .semibold)) + } + ) + } +} + +extension PageView { + init( + number: Int, + title: String, + color: Color, + @ViewBuilder content: () -> Content, + @ViewBuilder link: () -> Link, + @ViewBuilder destination: () -> Destination = { EmptyView() } + ) { + self.init( + number: number, + title: title, + color: color, + content: content(), + link: link(), + destination: destination() + ) + } +} + +extension PageView where Link == EmptyView, Destination == EmptyView { + static func final( + number: Int, + title: String, + color: Color, + @ViewBuilder content: () -> Content + ) -> some View { + Self( + number: number, + title: title, + color: color, + content: content(), + link: nil, + destination: nil + ) + } +} + +extension View { + func `do`( + @ViewBuilder modifierClosure: (Self) -> ModifiedView + ) -> some View { + modifierClosure(self) + } +} diff --git a/Demo/Demo/Pages.swift b/Demo/Demo/Pages.swift new file mode 100644 index 00000000..52a1fc76 --- /dev/null +++ b/Demo/Demo/Pages.swift @@ -0,0 +1,169 @@ +import SwiftUI + +struct PageOne: View { + var body: some View { + let content = Group { + Text("**NavigationTransitions** is a library that integrates seamlessly with SwiftUI's **Navigation** views, allowing complete customization over **push and pop transitions**!") + } + + PageView(number: 1, title: "Welcome", color: .orange) { + content + } link: { + PageLink(title: "Show me!") + } destination: { + PageTwo() + } + .do { + if #available(iOS 16, *) { + $0.navigationDestination(for: Int.self) { number in + switch number { + case 1: PageOne() + case 2: PageTwo() + case 3: PageThree() + case 4: PageFour() + case 5: PageFive() + default: EmptyView() + } + } + } else { + $0 + } + } + } +} + +struct PageTwo: View { + var body: some View { + let content = Group { + Text("The library is fully compatible with **NavigationView** in iOS 13+, and the new **NavigationStack** and **NavigationSplitView** in iOS 16.") + Text("In fact, that entire transition you just saw can be implemented in **one line** of SwiftUI code:") + Code(""" + NavigationView { + ... + } + .navigationViewStackTransition(.slide) + """ + ) + } + + PageView(number: 2, title: "Overview", color: .green) { + content + } link: { + PageLink(title: "🤯") + } destination: { + PageThree() + } + } +} + +struct PageThree: View { + var body: some View { + let content = Group { + Text("The API is designed to resemble that of built-in SwiftUI Transitions for maximum **familiarity** and **ease of use**.") + Text("You can apply **custom animations** just like with standard SwiftUI transitions:") + Code(""" + .navigationViewStackTransition( + .fade(.in).animation( + .easeInOut(duration: 0.3) + ) + ) + """ + ) + Text("... and you can even **combine** them too:") + Code(""" + .navigationViewStackTransition( + .slide.combined(with: .fade(.in)) + ) + """ + ) + } + + PageView(number: 3, title: "API Design", color: .red) { + content + } link: { + PageLink(title: "Sweet!") + } destination: { + PageFour() + } + } +} + +struct PageFour: View { + var body: some View { + let content = Group { + 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.") + Text("This demo features some presets to play with. You'll find them in the **settings** menu at the top.") + } + + PageView(number: 4, title: "Customization", color: .blue) { + content + } link: { + PageLink(title: "Awesome!") + } destination: { + PageFive() + } + } +} + +struct PageFive: View { + var body: some View { + let content = Group { + 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. 📖") + 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! 💬") + Text("I sincerely hope you enjoy using this library as much as I enjoyed building it.") + Text("❤️") + } + + PageView.final(number: 5, title: "Get Started", color: .purple) { + content + } + } +} + +struct PageLink: View { + var title: String + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.blue.opacity(0.8)) + Text(title) + .foregroundColor(.white) + .font(.system(size: 18, weight: .medium, design: .rounded)) + } + .frame(maxHeight: 50) + } +} + +struct Code: View { + var content: Content + + init(_ content: Content) { + self.content = content + } + + var lineLimit: Int { + content.split(separator: "\n").count + } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 4, style: .circular) + + Text(content) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .lineLimit(lineLimit) + .multilineTextAlignment(.leading) + .minimumScaleFactor(0.5) + .font(.system(size: 14, design: .monospaced)) + .background(shape.stroke(Color(white: 0.1).opacity(0.35), lineWidth: 1)) + .background(Color(white: 0.94).opacity(0.6).clipShape(shape)) + .do { + if #available(iOS 15, *) { + $0.textSelection(.enabled) + } else { + $0 + } + } + } +} diff --git a/Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json b/Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/Demo/RootView.swift b/Demo/Demo/RootView.swift new file mode 100644 index 00000000..9abc8d4e --- /dev/null +++ b/Demo/Demo/RootView.swift @@ -0,0 +1,26 @@ +import NavigationTransitions +import SwiftUI + +struct RootView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + Group { + if #available(iOS 16, *) { + NavigationStack { + PageOne() + } + } else { + NavigationView { + PageOne() + } + .navigationViewStyle(.stack) + } + } + .navigationViewStackTransition( + appState.transition().animation(appState.animation()), + interactivity: appState.interactivity() + ) + .sheet(isPresented: $appState.isPresentingSettings, content: SettingsView.init) + } +} diff --git a/Demo/Demo/SceneDelegate.swift b/Demo/Demo/SceneDelegate.swift new file mode 100644 index 00000000..487d05cb --- /dev/null +++ b/Demo/Demo/SceneDelegate.swift @@ -0,0 +1,13 @@ +import SwiftUI + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = UIHostingController(rootView: AppView()) + window?.makeKeyAndVisible() + } +} diff --git a/Demo/Demo/SettingsView.swift b/Demo/Demo/SettingsView.swift new file mode 100644 index 00000000..3cc21dd1 --- /dev/null +++ b/Demo/Demo/SettingsView.swift @@ -0,0 +1,91 @@ +import NavigationTransitions +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + NavigationView { + Form { + Section(header: Text("Transition"), footer: transitionFooter) { + picker("Transition", $appState.transition) + } + + Section(header: Text("Animation"), footer: animationFooter) { + picker("Curve", $appState.animation.curve) + picker("Duration", $appState.animation.duration) + } + + Section(header: Text("Interactivity"), footer: interactivityFooter) { + picker("Interactivity", $appState.interactivity) + } + } + .navigationBarTitle("Settings", displayMode: .inline) + .navigationBarItems( + leading: Button("Shuffle", action: shuffle), + trailing: Button(action: dismiss) { Text("Done").bold() } + ) + } + .navigationViewStyle(.stack) + } + + var transitionFooter: some View { + Text( + """ + "Swing" is a custom transition exclusive to this demo (only 12 lines of code!). + """ + ) + } + + var animationFooter: some View { + Text( + """ + Note: Duration is ignored when the Spring curve is selected. + """ + ) + } + + var interactivityFooter: some View { + Text( + """ + You can choose the swipe-back gesture to be: + + • Disabled. + • Edge Pan: recognized from the edge of the screen only. + • Pan: recognized anywhere on the screen! ✨ + """ + ) + } + + @ViewBuilder + func picker( + _ label: String, + _ selection: Binding + ) -> some View where Selection.AllCases: RandomAccessCollection { + Picker( + selection: selection, + label: Text(label) + ) { + ForEach(Selection.allCases, id: \.self) { + Text($0.description).tag($0) + } + } + } + + func shuffle() { + appState.transition = .allCases.randomElement()! + appState.animation.curve = .allCases.randomElement()! + appState.animation.duration = .allCases.randomElement()! + appState.interactivity = .allCases.randomElement()! + } + + func dismiss() { + appState.isPresentingSettings = false + } +} + +struct SettingsViewPreview: PreviewProvider { + static var previews: some View { + SettingsView().environmentObject(AppState()) + } +} diff --git a/Demo/Demo/Swing.swift b/Demo/Demo/Swing.swift new file mode 100644 index 00000000..aa3c5b2f --- /dev/null +++ b/Demo/Demo/Swing.swift @@ -0,0 +1,48 @@ +import NavigationTransition +import SwiftUI + +extension NavigationTransition { + // complex transitions start getting a little trickier to read clearly... + // maybe good motivation to introduce result builder syntax + // something like: + // + // Move(axis: .horizontal) + // OnPush { + // OnInsertion { + // Rotate(-angle) + // Offset(x: 150) + // Opacity() + // Scale(0.5) + // } + // OnRemoval { + // ... + // } + // } + // OnPop { + // ... + // } + // + // neat! + static var swing: Self { + let angle = Angle(degrees: 70) + let offset: CGFloat = 150 + return .move(axis: .horizontal).combined( + with: .asymmetric( + push: .asymmetric( + insertion: [.rotate(-angle), .offset(x: offset), .opacity, .scale(0.5)].combined(), + removal: [.rotate(angle), .offset(x: -offset)].combined() + ), + pop: .asymmetric( + insertion: [.rotate(angle), .offset(x: -offset), .opacity, .scale(0.5), .bringToFront].combined(), + removal: [.rotate(-angle), .offset(x: offset)].combined() + ) + ) + ) + } +} + +extension Angle { + static prefix func - (_ rhs: Self) -> Self { + .init(degrees: -rhs.degrees) + } +} diff --git a/Documentation/.gitkeep b/Documentation/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Documentation/Custom-Transitions.md b/Documentation/Custom-Transitions.md new file mode 100644 index 00000000..7c7cb3df --- /dev/null +++ b/Documentation/Custom-Transitions.md @@ -0,0 +1,336 @@ +# Custom Transitions + +This document will guide you through the entire technical explanation on how to build custom navigation transitions. + +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. + +- [**Core Concepts**](#Core-Concepts) + - [**`NavigationTransition`**](#NavigationTransition) + - [**`AtomicTransition`**](#AtomicTransition) + +- [**Implementation**](#Implementation) + + - [**Basic**](#Basic) + - [**Intermediate**](#Intermediate) + - [**Advanced**](#Advanced) + +## Core Concepts + +### `NavigationTransition` + +The main construct the library builds upon is called `NavigationTransition`. You may have seen some instances of this type in the code samples (e.g. `.slide`). + +Drawing from this previous example, if we dive into the implementation of `NavigationTransition.slide`, we'll find this: + +```swift +extension NavigationTransition { + /// Equivalent to `move(axis: .horizontal)`. + @inlinable + public static var slide: Self { + .move(axis: .horizontal) + } +} +``` + +As the comment rightly documents, this statement is in fact **equivalent** to another transition called `.move`, which accepts a parameter `axis` to modify the direction of the movement. + +This first glance at the type teaches us two things: + +1. Transitions are **simple static extensions** of the type `NavigationTransition`. +2. Transitions can be declared to **simplify access to more complex transitions**. + +Let's go ahead and dive even deeper, into the implementation of `NavigationTransition.move(axis:)`: + +```swift +extension NavigationTransition { + /// A transition that moves both views in and out along the specified axis. + /// + /// This transition: + /// - Pushes views right-to-left and pops views left-to-right when `axis` is `horizontal`. + /// - Pushes views bottom-to-top and pops views top-to-bottom when `axis` is `vertical`. + public static func move(axis: Axis) -> Self { + switch axis { + case .horizontal: + return .asymmetric( + push: .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + ), + pop: .asymmetric( + insertion: .move(edge: .leading), + removal: .move(edge: .trailing) + ) + ) + case .vertical: + return .asymmetric( + push: .asymmetric( + insertion: .move(edge: .bottom), + removal: .move(edge: .top) + ), + pop: .asymmetric( + insertion: .move(edge: .top), + removal: .move(edge: .bottom) + ) + ) + } + } +} +``` + +Ah, this is a lot more interesting! + +Observe how the implementation body follows a very specific symmetrical shape. Funnily enough however, transitions themselves are built by using the term `asymmetric`, which we'll get to know in depth later in this read. + +`NavigationTransition` instances describe both `push` and `pop` transitions for both *origin* and *destination* views. + +Notice how the entire transition is implemented concisely in around 25 lines of code, yet there's **no explicit `UIView` animation** code to be seen anywhere at this point. I'd like to direct your attention instead to what's actually describing the transition on each `.asymmetric` call: `.move(edge: ...)`. + +If you've used the SwiftUI `transition` modifier before, you could easily mistake this for `AnyTransition.move(edge:)`, but this is in fact not the case. `.move(edge:)` actually belongs to another type that ships with this library: `AtomicTransition`. + +### `AtomicTransition` + +`AtomicTransition` is a SwiftUI `AnyTransition`-inspired type which acts very much in the same manner. It can describe a specific set of mutations to view properties on an individual ("atomic") basis, for both **insertion** and **removal** of said view. + +Contrary to `NavigationTransition` and as the name indicates, `AtomicTransition` applies to only a **single view** out of the two, and is **agnostic** as to the **intent** (push or pop) of its **parent** `NavigationTransition`. + +If we dive even deeper into `AtomicTransition.move(edge:)`, this is what we find: + +```swift +extension AtomicTransition { + /// A transition entering from `edge` on insertion, and exiting towards `edge` on removal. + public static func move(edge: Edge) -> Self { + .custom { view, operation, container in + switch (edge, operation) { + case (.top, .insertion): + view.initial.translation.dy = -container.frame.height + view.animation.translation.dy = 0 + + case (.leading, .insertion): + view.initial.translation.dx = -container.frame.width + view.animation.translation.dx = 0 + + case (.trailing, .insertion): + view.initial.translation.dx = container.frame.width + view.animation.translation.dx = 0 + + case (.bottom, .insertion): + view.initial.translation.dy = container.frame.height + view.animation.translation.dy = 0 + + case (.top, .removal): + view.animation.translation.dy = -container.frame.height + view.completion.translation.dy = 0 + + case (.leading, .removal): + view.animation.translation.dx = -container.frame.width + view.completion.translation.dx = 0 + + case (.trailing, .removal): + view.animation.translation.dx = container.frame.width + view.completion.translation.dx = 0 + + case (.bottom, .removal): + view.animation.translation.dy = container.frame.height + view.completion.translation.dy = 0 + } + } + } +} +``` + +Now we're talking! There's some basic math and value assignments happening, but nothing resembling a typical `UIView` animation block just yet. Although there are some references to `animation` and `completion`, which are very familiar concepts in UIKit world. + +We'll be covering what these are in just a moment, but as a closing thought before we jump onto the nitty gritty of the implementation, take a moment to acknowledge the inherent **layered approach** this library uses to describe transitions. This design philosophy is the basis for building great, non-glitchy transitions down the line. + +## Implementation + +### Basic + +There are 2 main entry points for building a `NavigationTransition`: + +#### `NavigationTransition.combined(with:)` + +You can create a custom `NavigationTransition` by combining two existing transitions: + +```swift +.slide.combined(with: .fade(.in)) +``` + +It is rarely the case where you'd want to combine `NavigationTransition`s in this manner due to their nature as high level abstractions. In fact, most of the time they won't combine very well at all, and will produce glitchy or weird effects. This is because two or more fully-fledged transitions tend to override the same view properties with different values, producing unexpected outcomes. + +Instead, most combinations should happen with the lower level abstraction `AtomicTransition`. + +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. + +#### `NavigationTransition.asymmetric(push:pop:)` + +```swift +.asymmetric(push: .fade(.cross), pop: .slide) +``` + +This second, more interesting entry point is one reminiscent of SwiftUI's asymmetric transition API. As the name suggest, this transition splits the `push` transition from the `pop` transition, to make them as different as you wish. + +You can use this method with a pair of `NavigationTransition` values or, more importantly, a pair of `AtomicTransition` values. Most transitions will utilize the latter due to its superior granularity for customization. + +--- + +There are 2 main entry points for building an `AtomicTransition`: + +#### `AtomicTransition.combined(with:)` + +```swift +.move(edge: .trailing).combined(with: .scale(0.5)) +``` + +The API is remarkably similar to `AnyTransition` on purpose, and acts on a single view in the same way you'd expect the first-party API to behave. + +It's important to understand the **nuance** this entails: regardless of whether its parent transition is `push` or `pop`, this transition will insert the incoming view from the trailing edge and scale it from an initial value of 0.5 to a final value of 1. In the same manner, the outgoing view will be removed by moving away towards the same trailing edge and scaling down from 1 to 0.5. In order to actually apply a different edge movement for insertion vs removal you'll need to use the `.asymmetric` transition described below. + +#### `AtomicTransition.asymmetric(insertion:removal:)` + +```swift +.asymmetric( + insertion: .move(edge: .trailing).combined(with: .scale(0.5)), + removal: .move(edge: .leading).combined(with: .scale(0.5)) +) +``` + +Just like `AnyTransition.asymmetric`, this transition uses a different transition for insertion vs removal, and acts as a cornerstone for custom transitions along with its `NavigationTransition.asymmetric(push:pop:)` counterpart. + +Now that you understand the 4 basic customization entry points the library has to offer, you should be able to refer back to [**the example**](#NavigationTransition) from earlier on and understand a bit more about how the entire implementation works. + +### Intermediate + +In addition to the basic entry points to customization described in the previous section, let's delve even deeper into how primitive transitions actually work. By "primitive transitions" I'm referring to standalone transitions which are not the result of composing other transitions together, but which rather define what the transition actually does to a view in animation terms. + +The primitive transitions which currently ship with this library are: + +- `AtomicTransition.identity` +- `AtomicTransition.move(edge:)` +- `AtomicTransition.offset(x:y:)` +- `AtomicTransition.opacity(_:)` +- `AtomicTransition.rotate(_:)` +- `AtomicTransition.scale(_:)` +- `AtomicTransition.zPosition(_:)` + +Let's take another look at the implementation of `.move(edge:)`: + +```swift +extension AtomicTransition { + /// A transition entering from `edge` on insertion, and exiting towards `edge` on removal. + public static func move(edge: Edge) -> Self { + .custom { view, operation, container in + switch (edge, operation) { + case (.top, .insertion): + view.initial.translation.dy = -container.frame.height + view.animation.translation.dy = 0 + + case (.leading, .insertion): + view.initial.translation.dx = -container.frame.width + view.animation.translation.dx = 0 + + case (.trailing, .insertion): + view.initial.translation.dx = container.frame.width + view.animation.translation.dx = 0 + + case (.bottom, .insertion): + view.initial.translation.dy = container.frame.height + view.animation.translation.dy = 0 + + case (.top, .removal): + view.animation.translation.dy = -container.frame.height + view.completion.translation.dy = 0 + + case (.leading, .removal): + view.animation.translation.dx = -container.frame.width + view.completion.translation.dx = 0 + + case (.trailing, .removal): + view.animation.translation.dx = container.frame.width + view.completion.translation.dx = 0 + + case (.bottom, .removal): + view.animation.translation.dy = container.frame.height + view.completion.translation.dy = 0 + } + } + } +} +``` + +Here we find neither `.asymmetric` nor `.combined` are used for this primitive transition. Instead, we find a `.custom` initializer with the following signature: + +```swift +// AtomicTransition.swift +public static func custom(withTransientView handler: @escaping TransientViewHandler) -> Self +``` + +... where `TransientViewHandler` is a typealias for `(TransientView, Operation, Container) -> Void`. + +`TransientView` is actually an abstraction over the `UIView` which is being inserted or removed under the hood by UIKit (and thus SwiftUI) as part of a push or a pop. The reason this abstraction exists is because it helps abstract away all of the UIKit animation logic and instead allows one to focus on assigning the desired values for each stage of the transition (`initial`, `animation`, and `completion`). It also helps the transition engine with merging transition states under the hood, making sure two primitive transitions affecting the same property don't accidentally cause glitchy UI behavior. + +Alongside `TransientView`, `Operation` defines whether the operation being performed is an `insertion` or a `removal` of the view, which should help you differentiate and set up your property values accordingly. + +Finally, container is a direct typealias to `UIView`, and it represents the container in which the transition is ocurring. There's no need to add `TransientView` to this container as the library does this for you. Even better, there's no way to even accidentally do it because `TransientView` is not a `UIView` subclass. + +--- + +Whilst composing `AtomicTransition`s is the recommended of building up to a `NavigationTransition`, there is actually an **alternative** option for those who'd like to reach for a more wholistic API: + +```swift +// NavigationTransition.swift +public static func custom(withTransientViews handler: @escaping TransientViewsHandler) -> Self +``` + +... where `TransientViewsHandler` is a typealias for `(FromView, ToView, Operation, Container) -> Void`. + +`FromView` and `ToView` are typealiases for the `TransientView`s corresponding to the origin and destination views involved in the transition. + +Alongside them, `Operation` defines whether the operation being performed is a `push` or a `pop`. The concept of insertions or removals is entirely removed from this abstraction, since you can directly modify the property values for the views without needing atomic transitions. + +This approach is often a simple one to take in case you're working on an app that only requires one custom navigation transition. However, if you're working on app that features multiple custom transitions, it is recommended that you model your navigation transitions via atomic transitions as described earlier on. In the long term, this will be beneficial to your development and iteration speed, by promoting code reusability amongst your team. + +### Advanced + +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: + +- You intend to migrate one of your existing [`UIViewControllerAnimatedTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrolleranimatedtransitioning) implementations over to SwiftUI. +- You're well versed in UIKit Custom Navigation Transitions and are willing to dive straight into raw UIKit territory, including view snapshotting, hierarchy set-up, and animation lifecycle management. Even then, I highly encourage you to consider using one of the formerly discussed abstractions in order to accomplish the desired effect. + +Before we get started, I'd like to ask that if you're reaching for these abstractions because there's something missing in the previous customization mechanisms that you believe should be there to make your transition work 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 abstraction and make everyone's development experience richer. + +Let's delve into the two final customization mechanisms, which as mentioned interact with UIKit abstractions directly. + +The entire concept around 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). + +The interface looks as follows: + +```swift +@objc public protocol Animator { + func addAnimations(_ animation: @escaping () -> Void) + func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) +} +``` + +The `AtomicTransition` API for utilising this mechanism is: + +```swift +// AtomicTransition.swift +public static func custom(withAnimator handler: @escaping AnimatorHandler) -> Self +``` + +... where `AnimatorHandler` is a typealias for `(Animator, UIView, Operation, Context) -> Void`. + +In this case, the `Animator` is utilized to setup animations and completion logic for the inserted or removed `UIView` with access to a `Context` (typealias for [`UIViewControllerContextTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrollercontexttransitioning)). + +--- + +The same principle applies to `NavigationTransition` as well: + +```swift +public static func custom(withAnimator handler: @escaping AnimatorHandler) -> Self +``` + +... where `AnimatorHandler` is a typealias for `(Animator, Operation, Context) -> Void`. + +In this case, the `Animator` is utilized to setup animations and completion logic for the pushed or popped views which you must extract from `Context` (typealias for [`UIViewControllerContextTransitioning`](https://developer.apple.com/documentation/uikit/uiviewcontrollercontexttransitioning)). diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..fdddb29a --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/NavigationTransitions.xcworkspace/contents.xcworkspacedata b/NavigationTransitions.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..ed63a355 --- /dev/null +++ b/NavigationTransitions.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/NavigationTransitions.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/NavigationTransitions.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/NavigationTransitions.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/NavigationTransitions.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NavigationTransitions.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..f9779033 --- /dev/null +++ b/NavigationTransitions.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "Introspect", + "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect", + "state": { + "branch": null, + "revision": "f2616860a41f9d9932da412a8978fec79c06fe24", + "version": "0.1.4" + } + } + ] + }, + "version": 1 +} diff --git a/NavigationTransitions.xcworkspace/xcshareddata/xcschemes/NavigationTransitions.xcscheme b/NavigationTransitions.xcworkspace/xcshareddata/xcschemes/NavigationTransitions.xcscheme new file mode 100644 index 00000000..caa456e5 --- /dev/null +++ b/NavigationTransitions.xcworkspace/xcshareddata/xcschemes/NavigationTransitions.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..f9779033 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "Introspect", + "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect", + "state": { + "branch": null, + "revision": "f2616860a41f9d9932da412a8978fec79c06fe24", + "version": "0.1.4" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..1d291dd2 --- /dev/null +++ b/Package.swift @@ -0,0 +1,53 @@ +// swift-tools-version: 5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "swiftui-navigation-transitions", + platforms: [ + .iOS(.v13), + ] +) + +// MARK: Dependencies + +package.dependencies = [ + .package(url: "https://github.com/siteline/SwiftUI-Introspect", from: "0.1.4"), +] + +let introspect: Target.Dependency = .product( + name: "Introspect", + package: "SwiftUI-Introspect" +) + +// MARK: Targets + +package.targets += [ + .target(name: "Animation"), + + .target(name: "Animator"), + .testTarget(name: "AnimatorTests", dependencies: [ + "Animator" + ]), + + .target(name: "AtomicTransition", dependencies: [ + "Animator" + ]), + + .target(name: "NavigationTransition", dependencies: [ + "Animation", + "AtomicTransition", + introspect, + ]), + + .target(name: "NavigationTransitions", dependencies: [ + "NavigationTransition", + ]), +] + +// MARK: Product + +package.products += [ + .library(name: "NavigationTransitions", targets: ["NavigationTransitions"]), +] diff --git a/README.md b/README.md new file mode 100644 index 00000000..7ff47e82 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# NavigationTransitions + +[![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) +[![](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) +[![](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) + +

+ +

+ +**NavigationTransitions** is a library that integrates seamlessly with SwiftUI's **Navigation** views, allowing complete customization over **push and pop transitions**! + +The library is fully compatible with: + +- **`NavigationView`** (iOS 13+) +- **`NavigationStack`** & **`NavigationSplitView`** (iOS 16) + +## Overview + +As opposed reinventing entire navigation components in order to customize its transitions, `NavigationTransitions` ships as a simple set of 2 modifiers that can be applied directly to SwiftUI's very own first-party navigation components. + +### The Basics + +#### iOS 13+ + +```swift +NavigationView { + // ... +} +.navigationViewStyle(.stack) +.navigationViewStackTransition(.slide) +``` + +```swift +NavigationView { + // ... +} +.navigationViewStyle(.columns) +.navigationViewColumnTransition(.slide, forColumns: .all) +``` + +#### iOS 16 + +```swift +NavigationStack { + // ... +} +.navigationStackTransition(.slide) +``` + +```swift +NavigationSplitView { + // ... +} +.navigationSplitViewTransition(.slide, forColumns: .all) +``` + +--- + +The API is designed to resemble that of built-in SwiftUI Transitions for maximum **familiarity** and **ease of use**. + +You can apply **custom animations** just like with standard SwiftUI transitions: + +```swift +.navigationViewStackTransition( + .fade(.in).animation(.easeInOut(duration: 0.3)) +) +``` + +You can **combine** them: + +```swift +.navigationViewStackTransition( + .slide.combined(with: .fade(.in)) +) +``` + +And you can **dynamically** choose between transitions based on logic: + +```swift +.navigationViewStackTransition( + reduceMotion ? .fade(.in).animation(.linear) : .move(.vertically) +) +``` + +### Transitions + +The library ships with some **standard transitions** out of the box: + +- [`default`](Sources/NavigationTransition/Default.swift) +- [`fade(_:)`](Sources/NavigationTransition/Fade.swift) +- [`move(axis:)`](Sources/NavigationTransition/Move.swift) +- [`slide`](Sources/NavigationTransition/Slide.swift) + +In addition to these, you can create fully **custom transitions** in just a few lines of code with the following: + +- [`asymmetric(push:pop:)`](Sources/NavigationTransition/Asymmetric.swift) +- [`combined(with:)`](Sources/NavigationTransition/Combined.swift) +- [`custom(withAnimator:)`](Sources/NavigationTransition/Custom.swift) +- [`custom(withTransientViews:)`](Sources/NavigationTransition/Custom.swift) + +The [**Demo**](Demo) app showcases some of these transitions in action. + +### Interactivity + +A sweet additional feature is the ability to override the behavior of the **pop gesture** on the navigation view: + +```swift +.navigationViewStackTransition(.slide, interactivity: .pan) // full-pan screen gestures! +``` + +This even works to override its behavior while maintaining the **default system transition** in iOS: + +```swift +.navigationViewStackTransition(.default, interactivity: .pan) // ✨ +``` + +## Documentation + +The repository contains [**documentation**](Documentation) covering how to set up your own custom transitions. + +## Community + +Feel free to **post questions**, **ideas**, or any **cool transitions** you build in the [**Discussions**](https://github.com/davdroman/swiftui-navigation-transitions/discussions) section! + +I sincerely hope you enjoy using this library as much as I enjoyed building it ❤️ diff --git a/Sources/Animation/Animation.swift b/Sources/Animation/Animation.swift new file mode 100644 index 00000000..ba65b6df --- /dev/null +++ b/Sources/Animation/Animation.swift @@ -0,0 +1,28 @@ +import UIKit + +public struct Animation { + @_spi(package)public + static var defaultDuration: Double { 0.35 } + + @_spi(package)public + var duration: Double + @_spi(package)public + let timingParameters: UITimingCurveProvider + + init(duration: Double, timingParameters: UITimingCurveProvider) { + self.duration = duration + self.timingParameters = timingParameters + } + + init(duration: Double, curve: UIView.AnimationCurve) { + self.init(duration: duration, timingParameters: UICubicTimingParameters(animationCurve: curve)) + } +} + +extension Animation { + public func speed(_ speed: Double) -> Self { + var copy = self + copy.duration /= speed + return copy + } +} diff --git a/Sources/Animation/Default.swift b/Sources/Animation/Default.swift new file mode 100644 index 00000000..c3ddb614 --- /dev/null +++ b/Sources/Animation/Default.swift @@ -0,0 +1,5 @@ +extension Animation { + public static var `default`: Self { + .init(duration: defaultDuration, curve: .easeInOut) + } +} diff --git a/Sources/Animation/EaseIn.swift b/Sources/Animation/EaseIn.swift new file mode 100644 index 00000000..b7cb5986 --- /dev/null +++ b/Sources/Animation/EaseIn.swift @@ -0,0 +1,9 @@ +extension Animation { + public static func easeIn(duration: Double) -> Self { + .init(duration: duration, curve: .easeIn) + } + + public static var easeIn: Self { + .easeIn(duration: defaultDuration) + } +} diff --git a/Sources/Animation/EaseInOut.swift b/Sources/Animation/EaseInOut.swift new file mode 100644 index 00000000..ee114c5c --- /dev/null +++ b/Sources/Animation/EaseInOut.swift @@ -0,0 +1,9 @@ +extension Animation { + public static func easeInOut(duration: Double) -> Self { + .init(duration: duration, curve: .easeInOut) + } + + public static var easeInOut: Self { + .easeInOut(duration: defaultDuration) + } +} diff --git a/Sources/Animation/EaseOut.swift b/Sources/Animation/EaseOut.swift new file mode 100644 index 00000000..f09fac69 --- /dev/null +++ b/Sources/Animation/EaseOut.swift @@ -0,0 +1,9 @@ +extension Animation { + public static func easeOut(duration: Double) -> Self { + .init(duration: duration, curve: .easeOut) + } + + public static var easeOut: Self { + .easeOut(duration: defaultDuration) + } +} diff --git a/Sources/Animation/InterpolatingSpring.swift b/Sources/Animation/InterpolatingSpring.swift new file mode 100644 index 00000000..935ad4f5 --- /dev/null +++ b/Sources/Animation/InterpolatingSpring.swift @@ -0,0 +1,20 @@ +import UIKit + +extension Animation { + public static func interpolatingSpring( + mass: Double = 1.0, + stiffness: Double, + damping: Double, + initialVelocity: Double = 0.0 + ) -> Self { + .init( + duration: defaultDuration, + timingParameters: UISpringTimingParameters( + mass: mass, + stiffness: stiffness, + damping: damping, + initialVelocity: CGVector(dx: initialVelocity, dy: initialVelocity) + ) + ) + } +} diff --git a/Sources/Animation/Linear.swift b/Sources/Animation/Linear.swift new file mode 100644 index 00000000..9bb1824a --- /dev/null +++ b/Sources/Animation/Linear.swift @@ -0,0 +1,9 @@ +extension Animation { + public static func linear(duration: Double) -> Self { + .init(duration: duration, curve: .linear) + } + + public static var linear: Self { + .linear(duration: defaultDuration) + } +} diff --git a/Sources/Animation/TimingCurve.swift b/Sources/Animation/TimingCurve.swift new file mode 100644 index 00000000..b3c914bc --- /dev/null +++ b/Sources/Animation/TimingCurve.swift @@ -0,0 +1,28 @@ +import UIKit + +extension Animation { + public static func timingCurve( + _ c0x: Double, + _ c0y: Double, + _ c1x: Double, + _ c1y: Double, + duration: Double + ) -> Self { + .init( + duration: duration, + timingParameters: UICubicTimingParameters( + controlPoint1: CGPoint(x: c0x, y: c0y), + controlPoint2: CGPoint(x: c1x, y: c1y) + ) + ) + } + + public static func timingCurve( + _ c0x: Double, + _ c0y: Double, + _ c1x: Double, + _ c1y: Double + ) -> Self { + .timingCurve(c0x, c0y, c1x, c1y, duration: defaultDuration) + } +} diff --git a/Sources/Animator/AnimationTransientView.swift b/Sources/Animator/AnimationTransientView.swift new file mode 100644 index 00000000..271a4925 --- /dev/null +++ b/Sources/Animator/AnimationTransientView.swift @@ -0,0 +1,57 @@ +import UIKit + +/// An animation-transient view. +/// +/// This view's interface vaguely resembles that of a `UIView`, +/// however it's scoped to a subset of animatable properties exclusively. +/// +/// It also acts as a property container rather than an actual +/// view being animated, which helps compound mutating values across +/// different defined transitions before actually submitting them +/// to the animator. This helps ensure no jumpy behavior in animations occurs. +@dynamicMemberLookup +public final class AnimationTransientView { + /// Typealias for `AnimationTransientViewProperties`. + public typealias Properties = AnimationTransientViewProperties + + /// The initial set of properties that sets up the animation's initial state. + /// + /// Use this to prepare the view before the animation starts. + public var initial: Properties + + /// The set of property changes that get submitted to the animator. + /// + /// Use this to define the desired animation. + public var animation: Properties + + /// The set of property changes that occur after the animation finishes. + /// + /// Use this to clean up any messy view state or to make any final view + /// alterations before presentation. + /// + /// Note: these changes are *not* animated. + public var completion: Properties + + @_spi(package)public let uiView: UIView + + /// Read-only proxy to underlying `UIView` instance. + public subscript(dynamicMember keyPath: KeyPath) -> T { + uiView[keyPath: keyPath] + } + + @_spi(package)public init(_ uiView: UIView) { + let properties = Properties( + alpha: uiView.alpha, + transform: uiView.transform + ) + self.initial = properties + self.animation = properties + self.completion = properties + + self.uiView = uiView + } + + @_spi(package)public func setUIViewProperties(to properties: KeyPath) { + self[keyPath: properties].assignToUIView(uiView) + } +} diff --git a/Sources/Animator/AnimationTransientViewProperties.swift b/Sources/Animator/AnimationTransientViewProperties.swift new file mode 100644 index 00000000..19200de8 --- /dev/null +++ b/Sources/Animator/AnimationTransientViewProperties.swift @@ -0,0 +1,102 @@ +import UIKit + +/// Defines the allowed mutable properties in a transient view throughout each stage of the transition. +public struct AnimationTransientViewProperties: Equatable { + @OptionalWithDefault + public var alpha: CGFloat + @OptionalWithDefault + public var transform: CGAffineTransform + + func assignToUIView(_ uiView: UIView) { + $alpha.assignTo(uiView, \.alpha) + $transform.assignTo(uiView, \.transform) + } +} + +private extension Optional { + func assignTo(_ root: Root, _ valueKeyPath: ReferenceWritableKeyPath) { + if let value = self { + root[keyPath: valueKeyPath] = value + } + } +} + +extension AnimationTransientViewProperties { + /// Convenience property for `CGAffineTransform` translation component. + public var translation: CGVector { + get { transform.translation } + set { transform.translation = newValue } + } + + /// Convenience property for `CGAffineTransform` scale component. + public var scale: CGSize { + get { transform.scale } + set { transform.scale = newValue } + } + + /// Convenience property for `CGAffineTransform` rotation component. + public var rotation: CGFloat { + get { transform.rotation } + set { transform.rotation = newValue } + } +} + +extension CGAffineTransform { + var translation: CGVector { + get { components.translation } + set { components.translation = newValue } + } + + var scale: CGSize { + get { components.scale } + set { components.scale = newValue } + } + + var rotation: CGFloat { + get { components.rotation } + set { components.rotation = newValue } + } + + private typealias Components = _CGAffineTransformComponents + + private var components: Components { + get { + Components( + scale: CGSize( + width: sqrt(pow(a, 2) + pow(c, 2)), + height: sqrt(pow(b, 2) + pow(d, 2)) + ), + rotation: atan2(b, a), + translation: CGVector(dx: tx, dy: ty) + ) + } + set { + guard components != newValue else { return } + self = CGAffineTransform(newValue) + } + } + + private init(_ components: Components) { + self = .identity + .translatedBy(x: components.translation.dx, y: components.translation.dy) + .scaledBy(x: components.scale.width, y: components.scale.height) + .rotated(by: components.rotation) + } +} + +public struct _CGAffineTransformComponents: Equatable { + /// Scaling in X and Y dimensions. + public var scale: CGSize + + /// Rotation angle in radians. + public var rotation: Double + + /// Displacement from the origin (ty, ty). + public var translation: CGVector + + public init(scale: CGSize, rotation: Double, translation: CGVector) { + self.scale = scale + self.rotation = rotation + self.translation = translation + } +} diff --git a/Sources/Animator/Animator.swift b/Sources/Animator/Animator.swift new file mode 100644 index 00000000..8372f294 --- /dev/null +++ b/Sources/Animator/Animator.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// Typealias for `Animator`. Useful for disambiguation. +public typealias _Animator = Animator + +/// A protocol representing an abstract view animator, whose sole +/// responsibility is receiving animation and completion blocks +/// as a means to define end-to-end animations as the sum of said blocks. +/// +/// Its interface is a subset of the interface of `UIViewImplicitlyAnimating`. +@objc public protocol Animator { + /// Adds the specified animation block to the animator. + /// + /// Use this method to add new animation blocks to the animator. The animations in the new block run alongside + /// any previously configured animations. + /// + /// If the animation block modifies a property that’s being modified by a different property animator, then the + /// animators combine their changes in the most appropriate way. For many properties, the changes from each + /// animator are added together to yield a new intermediate value. If a property can’t be modified in this + /// additive manner, the new animations take over as if the beginFromCurrentState option had been specified + /// for a view-based animation. + /// + /// You can call this method multiple times to add multiple blocks to the animator. + func addAnimations(_ animation: @escaping () -> Void) + + /// Adds the specified completion block to the animator. + /// + /// Completion blocks are executed after the animations finish normally. + /// + /// - Parameters: + /// - completion: A block to execute when the animations finish. This block has no return value and takes + /// the following parameter: + /// + /// finalPosition + /// + /// The ending position of the animations. Use this value to determine whether the animations stopped at + /// the beginning, end, or somewhere in the middle. + func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) +} + +extension Animator where Self: UIViewImplicitlyAnimating { + public func addAnimations(_ animation: @escaping () -> Void) { + addAnimations?(animation) + } + + public func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) { + addCompletion?(completion) + } +} + +extension UIViewPropertyAnimator: Animator {} diff --git a/Sources/Animator/OptionalWithDefault.swift b/Sources/Animator/OptionalWithDefault.swift new file mode 100644 index 00000000..d5a8ff1f --- /dev/null +++ b/Sources/Animator/OptionalWithDefault.swift @@ -0,0 +1,17 @@ +@propertyWrapper +public struct OptionalWithDefault { + public private(set) var projectedValue: Value? = nil + + private var defaultValue: Value + + public var wrappedValue: Value { + get { projectedValue ?? defaultValue } + set { projectedValue = newValue } + } + + public init(wrappedValue: Value) { + self.defaultValue = wrappedValue + } +} + +extension OptionalWithDefault: Equatable where Value: Equatable {} diff --git a/Sources/AtomicTransition/AnyTransition.swift b/Sources/AtomicTransition/AnyTransition.swift new file mode 100644 index 00000000..78936bd4 --- /dev/null +++ b/Sources/AtomicTransition/AnyTransition.swift @@ -0,0 +1,37 @@ +import Animator +import UIKit + +public typealias _Animator = Animator + +/// Represents an atomic transition which applies to a single view. It is the building block of `NavigationTransition`. +/// +/// The API mirrors that of SwiftUI `AnyTransition`, striving for maximum familiarity. Like `AnyTransition`, +/// `AtomicTransition` is designed to handle both the insertion and the removal of a view, and is agnostic as to what +/// the overarching operation (push vs pop) is. This design allows great flexibility when defining fully-fledged +/// navigation transitions. In other words, a `NavigationTransition` is the aggregate of two or more `AtomicTransition`s. +public struct AtomicTransition { + public typealias Animator = _Animator + + public enum Operation { + case insertion + case removal + } + + /// Typealias for `AnimationTransientView`. + public typealias TransientView = AnimationTransientView + /// Typealias for `UIViewControllerContextTransitioning`. + public typealias Context = UIViewControllerContextTransitioning + + typealias _Handler = (Animator, TransientView, Operation, Context) -> Void + + private var handler: _Handler + + init(handler: @escaping _Handler) { + self.handler = handler + } + + @_spi(package)public + func prepare(_ animator: Animator, or view: TransientView, for operation: Operation, in context: Context) { + self.handler(animator, view, operation, context) + } +} diff --git a/Sources/AtomicTransition/Asymmetric.swift b/Sources/AtomicTransition/Asymmetric.swift new file mode 100644 index 00000000..eaf84845 --- /dev/null +++ b/Sources/AtomicTransition/Asymmetric.swift @@ -0,0 +1,13 @@ +extension AtomicTransition { + /// Provides a composite transition that uses a different transition for insertion versus removal. + public static func asymmetric(insertion: Self, removal: Self) -> Self { + .init { animator, view, operation, container in + switch operation { + case .insertion: + insertion.prepare(animator, or: view, for: operation, in: container) + case .removal: + removal.prepare(animator, or: view, for: operation, in: container) + } + } + } +} diff --git a/Sources/AtomicTransition/Combined.swift b/Sources/AtomicTransition/Combined.swift new file mode 100644 index 00000000..9efeb584 --- /dev/null +++ b/Sources/AtomicTransition/Combined.swift @@ -0,0 +1,18 @@ +extension AtomicTransition { + /// Combines this transition with another, returning a new transition that is the result of both transitions + /// being applied. + public func combined(with other: Self) -> Self { + .init { animator, view, operation, container in + self.prepare(animator, or: view, for: operation, in: container) + other.prepare(animator, or: view, for: operation, in: container) + } + } +} + +extension Collection where Element == AtomicTransition { + /// Combines this collection of transitions, returning a new transition that is the result of all transitions + /// being applied in original order. + public func combined() -> AtomicTransition { + reduce(.identity) { $0.combined(with: $1) } + } +} diff --git a/Sources/AtomicTransition/Custom.swift b/Sources/AtomicTransition/Custom.swift new file mode 100644 index 00000000..fef91cd5 --- /dev/null +++ b/Sources/AtomicTransition/Custom.swift @@ -0,0 +1,43 @@ +@_spi(package) import Animator +import UIKit + +extension AtomicTransition { + public typealias AnimatorHandler = (Animator, UIView, Operation, Context) -> Void + + /// A fully customizable transition that hands over a closure with the animator object used for the transition. + /// + /// - Parameters: + /// - Animator: The ``Animator`` object used for the transition. Attach animations or completion blocks to it. + /// - UIView: The raw `UIView` instance being animated. + /// - Operation: The ``Operation``. Possible values are `insertion` or `removal`. It's recommended that you + /// customize the behavior of your transition based on this parameter. + /// - Container: The raw `UIView` instance of the transition container. + /// + /// - Warning: Usage of this initializer is highly discouraged unless you know what you're doing. + /// Use ``custom(withTransientView:)`` instead to ensure correct transition behavior. + public static func custom(withAnimator handler: @escaping AnimatorHandler) -> Self { + .init { animator, view, operation, context in + handler(animator, view.uiView, operation, context) + } + } +} + +extension AtomicTransition { + /// Typealias for `UIView`. + public typealias Container = UIView + public typealias TransientViewHandler = (TransientView, Operation, Container) -> Void + + /// A customizable transition that hands over a closure with an abstracted animatable view object. + /// + /// - Parameters: + /// - TransientView: The ``TransientView`` instance being animated. Apply animations directly to this instance + /// by modifying specific sub-properties of its `initial`, `animation`, or `completion` properties. + /// - Operation: The ``Operation``. Possible values are `insertion` or `removal`. It's recommended that you + /// customize the behavior of your transition based on this parameter. + /// - Container: The raw `UIView` containing the transitioning views. + public static func custom(withTransientView handler: @escaping TransientViewHandler) -> Self { + .init { _, view, operation, context in + handler(view, operation, context.containerView) + } + } +} diff --git a/Sources/AtomicTransition/Identity.swift b/Sources/AtomicTransition/Identity.swift new file mode 100644 index 00000000..bebf22ff --- /dev/null +++ b/Sources/AtomicTransition/Identity.swift @@ -0,0 +1,6 @@ +extension AtomicTransition { + /// A transition that returns the input view, unmodified, as the output view. + public static var identity: Self { + .init { _, _, _, _ in } + } +} diff --git a/Sources/AtomicTransition/Move.swift b/Sources/AtomicTransition/Move.swift new file mode 100644 index 00000000..966b6f24 --- /dev/null +++ b/Sources/AtomicTransition/Move.swift @@ -0,0 +1,42 @@ +import SwiftUI + +extension AtomicTransition { + /// A transition entering from `edge` on insertion, and exiting towards `edge` on removal. + public static func move(edge: Edge) -> Self { + .custom { view, operation, container in + switch (edge, operation) { + case (.top, .insertion): + view.initial.translation.dy = -container.frame.height + view.animation.translation.dy = 0 + + case (.leading, .insertion): + view.initial.translation.dx = -container.frame.width + view.animation.translation.dx = 0 + + case (.trailing, .insertion): + view.initial.translation.dx = container.frame.width + view.animation.translation.dx = 0 + + case (.bottom, .insertion): + view.initial.translation.dy = container.frame.height + view.animation.translation.dy = 0 + + case (.top, .removal): + view.animation.translation.dy = -container.frame.height + view.completion.translation.dy = 0 + + case (.leading, .removal): + view.animation.translation.dx = -container.frame.width + view.completion.translation.dx = 0 + + case (.trailing, .removal): + view.animation.translation.dx = container.frame.width + view.completion.translation.dx = 0 + + case (.bottom, .removal): + view.animation.translation.dy = container.frame.height + view.completion.translation.dy = 0 + } + } + } +} diff --git a/Sources/AtomicTransition/Offset.swift b/Sources/AtomicTransition/Offset.swift new file mode 100644 index 00000000..63bb3559 --- /dev/null +++ b/Sources/AtomicTransition/Offset.swift @@ -0,0 +1,24 @@ +import UIKit + +extension AtomicTransition { + /// A transition that translates the view from `offset` to zero on insertion, and from zero to `offset` on removal. + public static func offset(x: CGFloat = 0, y: CGFloat = 0) -> Self { + .custom { view, operation, container in + switch operation { + case .insertion: + view.initial.translation.dx += x + view.initial.translation.dy += y + view.animation.transform = .identity + case .removal: + view.animation.translation.dx += x + view.animation.translation.dy += y + view.completion.transform = .identity + } + } + } + + /// A transition that translates the view from `offset` to zero on insertion, and from zero to `offset` on removal. + public static func offset(_ offset: CGSize) -> Self { + .offset(x: offset.width, y: offset.height) + } +} diff --git a/Sources/AtomicTransition/Opacity.swift b/Sources/AtomicTransition/Opacity.swift new file mode 100644 index 00000000..9cc09bf0 --- /dev/null +++ b/Sources/AtomicTransition/Opacity.swift @@ -0,0 +1,15 @@ +extension AtomicTransition { + /// A transition from transparent to opaque on insertion, and from opaque to transparent on removal. + public static var opacity: Self { + .custom { view, operation, _ in + switch operation { + case .insertion: + view.initial.alpha = 0 + view.animation.alpha = 1 + case .removal: + view.animation.alpha = 0 + view.completion.alpha = 1 + } + } + } +} diff --git a/Sources/AtomicTransition/Rotate.swift b/Sources/AtomicTransition/Rotate.swift new file mode 100644 index 00000000..3ef5a79d --- /dev/null +++ b/Sources/AtomicTransition/Rotate.swift @@ -0,0 +1,17 @@ +import SwiftUI + +extension AtomicTransition { + /// A transition that rotates the view from `angle` to zero on insertion, and from zero to `angle` on removal. + public static func rotate(_ angle: Angle) -> Self { + .custom { view, operation, container in + switch operation { + case .insertion: + view.initial.rotation += angle.radians + view.animation.transform = .identity + case .removal: + view.animation.rotation += angle.radians + view.completion.transform = .identity + } + } + } +} diff --git a/Sources/AtomicTransition/Scale.swift b/Sources/AtomicTransition/Scale.swift new file mode 100644 index 00000000..6f78965a --- /dev/null +++ b/Sources/AtomicTransition/Scale.swift @@ -0,0 +1,25 @@ +import UIKit + +extension AtomicTransition { + /// A transition that scales the view from `scale` to `1` on insertion, and from `1` to `scale` on removal. + /// + /// - Parameters: + /// - scale: The scale of the view, ranging from `0` to `1`. + public static func scale(_ scale: CGFloat) -> Self { + .custom { view, operation, container in + switch operation { + case .insertion: + view.initial.scale = .init(width: scale, height: scale) + view.animation.transform = .identity + case .removal: + view.animation.scale = .init(width: scale, height: scale) + view.completion.transform = .identity + } + } + } + + /// A transition that scales the view from `scale` to 1.0 on insertion, and from 1.0 to `scale` on removal. + public static var scale: Self { + .scale(.leastNonzeroMagnitude) + } +} diff --git a/Sources/AtomicTransition/ZPosition.swift b/Sources/AtomicTransition/ZPosition.swift new file mode 100644 index 00000000..11e7f935 --- /dev/null +++ b/Sources/AtomicTransition/ZPosition.swift @@ -0,0 +1,30 @@ +extension AtomicTransition { + public enum ZPosition { + case front + case back + } + + /// A transition that shifts the view's z axis to the given value, regardless of insertion or removal. + public static func zPosition(_ position: ZPosition) -> Self { + .custom { _, view, _, context in + switch position { + case .front: + context.containerView.bringSubviewToFront(view) + case .back: + context.containerView.sendSubviewToBack(view) + } + } + } + + /// Equivalent to `zPosition(.front)`. + @inlinable + public static var bringToFront: Self { + .zPosition(.front) + } + + /// Equivalent to `zPosition(.back)`. + @inlinable + public static var sendToBack: Self { + .zPosition(.back) + } +} diff --git a/Sources/AtomicTransition/_Exports.swift b/Sources/AtomicTransition/_Exports.swift new file mode 100644 index 00000000..9191b5d8 --- /dev/null +++ b/Sources/AtomicTransition/_Exports.swift @@ -0,0 +1 @@ +@_exported import Animator diff --git a/Sources/NavigationTransition/Asymmetric.swift b/Sources/NavigationTransition/Asymmetric.swift new file mode 100644 index 00000000..b3409d0a --- /dev/null +++ b/Sources/NavigationTransition/Asymmetric.swift @@ -0,0 +1,31 @@ +@_spi(package) import AtomicTransition + +extension NavigationTransition { + /// Provides a composite transition that uses a different transition for push versus pop. + public static func asymmetric(push: Self, pop: Self) -> Self { + .init { animator, operation, context in + switch operation { + case .push: + push.prepare(animator, for: operation, in: context) + case .pop: + pop.prepare(animator, for: operation, in: context) + } + } + } +} + +extension NavigationTransition { + /// Provides a composite transition that uses a different transition for push versus pop. + public static func asymmetric(push: AtomicTransition, pop: AtomicTransition) -> Self { + .init { animator, fromView, toView, operation, context in + switch operation { + case .push: + push.prepare(animator, or: fromView, for: .removal, in: context) + push.prepare(animator, or: toView, for: .insertion, in: context) + case .pop: + pop.prepare(animator, or: fromView, for: .removal, in: context) + pop.prepare(animator, or: toView, for: .insertion, in: context) + } + } + } +} diff --git a/Sources/NavigationTransition/Combined.swift b/Sources/NavigationTransition/Combined.swift new file mode 100644 index 00000000..911635ae --- /dev/null +++ b/Sources/NavigationTransition/Combined.swift @@ -0,0 +1,18 @@ +extension NavigationTransition { + /// Combines this transition with another, returning a new transition that is the result of both transitions + /// being applied. + public func combined(with other: Self) -> Self { + .init { animator, operation, context in + self.prepare(animator, for: operation, in: context) + other.prepare(animator, for: operation, in: context) + } + } +} + +extension Collection where Element == NavigationTransition { + /// Combines this collection of transitions, returning a new transition that is the result of all transitions + /// being applied in original order. + public func combined() -> NavigationTransition { + reduce(.identity) { $0.combined(with: $1) } + } +} diff --git a/Sources/NavigationTransition/Custom.swift b/Sources/NavigationTransition/Custom.swift new file mode 100644 index 00000000..20236eb2 --- /dev/null +++ b/Sources/NavigationTransition/Custom.swift @@ -0,0 +1,55 @@ +import UIKit + +extension NavigationTransition { + /// Typealias for `UIViewControllerContextTransitioning`. + public typealias Context = UIViewControllerContextTransitioning + public typealias AnimatorHandler = (Animator, Operation, Context) -> Void + + /// A fully customizable transition that hands over a closure with the animator object used for the transition. + /// + /// - Parameters: + /// - Animator: The `Animator` object used for the transition. Attach animations or completion blocks to it. + /// - Operation: The ``Operation``. Possible values are `push` or `pop`. It's recommended that you + /// customize the behavior of your transition based on this parameter. + /// - Context: The raw `UIViewControllerContextTransitioning` instance of the transition coordinator. + /// + /// - Warning: Usage of this initializer is highly discouraged unless you know what you're doing. + /// Use ``custom(withTransientViews:)`` instead to ensure correct transition behavior. + public static func custom(withAnimator handler: @escaping AnimatorHandler) -> Self { + .init { animator, operation, context in + handler(animator, operation, context.uiKitContext) + } + } +} + +// IDEA: extend AnimationTransientView to extract specific subviews by accessibility identifier in order to tailor +// more specific animations +// note: might require converting fromView into a snapshot first +// +// rough API idea: +// fromView.subview(withAccessibilityIdentifier: "logo") // specific subview as AnimationTransientView that can now be animated +// fromView["logo"] // theoretical shorthand syntax for the above +// +// identifiers could be made to be strongly typed and specifically applied to views via a modifier like: +// .navigationTransitionID(.logo) // .logo could be a user-defined enum case conforming to some vended protocol +extension NavigationTransition { + /// Typealias for `UIView`. + public typealias Container = UIView + public typealias TransientViewsHandler = (FromView, ToView, Operation, Container) -> Void + + /// A customizable transition that hands over a closure with abstractly animatable view objects. + /// + /// - Parameters: + /// - FromView: A `TransientView` abstracting over the origin view. Apply animations directly to this instance + /// by modifying specific sub-properties of its `initial`, `animation`, or `completion` properties. + /// - ToView: A `TransientView` abstracting over the destination view. Apply animations directly to this instance + /// by modifying specific sub-properties of its `initial`, `animation`, or `completion` properties. + /// - Operation: The ``Operation``. Possible values are `push` or `pop`. It's recommended that you + /// customize the behavior of your transition based on this parameter. + /// - Context: The raw `UIViewControllerContextTransitioning` instance of the transition coordinator. + public static func custom(withTransientViews handler: @escaping TransientViewsHandler) -> Self { + .init { _, fromView, toView, operation, context in + handler(fromView, toView, operation, context.containerView) + } + } +} diff --git a/Sources/NavigationTransition/Default.swift b/Sources/NavigationTransition/Default.swift new file mode 100644 index 00000000..ba37a8f7 --- /dev/null +++ b/Sources/NavigationTransition/Default.swift @@ -0,0 +1,20 @@ +extension NavigationTransition { + /// The system-default transition. + /// + /// Use this property if you wish to modify the interactivity of the transition without altering the + /// system-provided transition itself. For example: + /// + /// ```swift + /// NavigationStack { + /// // ... + /// } + /// .navigationStackTransition(.default, interactivity: .pan) // enables full-screen panning for system-provided pop + /// ``` + /// + /// - Note: The animation for `default` cannot be customized via ``animation(_:)``. + public static var `default`: Self { + var transition = Self { _, _, _ in } + transition.isDefault = true + return transition + } +} diff --git a/Sources/NavigationTransition/Fade.swift b/Sources/NavigationTransition/Fade.swift new file mode 100644 index 00000000..a571f610 --- /dev/null +++ b/Sources/NavigationTransition/Fade.swift @@ -0,0 +1,31 @@ +extension NavigationTransition { + public enum Fade { + case `in` + case out + case cross + } + + /// A transition that fades the pushed view in, fades the popped view out, or cross-fades both views. + public static func fade(_ fade: Fade) -> Self { + switch fade { + case .in: + return .asymmetric( + push: .asymmetric(insertion: .opacity, removal: .identity), + pop: .asymmetric(insertion: .identity, removal: .opacity) + ) + case .out: + return .asymmetric( + push: .asymmetric( + insertion: .identity, + removal: .opacity.combined(with: .bringToFront) + ), + pop: .asymmetric( + insertion: .opacity.combined(with: .bringToFront), + removal: .identity + ) + ) + case .cross: + return .asymmetric(push: .opacity, pop: .opacity) + } + } +} diff --git a/Sources/NavigationTransition/Identity.swift b/Sources/NavigationTransition/Identity.swift new file mode 100644 index 00000000..8341d458 --- /dev/null +++ b/Sources/NavigationTransition/Identity.swift @@ -0,0 +1,6 @@ +extension NavigationTransition { + // For internal use only. + static var identity: Self { + .init { _, _, _ in } + } +} diff --git a/Sources/NavigationTransition/Move.swift b/Sources/NavigationTransition/Move.swift new file mode 100644 index 00000000..4db774f2 --- /dev/null +++ b/Sources/NavigationTransition/Move.swift @@ -0,0 +1,35 @@ +import enum SwiftUI.Axis + +extension NavigationTransition { + /// A transition that moves both views in and out along the specified axis. + /// + /// This transition: + /// - Pushes views right-to-left and pops views left-to-right when `axis` is `horizontal`. + /// - Pushes views bottom-to-top and pops views top-to-bottom when `axis` is `vertical`. + public static func move(axis: Axis) -> Self { + switch axis { + case .horizontal: + return .asymmetric( + push: .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + ), + pop: .asymmetric( + insertion: .move(edge: .leading), + removal: .move(edge: .trailing) + ) + ) + case .vertical: + return .asymmetric( + push: .asymmetric( + insertion: .move(edge: .bottom), + removal: .move(edge: .top) + ), + pop: .asymmetric( + insertion: .move(edge: .top), + removal: .move(edge: .bottom) + ) + ) + } + } +} diff --git a/Sources/NavigationTransition/NavigationTransition.swift b/Sources/NavigationTransition/NavigationTransition.swift new file mode 100644 index 00000000..d35fe142 --- /dev/null +++ b/Sources/NavigationTransition/NavigationTransition.swift @@ -0,0 +1,150 @@ +import Animation +@_spi(package) import Animator +import UIKit + +public typealias _Animator = Animator + +/// Represents a transition which applies to two views: an origin ("from") view and a destination ("to") view. +/// +/// It is designed to handle both push and pop operations for a pair of views in a given navigation stack transition, +/// and is usually composed of smaller isolated transitions of type `AtomicTransition`, which act as building blocks. +/// +/// Although the library ships with a set of predefined transitions (e.g. ``move(axis:)``, one can also create +/// entirely new, fully customizable transitions via ``custom(withTransientViews:)`` or ``custom(withAnimator:)``. +public struct NavigationTransition { + public typealias Animator = _Animator + + public enum Operation: Hashable { + case push + case pop + + @_spi(package)public + init?(_ operation: UINavigationController.Operation) { + switch operation { + case .push: + self = .push + case .pop: + self = .pop + case .none: + return nil + @unknown default: + return nil + } + } + } + + @_spi(package)public + final class _Context { + @_spi(package)public + let uiKitContext: UIViewControllerContextTransitioning + @_spi(package)public + var transientViews: (NavigationTransition.FromView, NavigationTransition.ToView)? + + @_spi(package)public + init(uiKitContext: UIViewControllerContextTransitioning) { + self.uiKitContext = uiKitContext + } + } + + typealias _Handler = (Animator, Operation, _Context) -> Void + + private var handler: _Handler + @_spi(package)public + var animation: Animation = .default + @_spi(package)public var isDefault = false + + init(withAnimator animatorHandler: @escaping _Handler) { + self.handler = animatorHandler + } + + @_spi(package)public + func prepare(_ animator: Animator, for operation: Operation, in context: _Context) { + self.handler(animator, operation, context) + } +} + +extension NavigationTransition { + /// Typealias for ``AnimationTransientView``. + public typealias FromView = AnimationTransientView + /// Typealias for ``AnimationTransientView``. + public typealias ToView = AnimationTransientView + + typealias _TransientViewsHandler = (Animator, FromView, ToView, Operation, Context) -> Void + + init(withTransientViews transientViewsHandler: @escaping _TransientViewsHandler) { + self.init { animator, operation, context in + let uiKitContext = context.uiKitContext + + // short circuit repeated view set-ups when combining transitions + if let (fromView, toView) = context.transientViews { + transientViewsHandler(animator, fromView, toView, operation, uiKitContext) + return + } + + guard + let fromUIView = uiKitContext.view(forKey: .from), + let fromUIViewSnapshot = fromUIView.snapshotView(afterScreenUpdates: false), + let toUIView = uiKitContext.view(forKey: .to), + let toUIViewSnapshot = toUIView.snapshotView(afterScreenUpdates: true) + else { + return + } + + let fromView = FromView(fromUIViewSnapshot) + let toView = ToView(toUIViewSnapshot) + context.transientViews = (fromView, toView) + + let container = uiKitContext.containerView + fromUIView.removeFromSuperview() + container.addSubview(fromUIViewSnapshot) + switch operation { + case .push: + container.insertSubview(toUIViewSnapshot, aboveSubview: fromUIViewSnapshot) + case .pop: + container.insertSubview(toUIViewSnapshot, belowSubview: fromUIViewSnapshot) + } + + // this is a hack that uses a 0-sized container to ensure that + // toView is added to the view hierarchy but not visible, + // in order to have toViewSnapshot sized properly + let invisibleContainer = UIView() + invisibleContainer.clipsToBounds = true + invisibleContainer.addSubview(fromUIView) + invisibleContainer.addSubview(toUIView) + container.addSubview(invisibleContainer) + + animator.addCompletion { [weak container, weak fromUIView, weak toUIView] _ in + guard + let container = container, + let fromUIView = fromUIView, + let toUIView = toUIView + else { + return + } + for subview in container.subviews { + subview.removeFromSuperview() + } + if uiKitContext.transitionWasCancelled { + container.addSubview(fromUIView) + } else { + container.addSubview(toUIView) + } + } + + transientViewsHandler(animator, fromView, toView, operation, uiKitContext) + } + } +} + +public typealias _Animation = Animation + +extension NavigationTransition { + public typealias Animation = _Animation + + /// Attaches an animation to this transition. + public func animation(_ animation: Animation) -> Self { + var copy = self + copy.animation = animation + return copy + } +} diff --git a/Sources/NavigationTransition/Slide.swift b/Sources/NavigationTransition/Slide.swift new file mode 100644 index 00000000..25acd174 --- /dev/null +++ b/Sources/NavigationTransition/Slide.swift @@ -0,0 +1,7 @@ +extension NavigationTransition { + /// Equivalent to `move(axis: .horizontal)`. + @inlinable + public static var slide: Self { + .move(axis: .horizontal) + } +} diff --git a/Sources/NavigationTransition/_Exports.swift b/Sources/NavigationTransition/_Exports.swift new file mode 100644 index 00000000..5fb876b8 --- /dev/null +++ b/Sources/NavigationTransition/_Exports.swift @@ -0,0 +1,2 @@ +@_exported import Animation +@_exported import AtomicTransition diff --git a/Sources/NavigationTransitions/NavigationTransition+SwiftUI.swift b/Sources/NavigationTransitions/NavigationTransition+SwiftUI.swift new file mode 100644 index 00000000..55d2e25e --- /dev/null +++ b/Sources/NavigationTransitions/NavigationTransition+SwiftUI.swift @@ -0,0 +1,303 @@ +import Introspect +import SwiftUI + +// MARK: iOS 16 + +@available(iOS, introduced: 16) +public struct NavigationSplitViewColumns: OptionSet { + public static let sidebar = Self(rawValue: 1) + public static let content = Self(rawValue: 1 << 1) + public static let detail = Self(rawValue: 1 << 2) + + public static let compact = Self(rawValue: 1 << 3) + + public static let all: Self = [compact, sidebar, content, detail] + + public let rawValue: Int8 + + public init(rawValue: Int8) { + self.rawValue = rawValue + } +} + +@available(iOS, introduced: 16) +extension UISplitViewControllerColumns { + init(_ columns: NavigationSplitViewColumns) { + var _columns: Self = [] + if columns.contains(.sidebar) { + _columns.insert(.primary) + } + if columns.contains(.content) { + _columns.insert(.supplementary) + } + if columns.contains(.detail) { + _columns.insert(.secondary) + } + if columns.contains(.compact) { + _columns.insert(.compact) + } + self = _columns + } +} + +extension View { + @available(iOS, introduced: 16) + @ViewBuilder + public func navigationSplitViewTransition( + _ transition: NavigationTransition, + forColumns columns: NavigationSplitViewColumns, + interactivity: NavigationTransition.Interactivity = .default + ) -> some View { + self.modifier( + NavigationSplitOrStackTransitionModifier( + transition: transition, + target: .navigationSplitView(columns), + interactivity: interactivity + ) + ) + } + + @available(iOS, introduced: 16) + @ViewBuilder + public func navigationStackTransition( + _ transition: NavigationTransition, + interactivity: NavigationTransition.Interactivity = .default + ) -> some View { + self.modifier( + NavigationSplitOrStackTransitionModifier( + transition: transition, + target: .navigationStack, + interactivity: interactivity + ) + ) + } +} + +@available(iOS, introduced: 16) +struct NavigationSplitOrStackTransitionModifier: ViewModifier { + enum Target { + case navigationSplitView(NavigationSplitViewColumns) + case navigationStack + } + + let transition: NavigationTransition + let target: Target + let interactivity: NavigationTransition.Interactivity + + func body(content: Content) -> some View { + switch target { + case .navigationSplitView(let columns): + content.inject( + UIKitIntrospectionViewController { spy -> UISplitViewController? in + if let controller = Introspect.previousSibling(ofType: UISplitViewController.self, from: spy) { + return controller + } else { + runtimeWarn( + """ + Modifier "navigationSplitViewTransition" was applied to a view other than NavigationSplitView. This has no effect. + + Please make sure you're applying the modifier correctly: + + NavigationSplitView { + ... + } + .navigationSplitViewTransition(...) + + Otherwise, if you're using a NavigationStack, please apply the corresponding modifier "navigationStackTransition" instead. + """ + ) + return nil + } + } + customize: { (controller: UISplitViewController) in + controller.setNavigationTransition(transition, forColumns: .init(columns), interactivity: interactivity) + } + ) + + case .navigationStack: + content.inject( + UIKitIntrospectionViewController { spy -> UINavigationController? in + guard spy.parent != nil else { + return nil // don't evaluate view until it's on screen + } + if let controller = Introspect.previousSibling(ofType: UINavigationController.self, from: spy) { + return controller + } else if let controller = spy.navigationController { + return controller + } else { + runtimeWarn( + """ + Modifier "navigationStackTransition" was applied to a view other than NavigationStack. This has no effect. + + Please make sure you're applying the modifier correctly: + + NavigationStack { + ... + } + .navigationStackTransition(...) + + Otherwise, if you're using a NavigationSplitView, please apply the corresponding modifier "navigationSplitViewTransition" instead. + """ + ) + return nil + } + } + customize: { (controller: UINavigationController) in + controller.setNavigationTransition(transition, interactivity: interactivity) + } + ) + } + } +} + +// MARK: - Pre-iOS 16 + +@available(iOS, introduced: 13, deprecated: 16, message: + """ + Use `NavigationSplitView` and `.navigationSplitViewTransition` with `NavigationSplitViewColumns` instead. + """ +) +public struct NavigationViewColumns: OptionSet { + public static let sidebar = Self(rawValue: 1) + public static let detail = Self(rawValue: 1 << 1) + + public static let all: Self = [sidebar, detail] + + public let rawValue: Int8 + + public init(rawValue: Int8) { + self.rawValue = rawValue + } +} + +extension UISplitViewControllerColumns { + init(_ columns: NavigationViewColumns) { + var _columns: Self = [] + if columns.contains(.sidebar) { + _columns.insert(.primary) + } + if columns.contains(.detail) { + _columns.insert(.secondary) + } + self = _columns + } +} + +extension View { + @available(iOS, introduced: 13, deprecated: 16, renamed: "navigationSplitViewTransition", message: + """ + Use `NavigationSplitView` and `.navigationSplitViewTransition` instead. + """ + ) + @ViewBuilder + public func navigationViewColumnTransition( + _ transition: NavigationTransition, + forColumns columns: NavigationViewColumns, + interactivity: NavigationTransition.Interactivity = .default + ) -> some View { + self.modifier( + NavigationViewTransitionModifier( + transition: transition, + style: .columns(columns), + interactivity: interactivity + ) + ) + } + + @available(iOS, introduced: 13, deprecated: 16, renamed: "navigationStackTransition", message: + """ + Use `NavigationStack` and `.navigationStackTransition` instead. + """ + ) + @ViewBuilder + public func navigationViewStackTransition( + _ transition: NavigationTransition, + interactivity: NavigationTransition.Interactivity = .default + ) -> some View { + self.modifier( + NavigationViewTransitionModifier( + transition: transition, + style: .stack, + interactivity: interactivity + ) + ) + } +} + +struct NavigationViewTransitionModifier: ViewModifier { + enum Style { + case columns(NavigationViewColumns) + case stack + } + + let transition: NavigationTransition + let style: Style + let interactivity: NavigationTransition.Interactivity + + func body(content: Content) -> some View { + switch style { + case .columns(let columns): + content.inject( + UIKitIntrospectionViewController { spy -> UISplitViewController? in + if let controller = Introspect.previousSibling(ofType: UISplitViewController.self, from: spy) { + return controller + } else { + runtimeWarn( + """ + Modifier "navigationViewColumnTransition" was applied to a view other than NavigationView with .navigationViewStyle(.columns). This has no effect. + + Please make sure you're applying the modifier correctly: + + NavigationView { + ... + } + .navigationStyle(.columns) + .navigationViewTransition(...) + + Otherwise, if you're using a NavigationView with .navigationViewStyle(.stack), please apply the corresponding modifier "navigationViewStackTransition" instead. + """ + ) + return nil + } + } + customize: { (controller: UISplitViewController) in + controller.setNavigationTransition(transition, forColumns: .init(columns), interactivity: interactivity) + } + ) + + case .stack: + content.inject( + UIKitIntrospectionViewController { spy -> UINavigationController? in + guard spy.parent != nil else { + return nil // don't evaluate view until it's on screen + } + if let controller = Introspect.previousSibling(ofType: UINavigationController.self, from: spy) { + return controller + } else if let controller = spy.navigationController { + return controller + } else { + runtimeWarn( + """ + Modifier "navigationViewStackTransition" was applied to a view other than NavigationView with .navigationViewStyle(.stack). This has no effect. + + Please make sure you're applying the modifier correctly: + + NavigationStack { + ... + } + .navigationViewStyle(.stack) + .navigationStackTransition(...) + + Otherwise, if you're using a NavigationView with .navigationViewStyle(.columns), please apply the corresponding modifier "navigationViewColumnTransition" instead. + """ + ) + return nil + } + } + customize: { (controller: UINavigationController) in + controller.setNavigationTransition(transition, interactivity: interactivity) + } + ) + } + } +} diff --git a/Sources/NavigationTransitions/NavigationTransition+UIKit.swift b/Sources/NavigationTransitions/NavigationTransition+UIKit.swift new file mode 100644 index 00000000..ecf616e2 --- /dev/null +++ b/Sources/NavigationTransitions/NavigationTransition+UIKit.swift @@ -0,0 +1,266 @@ +@_spi(package) import NavigationTransition +import UIKit + +extension NavigationTransition { + public enum Interactivity { + case disabled + case edgePan + case pan + + @inlinable + public static var `default`: Self { + .edgePan + } + } +} + +public struct UISplitViewControllerColumns: OptionSet { + public static let primary = Self(rawValue: 1) + public static let supplementary = Self(rawValue: 1 << 1) + public static let secondary = Self(rawValue: 1 << 2) + + public static let compact = Self(rawValue: 1 << 3) + + public static let all: Self = [compact, primary, supplementary, secondary] + + public let rawValue: Int8 + + public init(rawValue: Int8) { + self.rawValue = rawValue + } +} + +extension UISplitViewController { + public func setNavigationTransition( + _ transition: NavigationTransition, + forColumns columns: UISplitViewControllerColumns, + interactivity: NavigationTransition.Interactivity = .default + ) { + if columns.contains(.compact), let compact = compactViewController as? UINavigationController { + compact.setNavigationTransition(transition, interactivity: interactivity) + } + if columns.contains(.primary), let primary = primaryViewController as? UINavigationController { + primary.setNavigationTransition(transition, interactivity: interactivity) + } + if columns.contains(.supplementary), let supplementary = supplementaryViewController as? UINavigationController { + supplementary.setNavigationTransition(transition, interactivity: interactivity) + } + if columns.contains(.secondary), let secondary = secondaryViewController as? UINavigationController { + secondary.setNavigationTransition(transition, interactivity: interactivity) + } + } +} + +extension UISplitViewController { + var compactViewController: UIViewController? { + if #available(iOS 14, *) { + return viewController(for: .compact) + } else { + if isCollapsed { + return viewControllers.first + } else { + return nil + } + } + } + + var primaryViewController: UIViewController? { + if #available(iOS 14, *) { + return viewController(for: .primary) + } else { + if !isCollapsed { + return viewControllers.first + } else { + return nil + } + } + } + + var supplementaryViewController: UIViewController? { + if #available(iOS 14, *) { + return viewController(for: .supplementary) + } else { + if !isCollapsed { + if viewControllers.count >= 3 { + return viewControllers[safe: 1] + } else { + return nil + } + } else { + return nil + } + } + } + + var secondaryViewController: UIViewController? { + if #available(iOS 14, *) { + return viewController(for: .secondary) + } else { + if !isCollapsed { + if viewControllers.count >= 3 { + return viewControllers[safe: 2] + } else { + return viewControllers[safe: 1] + } + } else { + return nil + } + } + } +} + +extension RandomAccessCollection where Index == Int { + subscript(safe index: Index) -> Element? { + self.dropFirst(index).first + } +} + +extension UINavigationController { + private static var defaultDelegateKey = "defaultDelegate" + private static var customDelegateKey = "customDelegate" + + private var defaultDelegate: UINavigationControllerDelegate! { + get { objc_getAssociatedObject(self, &Self.defaultDelegateKey) as? UINavigationControllerDelegate } + set { objc_setAssociatedObject(self, &Self.defaultDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var customDelegate: NavigationTransitionDelegate! { + get { + objc_getAssociatedObject(self, &Self.customDelegateKey) as? NavigationTransitionDelegate + } + set { + objc_setAssociatedObject(self, &Self.customDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + delegate = newValue + } + } + + public func setNavigationTransition( + _ transition: NavigationTransition, + interactivity: NavigationTransition.Interactivity = .default + ) { + if defaultDelegate == nil { + defaultDelegate = delegate + } + + if defaultPanRecognizer == nil { + defaultPanRecognizer = UIPanGestureRecognizer() + defaultPanRecognizer.targets = defaultEdgePanRecognizer?.targets // https://stackoverflow.com/a/60526328/1922543 + defaultPanRecognizer.strongDelegate = NavigationGestureRecognizerDelegate(controller: self) + view.addGestureRecognizer(defaultPanRecognizer) + } + + if edgePanRecognizer == nil { + edgePanRecognizer = UIScreenEdgePanGestureRecognizer() + edgePanRecognizer.edges = .left + edgePanRecognizer.addTarget(self, action: #selector(handleInteraction)) + edgePanRecognizer.strongDelegate = NavigationGestureRecognizerDelegate(controller: self) + view.addGestureRecognizer(edgePanRecognizer) + } + + if panRecognizer == nil { + panRecognizer = UIPanGestureRecognizer() + panRecognizer.addTarget(self, action: #selector(handleInteraction)) + panRecognizer.strongDelegate = NavigationGestureRecognizerDelegate(controller: self) + view.addGestureRecognizer(panRecognizer) + } + + if transition.isDefault { + delegate = defaultDelegate + + switch interactivity { + case .disabled: + exclusivelyEnableGestureRecognizer(.none) + case .edgePan: + exclusivelyEnableGestureRecognizer(defaultEdgePanRecognizer) + case .pan: + exclusivelyEnableGestureRecognizer(defaultPanRecognizer) + } + } else { + customDelegate = NavigationTransitionDelegate(transition: transition, baseDelegate: defaultDelegate) + + switch interactivity { + case .disabled: + exclusivelyEnableGestureRecognizer(.none) + case .edgePan: + exclusivelyEnableGestureRecognizer(edgePanRecognizer) + case .pan: + exclusivelyEnableGestureRecognizer(panRecognizer) + } + } + } + + private func exclusivelyEnableGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer?) { + for recognizer in [defaultEdgePanRecognizer!, defaultPanRecognizer!, edgePanRecognizer!, panRecognizer!] { + if let gestureRecognizer = gestureRecognizer, recognizer === gestureRecognizer { + recognizer.isEnabled = true + } else { + recognizer.isEnabled = false + } + } + } +} + +extension UINavigationController { + var defaultEdgePanRecognizer: UIScreenEdgePanGestureRecognizer! { + interactivePopGestureRecognizer as? UIScreenEdgePanGestureRecognizer + } + + private static var defaultInteractivePanGestureRecognizer = "defaultInteractivePanGestureRecognizer" + private static var interactiveEdgePanGestureRecognizer = "interactiveEdgePanGestureRecognizer" + private static var interactivePanGestureRecognizer = "interactivePanGestureRecognizer" + + var defaultPanRecognizer: UIPanGestureRecognizer! { + get { objc_getAssociatedObject(self, &Self.defaultInteractivePanGestureRecognizer) as? UIPanGestureRecognizer } + set { objc_setAssociatedObject(self, &Self.defaultInteractivePanGestureRecognizer, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var edgePanRecognizer: UIScreenEdgePanGestureRecognizer! { + get { objc_getAssociatedObject(self, &Self.interactiveEdgePanGestureRecognizer) as? UIScreenEdgePanGestureRecognizer } + set { objc_setAssociatedObject(self, &Self.interactiveEdgePanGestureRecognizer, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + var panRecognizer: UIPanGestureRecognizer! { + get { objc_getAssociatedObject(self, &Self.interactivePanGestureRecognizer) as? UIPanGestureRecognizer } + set { objc_setAssociatedObject(self, &Self.interactivePanGestureRecognizer, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } +} + +extension UIGestureRecognizer { + private static var strongDelegateKey = "strongDelegateKey" + + var strongDelegate: UIGestureRecognizerDelegate? { + get { + objc_getAssociatedObject(self, &Self.strongDelegateKey) as? UIGestureRecognizerDelegate + } + set { + objc_setAssociatedObject(self, &Self.strongDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + delegate = newValue + } + } + + var targets: Any? { + get { + value(forKey: #function) + } + set { + if let newValue = newValue { + setValue(newValue, forKey: #function) + } else { + setValue(NSMutableArray(), forKey: #function) + } + } + } +} + +final class NavigationGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate { + private unowned let navigationController: UINavigationController + + init(controller: UINavigationController) { + self.navigationController = controller + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let isNotOnRoot = navigationController.viewControllers.count > 1 + return isNotOnRoot + } +} diff --git a/Sources/NavigationTransitions/NavigationTransitionDelegate.swift b/Sources/NavigationTransitions/NavigationTransitionDelegate.swift new file mode 100644 index 00000000..399891e0 --- /dev/null +++ b/Sources/NavigationTransitions/NavigationTransitionDelegate.swift @@ -0,0 +1,94 @@ +@_spi(package) import Animation +@_spi(package) import Animator +@_spi(package) import NavigationTransition +import UIKit + +final class NavigationTransitionDelegate: NSObject, UINavigationControllerDelegate { + let transition: NavigationTransition + weak var baseDelegate: UINavigationControllerDelegate? + var interactionController: UIPercentDrivenInteractiveTransition? + + init(transition: NavigationTransition, baseDelegate: UINavigationControllerDelegate?) { + self.transition = transition + self.baseDelegate = baseDelegate + } + + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + baseDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated) + } + + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + baseDelegate?.navigationController?(navigationController, didShow: viewController, animated: animated) + } + + func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + return interactionController + } + + func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + if let operation = NavigationTransition.Operation(operation) { + return NavigationTransitionAnimatorProvider(transition: transition, operation: operation) + } else { + return nil + } + } +} + +final class NavigationTransitionAnimatorProvider: NSObject, UIViewControllerAnimatedTransitioning { + let transition: NavigationTransition + let operation: NavigationTransition.Operation + + init(transition: NavigationTransition, operation: NavigationTransition.Operation) { + self.transition = transition + self.operation = operation + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + transition.animation.duration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + transitionAnimator(for: transitionContext).startAnimation() + } + + func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating { + transitionAnimator(for: transitionContext) + } + + func animationEnded(_ transitionCompleted: Bool) { + cachedAnimators.removeAll(keepingCapacity: true) + } + + private var cachedAnimators: [ObjectIdentifier: UIViewPropertyAnimator] = .init(minimumCapacity: 1) + + private func transitionAnimator(for transitionContext: UIViewControllerContextTransitioning) -> UIViewPropertyAnimator { + if let cached = cachedAnimators[ObjectIdentifier(transitionContext)] { + return cached + } + + let animator = UIViewPropertyAnimator( + duration: transitionDuration(using: transitionContext), + timingParameters: transition.animation.timingParameters + ) + let operation = self.operation + let context = NavigationTransition._Context(uiKitContext: transitionContext) + + transition.prepare(animator, for: operation, in: context) + + if let (fromView, toView) = context.transientViews { + fromView.setUIViewProperties(to: \.initial) + animator.addAnimations { fromView.setUIViewProperties(to: \.animation) } + animator.addCompletion { _ in fromView.setUIViewProperties(to: \.completion) } + + toView.setUIViewProperties(to: \.initial) + animator.addAnimations { toView.setUIViewProperties(to: \.animation) } + animator.addCompletion { _ in toView.setUIViewProperties(to: \.completion) } + } + animator.addCompletion { _ in + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + + cachedAnimators[ObjectIdentifier(transitionContext)] = animator + return animator + } +} diff --git a/Sources/NavigationTransitions/NavigationTransitionInteraction.swift b/Sources/NavigationTransitions/NavigationTransitionInteraction.swift new file mode 100644 index 00000000..f7facfdd --- /dev/null +++ b/Sources/NavigationTransitions/NavigationTransitionInteraction.swift @@ -0,0 +1,54 @@ +import UIKit + +extension UINavigationController { + @objc func handleInteraction(_ gestureRecognizer: UIPanGestureRecognizer) { + guard let delegate = customDelegate else { + return + } + guard let gestureRecognizerView = gestureRecognizer.view else { + delegate.interactionController = nil + return + } + + let translation = gestureRecognizer.translation(in: gestureRecognizerView).x + let width = gestureRecognizerView.bounds.size.width + let percent = translation / width + + switch gestureRecognizer.state { + case .possible: + break + + case .began: + delegate.interactionController = UIPercentDrivenInteractiveTransition() + popViewController(animated: true) + delegate.interactionController?.update(percent) + + case .changed: + delegate.interactionController?.update(percent) + + case .ended: + let velocity = gestureRecognizer.velocity(in: gestureRecognizerView).x + + if velocity > 675 || (percent >= 0.2 && velocity > -200) { + let resistance: Double = 800 + let maxSpeed: Double = 2.25 + let nominalSpeed = max(0.99, velocity / resistance) + let speed = min(nominalSpeed, maxSpeed) + + delegate.interactionController?.completionSpeed = speed + delegate.interactionController?.finish() + } else { + delegate.interactionController?.cancel() + } + + delegate.interactionController = nil + + case .failed, .cancelled: + delegate.interactionController?.cancel() + delegate.interactionController = nil + + @unknown default: + break + } + } +} diff --git a/Sources/NavigationTransitions/RuntimeWarnings.swift b/Sources/NavigationTransitions/RuntimeWarnings.swift new file mode 100644 index 00000000..7c5ee1ad --- /dev/null +++ b/Sources/NavigationTransitions/RuntimeWarnings.swift @@ -0,0 +1,59 @@ +@_transparent +@usableFromInline +@inline(__always) +func runtimeWarn( + _ message: @autoclosure () -> String, + category: String? = "NavigationTransitions" +) { + #if DEBUG + let message = message() + let category = category ?? "Runtime Warning" + #if canImport(os) + os_log( + .fault, + dso: dso, + log: OSLog(subsystem: "com.apple.runtime-issues", category: category), + "%@", + message + ) + #else + fputs("\(formatter.string(from: Date())) [\(category)] \(message)\n", stderr) + #endif + #endif +} + +#if DEBUG + #if canImport(os) + import os + + // NB: Xcode runtime warnings offer a much better experience than traditional assertions and + // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. + // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. + // + // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc + @usableFromInline + let dso = { () -> UnsafeMutableRawPointer in + let count = _dyld_image_count() + for i in 0..