├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .spi.yml ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Example.xcscheme └── Example │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── Landscape1.imageset │ │ ├── Contents.json │ │ └── Landscape1.jpg │ ├── Landscape2.imageset │ │ ├── Contents.json │ │ └── Landscape2.jpg │ └── Landscape3.imageset │ │ ├── Contents.json │ │ └── Landscape3.jpg │ ├── ContentView.swift │ ├── DynamicIslandTransition.swift │ ├── ExampleApp.swift │ ├── Info.plist │ └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── LICENSE.md ├── Logo.png ├── Package.swift ├── README.md └── Sources └── Transmission ├── Sources ├── DestinationCoordinator.swift ├── DestinationLink.swift ├── DestinationLinkAdapter.swift ├── DestinationLinkAsymmetricTransition.swift ├── DestinationLinkModifier.swift ├── DestinationLinkTransition.swift ├── DestinationLinkTransitionRepresentable.swift ├── DestinationSourceViewLink.swift ├── DismissPresentationLink.swift ├── DynamicProperty │ └── WeakState.swift ├── Extensions │ ├── Animation+Extensions.swift │ ├── CGAffineTransform+Extensions.swift │ ├── CGPath+Extensions.swift │ ├── CGRect+Extensions.swift │ ├── UIColor+Extensions.swift │ ├── UIGestureRecognizer+Extensions.swift │ ├── UIKit+Extensions.swift │ ├── UINavigationController+Extensions.swift │ ├── UIScreen+Extensions.swift │ ├── UISheetPresentationController+Extensions.swift │ ├── UISplitViewController+Extensions.swift │ ├── UIView+Extensions.swift │ ├── UIViewController+Extensions.swift │ └── UIWindow+Extensions.swift ├── Hosting │ ├── AnyHostingView.swift │ ├── DestinationHostingController.swift │ ├── PresentationHostingController.swift │ ├── PresentationHostingWindow.swift │ └── SnapshotRenderer.swift ├── PresentationCoordinator.swift ├── PresentationLink.swift ├── PresentationLinkAdapter.swift ├── PresentationLinkAsymmetricTransition.swift ├── PresentationLinkModifier.swift ├── PresentationLinkTransition.swift ├── PresentationLinkTransitionRepresentable.swift ├── PresentationSourceViewLink.swift ├── QuickLookPreviewLink.swift ├── ShareSheetLink.swift ├── StatusBarModifier.swift ├── Supporting Files │ ├── ObjCBox.swift │ ├── PortalView.swift │ ├── PresentationDelegate.swift │ ├── TransitionSourceView.swift │ └── WindowReader.swift ├── TransitionReader+AppearanceTransition .swift ├── TransitionReader.swift ├── Transitions │ ├── CornerRadiusOptions.swift │ ├── DestinationLink Transitions │ │ ├── MatchedGeometryDestinationLinkTransition.swift │ │ └── SlideDestinationLinkTransition.swift │ ├── Presentation Controllers │ │ ├── CardPresentationController.swift │ │ ├── InteractivePresentationController.swift │ │ ├── MatchedGeometryPresentationController.swift │ │ ├── PopoverPresentationController.swift │ │ ├── PresentationController.swift │ │ ├── SheetPresentationController.swift │ │ ├── SlidePresentationController.swift │ │ └── ToastPresentationController.swift │ ├── PresentationLink Transitions │ │ ├── CardPresentationLinkTransition.swift │ │ ├── MatchedGeometryPresentationLinkTransition.swift │ │ ├── SlidePresentationLinkTransition.swift │ │ └── ToastPresentationLinkTransition.swift │ ├── ShadowOptions.swift │ └── View Controller Transitions │ │ ├── MatchedGeometryViewControllerTransition.swift │ │ ├── PresentationControllerTransition.swift │ │ └── ViewControllerTransition.swift ├── ViewControllerReader.swift ├── ViewControllerRepresentableAdapter.swift ├── ViewRepresentableAdapter.swift ├── WindowLink.swift ├── WindowLinkModifier.swift └── WindowLinkTransition.swift └── module.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Xcode version 18 | run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer 19 | - name: Show available destinations 20 | run: xcodebuild -scheme Transmission -showdestinations 21 | - name: Build for Catalyst 22 | run: xcodebuild -scheme Transmission -destination 'platform=macOS,variant=Mac Catalyst' build 23 | - name: Build for iOS 24 | run: xcodebuild -scheme Transmission -destination 'platform=iOS Simulator,name=iPhone 16' build 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | /.swiftpm 8 | .netrc 9 | Package.resolved -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Transmission] 5 | platform: ios 6 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Landscape1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Landscape1.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Landscape1.imageset/Landscape1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/Transmission/8a10e5a34f2ee72b13cf906cecd108ac7444d8f6/Example/Example/Assets.xcassets/Landscape1.imageset/Landscape1.jpg -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Landscape2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Landscape2.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Landscape2.imageset/Landscape2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/Transmission/8a10e5a34f2ee72b13cf906cecd108ac7444d8f6/Example/Example/Assets.xcassets/Landscape2.imageset/Landscape2.jpg -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Landscape3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Landscape3.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Landscape3.imageset/Landscape3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/Transmission/8a10e5a34f2ee72b13cf906cecd108ac7444d8f6/Example/Example/Assets.xcassets/Landscape3.imageset/Landscape3.jpg -------------------------------------------------------------------------------- /Example/Example/DynamicIslandTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DynamicIslandTransition.swift 3 | // Example 4 | // 5 | // Created by Nathan Tannar on 2024-05-21. 6 | // 7 | 8 | import UIKit 9 | import Transmission 10 | 11 | extension PresentationLinkTransition { 12 | static let dynamicIsland: PresentationLinkTransition = .custom( 13 | options: Options( 14 | modalPresentationCapturesStatusBarAppearance: true, 15 | preferredPresentationBackgroundColor: .black 16 | ), 17 | DynamicIslandTransition() 18 | ) 19 | } 20 | 21 | struct DynamicIslandTransition: PresentationLinkTransitionRepresentable { 22 | 23 | func makeUIPresentationController( 24 | presented: UIViewController, 25 | presenting: UIViewController?, 26 | context: Context 27 | ) -> DynamicIslandPresentationController { 28 | DynamicIslandPresentationController( 29 | presentedViewController: presented, 30 | presenting: presenting 31 | ) 32 | } 33 | 34 | func updateUIPresentationController( 35 | presentationController: DynamicIslandPresentationController, 36 | context: Context 37 | ) { 38 | 39 | } 40 | 41 | func animationController( 42 | forPresented presented: UIViewController, 43 | presenting: UIViewController, 44 | context: Context 45 | ) -> DynamicIslandPresentationControllerTransition? { 46 | DynamicIslandPresentationControllerTransition( 47 | isPresenting: true, 48 | animation: context.transaction.animation 49 | ) 50 | } 51 | 52 | func animationController( 53 | forDismissed dismissed: UIViewController, 54 | context: Context 55 | ) -> DynamicIslandPresentationControllerTransition? { 56 | let transition = DynamicIslandPresentationControllerTransition( 57 | isPresenting: false, 58 | animation: context.transaction.animation 59 | ) 60 | transition.wantsInteractiveStart = false 61 | return transition 62 | } 63 | } 64 | 65 | class DynamicIslandPresentationController: InteractivePresentationController { 66 | 67 | let dynamicIslandTopInset: CGFloat = 11 68 | 69 | override var frameOfPresentedViewInContainerView: CGRect { 70 | guard let presentedView else { return .zero } 71 | var frame = super.frameOfPresentedViewInContainerView 72 | frame = frame.inset( 73 | by: UIEdgeInsets( 74 | top: dynamicIslandTopInset, 75 | left: dynamicIslandTopInset, 76 | bottom: 0, 77 | right: dynamicIslandTopInset 78 | ) 79 | ) 80 | let fittingSize = CGSize( 81 | width: frame.size.width, 82 | height: UIView.layoutFittingCompressedSize.height 83 | ) 84 | let targetHeight = presentedView.systemLayoutSizeFitting( 85 | fittingSize, 86 | withHorizontalFittingPriority: .required, 87 | verticalFittingPriority: .defaultLow 88 | ).height 89 | frame.size.height = max(targetHeight - presentedView.safeAreaInsets.top, 0) 90 | return frame 91 | } 92 | 93 | override init( 94 | presentedViewController: UIViewController, 95 | presenting presentingViewController: UIViewController? 96 | ) { 97 | super.init( 98 | presentedViewController: presentedViewController, 99 | presenting: presentingViewController 100 | ) 101 | edges = [.top] 102 | } 103 | 104 | override func presentedViewTransform(for translation: CGPoint) -> CGAffineTransform { 105 | let dy = frictionCurve(translation.y, coefficient: 0.1) 106 | let frame = frameOfPresentedViewInContainerView 107 | let scale = 1 + (dy / 600) 108 | return CGAffineTransform( 109 | scaleX: scale, 110 | y: scale 111 | ) 112 | .translatedBy( 113 | x: (1 - scale) * 0.5 * frame.size.width, 114 | y: 0 115 | ) 116 | } 117 | 118 | override func presentationTransitionWillBegin() { 119 | super.presentationTransitionWillBegin() 120 | 121 | presentedView?.layer.cornerCurve = .continuous 122 | presentedView?.layer.cornerRadius = max(UIScreen.main._displayCornerRadius - 11, 0) 123 | } 124 | 125 | override func layoutPresentedView(frame: CGRect) { 126 | super.layoutPresentedView(frame: frame) 127 | 128 | presentedView?.layer.cornerRadius = min(frame.size.height / 2, max(UIScreen.main._displayCornerRadius - 11, 0)) 129 | } 130 | } 131 | 132 | class DynamicIslandPresentationControllerTransition: PresentationControllerTransition { 133 | 134 | override func configureTransitionAnimator( 135 | using transitionContext: UIViewControllerContextTransitioning, 136 | animator: UIViewPropertyAnimator 137 | ) { 138 | 139 | guard 140 | let presented = transitionContext.viewController(forKey: isPresenting ? .to : .from) 141 | else { 142 | transitionContext.completeTransition(false) 143 | return 144 | } 145 | 146 | let dynamicIslandFrame = CGRect(x: 135, y: 11, width: 123, height: 36) 147 | let hostingController = presented as? AnyHostingController 148 | let oldValue = hostingController?.disableSafeArea ?? false 149 | hostingController?.disableSafeArea = true 150 | let cornerRadius = presented.view.layer.cornerRadius 151 | 152 | if isPresenting { 153 | let finalFrame = transitionContext.finalFrame(for: presented) 154 | transitionContext.containerView.addSubview(presented.view) 155 | presented.view.frame = dynamicIslandFrame 156 | presented.view.layer.cornerRadius = dynamicIslandFrame.height / 2 157 | presented.view.clipsToBounds = true 158 | presented.view.layoutIfNeeded() 159 | hostingController?.render() 160 | animator.addAnimations { 161 | presented.view.frame = finalFrame 162 | presented.view.layer.cornerRadius = min(finalFrame.size.height / 2, cornerRadius) 163 | hostingController?.disableSafeArea = oldValue 164 | presented.view.layoutIfNeeded() 165 | } 166 | } else { 167 | let initialFrame = transitionContext.initialFrame(for: presented) 168 | presented.view.frame = initialFrame 169 | presented.view.clipsToBounds = true 170 | animator.addAnimations { 171 | presented.view.frame = dynamicIslandFrame 172 | presented.view.layer.cornerRadius = dynamicIslandFrame.height / 2 173 | presented.view.layoutIfNeeded() 174 | } 175 | } 176 | animator.addCompletion { animatingPosition in 177 | hostingController?.disableSafeArea = oldValue 178 | presented.view.layoutIfNeeded() 179 | switch animatingPosition { 180 | case .end: 181 | transitionContext.completeTransition(true) 182 | default: 183 | transitionContext.completeTransition(false) 184 | } 185 | } 186 | } 187 | } 188 | 189 | extension UISpringTimingParameters { 190 | convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) { 191 | let stiffness = pow(2 * .pi / response, 2) 192 | let damp = 4 * .pi * damping / response 193 | self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Example/Example/ExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleApp.swift 3 | // Example 4 | // 5 | // Created by Nathan Tannar on 2022-12-06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | NavigationView { 15 | ContentView() 16 | } 17 | .navigationViewStyle(.stack) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIViewControllerBasedStatusBarAppearance 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (C) 2022, Nathan Tannar 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in 13 | the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 17 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 18 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 19 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/Transmission/8a10e5a34f2ee72b13cf906cecd108ac7444d8f6/Logo.png -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Transmission", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .macCatalyst(.v13), 11 | .visionOS(.v1), 12 | ], 13 | products: [ 14 | .library( 15 | name: "Transmission", 16 | targets: ["Transmission"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/nathantannar4/Engine", from: "2.1.9"), 21 | ], 22 | targets: [ 23 | .target( 24 | name: "Transmission", 25 | dependencies: [ 26 | "Engine", 27 | ] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DestinationCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A coordinator that can be used to programatically dismiss a view. 11 | /// 12 | /// See Also: 13 | /// - ``DestinationLink`` 14 | /// 15 | @available(iOS 14.0, *) 16 | @frozen 17 | public struct DestinationCoordinator { 18 | public var isPresented: Bool 19 | 20 | @usableFromInline 21 | var dismissBlock: (Int, Transaction) -> Void 22 | 23 | /// Dismisses all presented views with an optional animation 24 | @inlinable 25 | public func popToRoot(animation: Animation? = .default) { 26 | pop(count: .max, animation: animation) 27 | } 28 | 29 | /// Dismisses all presented views with the transaction 30 | @inlinable 31 | public func popToRoot(transaction: Transaction) { 32 | pop(count: .max, transaction: transaction) 33 | } 34 | 35 | /// Dismisses the presented view with an optional animation 36 | @inlinable 37 | public func pop(animation: Animation? = .default) { 38 | pop(count: 1, animation: animation) 39 | } 40 | 41 | /// Dismisses the presented view with the transaction 42 | @inlinable 43 | public func pop(transaction: Transaction) { 44 | pop(count: 1, transaction: transaction) 45 | } 46 | 47 | /// Dismisses the presented view with an optional animation 48 | @inlinable 49 | public func pop(count: Int, animation: Animation? = .default) { 50 | pop(count: count, transaction: Transaction(animation: animation)) 51 | } 52 | 53 | /// Dismisses the presented view with the transaction 54 | @inlinable 55 | public func pop(count: Int, transaction: Transaction) { 56 | dismissBlock(count, transaction) 57 | } 58 | } 59 | 60 | @available(iOS 14.0, *) 61 | enum DestinationCoordinatorKey: EnvironmentKey { 62 | static let defaultValue: DestinationCoordinator? = nil 63 | } 64 | 65 | @available(iOS 14.0, *) 66 | extension EnvironmentValues { 67 | 68 | /// A coordinator that can be used to programatically dismiss a view 69 | public var destinationCoordinator: DestinationCoordinator { 70 | get { 71 | if let coordinator = self[DestinationCoordinatorKey.self] { 72 | return coordinator 73 | } 74 | return DestinationCoordinator( 75 | isPresented: false, 76 | dismissBlock: { _, _ in } 77 | ) 78 | } 79 | set { self[DestinationCoordinatorKey.self] = newValue } 80 | } 81 | } 82 | 83 | @available(iOS 14.0, *) 84 | struct DestinationBridgeAdapter: ViewModifier { 85 | var destinationCoordinator: DestinationCoordinator 86 | @State var didAppear = false 87 | 88 | func body(content: Content) -> some View { 89 | content 90 | .modifier(_ViewInputsBridgeModifier()) 91 | .environment(\.destinationCoordinator, destinationCoordinator) 92 | .onAppear { 93 | // Need to trigger a render update during presentation to fix DatePicker 94 | withCATransaction { 95 | didAppear = true 96 | } 97 | } 98 | } 99 | } 100 | 101 | #endif 102 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DestinationLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A button that pushes a destination view in a new `UIViewController`. 11 | /// 12 | /// The destination view is presented with the provided `transition`. 13 | /// By default, the ``DestinationLinkTransition/default`` transition is used. 14 | /// 15 | /// See Also: 16 | /// - ``DestinationLinkModifier`` 17 | /// - ``DestinationLinkTransition`` 18 | /// - ``DestinationSourceViewLink`` 19 | /// - ``TransitionReader`` 20 | /// 21 | /// > Tip: You can implement custom transitions with the ``DestinationLinkTransition/custom(_:)`` 22 | /// transition. 23 | /// 24 | @available(iOS 14.0, *) 25 | @frozen 26 | public struct DestinationLink< 27 | Label: View, 28 | Destination: View 29 | >: View { 30 | var label: Label 31 | var destination: Destination 32 | var transition: DestinationLinkTransition 33 | var animation: Animation? 34 | @StateOrBinding var isPresented: Bool 35 | 36 | public init( 37 | transition: DestinationLinkTransition = .default, 38 | animation: Animation? = .default, 39 | @ViewBuilder destination: () -> Destination, 40 | @ViewBuilder label: () -> Label 41 | ) { 42 | self.label = label() 43 | self.destination = destination() 44 | self.transition = transition 45 | self.animation = animation 46 | self._isPresented = .init(false) 47 | } 48 | 49 | public init( 50 | transition: DestinationLinkTransition = .default, 51 | animation: Animation? = .default, 52 | isPresented: Binding, 53 | @ViewBuilder destination: () -> Destination, 54 | @ViewBuilder label: () -> Label 55 | ) { 56 | self.label = label() 57 | self.destination = destination() 58 | self.transition = transition 59 | self.animation = animation 60 | self._isPresented = .init(isPresented) 61 | } 62 | 63 | public var body: some View { 64 | Button { 65 | withAnimation(animation) { 66 | isPresented.toggle() 67 | } 68 | } label: { 69 | label 70 | } 71 | .modifier( 72 | DestinationLinkModifier( 73 | transition: transition, 74 | isPresented: $isPresented, 75 | destination: destination 76 | ) 77 | ) 78 | } 79 | } 80 | 81 | @available(iOS 14.0, *) 82 | extension DestinationLink { 83 | @_disfavoredOverload 84 | public init( 85 | transition: DestinationLinkTransition = .default, 86 | destination: @escaping () -> ViewController, 87 | @ViewBuilder label: () -> Label 88 | ) where Destination == ViewControllerRepresentableAdapter { 89 | self.init(transition: transition) { 90 | ViewControllerRepresentableAdapter(destination) 91 | } label: { 92 | label() 93 | } 94 | } 95 | 96 | public init( 97 | transition: DestinationLinkTransition = .default, 98 | destination: @escaping (Destination.Context) -> ViewController, 99 | @ViewBuilder label: () -> Label 100 | ) where Destination == ViewControllerRepresentableAdapter { 101 | self.init(transition: transition) { 102 | ViewControllerRepresentableAdapter(destination) 103 | } label: { 104 | label() 105 | } 106 | } 107 | 108 | @_disfavoredOverload 109 | public init( 110 | transition: DestinationLinkTransition = .default, 111 | isPresented: Binding, 112 | destination: @escaping () -> ViewController, 113 | @ViewBuilder label: () -> Label 114 | ) where Destination == ViewControllerRepresentableAdapter { 115 | self.init(transition: transition, isPresented: isPresented) { 116 | ViewControllerRepresentableAdapter(destination) 117 | } label: { 118 | label() 119 | } 120 | } 121 | 122 | public init( 123 | transition: DestinationLinkTransition = .default, 124 | isPresented: Binding, 125 | destination: @escaping (Destination.Context) -> ViewController, 126 | @ViewBuilder label: () -> Label 127 | ) where Destination == ViewControllerRepresentableAdapter { 128 | self.init(transition: transition, isPresented: isPresented) { 129 | ViewControllerRepresentableAdapter(destination) 130 | } label: { 131 | label() 132 | } 133 | } 134 | } 135 | 136 | #endif 137 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DestinationLinkAsymmetricTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | @available(iOS 14.0, *) 10 | extension DestinationLinkTransition { 11 | 12 | public static func asymmetric< 13 | PushAnimationController: DestinationLinkPushTransitionRepresentable, 14 | PopAnimationController: DestinationLinkPopTransitionRepresentable 15 | >( 16 | push pushAnimationController: PushAnimationController, 17 | pop popAnimationController: PopAnimationController, 18 | options: DestinationLinkTransition.Options = .init() 19 | ) -> DestinationLinkTransition { 20 | .custom( 21 | options: options, 22 | DestinationLinkTransitionAsymmetricTransition( 23 | push: pushAnimationController, 24 | pop: popAnimationController 25 | ) 26 | ) 27 | } 28 | } 29 | 30 | @frozen 31 | @available(iOS 14.0, *) 32 | public struct DestinationLinkTransitionAsymmetricTransition< 33 | PushAnimationController: DestinationLinkPushTransitionRepresentable, 34 | PopAnimationController: DestinationLinkPopTransitionRepresentable 35 | >: DestinationLinkTransitionRepresentable { 36 | 37 | public typealias UIPushAnimationControllerType = PushAnimationController.UIPushAnimationControllerType 38 | public typealias UIPushInteractionControllerType = PushAnimationController.UIPushInteractionControllerType 39 | public typealias UIPopAnimationControllerType = PopAnimationController.UIPopAnimationControllerType 40 | public typealias UIPopInteractionControllerType = PopAnimationController.UIPopInteractionControllerType 41 | 42 | public var pushAnimationController: PushAnimationController 43 | public var popAnimationController: PopAnimationController 44 | 45 | public init( 46 | push pushAnimationController: PushAnimationController, 47 | pop popAnimationController: PopAnimationController 48 | ) { 49 | self.pushAnimationController = pushAnimationController 50 | self.popAnimationController = popAnimationController 51 | } 52 | 53 | public func navigationController( 54 | _ navigationController: UINavigationController, 55 | pushing toVC: UIViewController, 56 | from fromVC: UIViewController, 57 | context: Context 58 | ) -> UIPushAnimationControllerType? { 59 | pushAnimationController.navigationController( 60 | navigationController, 61 | pushing: toVC, 62 | from: fromVC, 63 | context: context 64 | ) 65 | } 66 | 67 | public func navigationController( 68 | _ navigationController: UINavigationController, 69 | interactionControllerForPush animationController: any UIViewControllerAnimatedTransitioning, 70 | context: Context 71 | ) -> UIPushInteractionControllerType? { 72 | pushAnimationController.navigationController( 73 | navigationController, 74 | interactionControllerForPush: animationController, 75 | context: context 76 | ) 77 | } 78 | 79 | public func navigationController( 80 | _ navigationController: UINavigationController, 81 | popping fromVC: UIViewController, 82 | to toVC: UIViewController, 83 | context: Context 84 | ) -> UIPopAnimationControllerType? { 85 | if UIPushAnimationControllerType.self == MatchedGeometryPresentationControllerTransition.self, 86 | UIPopAnimationControllerType.self != MatchedGeometryPresentationControllerTransition.self 87 | { 88 | context.sourceView?.alpha = 1 89 | } 90 | return popAnimationController.navigationController( 91 | navigationController, 92 | popping: fromVC, 93 | to: toVC, 94 | context: context 95 | ) 96 | } 97 | 98 | public func navigationController( 99 | _ navigationController: UINavigationController, 100 | interactionControllerForPop animationController: any UIViewControllerAnimatedTransitioning, 101 | context: Context 102 | ) -> UIPopInteractionControllerType? { 103 | popAnimationController.navigationController( 104 | navigationController, 105 | interactionControllerForPop: animationController, 106 | context: context 107 | ) 108 | } 109 | } 110 | 111 | #endif 112 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DestinationLinkModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | import EngineCore 10 | 11 | /// A modifier that pushes a destination view in a new `UIViewController`. 12 | /// 13 | /// To present the destination view with an animation, `isPresented` should 14 | /// be updated with a transaction that has an animation. For example: 15 | /// 16 | /// ``` 17 | /// withAnimation { 18 | /// isPresented = true 19 | /// } 20 | /// ``` 21 | /// 22 | /// See Also: 23 | /// - ``DestinationLink`` 24 | /// - ``DestinationLinkTransition`` 25 | /// - ``DestinationLinkAdapter`` 26 | /// - ``TransitionReader`` 27 | /// 28 | /// > Tip: You can implement custom transitions with the ``DestinationLinkTransition/custom(_:)`` 29 | /// transition. 30 | /// 31 | @available(iOS 14.0, *) 32 | @frozen 33 | public struct DestinationLinkModifier< 34 | Destination: View 35 | >: ViewModifier { 36 | 37 | var isPresented: Binding 38 | var destination: Destination 39 | var transition: DestinationLinkTransition 40 | 41 | public init( 42 | transition: DestinationLinkTransition = .default, 43 | isPresented: Binding, 44 | destination: Destination 45 | ) { 46 | self.isPresented = isPresented 47 | self.destination = destination 48 | self.transition = transition 49 | } 50 | 51 | public func body(content: Content) -> some View { 52 | content.background( 53 | DestinationLinkAdapter( 54 | transition: transition, 55 | isPresented: isPresented 56 | ) { 57 | destination 58 | } 59 | ) 60 | } 61 | } 62 | 63 | @available(iOS 14.0, *) 64 | extension View { 65 | /// A modifier that pushes a destination view in a new `UIViewController`. 66 | /// 67 | /// To present the destination view with an animation, `isPresented` should 68 | /// be updated with a transaction that has an animation. For example: 69 | /// 70 | /// ``` 71 | /// withAnimation { 72 | /// isPresented = true 73 | /// } 74 | /// ``` 75 | /// 76 | public func destination( 77 | transition: DestinationLinkTransition = .default, 78 | isPresented: Binding, 79 | @ViewBuilder destination: () -> Destination 80 | ) -> some View { 81 | modifier( 82 | DestinationLinkModifier( 83 | transition: transition, 84 | isPresented: isPresented, 85 | destination: destination() 86 | ) 87 | ) 88 | } 89 | 90 | /// A modifier that pushes a destination view in a new `UIViewController`. 91 | /// 92 | /// To present the destination view with an animation, `isPresented` should 93 | /// be updated with a transaction that has an animation. For example: 94 | /// 95 | /// ``` 96 | /// withAnimation { 97 | /// isPresented = true 98 | /// } 99 | /// ``` 100 | /// 101 | public func destination( 102 | _ value: Binding, 103 | transition: DestinationLinkTransition = .default, 104 | @ViewBuilder destination: (Binding) -> Destination 105 | ) -> some View { 106 | self.destination(transition: transition, isPresented: value.isNotNil()) { 107 | OptionalAdapter(value, content: destination) 108 | } 109 | } 110 | 111 | /// A modifier that pushes a destination `UIViewController`. 112 | /// 113 | /// To present the destination view with an animation, `isPresented` should 114 | /// be updated with a transaction that has an animation. For example: 115 | /// 116 | /// ``` 117 | /// withAnimation { 118 | /// isPresented = true 119 | /// } 120 | /// ``` 121 | /// 122 | @_disfavoredOverload 123 | public func destination( 124 | transition: DestinationLinkTransition = .default, 125 | isPresented: Binding, 126 | destination: @escaping (ViewControllerRepresentableAdapter.Context) -> ViewController 127 | ) -> some View { 128 | self.destination(transition: transition, isPresented: isPresented) { 129 | ViewControllerRepresentableAdapter(destination) 130 | } 131 | } 132 | 133 | /// A modifier that pushes a destination `UIViewController`. 134 | /// 135 | /// To present the destination view with an animation, `isPresented` should 136 | /// be updated with a transaction that has an animation. For example: 137 | /// 138 | /// ``` 139 | /// withAnimation { 140 | /// isPresented = true 141 | /// } 142 | /// ``` 143 | /// 144 | @_disfavoredOverload 145 | public func destination( 146 | _ value: Binding, 147 | transition: DestinationLinkTransition = .default, 148 | destination: @escaping (Binding, ViewControllerRepresentableAdapter.Context) -> UIViewController 149 | ) -> some View { 150 | self.destination(transition: transition, isPresented: value.isNotNil()) { 151 | ViewControllerRepresentableAdapter { context in 152 | guard let value = value.unwrap() else { return UIViewController() } 153 | return destination(value, context) 154 | } 155 | } 156 | } 157 | } 158 | 159 | #endif 160 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DestinationLinkTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// The transition and presentation style for a ``DestinationLink`` or ``DestinationLinkModifier``. 11 | @available(iOS 14.0, *) 12 | public struct DestinationLinkTransition: Sendable { 13 | enum Value: @unchecked Sendable { 14 | case `default`(Options) 15 | case zoom(Options) 16 | case representable(Options, any DestinationLinkTransitionRepresentable) 17 | 18 | var options: Options { 19 | switch self { 20 | case .default(let options), .zoom(let options), .representable(let options, _): 21 | return options 22 | } 23 | } 24 | } 25 | var value: Value 26 | 27 | /// The default presentation style of the `UINavigationController`. 28 | public static let `default`: DestinationLinkTransition = DestinationLinkTransition(value: .default(.init())) 29 | 30 | /// The zoom presentation style. 31 | @available(iOS 18.0, *) 32 | public static let zoom = DestinationLinkTransition(value: .zoom(.init())) 33 | 34 | /// The zoom presentation style if available, otherwise the default transition style. 35 | public static var zoomIfAvailable: DestinationLinkTransition { 36 | if #available(iOS 18.0, *) { 37 | return .zoom 38 | } 39 | return .default 40 | } 41 | 42 | /// A custom presentation style. 43 | public static func custom(_ transition: T) -> DestinationLinkTransition { 44 | DestinationLinkTransition(value: .representable(.init(), transition)) 45 | } 46 | } 47 | 48 | @available(iOS 14.0, *) 49 | extension DestinationLinkTransition { 50 | /// The transition options. 51 | @frozen 52 | public struct Options { 53 | /// Used when the presentation delegate asks if it should dismiss 54 | public var isInteractive: Bool 55 | /// When `true`, the destination will be dismissed when the presentation source is dismantled 56 | public var shouldAutomaticallyDismissDestination: Bool 57 | public var preferredPresentationBackgroundColor: Color? 58 | public var hidesBottomBarWhenPushed: Bool 59 | 60 | public init( 61 | isInteractive: Bool = true, 62 | shouldAutomaticallyDismissDestination: Bool = true, 63 | preferredPresentationBackgroundColor: Color? = nil, 64 | hidesBottomBarWhenPushed: Bool = false 65 | ) { 66 | self.isInteractive = isInteractive 67 | self.shouldAutomaticallyDismissDestination = shouldAutomaticallyDismissDestination 68 | self.preferredPresentationBackgroundColor = preferredPresentationBackgroundColor 69 | self.hidesBottomBarWhenPushed = hidesBottomBarWhenPushed 70 | } 71 | 72 | var preferredPresentationBackgroundUIColor: UIColor? { 73 | preferredPresentationBackgroundColor?.toUIColor() 74 | } 75 | } 76 | } 77 | 78 | @available(iOS 14.0, *) 79 | extension DestinationLinkTransition { 80 | /// The default presentation style of the `UINavigationController`. 81 | public static func `default`( 82 | options: DestinationLinkTransition.Options 83 | ) -> DestinationLinkTransition { 84 | DestinationLinkTransition(value: .default(options)) 85 | } 86 | 87 | /// The zoom presentation style. 88 | @available(iOS 18.0, *) 89 | public static func zoom( 90 | options: Options 91 | ) -> DestinationLinkTransition { 92 | DestinationLinkTransition(value: .zoom(options)) 93 | } 94 | 95 | /// The zoom presentation style if available, otherwise a fallback transition style. 96 | public static func zoomIfAvailable( 97 | options: Options, 98 | otherwise fallback: DestinationLinkTransition = .default 99 | ) -> DestinationLinkTransition { 100 | if #available(iOS 18.0, *) { 101 | return .zoom(options: options) 102 | } 103 | return fallback 104 | } 105 | 106 | /// A custom presentation style. 107 | public static func custom( 108 | options: DestinationLinkTransition.Options, 109 | _ transition: T 110 | ) -> DestinationLinkTransition { 111 | DestinationLinkTransition(value: .representable(options, transition)) 112 | } 113 | } 114 | 115 | #endif 116 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DestinationLinkTransitionRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import EngineCore 9 | 10 | /// The context for a ``DestinationLinkTransitionRepresentableContext`` 11 | @available(iOS 14.0, *) 12 | @frozen 13 | public struct DestinationLinkTransitionRepresentableContext { 14 | public weak var sourceView: UIView? 15 | public var options: DestinationLinkTransition.Options 16 | public var environment: EnvironmentValues 17 | public var transaction: Transaction 18 | } 19 | 20 | /// A protocol that defines a custom transition for a ``DestinationLinkTransition`` 21 | @available(iOS 14.0, *) 22 | @MainActor @preconcurrency 23 | public protocol DestinationLinkTransitionRepresentable: DestinationLinkPushTransitionRepresentable, DestinationLinkPopTransitionRepresentable { 24 | 25 | /// Updates the presented hosting controller 26 | @MainActor @preconcurrency func updateHostingController( 27 | presenting: HostingController, 28 | context: Context 29 | ) 30 | } 31 | 32 | @available(iOS 14.0, *) 33 | extension DestinationLinkTransitionRepresentable { 34 | 35 | public func updateHostingController( 36 | presenting: HostingController, 37 | context: Context 38 | ) { } 39 | } 40 | 41 | /// A protocol that defines a custom push transition for a ``DestinationLinkTransition`` 42 | @available(iOS 14.0, *) 43 | @MainActor @preconcurrency 44 | public protocol DestinationLinkPushTransitionRepresentable: Sendable { 45 | 46 | typealias Context = DestinationLinkTransitionRepresentableContext 47 | associatedtype UIPushAnimationControllerType: UIViewControllerAnimatedTransitioning 48 | associatedtype UIPushInteractionControllerType: UIViewControllerInteractiveTransitioning 49 | 50 | /// The interaction controller to use for the transition presentation. 51 | /// 52 | /// > Note: This protocol implementation is optional and defaults to `nil` 53 | /// 54 | @MainActor @preconcurrency func navigationController( 55 | _ navigationController: UINavigationController, 56 | interactionControllerForPush animationController: UIViewControllerAnimatedTransitioning, 57 | context: Context 58 | ) -> UIPushInteractionControllerType? 59 | 60 | /// The animation controller to use for the transition presentation. 61 | @MainActor @preconcurrency func navigationController( 62 | _ navigationController: UINavigationController, 63 | pushing toVC: UIViewController, 64 | from fromVC: UIViewController, 65 | context: Context 66 | ) -> UIPushAnimationControllerType? 67 | } 68 | 69 | @available(iOS 14.0, *) 70 | extension DestinationLinkPushTransitionRepresentable { 71 | 72 | public func navigationController( 73 | _ navigationController: UINavigationController, 74 | interactionControllerForPush animationController: UIViewControllerAnimatedTransitioning, 75 | context: Context 76 | ) -> UIViewControllerInteractiveTransitioning? { 77 | return nil 78 | } 79 | } 80 | 81 | @frozen 82 | @available(iOS 14.0, *) 83 | public struct DestinationLinkDefaultPushTransition: DestinationLinkPushTransitionRepresentable { 84 | 85 | public func navigationController( 86 | _ navigationController: UINavigationController, 87 | pushing toVC: UIViewController, 88 | from fromVC: UIViewController, 89 | context: Context 90 | ) -> UIViewControllerAnimatedTransitioning? { 91 | return nil 92 | } 93 | } 94 | 95 | @available(iOS 14.0, *) 96 | extension DestinationLinkPushTransitionRepresentable where Self == DestinationLinkDefaultPushTransition { 97 | public static var `default`: DestinationLinkDefaultPushTransition { .init() } 98 | } 99 | 100 | /// A protocol that defines a custom pop transition for a ``DestinationLinkTransition`` 101 | @available(iOS 14.0, *) 102 | @MainActor @preconcurrency 103 | public protocol DestinationLinkPopTransitionRepresentable: Sendable { 104 | 105 | typealias Context = DestinationLinkTransitionRepresentableContext 106 | associatedtype UIPopAnimationControllerType: UIViewControllerAnimatedTransitioning 107 | associatedtype UIPopInteractionControllerType: UIViewControllerInteractiveTransitioning 108 | 109 | /// The interaction controller to use for the transition presentation. 110 | /// 111 | /// > Note: This protocol implementation is optional and defaults to `nil` 112 | /// 113 | @MainActor @preconcurrency func navigationController( 114 | _ navigationController: UINavigationController, 115 | interactionControllerForPop animationController: UIViewControllerAnimatedTransitioning, 116 | context: Context 117 | ) -> UIPopInteractionControllerType? 118 | 119 | /// The animation controller to use for the transition presentation. 120 | @MainActor @preconcurrency func navigationController( 121 | _ navigationController: UINavigationController, 122 | popping fromVC: UIViewController, 123 | to toVC: UIViewController, 124 | context: Context 125 | ) -> UIPopAnimationControllerType? 126 | } 127 | 128 | @available(iOS 14.0, *) 129 | extension DestinationLinkPopTransitionRepresentable { 130 | 131 | public func navigationController( 132 | _ navigationController: UINavigationController, 133 | interactionControllerForPop animationController: UIViewControllerAnimatedTransitioning, 134 | context: Context 135 | ) -> UIViewControllerInteractiveTransitioning? { 136 | return animationController as? UIViewControllerInteractiveTransitioning 137 | } 138 | } 139 | 140 | @frozen 141 | @available(iOS 14.0, *) 142 | public struct DestinationLinkDefaultPopTransition: DestinationLinkPopTransitionRepresentable { 143 | 144 | public func navigationController( 145 | _ navigationController: UINavigationController, 146 | popping fromVC: UIViewController, 147 | to toVC: UIViewController, 148 | context: Context 149 | ) -> UIViewControllerAnimatedTransitioning? { 150 | return nil 151 | } 152 | } 153 | 154 | @available(iOS 14.0, *) 155 | extension DestinationLinkPopTransitionRepresentable where Self == DestinationLinkDefaultPopTransition { 156 | public static var `default`: DestinationLinkDefaultPopTransition { .init() } 157 | } 158 | 159 | #endif 160 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DestinationSourceViewLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A button that presents a destination view in a new `UIViewController`. The presentation is 11 | /// sourced from this view. 12 | /// 13 | /// Use ``DestinationLink`` if the transition does not require animating the source view. For 14 | /// example, the `.zoom` transition morphs the source view into the destination view, so using 15 | /// ``DestinationSourceViewLink`` allows the transition to animate the source view. 16 | /// 17 | /// To present the destination view with an animation, `isPresented` should 18 | /// be updated with a transaction that has an animation. For example: 19 | /// 20 | /// ``` 21 | /// withAnimation { 22 | /// isPresented = true 23 | /// } 24 | /// ``` 25 | /// 26 | /// See Also: 27 | /// - ``DestinationLink`` 28 | /// - ``DestinationLinkTransition`` 29 | /// - ``DestinationLinkAdapter`` 30 | /// - ``TransitionReader`` 31 | /// 32 | /// > Tip: You can implement custom transitions with the ``DestinationLinkTransition/custom(_:)`` 33 | /// transition. 34 | /// 35 | @available(iOS 14.0, *) 36 | @frozen 37 | public struct DestinationSourceViewLink< 38 | Label: View, 39 | Destination: View 40 | >: View { 41 | var label: Label 42 | var destination: Destination 43 | var transition: DestinationLinkTransition 44 | var animation: Animation? 45 | 46 | @StateOrBinding var isPresented: Bool 47 | 48 | public init( 49 | transition: DestinationLinkTransition = .zoomIfAvailable, 50 | animation: Animation? = .default, 51 | @ViewBuilder destination: () -> Destination, 52 | @ViewBuilder label: () -> Label 53 | ) { 54 | self.label = label() 55 | self.destination = destination() 56 | self.transition = transition 57 | self.animation = animation 58 | self._isPresented = .init(false) 59 | } 60 | 61 | public init( 62 | transition: DestinationLinkTransition = .zoomIfAvailable, 63 | animation: Animation? = .default, 64 | isPresented: Binding, 65 | @ViewBuilder destination: () -> Destination, 66 | @ViewBuilder label: () -> Label 67 | ) { 68 | self.label = label() 69 | self.destination = destination() 70 | self.transition = transition 71 | self.animation = animation 72 | self._isPresented = .init(isPresented) 73 | } 74 | 75 | public var body: some View { 76 | DestinationLinkAdapter( 77 | transition: transition, 78 | isPresented: $isPresented 79 | ) { 80 | destination 81 | } content: { 82 | Button { 83 | withAnimation(animation) { 84 | isPresented.toggle() 85 | } 86 | } label: { 87 | label 88 | } 89 | } 90 | } 91 | } 92 | 93 | @available(iOS 14.0, *) 94 | extension DestinationSourceViewLink { 95 | @_disfavoredOverload 96 | public init( 97 | transition: DestinationLinkTransition = .default, 98 | destination: @escaping () -> ViewController, 99 | @ViewBuilder label: () -> Label 100 | ) where Destination == ViewControllerRepresentableAdapter { 101 | self.init(transition: transition) { 102 | ViewControllerRepresentableAdapter(destination) 103 | } label: { 104 | label() 105 | } 106 | } 107 | 108 | public init( 109 | transition: DestinationLinkTransition = .default, 110 | destination: @escaping (Destination.Context) -> ViewController, 111 | @ViewBuilder label: () -> Label 112 | ) where Destination == ViewControllerRepresentableAdapter { 113 | self.init(transition: transition) { 114 | ViewControllerRepresentableAdapter(destination) 115 | } label: { 116 | label() 117 | } 118 | } 119 | 120 | @_disfavoredOverload 121 | public init( 122 | transition: DestinationLinkTransition = .default, 123 | isPresented: Binding, 124 | destination: @escaping () -> ViewController, 125 | @ViewBuilder label: () -> Label 126 | ) where Destination == ViewControllerRepresentableAdapter { 127 | self.init(transition: transition, isPresented: isPresented) { 128 | ViewControllerRepresentableAdapter(destination) 129 | } label: { 130 | label() 131 | } 132 | } 133 | 134 | public init( 135 | transition: DestinationLinkTransition = .default, 136 | isPresented: Binding, 137 | destination: @escaping (Destination.Context) -> ViewController, 138 | @ViewBuilder label: () -> Label 139 | ) where Destination == ViewControllerRepresentableAdapter { 140 | self.init(transition: transition, isPresented: isPresented) { 141 | ViewControllerRepresentableAdapter(destination) 142 | } label: { 143 | label() 144 | } 145 | } 146 | } 147 | 148 | 149 | #endif 150 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DismissPresentationLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | /// A button that's action dismisses the presented view. 10 | /// 11 | /// Compatible with ``PresentationLink`` and ``WindowLink``. 12 | /// 13 | /// > Note: The button is disabled if there is no presented view 14 | /// 15 | @available(iOS 14.0, *) 16 | @frozen 17 | public struct DismissPresentationLink: View { 18 | 19 | var animation: Animation? 20 | var label: Label 21 | 22 | @Environment(\.presentationCoordinator) var presentationCoordinator 23 | 24 | public init( 25 | animation: Animation? = .default, 26 | @ViewBuilder label: () -> Label 27 | ) { 28 | self.animation = animation 29 | self.label = label() 30 | } 31 | 32 | public var body: some View { 33 | Button { 34 | presentationCoordinator.dismiss(animation: animation) 35 | } label: { 36 | label 37 | } 38 | .disabled(!presentationCoordinator.isPresented) 39 | } 40 | } 41 | 42 | #endif 43 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/DynamicProperty/WeakState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import SwiftUI 6 | import Combine 7 | 8 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) 9 | @propertyWrapper 10 | @MainActor @preconcurrency 11 | struct WeakState: DynamicProperty { 12 | 13 | @usableFromInline 14 | @MainActor @preconcurrency 15 | class Storage: ObservableObject { 16 | weak var value: Value? { 17 | didSet { 18 | if oldValue !== value { 19 | objectWillChange.send() 20 | } 21 | } 22 | } 23 | @usableFromInline 24 | init(value: Value?) { self.value = value } 25 | } 26 | 27 | @usableFromInline 28 | var storage: StateObject 29 | 30 | @inlinable 31 | @MainActor @preconcurrency 32 | init(wrappedValue thunk: @autoclosure @escaping () -> Value?) { 33 | storage = StateObject(wrappedValue: { Storage(value: thunk()) }()) 34 | } 35 | 36 | @MainActor @preconcurrency 37 | var wrappedValue: Value? { 38 | get { storage.wrappedValue.value } 39 | nonmutating set { storage.wrappedValue.value = newValue } 40 | } 41 | 42 | @MainActor @preconcurrency 43 | var projectedValue: Binding { 44 | storage.projectedValue.value 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/Animation+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | extension Animation { 11 | 12 | public var timingParameters: UITimingCurveProvider? { 13 | guard let resolved = Resolved(animation: self) else { return nil } 14 | switch resolved.timingCurve { 15 | case .default, .custom: 16 | return nil 17 | case .bezier, .spring, .fluidSpring: 18 | return AnimationTimingCurveProvider( 19 | timingCurve: resolved.timingCurve 20 | ) 21 | } 22 | } 23 | 24 | } 25 | 26 | extension UIViewPropertyAnimator { 27 | 28 | public convenience init( 29 | animation: Animation?, 30 | defaultDuration: TimeInterval = 0.35, 31 | defaultCompletionCurve: UIView.AnimationCurve = .easeInOut 32 | ) { 33 | if let resolved = animation?.resolved() { 34 | switch resolved.timingCurve { 35 | case .default: 36 | self.init( 37 | duration: defaultDuration / resolved.speed, 38 | curve: defaultCompletionCurve 39 | ) 40 | case .custom(let animation): 41 | self.init( 42 | duration: (animation.duration ?? defaultDuration) / resolved.speed, 43 | curve: defaultCompletionCurve 44 | ) 45 | case .bezier, .spring, .fluidSpring: 46 | let duration = (resolved.timingCurve.duration ?? defaultDuration) / resolved.speed 47 | self.init( 48 | duration: duration, 49 | timingParameters: AnimationTimingCurveProvider( 50 | timingCurve: resolved.timingCurve 51 | ) 52 | ) 53 | } 54 | } else { 55 | self.init(duration: defaultDuration, curve: defaultCompletionCurve) 56 | } 57 | } 58 | } 59 | 60 | extension UIView { 61 | 62 | @available(iOS, deprecated: 18.0, message: "Use the builtin UIView.animate") 63 | public static func animate( 64 | with animation: Animation?, 65 | animations: @escaping () -> Void, 66 | completion: ((Bool) -> Void)? = nil 67 | ) { 68 | guard let animation else { 69 | animations() 70 | completion?(true) 71 | return 72 | } 73 | 74 | let animator = UIViewPropertyAnimator(animation: animation) 75 | animator.addAnimations(animations) 76 | if let completion { 77 | animator.addCompletion { position in 78 | completion(position == .end) 79 | } 80 | } 81 | animator.startAnimation(afterDelay: animation.delay ?? 0) 82 | } 83 | } 84 | 85 | @objc(TransmissionAnimationTimingCurveProvider) 86 | private class AnimationTimingCurveProvider: NSObject, UITimingCurveProvider { 87 | 88 | let timingCurve: Animation.Resolved.TimingCurve 89 | init(timingCurve: Animation.Resolved.TimingCurve) { 90 | self.timingCurve = timingCurve 91 | } 92 | 93 | required init?(coder: NSCoder) { 94 | if let data = coder.decodeData(), 95 | let timingCurve = try? JSONDecoder().decode(Animation.Resolved.TimingCurve.self, from: data) { 96 | self.timingCurve = timingCurve 97 | } else { 98 | return nil 99 | } 100 | } 101 | 102 | func encode(with coder: NSCoder) { 103 | if let data = try? JSONEncoder().encode(timingCurve) { 104 | coder.encode(data) 105 | } 106 | } 107 | 108 | func copy(with zone: NSZone? = nil) -> Any { 109 | AnimationTimingCurveProvider(timingCurve: timingCurve) 110 | } 111 | 112 | 113 | // MARK: - UITimingCurveProvider 114 | 115 | var timingCurveType: UITimingCurveType { 116 | switch timingCurve { 117 | case .default, .custom: 118 | return .builtin 119 | case .bezier: 120 | return .cubic 121 | case .spring, .fluidSpring: 122 | return .spring 123 | } 124 | } 125 | 126 | var cubicTimingParameters: UICubicTimingParameters? { 127 | switch timingCurve { 128 | case .bezier(let bezierCurve): 129 | let curve = bezierCurve.curve 130 | let p1x = curve.cx / 3 131 | let p1y = curve.cy / 3 132 | let p1 = CGPoint(x: p1x, y: p1y) 133 | let p2x = curve.cx - (1 / 3) * (curve.cx - curve.bx) 134 | let p2y = curve.cy - (1 / 3) * (curve.cy - curve.by) 135 | let p2 = CGPoint(x: p2x, y: p2y) 136 | return UICubicTimingParameters( 137 | controlPoint1: p1, 138 | controlPoint2: p2 139 | ) 140 | case .default, .custom, .spring, .fluidSpring: 141 | return nil 142 | } 143 | } 144 | 145 | var springTimingParameters: UISpringTimingParameters? { 146 | switch timingCurve { 147 | case .spring(let springCurve): 148 | return UISpringTimingParameters( 149 | mass: springCurve.mass, 150 | stiffness: springCurve.stiffness, 151 | damping: springCurve.damping, 152 | initialVelocity: CGVector( 153 | dx: springCurve.initialVelocity, 154 | dy: springCurve.initialVelocity 155 | ) 156 | ) 157 | case .fluidSpring(let fluidSpringCurve): 158 | let initialVelocity = log(fluidSpringCurve.dampingFraction) / (fluidSpringCurve.duration - fluidSpringCurve.blendDuration) 159 | return UISpringTimingParameters( 160 | dampingRatio: fluidSpringCurve.dampingFraction, 161 | initialVelocity: CGVector( 162 | dx: initialVelocity, 163 | dy: initialVelocity 164 | ) 165 | ) 166 | case .default, .custom, .bezier: 167 | return nil 168 | } 169 | } 170 | } 171 | 172 | #endif 173 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/CGAffineTransform+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension CGAffineTransform { 10 | 11 | init(to targetRect: CGRect, from sourceRect: CGRect) { 12 | let scaleX = sourceRect.width / targetRect.width 13 | let scaleY = sourceRect.height / targetRect.height 14 | let translateX = sourceRect.midX - targetRect.midX 15 | let translateY = sourceRect.midY - targetRect.midY 16 | self = CGAffineTransformMake( 17 | scaleX, 18 | 0, 19 | 0, 20 | scaleY, 21 | translateX, 22 | translateY 23 | ) 24 | } 25 | } 26 | 27 | #endif 28 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/CGPath+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension CGPath { 10 | static func roundedRect( 11 | bounds: CGRect, 12 | topLeft: CGFloat = 0, 13 | topRight: CGFloat = 0, 14 | bottomLeft: CGFloat = 0, 15 | bottomRight: CGFloat = 0 16 | ) -> CGPath { 17 | 18 | let path = UIBezierPath() 19 | path.move( 20 | to: CGPoint(x: bounds.minX + topLeft, y: bounds.minY) 21 | ) 22 | path.addLine( 23 | to: CGPoint(x: bounds.maxX - topRight, y: bounds.minY) 24 | ) 25 | path.addQuadCurve( 26 | to: CGPoint(x: bounds.maxX, y: bounds.minY + topRight), 27 | controlPoint: CGPoint(x: bounds.maxX, y: bounds.minY) 28 | ) 29 | path.addLine( 30 | to: CGPoint(x: bounds.maxX, y: bounds.maxY - bottomRight) 31 | ) 32 | path.addQuadCurve( 33 | to: CGPoint(x: bounds.maxX - bottomRight, y: bounds.maxY), 34 | controlPoint: CGPoint(x: bounds.maxX, y: bounds.maxY) 35 | ) 36 | path.addLine( 37 | to: CGPoint(x: bounds.minX + bottomLeft, y: bounds.maxY) 38 | ) 39 | path.addQuadCurve( 40 | to: CGPoint(x: bounds.minX, y: bounds.maxY - bottomLeft), 41 | controlPoint: CGPoint(x: bounds.minX, y: bounds.maxY) 42 | ) 43 | path.addLine( 44 | to: CGPoint(x: bounds.minX, y: bounds.minY + topLeft) 45 | ) 46 | path.addQuadCurve( 47 | to: CGPoint(x: bounds.minX + topLeft, y: bounds.minY), 48 | controlPoint: CGPoint(x: bounds.minX, y: bounds.minY) 49 | ) 50 | path.close() 51 | 52 | return path.cgPath 53 | } 54 | } 55 | 56 | #endif 57 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/CGRect+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension CGSize { 10 | func isApproximatelyEqual(to size: CGSize, tolerance: CGFloat = 1e-5) -> Bool { 11 | return abs(width - size.width) <= tolerance && 12 | abs(height - size.height) <= tolerance 13 | } 14 | } 15 | 16 | extension CGRect { 17 | func isApproximatelyEqual(to rect: CGRect, tolerance: CGFloat = 1e-5) -> Bool { 18 | return abs(origin.x - rect.origin.x) <= tolerance && 19 | abs(origin.y - rect.origin.y) <= tolerance && 20 | abs(size.width - rect.size.width) <= tolerance && 21 | abs(size.height - rect.size.height) <= tolerance 22 | } 23 | } 24 | 25 | #endif 26 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UIColor+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import UIKit 9 | 10 | extension UIColor { 11 | 12 | var isTranslucent: Bool { 13 | var alpha: CGFloat = 0 14 | if getWhite(nil, alpha: &alpha) { 15 | return alpha < 1 16 | } 17 | return false 18 | } 19 | } 20 | 21 | #endif 22 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UIGestureRecognizer+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | public func frictionCurve( 10 | _ value: CGFloat, 11 | distance: CGFloat = 200, 12 | coefficient: CGFloat = 0.3 13 | ) -> CGFloat { 14 | if value < 0 { 15 | return -frictionCurve(abs(value), distance: distance, coefficient: coefficient) 16 | } 17 | return (1.0 - (1.0 / ((value * coefficient / distance) + 1.0))) * distance 18 | } 19 | 20 | extension CGFloat { 21 | public func rounded(scale: CGFloat) -> CGFloat { 22 | (self * scale).rounded() / scale 23 | } 24 | } 25 | 26 | extension UIGestureRecognizer { 27 | 28 | var isInteracting: Bool { 29 | let isInteracting = state == .began || state == .changed 30 | return isInteracting 31 | } 32 | 33 | var isSimultaneousWithTransition: Bool { 34 | isScrollViewPanGesture || isWebViewPanGesture 35 | || delaysTouchesBegan 36 | || isKind(of: UIPinchGestureRecognizer.self) 37 | } 38 | 39 | private static let UIScrollViewPanGestureRecognizer: AnyClass? = NSClassFromString("UIScrollViewPanGestureRecognizer") 40 | var isScrollViewPanGesture: Bool { 41 | guard let aClass = Self.UIScrollViewPanGestureRecognizer else { 42 | return false 43 | } 44 | return isKind(of: aClass) 45 | } 46 | 47 | private static let WKScrollView: AnyClass? = NSClassFromString("WKScrollView") 48 | var isWebViewPanGesture: Bool { 49 | guard let view, let aClass = Self.WKScrollView else { 50 | return false 51 | } 52 | return view.isKind(of: aClass) 53 | } 54 | 55 | var isSheetDismissPanGesture: Bool { 56 | guard name == "_UISheetInteractionBackgroundDismissRecognizer" else { 57 | return false 58 | } 59 | return self is UIPanGestureRecognizer 60 | } 61 | 62 | var isZoomDismissPanGesture: Bool { 63 | guard name == "com.apple.UIKit.ZoomInteractiveDismissSwipeDown" else { 64 | return false 65 | } 66 | return self is UIPanGestureRecognizer 67 | } 68 | } 69 | 70 | #endif 71 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UIKit+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | extension UIEdgeInsets { 10 | init(_ edgeInsets: EdgeInsets, layoutDirection: LayoutDirection) { 11 | self = UIEdgeInsets( 12 | top: edgeInsets.top, 13 | left: layoutDirection == .leftToRight ? edgeInsets.leading : edgeInsets.trailing, 14 | bottom: edgeInsets.bottom, 15 | right: layoutDirection == .leftToRight ? edgeInsets.trailing : edgeInsets.leading 16 | ) 17 | } 18 | } 19 | 20 | extension UIView.AnimationCurve { 21 | 22 | func toSwiftUI(duration: TimeInterval) -> Animation { 23 | switch self { 24 | case .linear: 25 | return .linear(duration: duration) 26 | case .easeIn: 27 | return .easeIn(duration: duration) 28 | case .easeOut: 29 | return .easeOut(duration: duration) 30 | case .easeInOut: 31 | return .easeInOut(duration: duration) 32 | @unknown default: 33 | return .easeInOut(duration: duration) 34 | } 35 | } 36 | } 37 | 38 | #endif 39 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UINavigationController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension UINavigationController { 10 | func popViewController(animated: Bool, completion: @escaping () -> Void) { 11 | CATransaction.begin() 12 | CATransaction.setCompletionBlock(completion) 13 | popViewController(animated: animated) 14 | CATransaction.commit() 15 | } 16 | 17 | func pushViewController(_ viewController: UIViewController, animated: Bool, _ completion: @escaping () -> Void) { 18 | CATransaction.begin() 19 | CATransaction.setCompletionBlock(completion) 20 | pushViewController(viewController, animated: animated) 21 | CATransaction.commit() 22 | } 23 | 24 | func popToViewController(_ viewController: UIViewController, animated: Bool, _ completion: @escaping () -> Void) { 25 | CATransaction.begin() 26 | CATransaction.setCompletionBlock(completion) 27 | popToViewController(viewController, animated: animated) 28 | CATransaction.commit() 29 | } 30 | 31 | func popToRootViewController(animated: Bool, completion: @escaping () -> Void) { 32 | CATransaction.begin() 33 | CATransaction.setCompletionBlock(completion) 34 | popToRootViewController(animated: animated) 35 | CATransaction.commit() 36 | } 37 | } 38 | 39 | #endif 40 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UIScreen+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension UIScreen { 10 | 11 | var displayCornerRadius: CGFloat { 12 | _displayCornerRadius 13 | } 14 | 15 | func displayCornerRadius(min: CGFloat = 12) -> CGFloat { 16 | max(min, _displayCornerRadius) 17 | } 18 | 19 | public var _displayCornerRadius: CGFloat { 20 | let key = String("suidaRrenroCyalpsid_".reversed()) 21 | let value = value(forKey: key) as? CGFloat ?? 0 22 | return value 23 | } 24 | } 25 | 26 | #endif 27 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UISheetPresentationController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | @available(iOS 16.0, *) 10 | private class _UISheetPresentationControllerDetentResolutionContext: NSObject, UISheetPresentationControllerDetentResolutionContext { 11 | var containerTraitCollection: UITraitCollection 12 | var maximumDetentValue: CGFloat 13 | 14 | init(containerTraitCollection: UITraitCollection, maximumDetentValue: CGFloat) { 15 | self.containerTraitCollection = containerTraitCollection 16 | self.maximumDetentValue = maximumDetentValue 17 | } 18 | } 19 | 20 | private class _UISheetPresentationControllerDetentResolver: NSObject { 21 | var resolution: (UITraitCollection, CGFloat) -> CGFloat? 22 | 23 | init(resolution: @escaping (UITraitCollection, CGFloat) -> CGFloat?) { 24 | self.resolution = resolution 25 | } 26 | } 27 | 28 | @available(iOS 15.0, *) 29 | extension UISheetPresentationController.Detent { 30 | var id: String? { 31 | if #available(iOS 16.0, *) { 32 | return identifier.rawValue 33 | } else { 34 | if responds(to: NSSelectorFromString("_identifier")), 35 | let identifier = value(forKey: "_identifier") as? String 36 | { 37 | return identifier 38 | } else { 39 | return nil 40 | } 41 | } 42 | } 43 | 44 | var isDynamic: Bool { 45 | guard let id = id else { 46 | return false 47 | } 48 | switch id { 49 | case UISheetPresentationController.Detent.Identifier.large.rawValue, 50 | UISheetPresentationController.Detent.Identifier.medium.rawValue: 51 | return false 52 | case PresentationLinkTransition.SheetTransitionOptions.Detent.ideal.identifier.rawValue: 53 | return true 54 | default: 55 | if #available(iOS 16.0, *) { 56 | if responds(to: NSSelectorFromString("_type")), 57 | let type = value(forKey: "_type") as? Int 58 | { 59 | return type == 0 60 | } 61 | } 62 | return resolution != nil 63 | } 64 | } 65 | 66 | @available(iOS 18.0, *) 67 | static func fullScreen() -> UISheetPresentationController.Detent? { 68 | // https://x.com/SebJVidal/status/1924721754074714258 69 | let aSelector = String("tneteDlluf_".reversed()) 70 | guard responds(to: NSSelectorFromString(aSelector)) else { return nil } 71 | return value(forKey: aSelector) as? UISheetPresentationController.Detent 72 | } 73 | 74 | static var legacyResolutionKey: UInt = 0 75 | var resolution: ((UITraitCollection, CGFloat) -> CGFloat?)? { 76 | get { 77 | if #available(iOS 16.0, *) { 78 | return nil 79 | } else { 80 | let object = objc_getAssociatedObject(self, &Self.legacyResolutionKey) as? _UISheetPresentationControllerDetentResolver 81 | return object?.resolution 82 | } 83 | } 84 | set { 85 | if #available(iOS 16.0, *) { } else { 86 | let object = newValue.map { _UISheetPresentationControllerDetentResolver(resolution: $0) } 87 | objc_setAssociatedObject(self, &Self.legacyResolutionKey, object, .OBJC_ASSOCIATION_RETAIN) 88 | } 89 | } 90 | } 91 | 92 | var constant: CGFloat? { 93 | if responds(to: NSSelectorFromString("_constant")), 94 | let constant = value(forKey: "_constant") as? CGFloat, 95 | constant > 0 96 | { 97 | return constant 98 | } 99 | return nil 100 | } 101 | 102 | func resolvedValue(containerTraitCollection: UITraitCollection, maximumDetentValue: CGFloat) -> CGFloat? { 103 | if #available(iOS 16.0, *) { 104 | let context = _UISheetPresentationControllerDetentResolutionContext( 105 | containerTraitCollection: containerTraitCollection, 106 | maximumDetentValue: maximumDetentValue 107 | ) 108 | return resolvedValue(in: context) 109 | } else if let resolution { 110 | return resolution(containerTraitCollection, maximumDetentValue) 111 | } 112 | return nil 113 | } 114 | } 115 | 116 | 117 | 118 | #endif 119 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UISplitViewController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | @available(iOS 14.0, *) 10 | extension UISplitViewController.DisplayMode { 11 | func isCollapsed( 12 | column: UISplitViewController.Column, 13 | style: UISplitViewController.Style, 14 | isCollapsed: Bool 15 | ) -> Bool { 16 | if isCollapsed { 17 | return column != .primary 18 | } else { 19 | switch self { 20 | case .secondaryOnly: 21 | return column == .secondary ? false : true 22 | case .oneBesideSecondary, .oneOverSecondary: 23 | switch style { 24 | case .doubleColumn: 25 | return column == .supplementary ? true : false 26 | case .tripleColumn: 27 | return column == .primary ? true : false 28 | default: 29 | return false 30 | } 31 | case .twoBesideSecondary, .twoOverSecondary, .twoDisplaceSecondary: 32 | return false 33 | case .automatic: 34 | return false 35 | @unknown default: 36 | return true 37 | } 38 | } 39 | } 40 | } 41 | 42 | #endif 43 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UIView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension UIView { 10 | var viewController: UIViewController? { 11 | _viewController 12 | } 13 | 14 | public var _viewController: UIViewController? { 15 | var responder: UIResponder? = next 16 | while responder != nil, !(responder is UIViewController) { 17 | responder = responder?.next 18 | } 19 | return responder as? UIViewController 20 | } 21 | 22 | func idealSize(for width: CGFloat) -> CGSize { 23 | var size = intrinsicContentSize 24 | if size.height <= 0 { 25 | size.width = width 26 | size.height = idealHeight(for: width) 27 | } 28 | return size 29 | } 30 | 31 | func idealHeight(for width: CGFloat) -> CGFloat { 32 | var height = systemLayoutSizeFitting( 33 | CGSize(width: width, height: UIView.layoutFittingExpandedSize.height), 34 | withHorizontalFittingPriority: .fittingSizeLevel, 35 | verticalFittingPriority: .defaultLow 36 | ).height 37 | if height >= UIView.layoutFittingExpandedSize.height { 38 | let sizeThatFits = sizeThatFits(CGSize(width: width, height: .infinity)) 39 | if sizeThatFits.height > 0 { 40 | height = sizeThatFits.height 41 | } 42 | } 43 | return height 44 | } 45 | } 46 | 47 | #endif 48 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UIViewController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension UIViewController { 10 | 11 | func _popViewController( 12 | count: Int = 1, 13 | animated: Bool, 14 | completion: (() -> Void)? = nil 15 | ) { 16 | guard let navigationController = navigationController, 17 | let index = navigationController.viewControllers.firstIndex(of: self), 18 | index > 0 19 | else { 20 | completion?() 21 | return 22 | } 23 | 24 | if animated { 25 | CATransaction.begin() 26 | CATransaction.setCompletionBlock(completion) 27 | } 28 | let toIndex = max(index - count, 0) 29 | navigationController.popToViewController(navigationController.viewControllers[toIndex], animated: animated) 30 | if animated { 31 | CATransaction.commit() 32 | } else { 33 | completion?() 34 | } 35 | } 36 | 37 | var _transitionCoordinator: UIViewControllerTransitionCoordinator? { 38 | guard let transitionCoordinator = transitionCoordinator else { 39 | for child in children { 40 | if let transitionCoordinator = child._transitionCoordinator { 41 | return transitionCoordinator 42 | } 43 | } 44 | return nil 45 | } 46 | return transitionCoordinator 47 | } 48 | 49 | var _activePresentationController: UIPresentationController? { 50 | guard presentingViewController != nil else { return nil } 51 | let active: UIPresentationController? = { 52 | if #available(iOS 16.0, *), let activePresentationController { 53 | return activePresentationController 54 | } 55 | return presentationController 56 | }() 57 | guard active?.presentedViewController == self else { return nil } 58 | return active 59 | } 60 | } 61 | 62 | #endif 63 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Extensions/UIWindow+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | extension UIWindow { 11 | func present( 12 | _ window: UIWindow, 13 | animation: Animation?, 14 | transition: ((Bool) -> Void)? = nil, 15 | completion: ((Bool) -> Void)? = nil 16 | ) { 17 | window.parent = self 18 | if window.windowLevel.rawValue > windowLevel.rawValue { 19 | window.makeKeyAndVisible() 20 | } else { 21 | window.isHidden = false 22 | } 23 | transition?(false) 24 | UIView.animate( 25 | with: animation 26 | ) { 27 | transition?(true) 28 | } completion: { success in 29 | completion?(success) 30 | } 31 | } 32 | 33 | func dismiss( 34 | animation: Animation?, 35 | transition: (() -> Void)? = nil, 36 | completion: ((Bool) -> Void)? = nil 37 | ) { 38 | self.resignKey() 39 | UIView.animate( 40 | with: animation 41 | ) { 42 | transition?() 43 | } completion: { success in 44 | if success { 45 | self.isHidden = true 46 | self.parent = nil 47 | } 48 | completion?(success) 49 | } 50 | } 51 | 52 | var presentedViewController: UIViewController? { 53 | var viewController = rootViewController 54 | while let next = viewController?.presentedViewController { 55 | viewController = next 56 | } 57 | return viewController 58 | } 59 | 60 | @objc 61 | var parent: UIWindow? { 62 | get { 63 | let aSel: Selector = #selector(getter:UIWindow.parent) 64 | return objc_getAssociatedObject(self, unsafeBitCast(aSel, to: UnsafeRawPointer.self)) as? UIWindow 65 | } 66 | set { 67 | let aSel: Selector = #selector(getter:UIWindow.parent) 68 | objc_setAssociatedObject(self, unsafeBitCast(aSel, to: UnsafeRawPointer.self), newValue, .OBJC_ASSOCIATION_ASSIGN) 69 | } 70 | } 71 | } 72 | 73 | #endif 74 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Hosting/AnyHostingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import EngineCore 9 | 10 | public protocol AnyHostingView: UIView { 11 | func render() 12 | } 13 | 14 | extension _UIHostingView: AnyHostingView { 15 | public func render() { 16 | _renderForTest(interval: 1 / 60) 17 | } 18 | } 19 | 20 | public protocol AnyHostingController: UIViewController { 21 | var disableSafeArea: Bool { get set } 22 | func render() 23 | } 24 | 25 | extension UIHostingController: AnyHostingController { 26 | public var disableSafeArea: Bool { 27 | get { _disableSafeArea } 28 | set { 29 | if #available(macOS 13.3, iOS 16.4, tvOS 16.4, *) { 30 | safeAreaRegions = newValue ? [] : .all 31 | } 32 | _disableSafeArea = newValue 33 | } 34 | } 35 | 36 | public func render() { 37 | (view as! AnyHostingView).render() 38 | } 39 | } 40 | 41 | #endif 42 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Hosting/DestinationHostingController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | open class DestinationHostingController< 11 | Content: View 12 | >: HostingController { 13 | 14 | open override func viewDidLayoutSubviews() { 15 | super.viewDidLayoutSubviews() 16 | 17 | guard let navigationController, 18 | let index = navigationController.viewControllers.firstIndex(of: self), 19 | index > 0, 20 | let hostingController = navigationController.viewControllers[index - 1] as? AnyHostingController, 21 | hostingController.view.superview == nil 22 | else { 23 | return 24 | } 25 | hostingController.render() 26 | } 27 | } 28 | 29 | #endif 30 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Hosting/PresentationHostingWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | open class PresentationHostingWindow: UIWindow { 10 | 11 | public var content: Content { 12 | get { host.content } 13 | set { host.content = newValue } 14 | } 15 | 16 | private let host: PresentationHostingWindowController 17 | 18 | public init(windowScene: UIWindowScene, content: Content) { 19 | self.host = PresentationHostingWindowController(content: content) 20 | super.init(windowScene: windowScene) 21 | rootViewController = host 22 | } 23 | 24 | public convenience init(windowScene: UIWindowScene, @ViewBuilder content: () -> Content) { 25 | self.init(windowScene: windowScene, content: content()) 26 | } 27 | 28 | @available(iOS, obsoleted: 13.0, renamed: "init(content:)") 29 | public override init(frame: CGRect) { 30 | fatalError("init(frame:) has not been implemented") 31 | } 32 | 33 | @available(iOS, obsoleted: 13.0, renamed: "init(windowScene:content:)") 34 | public override init(windowScene: UIWindowScene) { 35 | fatalError("init(windowScene:) has not been implemented") 36 | } 37 | 38 | public required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 43 | let result = super.hitTest(point, with: event) 44 | if result == self { 45 | return nil 46 | } 47 | return result 48 | } 49 | 50 | private class PresentationHostingWindowController: UIViewController { 51 | 52 | var content: Content { 53 | get { host.content } 54 | set { host.content = newValue } 55 | } 56 | 57 | private let host: HostingView 58 | 59 | override var preferredStatusBarStyle: UIStatusBarStyle { 60 | guard let proxy = viewControllerForStatusBarAppearance else { 61 | return super.preferredStatusBarStyle 62 | } 63 | if proxy.modalPresentationCapturesStatusBarAppearance { 64 | return proxy.preferredStatusBarStyle 65 | } else if let presentingViewController = proxy.presentingViewController { 66 | if proxy._activePresentationController is UISheetPresentationController { 67 | return .lightContent 68 | } 69 | return presentingViewController.preferredStatusBarStyle 70 | } 71 | return super.preferredStatusBarStyle 72 | } 73 | 74 | override var childForStatusBarStyle: UIViewController? { 75 | viewControllerForStatusBarAppearance ?? super.childForStatusBarStyle 76 | } 77 | 78 | override var prefersStatusBarHidden: Bool { 79 | guard let proxy = viewControllerForStatusBarAppearance else { 80 | return super.prefersStatusBarHidden 81 | } 82 | if proxy.modalPresentationCapturesStatusBarAppearance { 83 | return proxy.prefersStatusBarHidden 84 | } else if let presentingViewController = proxy.presentingViewController { 85 | if proxy._activePresentationController is UISheetPresentationController { 86 | return false 87 | } 88 | return presentingViewController.prefersStatusBarHidden 89 | } 90 | return super.prefersStatusBarHidden 91 | } 92 | 93 | override var childForStatusBarHidden: UIViewController? { 94 | viewControllerForStatusBarAppearance ?? super.childForStatusBarHidden 95 | } 96 | 97 | var viewControllerForStatusBarAppearance: UIViewController? { 98 | guard let window = view.window, 99 | let parent = window.parent, 100 | window.windowLevel.rawValue <= parent.windowLevel.rawValue, 101 | let parentViewController = parent.presentedViewController 102 | else { 103 | return nil 104 | } 105 | return parentViewController 106 | } 107 | 108 | init(content: Content) { 109 | self.host = HostingView(content: content) 110 | super.init(nibName: nil, bundle: nil) 111 | } 112 | 113 | required init?(coder: NSCoder) { 114 | fatalError("init(coder:) has not been implemented") 115 | } 116 | 117 | override func loadView() { 118 | view = host 119 | } 120 | } 121 | } 122 | 123 | #endif 124 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Hosting/SnapshotRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | @frozen 11 | public enum SnapshotRendererColorSpace { 12 | // The extended linear sRGB working color space. 13 | case extendedLinear 14 | 15 | // The linear sRGB working color space. 16 | case linear 17 | 18 | // The non-linear sRGB working color space. 19 | case nonLinear 20 | 21 | func toCoreGraphics() -> CGColorSpace { 22 | switch self { 23 | case .extendedLinear: 24 | return CGColorSpace(name: CGColorSpace.extendedLinearSRGB)! 25 | case .linear: 26 | return CGColorSpace(name: CGColorSpace.linearSRGB)! 27 | case .nonLinear: 28 | return CGColorSpace(name: CGColorSpace.sRGB)! 29 | } 30 | } 31 | 32 | func toUIKit() -> UIGraphicsImageRendererFormat.Range { 33 | switch self { 34 | case .extendedLinear: 35 | return .extended 36 | case .linear, .nonLinear: 37 | return .standard 38 | } 39 | } 40 | } 41 | 42 | /// A backwards compatible port of `ImageRenderer` 43 | /// 44 | /// See Also: 45 | /// - ``SnapshotItemProvider`` 46 | @MainActor 47 | public final class SnapshotRenderer: ObservableObject { 48 | 49 | public var content: Content { 50 | get { host.content.content } 51 | set { 52 | host.content.content = newValue 53 | objectWillChange.send() 54 | } 55 | } 56 | 57 | public var scale: CGFloat { 58 | get { host.contentScaleFactor } 59 | set { 60 | host.contentScaleFactor = newValue 61 | host.layer.contentsScale = newValue 62 | host.content.modifier.scale = newValue 63 | } 64 | } 65 | 66 | public var isOpaque: Bool { 67 | get { host.layer.isOpaque } 68 | set { 69 | host.layer.isOpaque = newValue 70 | objectWillChange.send() 71 | } 72 | } 73 | 74 | public var colorSpace: SnapshotRendererColorSpace = .nonLinear 75 | 76 | public var proposedSize: ProposedSize = .unspecified 77 | 78 | private let host: HostingView> 79 | 80 | public init(content: Content) { 81 | let host = HostingView( 82 | content: content.modifier(SnapshotRendererModifier(scale: 1)) 83 | ) 84 | host.disablesSafeArea = true 85 | host.layer.shouldRasterize = true 86 | self.host = host 87 | isOpaque = false 88 | scale = 1 89 | } 90 | 91 | public func render( 92 | rasterizationScale: CGFloat = 1, 93 | renderer: (CGSize, (CGContext) -> Void) -> Result 94 | ) -> Result { 95 | let size: CGSize = { 96 | let intrinsicContentSize = host.intrinsicContentSize 97 | return CGSize( 98 | width: proposedSize.width ?? intrinsicContentSize.width, 99 | height: proposedSize.height ?? intrinsicContentSize.height 100 | ) 101 | }() 102 | host.frame = CGRect(origin: .zero, size: size) 103 | host.layer.rasterizationScale = rasterizationScale 104 | host.render() 105 | return renderer(host.frame.size, { context in 106 | host.layer.render(in: context) 107 | }) 108 | } 109 | 110 | public var cgImage: CGImage? { 111 | render { size, callback in 112 | let context = CGContext( 113 | data: nil, 114 | width: Int(size.width), 115 | height: Int(size.height), 116 | bitsPerComponent: 8, 117 | bytesPerRow: 0, // Calculated automatically 118 | space: colorSpace.toCoreGraphics(), 119 | bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 120 | ) 121 | guard let context else { 122 | return nil 123 | } 124 | context.concatenate( 125 | CGAffineTransformMake(1, 0, 0, -1, 0, CGFloat(context.height)) 126 | ) 127 | callback(context) 128 | let image = context.makeImage() 129 | return image 130 | } 131 | } 132 | 133 | public var uiImage: UIImage? { 134 | render { size, callback in 135 | let format = UIGraphicsImageRendererFormat() 136 | format.scale = scale 137 | format.opaque = isOpaque 138 | format.preferredRange = colorSpace.toUIKit() 139 | let renderer = UIGraphicsImageRenderer( 140 | size: size, 141 | format: format 142 | ) 143 | return renderer.image { context in 144 | callback(context.cgContext) 145 | } 146 | } 147 | } 148 | } 149 | 150 | private struct SnapshotRendererModifier: ViewModifier { 151 | var scale: CGFloat 152 | 153 | func body(content: Content) -> some View { 154 | content.environment(\.displayScale, scale) 155 | } 156 | } 157 | 158 | // MARK: - Previews 159 | 160 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 161 | struct SnapshotRenderer_Previews: PreviewProvider { 162 | struct Preview: View { 163 | @StateObject var rendererA = SnapshotRenderer(content: Snapshot()) 164 | @StateObject var rendererC = ImageRenderer(content: Snapshot()) 165 | 166 | var body: some View { 167 | VStack { 168 | VStack { 169 | Snapshot() 170 | 171 | if let contentA = rendererA.uiImage { 172 | Image(uiImage: contentA) 173 | } 174 | 175 | 176 | if let contentC = rendererC.uiImage { 177 | Image(uiImage: contentC) 178 | } 179 | } 180 | } 181 | } 182 | 183 | struct Snapshot: View { 184 | @Environment(\.displayScale) var displayScale 185 | var body: some View { 186 | Text("Hello, World") 187 | } 188 | } 189 | } 190 | 191 | static var previews: some View { 192 | Preview() 193 | } 194 | } 195 | 196 | #endif 197 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/PresentationCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A coordinator that can be used to programatically dismiss a view. 11 | /// 12 | /// See Also: 13 | /// - ``PresentationLink`` 14 | /// - ``WindowLink`` 15 | /// 16 | @available(iOS 14.0, *) 17 | @frozen 18 | public struct PresentationCoordinator { 19 | public var isPresented: Bool 20 | 21 | public weak var sourceView: UIView? 22 | 23 | @usableFromInline 24 | var dismissBlock: (Int, Transaction) -> Void 25 | 26 | /// Dismisses all presented views with an optional animation 27 | @inlinable 28 | public func dismissToRoot(animation: Animation? = .default) { 29 | dismiss(count: .max, transaction: Transaction(animation: animation)) 30 | } 31 | 32 | /// Dismisses all presented views with the transaction 33 | @inlinable 34 | public func dismissToRoot(transaction: Transaction) { 35 | dismiss(count: .max, transaction: transaction) 36 | } 37 | 38 | /// Dismisses the presented view with an optional animation 39 | @inlinable 40 | public func dismiss(animation: Animation? = .default) { 41 | dismiss(count: 1, transaction: Transaction(animation: animation)) 42 | } 43 | 44 | /// Dismisses the presented view with the transaction 45 | @inlinable 46 | public func dismiss(transaction: Transaction) { 47 | dismiss(count: 1, transaction: transaction) 48 | } 49 | 50 | /// Dismisses the presented views with an optional animation 51 | @inlinable 52 | public func dismiss(count: Int, animation: Animation? = .default) { 53 | dismiss(count: count, transaction: Transaction(animation: animation)) 54 | } 55 | 56 | /// Dismisses the presented views with the transaction 57 | @inlinable 58 | public func dismiss(count: Int, transaction: Transaction) { 59 | dismissBlock(count, transaction) 60 | } 61 | } 62 | 63 | @available(iOS 14.0, *) 64 | enum PresentationCoordinatorKey: EnvironmentKey { 65 | static let defaultValue: PresentationCoordinator? = nil 66 | } 67 | 68 | @available(iOS 14.0, *) 69 | extension EnvironmentValues { 70 | 71 | /// A coordinator that can be used to programatically dismiss a view 72 | /// 73 | /// If a `PresentationLink` or `WindowLink` was not used to present 74 | /// the view, a coordinator will be created that wraps SwiftUI's `DismissAction`. 75 | /// 76 | public var presentationCoordinator: PresentationCoordinator { 77 | get { 78 | if let coordinator = self[PresentationCoordinatorKey.self] { 79 | return coordinator 80 | } 81 | if #available(iOS 15.0, *) { 82 | let dismissAction = dismiss 83 | return PresentationCoordinator( 84 | isPresented: isPresented, 85 | dismissBlock: { _, transaction in 86 | withTransaction(transaction) { 87 | dismissAction() 88 | } 89 | } 90 | ) 91 | } else { 92 | let presentationMode = presentationMode 93 | return PresentationCoordinator( 94 | isPresented: presentationMode.wrappedValue.isPresented, 95 | dismissBlock: { _, transaction in 96 | withTransaction(transaction) { 97 | presentationMode.wrappedValue.dismiss() 98 | } 99 | } 100 | ) 101 | } 102 | } 103 | set { self[PresentationCoordinatorKey.self] = newValue } 104 | } 105 | } 106 | 107 | @available(iOS 14.0, *) 108 | struct PresentationBridgeAdapter: ViewModifier { 109 | var presentationCoordinator: PresentationCoordinator 110 | @State var didAppear = false 111 | 112 | func body(content: Content) -> some View { 113 | content 114 | .modifier(_ViewInputsBridgeModifier()) 115 | .environment(\.presentationCoordinator, presentationCoordinator) 116 | .onAppear { 117 | // Need to trigger a render update during presentation to fix DatePicker 118 | withCATransaction { 119 | didAppear = true 120 | } 121 | } 122 | } 123 | } 124 | 125 | #endif 126 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/PresentationLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A button that presents a destination view in a new `UIViewController`. 11 | /// 12 | /// The destination view is presented with the provided `transition`. 13 | /// By default, the ``PresentationLinkTransition/default`` transition is used. 14 | /// 15 | /// See Also: 16 | /// - ``PresentationSourceViewLink`` 17 | /// - ``PresentationLinkTransition`` 18 | /// - ``PresentationLinkModifier`` 19 | /// - ``TransitionReader`` 20 | /// 21 | /// > Tip: You can implement custom transitions with a `UIPresentationController` and/or 22 | /// `UIViewControllerInteractiveTransitioning` with the ``PresentationLinkTransition/custom(_:)`` 23 | /// transition. 24 | /// 25 | @available(iOS 14.0, *) 26 | @frozen 27 | public struct PresentationLink< 28 | Label: View, 29 | Destination: View 30 | >: View { 31 | var label: Label 32 | var destination: Destination 33 | var transition: PresentationLinkTransition 34 | var animation: Animation? 35 | 36 | @StateOrBinding var isPresented: Bool 37 | 38 | public init( 39 | transition: PresentationLinkTransition = .default, 40 | animation: Animation? = .default, 41 | @ViewBuilder destination: () -> Destination, 42 | @ViewBuilder label: () -> Label 43 | ) { 44 | self.label = label() 45 | self.destination = destination() 46 | self.transition = transition 47 | self.animation = animation 48 | self._isPresented = .init(false) 49 | } 50 | 51 | public init( 52 | transition: PresentationLinkTransition = .default, 53 | animation: Animation? = .default, 54 | isPresented: Binding, 55 | @ViewBuilder destination: () -> Destination, 56 | @ViewBuilder label: () -> Label 57 | ) { 58 | self.label = label() 59 | self.destination = destination() 60 | self.transition = transition 61 | self.animation = animation 62 | self._isPresented = .init(isPresented) 63 | } 64 | 65 | public var body: some View { 66 | Button { 67 | withAnimation(animation) { 68 | isPresented.toggle() 69 | } 70 | } label: { 71 | label 72 | } 73 | .modifier( 74 | PresentationLinkModifier( 75 | transition: transition, 76 | isPresented: $isPresented, 77 | destination: destination 78 | ) 79 | ) 80 | } 81 | } 82 | 83 | @available(iOS 14.0, *) 84 | extension PresentationLink { 85 | @_disfavoredOverload 86 | public init( 87 | transition: PresentationLinkTransition = .default, 88 | destination: @escaping () -> ViewController, 89 | @ViewBuilder label: () -> Label 90 | ) where Destination == ViewControllerRepresentableAdapter { 91 | self.init(transition: transition) { 92 | ViewControllerRepresentableAdapter(destination) 93 | } label: { 94 | label() 95 | } 96 | } 97 | 98 | public init( 99 | transition: PresentationLinkTransition = .default, 100 | destination: @escaping (Destination.Context) -> ViewController, 101 | @ViewBuilder label: () -> Label 102 | ) where Destination == ViewControllerRepresentableAdapter { 103 | self.init(transition: transition) { 104 | ViewControllerRepresentableAdapter(destination) 105 | } label: { 106 | label() 107 | } 108 | } 109 | 110 | @_disfavoredOverload 111 | public init( 112 | transition: PresentationLinkTransition = .default, 113 | isPresented: Binding, 114 | destination: @escaping () -> ViewController, 115 | @ViewBuilder label: () -> Label 116 | ) where Destination == ViewControllerRepresentableAdapter { 117 | self.init(transition: transition, isPresented: isPresented) { 118 | ViewControllerRepresentableAdapter(destination) 119 | } label: { 120 | label() 121 | } 122 | } 123 | 124 | public init( 125 | transition: PresentationLinkTransition = .default, 126 | isPresented: Binding, 127 | destination: @escaping (Destination.Context) -> ViewController, 128 | @ViewBuilder label: () -> Label 129 | ) where Destination == ViewControllerRepresentableAdapter { 130 | self.init(transition: transition, isPresented: isPresented) { 131 | ViewControllerRepresentableAdapter(destination) 132 | } label: { 133 | label() 134 | } 135 | } 136 | } 137 | 138 | // MARK: - Previews 139 | 140 | @available(iOS 15.0, *) 141 | struct PresentationLink_Previews: PreviewProvider { 142 | struct Preview: View { 143 | var body: some View { 144 | VStack(spacing: 20) { 145 | PresentationLink { 146 | Preview() 147 | } label: { 148 | Text("Default") 149 | } 150 | 151 | PresentationLink( 152 | transition: .sheet(detents: [.medium]) 153 | ) { 154 | Preview() 155 | } label: { 156 | Text("Present Partial Sheet") 157 | } 158 | 159 | PresentationLink( 160 | transition: .fullscreen 161 | ) { 162 | Preview() 163 | } label: { 164 | Text("Present Fullscreen") 165 | } 166 | 167 | PresentationLink( 168 | transition: .popover 169 | ) { 170 | Preview() 171 | } label: { 172 | Text("Present Popover") 173 | } 174 | } 175 | } 176 | } 177 | 178 | static var previews: some View { 179 | Preview() 180 | } 181 | } 182 | 183 | #endif 184 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/PresentationLinkAsymmetricTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | @available(iOS 14.0, *) 10 | extension PresentationLinkTransition { 11 | 12 | public static func asymmetric< 13 | PresentedController: PresentationLinkPresentedTransitionRepresentable, 14 | PresentingAnimationController: PresentationLinkPresentingTransitionRepresentable, 15 | DismissingAnimationController: PresentationLinkDismissingTransitionRepresentable 16 | >( 17 | presented presentedController: PresentedController, 18 | presenting presentingAnimationController: PresentingAnimationController, 19 | dismissing dismissingAnimationController: DismissingAnimationController, 20 | options: PresentationLinkTransition.Options = .init( 21 | modalPresentationCapturesStatusBarAppearance: true 22 | ) 23 | ) -> PresentationLinkTransition { 24 | .custom( 25 | options: options, 26 | PresentationLinkAsymmetricTransition( 27 | presented: presentedController, 28 | presenting: presentingAnimationController, 29 | dismissing: dismissingAnimationController 30 | ) 31 | ) 32 | } 33 | } 34 | 35 | @frozen 36 | @available(iOS 14.0, *) 37 | public struct PresentationLinkAsymmetricTransition< 38 | PresentedController: PresentationLinkPresentedTransitionRepresentable, 39 | PresentingAnimationController: PresentationLinkPresentingTransitionRepresentable, 40 | DismissingAnimationController: PresentationLinkDismissingTransitionRepresentable 41 | >: PresentationLinkTransitionRepresentable { 42 | 43 | public typealias UIPresentationControllerType = PresentedController.UIPresentationControllerType 44 | public typealias UIPresentingAnimationControllerType = PresentingAnimationController.UIPresentingAnimationControllerType 45 | public typealias UIPresentingInteractionControllerType = PresentingAnimationController.UIPresentingInteractionControllerType 46 | public typealias UIDismissingAnimationControllerType = DismissingAnimationController.UIDismissingAnimationControllerType 47 | public typealias UIDismissingInteractionControllerType = DismissingAnimationController.UIDismissingInteractionControllerType 48 | 49 | public var presentedController: PresentedController 50 | public var presentingAnimationController: PresentingAnimationController 51 | public var dismissingAnimationController: DismissingAnimationController 52 | 53 | public init( 54 | presented presentedController: PresentedController, 55 | presenting presentingAnimationController: PresentingAnimationController, 56 | dismissing dismissingAnimationController: DismissingAnimationController 57 | ) { 58 | self.presentedController = presentedController 59 | self.presentingAnimationController = presentingAnimationController 60 | self.dismissingAnimationController = dismissingAnimationController 61 | } 62 | 63 | public func makeUIPresentationController( 64 | presented: UIViewController, 65 | presenting: UIViewController?, 66 | context: Context 67 | ) -> UIPresentationControllerType { 68 | let presentationController = presentedController.makeUIPresentationController( 69 | presented: presented, 70 | presenting: presenting, 71 | context: context 72 | ) 73 | if DismissingAnimationController.self != PresentedController.self, 74 | let interactivePresentationController = presentationController as? InteractivePresentationController 75 | { 76 | interactivePresentationController.prefersInteractiveDismissal = true 77 | } 78 | return presentationController 79 | } 80 | 81 | public func updateUIPresentationController( 82 | presentationController: UIPresentationControllerType, 83 | context: Context 84 | ) { 85 | presentedController.updateUIPresentationController( 86 | presentationController: presentationController, 87 | context: context 88 | ) 89 | } 90 | 91 | public func updateHostingController( 92 | presenting: PresentationHostingController, 93 | context: Context 94 | ) { 95 | presentedController.updateHostingController( 96 | presenting: presenting, 97 | context: context 98 | ) 99 | } 100 | 101 | public func animationController( 102 | forPresented presented: UIViewController, 103 | presenting: UIViewController, 104 | context: Context 105 | ) -> UIPresentingAnimationControllerType? { 106 | presentingAnimationController.animationController( 107 | forPresented: presented, 108 | presenting: presenting, 109 | context: context 110 | ) 111 | } 112 | 113 | public func interactionControllerForPresentation( 114 | using animator: UIViewControllerAnimatedTransitioning, 115 | context: Context 116 | ) -> UIPresentingInteractionControllerType? { 117 | presentingAnimationController.interactionControllerForPresentation( 118 | using: animator, 119 | context: context 120 | ) 121 | } 122 | 123 | public func animationController( 124 | forDismissed dismissed: UIViewController, 125 | context: Context 126 | ) -> UIDismissingAnimationControllerType? { 127 | let animationController = dismissingAnimationController.animationController( 128 | forDismissed: dismissed, 129 | context: context 130 | ) 131 | if UIPresentingAnimationControllerType.self == MatchedGeometryPresentationControllerTransition.self, 132 | UIDismissingAnimationControllerType.self != MatchedGeometryPresentationControllerTransition.self 133 | { 134 | context.sourceView?.alpha = 1 135 | } 136 | return animationController 137 | } 138 | 139 | public func interactionControllerForDismissal( 140 | using animator: UIViewControllerAnimatedTransitioning, 141 | context: Context 142 | ) -> UIDismissingInteractionControllerType? { 143 | dismissingAnimationController.interactionControllerForDismissal( 144 | using: animator, 145 | context: context 146 | ) 147 | } 148 | } 149 | 150 | #endif 151 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/PresentationLinkModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | /// A modifier that presents a destination view in a new `UIViewController`. 10 | /// 11 | /// To present the destination view with an animation, `isPresented` should 12 | /// be updated with a transaction that has an animation. For example: 13 | /// 14 | /// ``` 15 | /// withAnimation { 16 | /// isPresented = true 17 | /// } 18 | /// ``` 19 | /// 20 | /// The destination view is presented with the provided `transition`. 21 | /// By default, the ``PresentationLinkTransition/default`` transition is used. 22 | /// 23 | /// See Also: 24 | /// - ``PresentationLink`` 25 | /// - ``PresentationLinkTransition`` 26 | /// - ``PresentationLinkAdapter`` 27 | /// - ``TransitionReader`` 28 | /// 29 | /// > Tip: You can implement custom transitions with a `UIPresentationController` and/or 30 | /// `UIViewControllerInteractiveTransitioning` with the ``PresentationLinkTransition/custom(_:)`` 31 | /// transition. 32 | /// 33 | @available(iOS 14.0, *) 34 | @frozen 35 | public struct PresentationLinkModifier< 36 | Destination: View 37 | >: ViewModifier { 38 | 39 | var isPresented: Binding 40 | var destination: Destination 41 | var transition: PresentationLinkTransition 42 | 43 | public init( 44 | transition: PresentationLinkTransition = .default, 45 | isPresented: Binding, 46 | destination: Destination 47 | ) { 48 | self.isPresented = isPresented 49 | self.destination = destination 50 | self.transition = transition 51 | } 52 | 53 | public func body(content: Content) -> some View { 54 | content.background( 55 | PresentationLinkAdapter( 56 | transition: transition, 57 | isPresented: isPresented 58 | ) { 59 | destination 60 | } 61 | ) 62 | } 63 | } 64 | 65 | @available(iOS 14.0, *) 66 | extension View { 67 | /// A modifier that presents a destination view in a new `UIViewController`. 68 | /// 69 | /// To present the destination view with an animation, `isPresented` should 70 | /// be updated with a transaction that has an animation. For example: 71 | /// 72 | /// ``` 73 | /// withAnimation { 74 | /// isPresented = true 75 | /// } 76 | /// ``` 77 | /// 78 | /// See Also: 79 | /// - ``PresentationLinkModifier`` 80 | /// 81 | public func presentation( 82 | transition: PresentationLinkTransition = .default, 83 | isPresented: Binding, 84 | @ViewBuilder destination: () -> Destination 85 | ) -> some View { 86 | modifier( 87 | PresentationLinkModifier( 88 | transition: transition, 89 | isPresented: isPresented, 90 | destination: destination() 91 | ) 92 | ) 93 | } 94 | 95 | /// A modifier that presents a destination view in a new `UIViewController`. 96 | /// 97 | /// To present the destination view with an animation, `isPresented` should 98 | /// be updated with a transaction that has an animation. For example: 99 | /// 100 | /// ``` 101 | /// withAnimation { 102 | /// isPresented = true 103 | /// } 104 | /// ``` 105 | /// 106 | /// See Also: 107 | /// - ``PresentationLinkModifier`` 108 | /// 109 | public func presentation( 110 | _ value: Binding, 111 | transition: PresentationLinkTransition = .default, 112 | @ViewBuilder destination: (Binding) -> Destination 113 | ) -> some View { 114 | presentation(transition: transition, isPresented: value.isNotNil()) { 115 | OptionalAdapter(value, content: destination) 116 | } 117 | } 118 | 119 | /// A modifier that presents a destination `UIViewController`. 120 | /// 121 | /// To present the destination view with an animation, `isPresented` should 122 | /// be updated with a transaction that has an animation. For example: 123 | /// 124 | /// ``` 125 | /// withAnimation { 126 | /// isPresented = true 127 | /// } 128 | /// ``` 129 | /// 130 | /// See Also: 131 | /// - ``PresentationLinkModifier`` 132 | /// 133 | @_disfavoredOverload 134 | public func presentation( 135 | transition: PresentationLinkTransition = .default, 136 | isPresented: Binding, 137 | destination: @escaping (ViewControllerRepresentableAdapter.Context) -> ViewController 138 | ) -> some View { 139 | presentation(transition: transition, isPresented: isPresented) { 140 | ViewControllerRepresentableAdapter(destination) 141 | } 142 | } 143 | 144 | /// A modifier that presents a destination view in a new `UIViewController`. 145 | /// 146 | /// To present the destination view with an animation, `isPresented` should 147 | /// be updated with a transaction that has an animation. For example: 148 | /// 149 | /// ``` 150 | /// withAnimation { 151 | /// isPresented = true 152 | /// } 153 | /// ``` 154 | /// 155 | /// See Also: 156 | /// - ``PresentationLinkModifier`` 157 | /// 158 | @_disfavoredOverload 159 | public func presentation( 160 | _ value: Binding, 161 | transition: PresentationLinkTransition = .default, 162 | destination: @escaping (Binding, ViewControllerRepresentableAdapter.Context) -> UIViewController 163 | ) -> some View { 164 | presentation(transition: transition, isPresented: value.isNotNil()) { 165 | ViewControllerRepresentableAdapter { context in 166 | guard let value = value.unwrap() else { return UIViewController() } 167 | return destination(value, context) 168 | } 169 | } 170 | } 171 | } 172 | 173 | #endif 174 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/PresentationSourceViewLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A button that presents a destination view in a new `UIViewController`. The presentation is 11 | /// sourced from this view. 12 | /// 13 | /// Use ``PresentationLink`` if the transition does not require animating the source view. For 14 | /// example, the `.zoom` transition morphs the source view into the destination view, so using 15 | /// ``PresentationSourceViewLink`` allows the transition to animate the source view. 16 | /// 17 | /// To present the destination view with an animation, `isPresented` should 18 | /// be updated with a transaction that has an animation. For example: 19 | /// 20 | /// ``` 21 | /// withAnimation { 22 | /// isPresented = true 23 | /// } 24 | /// ``` 25 | /// 26 | /// See Also: 27 | /// - ``PresentationLink`` 28 | /// - ``PresentationLinkTransition`` 29 | /// - ``PresentationLinkAdapter`` 30 | /// - ``TransitionReader`` 31 | /// 32 | /// > Tip: You can implement custom transitions with a `UIPresentationController` and/or 33 | /// `UIViewControllerInteractiveTransitioning` with the ``PresentationLinkTransition/custom(_:)`` 34 | /// transition. 35 | /// 36 | @available(iOS 14.0, *) 37 | @frozen 38 | public struct PresentationSourceViewLink< 39 | Label: View, 40 | Destination: View 41 | >: View { 42 | var label: Label 43 | var destination: Destination 44 | var transition: PresentationLinkTransition 45 | var animation: Animation? 46 | 47 | @StateOrBinding var isPresented: Bool 48 | 49 | public init( 50 | transition: PresentationLinkTransition = .zoomIfAvailable, 51 | animation: Animation? = .default, 52 | @ViewBuilder destination: () -> Destination, 53 | @ViewBuilder label: () -> Label 54 | ) { 55 | self.label = label() 56 | self.destination = destination() 57 | self.transition = transition 58 | self.animation = animation 59 | self._isPresented = .init(false) 60 | } 61 | 62 | public init( 63 | transition: PresentationLinkTransition = .zoomIfAvailable, 64 | animation: Animation? = .default, 65 | isPresented: Binding, 66 | @ViewBuilder destination: () -> Destination, 67 | @ViewBuilder label: () -> Label 68 | ) { 69 | self.label = label() 70 | self.destination = destination() 71 | self.transition = transition 72 | self.animation = animation 73 | self._isPresented = .init(isPresented) 74 | } 75 | 76 | public var body: some View { 77 | PresentationLinkAdapter( 78 | transition: transition, 79 | isPresented: $isPresented 80 | ) { 81 | destination 82 | } content: { 83 | Button { 84 | withAnimation(animation) { 85 | isPresented.toggle() 86 | } 87 | } label: { 88 | label 89 | } 90 | } 91 | } 92 | } 93 | 94 | @available(iOS 14.0, *) 95 | extension PresentationSourceViewLink { 96 | @_disfavoredOverload 97 | public init( 98 | transition: PresentationLinkTransition = .default, 99 | destination: @escaping () -> ViewController, 100 | @ViewBuilder label: () -> Label 101 | ) where Destination == ViewControllerRepresentableAdapter { 102 | self.init(transition: transition) { 103 | ViewControllerRepresentableAdapter(destination) 104 | } label: { 105 | label() 106 | } 107 | } 108 | 109 | public init( 110 | transition: PresentationLinkTransition = .default, 111 | destination: @escaping (Destination.Context) -> ViewController, 112 | @ViewBuilder label: () -> Label 113 | ) where Destination == ViewControllerRepresentableAdapter { 114 | self.init(transition: transition) { 115 | ViewControllerRepresentableAdapter(destination) 116 | } label: { 117 | label() 118 | } 119 | } 120 | 121 | @_disfavoredOverload 122 | public init( 123 | transition: PresentationLinkTransition = .default, 124 | isPresented: Binding, 125 | destination: @escaping () -> ViewController, 126 | @ViewBuilder label: () -> Label 127 | ) where Destination == ViewControllerRepresentableAdapter { 128 | self.init(transition: transition, isPresented: isPresented) { 129 | ViewControllerRepresentableAdapter(destination) 130 | } label: { 131 | label() 132 | } 133 | } 134 | 135 | public init( 136 | transition: PresentationLinkTransition = .default, 137 | isPresented: Binding, 138 | destination: @escaping (Destination.Context) -> ViewController, 139 | @ViewBuilder label: () -> Label 140 | ) where Destination == ViewControllerRepresentableAdapter { 141 | self.init(transition: transition, isPresented: isPresented) { 142 | ViewControllerRepresentableAdapter(destination) 143 | } label: { 144 | label() 145 | } 146 | } 147 | } 148 | 149 | #endif 150 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/QuickLookPreviewLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | import QuickLook 10 | 11 | /// A quick look preview provider. 12 | @available(iOS 14.0, *) 13 | @frozen 14 | public struct QuickLookPreviewItem: Equatable { 15 | 16 | public var url: URL 17 | public var label: Text? 18 | 19 | public init(url: URL, label: Text? = nil) { 20 | self.url = url 21 | self.label = label 22 | } 23 | } 24 | 25 | /// A quick look preview transition. 26 | @available(iOS 14.0, *) 27 | @frozen 28 | public struct QuickLookPreviewTransition: Equatable { 29 | 30 | var rawValue: UInt8 31 | 32 | /// The default transition effect 33 | public static let `default` = QuickLookPreviewTransition(rawValue: 0) 34 | 35 | /// A scaled transition effect from the presenting source view 36 | public static let scale = QuickLookPreviewTransition(rawValue: 1 << 0) 37 | } 38 | 39 | /// A button that presents a `QLPreviewController` 40 | /// 41 | /// To present the preview with an animation, `isPresented` should 42 | /// be updated with a transaction that has an animation. For example: 43 | /// 44 | /// ``` 45 | /// withAnimation { 46 | /// isPresented = true 47 | /// } 48 | /// ``` 49 | /// 50 | @available(iOS 14.0, *) 51 | @frozen 52 | public struct QuickLookPreviewLink< 53 | Label: View 54 | >: View { 55 | var label: Label 56 | var items: [QuickLookPreviewItem] 57 | var transition: QuickLookPreviewTransition 58 | 59 | @StateOrBinding var isPresented: Bool 60 | 61 | public init( 62 | items: [QuickLookPreviewItem], 63 | transition: QuickLookPreviewTransition = .default, 64 | @ViewBuilder label: () -> Label 65 | ) { 66 | self.label = label() 67 | self.items = items 68 | self.transition = transition 69 | self._isPresented = .init(false) 70 | } 71 | 72 | public init( 73 | items: [QuickLookPreviewItem], 74 | transition: QuickLookPreviewTransition = .default, 75 | isPresented: Binding, 76 | @ViewBuilder label: () -> Label 77 | ) { 78 | self.label = label() 79 | self.items = items 80 | self.transition = transition 81 | self._isPresented = .init(isPresented) 82 | } 83 | 84 | public init( 85 | url: URL, 86 | transition: QuickLookPreviewTransition = .default, 87 | @ViewBuilder label: () -> Label 88 | ) { 89 | self.init(items: [.init(url: url)], transition: transition, label: label) 90 | } 91 | 92 | public var body: some View { 93 | Button { 94 | withAnimation { 95 | isPresented = true 96 | } 97 | } label: { 98 | label 99 | } 100 | .modifier( 101 | QuickLookPreviewLinkModifier( 102 | items: items, 103 | transition: transition, 104 | isPresented: $isPresented 105 | ) 106 | ) 107 | } 108 | } 109 | 110 | @available(iOS 14.0, *) 111 | extension View { 112 | 113 | /// A modifier that presents a `QLPreviewController` 114 | public func quickLookPreview( 115 | items: [QuickLookPreviewItem], 116 | transition: QuickLookPreviewTransition = .default, 117 | isPresented: Binding 118 | ) -> some View { 119 | modifier( 120 | QuickLookPreviewLinkModifier( 121 | items: items, 122 | transition: transition, 123 | isPresented: isPresented 124 | ) 125 | ) 126 | } 127 | 128 | /// A modifier that presents a `QLPreviewController` 129 | public func quickLookPreview( 130 | url: URL, 131 | transition: QuickLookPreviewTransition = .default, 132 | isPresented: Binding 133 | ) -> some View { 134 | modifier( 135 | QuickLookPreviewLinkModifier( 136 | items: [.init(url: url)], 137 | transition: transition, 138 | isPresented: isPresented 139 | ) 140 | ) 141 | } 142 | } 143 | 144 | /// A modifier that presents a `QLPreviewController` 145 | @available(iOS 14.0, *) 146 | @frozen 147 | public struct QuickLookPreviewLinkModifier: ViewModifier { 148 | 149 | var items: [QuickLookPreviewItem] 150 | var transition: QuickLookPreviewTransition 151 | var isPresented: Binding 152 | 153 | public init( 154 | items: [QuickLookPreviewItem], 155 | transition: QuickLookPreviewTransition = .default, 156 | isPresented: Binding 157 | ) { 158 | self.items = items 159 | self.transition = transition 160 | self.isPresented = isPresented 161 | } 162 | 163 | public func body(content: Content) -> some View { 164 | content 165 | .modifier( 166 | PresentationLinkModifier( 167 | transition: .default, 168 | isPresented: isPresented, 169 | destination: Destination( 170 | items: items, 171 | transition: transition 172 | ) 173 | ) 174 | ) 175 | } 176 | 177 | private struct Destination: UIViewControllerRepresentable { 178 | var items: [QuickLookPreviewItem] 179 | var transition: QuickLookPreviewTransition 180 | 181 | func makeUIViewController( 182 | context: Context 183 | ) -> PreviewController { 184 | let uiViewController = PreviewController() 185 | return uiViewController 186 | } 187 | 188 | func updateUIViewController( 189 | _ uiViewController: PreviewController, 190 | context: Context 191 | ) { 192 | let items = items.map { $0.resolve(in: context.environment) } 193 | uiViewController.openURL = context.environment.openURL 194 | uiViewController.sourceView = transition == .default ? nil : context.environment.presentationCoordinator.sourceView 195 | uiViewController.items = items 196 | } 197 | 198 | class PreviewController: QLPreviewController, QLPreviewControllerDataSource, QLPreviewControllerDelegate { 199 | var openURL: OpenURLAction? 200 | weak var sourceView: UIView? 201 | var items: [QuickLookPreviewItem.Resolved] = [] { 202 | didSet { 203 | guard oldValue != items else { return } 204 | previewItems = items.map { $0.makeQLPreviewItem() } 205 | if viewIfLoaded != nil { 206 | reloadData() 207 | } 208 | } 209 | } 210 | 211 | private var previewItems: [QuickLookPreviewItem.Resolved.PreviewItem] = [] 212 | 213 | init() { 214 | super.init(nibName: nil, bundle: nil) 215 | dataSource = self 216 | delegate = self 217 | } 218 | 219 | required init?(coder: NSCoder) { 220 | fatalError("init(coder:) has not been implemented") 221 | } 222 | 223 | func numberOfPreviewItems( 224 | in controller: QLPreviewController 225 | ) -> Int { 226 | previewItems.count 227 | } 228 | 229 | func previewController( 230 | _ controller: QLPreviewController, 231 | previewItemAt index: Int 232 | ) -> QLPreviewItem { 233 | previewItems[index] 234 | } 235 | 236 | func previewController( 237 | _ controller: QLPreviewController, 238 | shouldOpen url: URL, 239 | for item: QLPreviewItem 240 | ) -> Bool { 241 | if let openURL { 242 | openURL(url) 243 | return false 244 | } 245 | return true 246 | } 247 | 248 | func previewController( 249 | _ controller: QLPreviewController, 250 | transitionViewFor item: QLPreviewItem 251 | ) -> UIView? { 252 | if item === previewItems.first { 253 | return sourceView 254 | } 255 | return nil 256 | } 257 | } 258 | } 259 | } 260 | 261 | @available(iOS 14.0, *) 262 | extension QuickLookPreviewItem { 263 | struct Resolved: Equatable { 264 | var label: String? 265 | var url: URL 266 | 267 | class PreviewItem: NSObject, QLPreviewItem { 268 | var item: QuickLookPreviewItem.Resolved 269 | 270 | var previewItemTitle: String? { item.label } 271 | var previewItemURL: URL? { item.url } 272 | 273 | init(item: QuickLookPreviewItem.Resolved) { 274 | self.item = item 275 | } 276 | } 277 | func makeQLPreviewItem() -> PreviewItem { 278 | PreviewItem(item: self) 279 | } 280 | } 281 | 282 | func resolve(in environment: EnvironmentValues) -> Resolved { 283 | Resolved(label: label?.resolve(in: environment), url: url) 284 | } 285 | } 286 | 287 | #endif 288 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Supporting Files/ObjCBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | import Foundation 6 | 7 | final class ObjCBox: NSObject { 8 | var value: Value 9 | init(value: Value) { self.value = value } 10 | } 11 | 12 | final class ObjCWeakBox: NSObject { 13 | weak var value: Value? 14 | init(value: Value?) { self.value = value } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Supporting Files/PortalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | open class PortalView: UIView { 10 | 11 | let contentView: UIView 12 | 13 | public var hidesSourceView: Bool { 14 | get { 15 | let aSelector = NSSelectorFromString("hidesSourceView") 16 | guard contentView.responds(to: aSelector) else { return false } 17 | return contentView.perform(aSelector).takeUnretainedValue() as? Bool ?? false 18 | } 19 | set { 20 | let aSelector = NSSelectorFromString("setHidesSourceView:") 21 | guard contentView.responds(to: aSelector) else { return } 22 | contentView.perform(aSelector, with: newValue) 23 | } 24 | } 25 | 26 | public init?(sourceView: UIView) { 27 | let allocSelector = NSSelectorFromString("alloc") 28 | let initSelector = NSSelectorFromString("initWithSourceView:") 29 | guard 30 | let portalViewClassName = String(data: Data(base64Encoded: "X1VJUG9ydGFsVmlldw==")!, encoding: .utf8), // _UIPortalView 31 | let portalViewClass = NSClassFromString(portalViewClassName) as? UIView.Type 32 | else { 33 | return nil 34 | } 35 | let instance = portalViewClass.perform(allocSelector).takeUnretainedValue() 36 | guard 37 | instance.responds(to: initSelector), 38 | let portalView = instance.perform(initSelector, with: sourceView).takeUnretainedValue() as? UIView 39 | else { 40 | return nil 41 | } 42 | contentView = portalView 43 | super.init(frame: sourceView.frame) 44 | addSubview(contentView) 45 | } 46 | 47 | public required init?(coder: NSCoder) { 48 | fatalError("init(coder:) has not been implemented") 49 | } 50 | 51 | open override func layoutSubviews() { 52 | super.layoutSubviews() 53 | contentView.frame = bounds 54 | } 55 | } 56 | 57 | #endif 58 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Supporting Files/PresentationDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | protocol UIViewControllerPresentationDelegate: NSObject { 10 | func viewControllerDidDismiss(_ presentingViewController: UIViewController?, animated: Bool) 11 | } 12 | 13 | extension UIViewController { 14 | 15 | private static var presentationDelegateKey: Bool = false 16 | 17 | var presentationDelegate: UIViewControllerPresentationDelegate? { 18 | get { 19 | guard let obj = objc_getAssociatedObject(self, &Self.presentationDelegateKey) as? ObjCWeakBox else { 20 | return nil 21 | } 22 | return obj.value as? UIViewControllerPresentationDelegate 23 | } 24 | set { 25 | if !Self.presentationDelegateKey { 26 | Self.presentationDelegateKey = true 27 | 28 | let original = #selector(UIViewController.dismiss(animated:completion:)) 29 | let swizzled = #selector(UIViewController.swizzled_dismiss(animated:completion:)) 30 | if let originalMethod = class_getInstanceMethod(UIViewController.self, original), 31 | let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzled) 32 | { 33 | method_exchangeImplementations(originalMethod, swizzledMethod) 34 | } 35 | } 36 | 37 | if let box = objc_getAssociatedObject(self, &Self.presentationDelegateKey) as? ObjCWeakBox { 38 | box.value = newValue 39 | } else { 40 | let box = ObjCWeakBox(value: newValue) 41 | objc_setAssociatedObject(self, &Self.presentationDelegateKey, box, .OBJC_ASSOCIATION_RETAIN) 42 | } 43 | } 44 | } 45 | 46 | @objc 47 | func swizzled_dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { 48 | var parentDelegates = [UIViewControllerPresentationDelegate]() 49 | var presentedDelegates = [UIViewControllerPresentationDelegate]() 50 | var next: UIViewController? = parent 51 | while let current = next { 52 | if let presentationDelegate = current.presentationDelegate { 53 | parentDelegates.append(presentationDelegate) 54 | } 55 | next = current.parent 56 | } 57 | next = presentedViewController 58 | while let current = next { 59 | if let presentationDelegate = current.presentationDelegate { 60 | presentedDelegates.append(presentationDelegate) 61 | } 62 | next = current.presentedViewController 63 | } 64 | let presentingViewController = presentingViewController 65 | swizzled_dismiss(animated: flag) { 66 | if self.transitionCoordinator?.isCancelled != true { 67 | for delegate in presentedDelegates.reversed() { 68 | delegate.viewControllerDidDismiss(presentingViewController, animated: flag) 69 | } 70 | 71 | if self.presentingViewController == nil, let delegate = self.presentationDelegate { 72 | delegate.viewControllerDidDismiss(presentingViewController, animated: flag) 73 | } 74 | 75 | for delegate in parentDelegates.reversed() { 76 | delegate.viewControllerDidDismiss(presentingViewController, animated: flag) 77 | } 78 | } 79 | completion?() 80 | } 81 | } 82 | 83 | func fixSwiftUIHitTesting() { 84 | if let tabBarController = self as? UITabBarController { 85 | tabBarController.selectedViewController?.fixSwiftUIHitTesting() 86 | } else if let navigationController = self as? UINavigationController { 87 | navigationController.topViewController?.fixSwiftUIHitTesting() 88 | } else if let splitViewController = self as? UISplitViewController { 89 | for viewController in splitViewController.viewControllers { 90 | viewController.fixSwiftUIHitTesting() 91 | } 92 | } else if let pageViewController = self as? UIPageViewController { 93 | for viewController in pageViewController.viewControllers ?? [] { 94 | viewController.fixSwiftUIHitTesting() 95 | } 96 | } else if let view = viewIfLoaded { 97 | // This fixes SwiftUI's gesture handling that can get messed up when applying 98 | // transforms and/or frame changes during an interactive presentation. This resets 99 | // SwiftUI's geometry in a clean way, fixing hit testing. 100 | let frame = view.frame 101 | view.frame = .zero 102 | view.frame = frame 103 | } 104 | } 105 | } 106 | 107 | #endif 108 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Supporting Files/TransitionSourceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | class ViewControllerReader: UIView { 10 | 11 | let onDidMoveToWindow: (UIViewController?) -> Void 12 | 13 | init(onDidMoveToWindow: @escaping (UIViewController?) -> Void) { 14 | self.onDidMoveToWindow = onDidMoveToWindow 15 | super.init(frame: .zero) 16 | isHidden = true 17 | } 18 | 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | override func sizeThatFits(_ size: CGSize) -> CGSize { 24 | return size 25 | } 26 | 27 | override func didMoveToWindow() { 28 | super.didMoveToWindow() 29 | onDidMoveToWindow(viewController) 30 | } 31 | } 32 | 33 | class TransitionSourceView: ViewControllerReader { 34 | 35 | var hostingView: HostingView? 36 | 37 | init( 38 | onDidMoveToWindow: @escaping (UIViewController?) -> Void, 39 | content: Content 40 | ) { 41 | super.init(onDidMoveToWindow: onDidMoveToWindow) 42 | if Content.self != EmptyView.self { 43 | isHidden = false 44 | let hostingView = HostingView(content: content) 45 | addSubview(hostingView) 46 | hostingView.disablesSafeArea = true 47 | self.hostingView = hostingView 48 | } 49 | } 50 | 51 | required init?(coder: NSCoder) { 52 | fatalError("init(coder:) has not been implemented") 53 | } 54 | 55 | override func sizeThatFits(_ size: CGSize) -> CGSize { 56 | hostingView?.sizeThatFits(size) ?? super.sizeThatFits(size) 57 | } 58 | 59 | override func layoutSubviews() { 60 | super.layoutSubviews() 61 | hostingView?.frame = bounds 62 | } 63 | } 64 | 65 | 66 | #endif 67 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Supporting Files/WindowReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | final class WindowReader: UIView { 11 | let presentingWindow: Binding 12 | 13 | init(presentingWindow: Binding) { 14 | self.presentingWindow = presentingWindow 15 | super.init(frame: .zero) 16 | isHidden = true 17 | } 18 | 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | override func didMoveToWindow() { 24 | super.didMoveToWindow() 25 | withCATransaction { [weak self] in 26 | guard let self = self else { 27 | return 28 | } 29 | self.presentingWindow.wrappedValue = self.window 30 | } 31 | } 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/TransitionReader+AppearanceTransition .swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | extension UIViewController { 10 | 11 | private static var beginAppearanceTransitionKey: Bool = false 12 | 13 | struct BeginAppearanceTransition { 14 | var value: () -> Void 15 | } 16 | 17 | func swizzle_beginAppearanceTransition(_ transition: (() -> Void)?) { 18 | let original = #selector(UIViewController.beginAppearanceTransition(_:animated:)) 19 | let swizzled = #selector(UIViewController.swizzled_beginAppearanceTransition(_:animated:)) 20 | 21 | if !Self.beginAppearanceTransitionKey { 22 | Self.beginAppearanceTransitionKey = true 23 | 24 | if let originalMethod = class_getInstanceMethod(Self.self, original), 25 | let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzled) 26 | { 27 | method_exchangeImplementations(originalMethod, swizzledMethod) 28 | } 29 | } 30 | 31 | if let transition { 32 | let box = ObjCBox(value: BeginAppearanceTransition(value: transition)) 33 | objc_setAssociatedObject(self, &Self.beginAppearanceTransitionKey, box, .OBJC_ASSOCIATION_RETAIN) 34 | } else { 35 | objc_setAssociatedObject(self, &Self.beginAppearanceTransitionKey, nil, .OBJC_ASSOCIATION_RETAIN) 36 | } 37 | } 38 | 39 | @objc 40 | func swizzled_beginAppearanceTransition(_ isAppearing: Bool, animated: Bool) { 41 | if let box = objc_getAssociatedObject(self, &Self.beginAppearanceTransitionKey) as? ObjCBox { 42 | box.value.value() 43 | } 44 | 45 | typealias BeginAppearanceTransitionMethod = @convention(c) (NSObject, Selector, Bool, Bool) -> Void 46 | let swizzled = #selector(UIViewController.swizzled_beginAppearanceTransition(_:animated:)) 47 | unsafeBitCast(method(for: swizzled), to: BeginAppearanceTransitionMethod.self)(self, swizzled, isAppearing, animated) 48 | } 49 | 50 | private static var endAppearanceTransitionKey: Bool = false 51 | 52 | struct EndAppearanceTransition { 53 | var value: () -> Void 54 | } 55 | 56 | func swizzle_endAppearanceTransition(_ transition: (() -> Void)?) { 57 | let original = #selector(UIViewController.endAppearanceTransition) 58 | let swizzled = #selector(UIViewController.swizzled_endAppearanceTransition) 59 | 60 | if !Self.endAppearanceTransitionKey { 61 | Self.endAppearanceTransitionKey = true 62 | 63 | if let originalMethod = class_getInstanceMethod(Self.self, original), 64 | let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzled) 65 | { 66 | method_exchangeImplementations(originalMethod, swizzledMethod) 67 | } 68 | } 69 | 70 | if let transition { 71 | let box = ObjCBox(value: EndAppearanceTransition(value: transition)) 72 | objc_setAssociatedObject(self, &Self.endAppearanceTransitionKey, box, .OBJC_ASSOCIATION_RETAIN) 73 | } else { 74 | objc_setAssociatedObject(self, &Self.endAppearanceTransitionKey, nil, .OBJC_ASSOCIATION_RETAIN) 75 | } 76 | } 77 | 78 | @objc 79 | func swizzled_endAppearanceTransition() { 80 | if let box = objc_getAssociatedObject(self, &Self.endAppearanceTransitionKey) as? ObjCBox { 81 | box.value.value() 82 | } 83 | 84 | typealias EndAppearanceTransitionMethod = @convention(c) (NSObject, Selector) -> Void 85 | let swizzled = #selector(UIViewController.swizzled_endAppearanceTransition) 86 | unsafeBitCast(method(for: swizzled), to: EndAppearanceTransitionMethod.self)(self, swizzled) 87 | } 88 | } 89 | 90 | #endif 91 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/CornerRadiusOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | @frozen 10 | public enum CornerRadiusOptions: Equatable, Sendable { 11 | 12 | @frozen 13 | public struct RoundedRectangle: Equatable, Sendable { 14 | public var cornerRadius: CGFloat 15 | public var mask: CACornerMask 16 | public var style: CALayerCornerCurve 17 | 18 | private init( 19 | cornerRadius: CGFloat, 20 | mask: CACornerMask, 21 | style: CALayerCornerCurve 22 | ) { 23 | self.cornerRadius = cornerRadius 24 | self.mask = mask 25 | self.style = style 26 | } 27 | 28 | public static func rounded( 29 | cornerRadius: CGFloat, 30 | mask: CACornerMask, 31 | style: CALayerCornerCurve 32 | ) -> RoundedRectangle { 33 | RoundedRectangle( 34 | cornerRadius: cornerRadius, 35 | mask: mask, 36 | style: style 37 | ) 38 | } 39 | 40 | public static func rounded( 41 | cornerRadius: CGFloat, 42 | style: CALayerCornerCurve = .circular 43 | ) -> RoundedRectangle { 44 | .rounded( 45 | cornerRadius: cornerRadius, 46 | mask: .all, 47 | style: style 48 | ) 49 | } 50 | 51 | public static func screen( 52 | min: CGFloat = 12 53 | ) -> RoundedRectangle { 54 | .rounded( 55 | cornerRadius: UIScreen.main.displayCornerRadius(min: min), 56 | style: .continuous 57 | ) 58 | } 59 | } 60 | 61 | case rounded(RoundedRectangle) 62 | case circle 63 | 64 | public var mask: CACornerMask { 65 | switch self { 66 | case .rounded(let options): 67 | return options.mask 68 | case .circle: 69 | return .all 70 | } 71 | } 72 | 73 | public var style: CALayerCornerCurve { 74 | switch self { 75 | case .rounded(let options): 76 | return options.style 77 | case .circle: 78 | return .circular 79 | } 80 | } 81 | 82 | public func cornerRadius(for height: CGFloat) -> CGFloat { 83 | switch self { 84 | case .rounded(let options): 85 | return options.cornerRadius 86 | case .circle: 87 | return height / 2 88 | } 89 | } 90 | 91 | public static func rounded( 92 | cornerRadius: CGFloat, 93 | style: CALayerCornerCurve = .circular 94 | ) -> CornerRadiusOptions { 95 | .rounded( 96 | cornerRadius: cornerRadius, 97 | mask: .all, 98 | style: style 99 | ) 100 | } 101 | 102 | public static func rounded( 103 | cornerRadius: CGFloat, 104 | mask: CACornerMask, 105 | style: CALayerCornerCurve 106 | ) -> CornerRadiusOptions { 107 | .rounded( 108 | .rounded( 109 | cornerRadius: cornerRadius, 110 | mask: mask, 111 | style: style 112 | ) 113 | ) 114 | } 115 | 116 | public func apply(to layer: CALayer, height: CGFloat) { 117 | switch self { 118 | case .rounded(let options): 119 | options.apply(to: layer) 120 | case .circle: 121 | layer.cornerRadius = cornerRadius(for: height) 122 | layer.maskedCorners = mask 123 | layer.cornerCurve = style 124 | layer.masksToBounds = true 125 | } 126 | } 127 | } 128 | 129 | extension CornerRadiusOptions.RoundedRectangle { 130 | public func apply(to layer: CALayer) { 131 | let cornerRadius = cornerRadius 132 | layer.cornerRadius = cornerRadius 133 | layer.maskedCorners = mask 134 | layer.cornerCurve = style 135 | layer.masksToBounds = cornerRadius > 0 136 | } 137 | } 138 | 139 | extension CACornerMask { 140 | static let all: CACornerMask = [ 141 | .layerMaxXMaxYCorner, 142 | .layerMaxXMinYCorner, 143 | .layerMinXMaxYCorner, 144 | .layerMinXMinYCorner 145 | ] 146 | } 147 | 148 | #endif 149 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/DestinationLink Transitions/MatchedGeometryDestinationLinkTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | extension DestinationLinkTransition { 12 | 13 | /// The matched geometry transition style. 14 | public static var matchedGeometry: DestinationLinkTransition { 15 | .matchedGeometry(.init()) 16 | } 17 | 18 | /// The matched geometry transition style. 19 | public static func matchedGeometry( 20 | _ transitionOptions: MatchedGeometryDestinationLinkTransition.Options, 21 | options: DestinationLinkTransition.Options = .init() 22 | ) -> DestinationLinkTransition { 23 | .custom( 24 | options: options, 25 | MatchedGeometryDestinationLinkTransition(options: transitionOptions) 26 | ) 27 | } 28 | 29 | /// The matched geometry transition style. 30 | public static func matchedGeometry( 31 | preferredFromCornerRadius: CornerRadiusOptions? = nil, 32 | prefersZoomEffect: Bool = false, 33 | initialOpacity: CGFloat = 1, 34 | isInteractive: Bool = true, 35 | preferredPresentationBackgroundColor: Color? = nil 36 | ) -> DestinationLinkTransition { 37 | .matchedGeometry( 38 | .init( 39 | preferredFromCornerRadius: preferredFromCornerRadius, 40 | prefersZoomEffect: prefersZoomEffect, 41 | initialOpacity: initialOpacity 42 | ), 43 | options: .init( 44 | isInteractive: isInteractive, 45 | preferredPresentationBackgroundColor: preferredPresentationBackgroundColor 46 | ) 47 | ) 48 | } 49 | 50 | /// The matched geometry transition style. 51 | public static let matchedGeometryZoom: DestinationLinkTransition = .matchedGeometryZoom() 52 | 53 | /// The matched geometry transition style. 54 | public static func matchedGeometryZoom( 55 | preferredFromCornerRadius: CornerRadiusOptions? = nil 56 | ) -> DestinationLinkTransition { 57 | .matchedGeometry( 58 | preferredFromCornerRadius: preferredFromCornerRadius, 59 | prefersZoomEffect: true, 60 | initialOpacity: 0 61 | ) 62 | } 63 | } 64 | 65 | @available(iOS 14.0, *) 66 | public struct MatchedGeometryDestinationLinkTransition: DestinationLinkTransitionRepresentable { 67 | 68 | /// The transition options for a matched geometry transition. 69 | @frozen 70 | public struct Options { 71 | 72 | public var preferredFromCornerRadius: CornerRadiusOptions? 73 | public var prefersZoomEffect: Bool 74 | public var initialOpacity: CGFloat 75 | 76 | public init( 77 | preferredFromCornerRadius: CornerRadiusOptions? = nil, 78 | prefersZoomEffect: Bool = false, 79 | initialOpacity: CGFloat = 1 80 | ) { 81 | self.preferredFromCornerRadius = preferredFromCornerRadius 82 | self.prefersZoomEffect = prefersZoomEffect 83 | self.initialOpacity = initialOpacity 84 | } 85 | } 86 | public var options: Options 87 | 88 | public init(options: Options = .init()) { 89 | self.options = options 90 | } 91 | 92 | public func navigationController( 93 | _ navigationController: UINavigationController, 94 | pushing toVC: UIViewController, 95 | from fromVC: UIViewController, 96 | context: Context 97 | ) -> MatchedGeometryNavigationControllerTransition? { 98 | 99 | let transition = MatchedGeometryNavigationControllerTransition( 100 | sourceView: context.sourceView, 101 | prefersScaleEffect: false, 102 | prefersZoomEffect: options.prefersZoomEffect, 103 | preferredFromCornerRadius: options.preferredFromCornerRadius, 104 | preferredToCornerRadius: nil, 105 | initialOpacity: options.initialOpacity, 106 | isPresenting: true, 107 | animation: context.transaction.animation 108 | ) 109 | return transition 110 | } 111 | 112 | public func navigationController( 113 | _ navigationController: UINavigationController, 114 | popping fromVC: UIViewController, 115 | to toVC: UIViewController, 116 | context: Context 117 | ) -> MatchedGeometryNavigationControllerTransition? { 118 | 119 | let transition = MatchedGeometryNavigationControllerTransition( 120 | sourceView: context.sourceView, 121 | prefersScaleEffect: false, 122 | prefersZoomEffect: options.prefersZoomEffect, 123 | preferredFromCornerRadius: options.preferredFromCornerRadius, 124 | preferredToCornerRadius: nil, 125 | initialOpacity: options.initialOpacity, 126 | isPresenting: false, 127 | animation: context.transaction.animation 128 | ) 129 | transition.wantsInteractiveStart = true 130 | return transition 131 | } 132 | } 133 | 134 | @available(iOS 14.0, *) 135 | open class MatchedGeometryNavigationControllerTransition: MatchedGeometryViewControllerTransition { 136 | 137 | open override func update(_ percentComplete: CGFloat) { 138 | let frictionPercentComplete = frictionCurve(percentComplete, distance: 1, coefficient: 0.75) 139 | super.update(frictionPercentComplete) 140 | } 141 | } 142 | 143 | #endif 144 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/DestinationLink Transitions/SlideDestinationLinkTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | extension DestinationLinkTransition { 12 | 13 | /// The slide transition style. 14 | public static var slide: DestinationLinkTransition { 15 | .slide(.init()) 16 | } 17 | 18 | /// The slide transition style. 19 | public static func slide( 20 | _ transitionOptions: SlideDestinationLinkTransition.Options, 21 | options: DestinationLinkTransition.Options = .init() 22 | ) -> DestinationLinkTransition { 23 | .custom( 24 | options: options, 25 | SlideDestinationLinkTransition(options: transitionOptions) 26 | ) 27 | } 28 | 29 | /// The slide transition style. 30 | public static func slide( 31 | initialOpacity: CGFloat = 1, 32 | isInteractive: Bool = true, 33 | preferredPresentationBackgroundColor: Color? = nil 34 | ) -> DestinationLinkTransition { 35 | .slide( 36 | .init( 37 | initialOpacity: initialOpacity 38 | ), 39 | options: .init( 40 | isInteractive: isInteractive, 41 | preferredPresentationBackgroundColor: preferredPresentationBackgroundColor 42 | ) 43 | ) 44 | } 45 | } 46 | 47 | @available(iOS 14.0, *) 48 | public struct SlideDestinationLinkTransition: DestinationLinkTransitionRepresentable { 49 | 50 | /// The transition options for a slide transition. 51 | @frozen 52 | public struct Options { 53 | 54 | public var initialOpacity: CGFloat 55 | 56 | public init( 57 | initialOpacity: CGFloat = 1 58 | ) { 59 | self.initialOpacity = initialOpacity 60 | } 61 | } 62 | public var options: Options 63 | 64 | public init(options: Options = .init()) { 65 | self.options = options 66 | } 67 | 68 | public func navigationController( 69 | _ navigationController: UINavigationController, 70 | pushing toVC: UIViewController, 71 | from fromVC: UIViewController, 72 | context: Context 73 | ) -> SlideNavigationControllerTransition? { 74 | let transition = SlideNavigationControllerTransition( 75 | initialOpacity: options.initialOpacity, 76 | isPresenting: true, 77 | animation: context.transaction.animation 78 | ) 79 | transition.wantsInteractiveStart = false 80 | return transition 81 | } 82 | 83 | public func navigationController( 84 | _ navigationController: UINavigationController, 85 | popping fromVC: UIViewController, 86 | to toVC: UIViewController, 87 | context: Context 88 | ) -> SlideNavigationControllerTransition? { 89 | let transition = SlideNavigationControllerTransition( 90 | initialOpacity: options.initialOpacity, 91 | isPresenting: false, 92 | animation: context.transaction.animation 93 | ) 94 | transition.wantsInteractiveStart = true 95 | return transition 96 | } 97 | } 98 | 99 | @available(iOS 14.0, *) 100 | open class SlideNavigationControllerTransition: ViewControllerTransition { 101 | 102 | public let initialOpacity: CGFloat 103 | 104 | public init( 105 | initialOpacity: CGFloat, 106 | isPresenting: Bool, 107 | animation: Animation? 108 | ) { 109 | self.initialOpacity = initialOpacity 110 | super.init(isPresenting: isPresenting, animation: animation) 111 | } 112 | 113 | open override func configureTransitionAnimator( 114 | using transitionContext: any UIViewControllerContextTransitioning, 115 | animator: UIViewPropertyAnimator 116 | ) { 117 | guard 118 | let fromVC = transitionContext.viewController(forKey: .from), 119 | let toVC = transitionContext.viewController(forKey: .to) 120 | else { 121 | transitionContext.completeTransition(false) 122 | return 123 | } 124 | 125 | let width = transitionContext.containerView.frame.width 126 | transitionContext.containerView.addSubview(toVC.view) 127 | toVC.view.frame = transitionContext.finalFrame(for: toVC) 128 | toVC.view.transform = CGAffineTransform( 129 | translationX: isPresenting ? width : -width, 130 | y: 0 131 | ) 132 | toVC.view.layoutIfNeeded() 133 | toVC.view.alpha = initialOpacity 134 | 135 | let fromVCTransform = CGAffineTransform( 136 | translationX: isPresenting ? -width : width, 137 | y: 0 138 | ) 139 | 140 | animator.addAnimations { [initialOpacity] in 141 | toVC.view.transform = .identity 142 | toVC.view.alpha = 1 143 | fromVC.view.transform = fromVCTransform 144 | fromVC.view.alpha = initialOpacity 145 | } 146 | animator.addCompletion { animatingPosition in 147 | toVC.view.transform = .identity 148 | fromVC.view.transform = .identity 149 | switch animatingPosition { 150 | case .end: 151 | transitionContext.completeTransition(true) 152 | default: 153 | transitionContext.completeTransition(false) 154 | } 155 | } 156 | } 157 | } 158 | 159 | #endif 160 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/Presentation Controllers/MatchedGeometryPresentationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | /// A presentation controller that presents the view from a source view rect 11 | @available(iOS 14.0, *) 12 | open class MatchedGeometryPresentationController: InteractivePresentationController { 13 | 14 | public var prefersZoomEffect: Bool 15 | 16 | public var minimumScaleFactor: CGFloat 17 | 18 | open override var wantsInteractiveDismissal: Bool { 19 | return true 20 | } 21 | 22 | public init( 23 | edges: Edge.Set = .all, 24 | minimumScaleFactor: CGFloat = 0.5, 25 | prefersZoomEffect: Bool = false, 26 | presentedViewController: UIViewController, 27 | presenting presentingViewController: UIViewController? 28 | ) { 29 | self.minimumScaleFactor = minimumScaleFactor 30 | self.prefersZoomEffect = prefersZoomEffect 31 | super.init( 32 | presentedViewController: presentedViewController, 33 | presenting: presentingViewController 34 | ) 35 | self.edges = edges 36 | } 37 | 38 | open override func dismissalTransitionShouldBegin( 39 | translation: CGPoint, 40 | delta: CGPoint, 41 | velocity: CGPoint 42 | ) -> Bool { 43 | guard panGesture.state == .ended else { 44 | return false 45 | } 46 | let dz = sqrt(pow(translation.y, 2) + pow(translation.x, 2)) 47 | let magnitude = sqrt(pow(velocity.y, 2) + pow(velocity.x, 2)) 48 | let canFinish = (dz >= 200 && magnitude > 0) || magnitude >= 1000 49 | guard canFinish else { return false } 50 | return super.dismissalTransitionShouldBegin( 51 | translation: translation, 52 | delta: delta, 53 | velocity: velocity 54 | ) 55 | } 56 | 57 | open override func presentationTransitionWillBegin() { 58 | super.presentationTransitionWillBegin() 59 | presentedViewController.view.layer.cornerCurve = .continuous 60 | } 61 | 62 | open override func presentationTransitionDidEnd(_ completed: Bool) { 63 | super.presentationTransitionDidEnd(completed) 64 | if completed { 65 | presentedViewController.view.layer.cornerRadius = 0 66 | } 67 | } 68 | 69 | open override func transformPresentedView(transform: CGAffineTransform) { 70 | if prefersZoomEffect { 71 | if transform.isIdentity { 72 | presentedViewController.view.layer.cornerRadius = 0 73 | } else { 74 | presentedViewController.view.layer.cornerRadius = UIScreen.main.displayCornerRadius() 75 | } 76 | presentedViewController.view.transform = transform 77 | layoutBackgroundViews() 78 | 79 | } else { 80 | super.transformPresentedView(transform: transform) 81 | 82 | if transform.isIdentity { 83 | presentedViewController.view.layer.cornerRadius = 0 84 | } else { 85 | let progress = prefersZoomEffect ? 0 : max(0, min(transform.d, 1)) 86 | let cornerRadius = progress * UIScreen.main.displayCornerRadius() 87 | presentedViewController.view.layer.cornerRadius = cornerRadius 88 | } 89 | } 90 | } 91 | 92 | open override func presentedViewTransform(for translation: CGPoint) -> CGAffineTransform { 93 | let frame = frameOfPresentedViewInContainerView 94 | if prefersZoomEffect { 95 | let dx = frictionCurve(translation.x, distance: frame.width, coefficient: 0.4) 96 | let dy = frictionCurve(translation.y, distance: frame.height, coefficient: 0.3) 97 | let scale = min(max(1 - dy / frame.width, minimumScaleFactor), 1) 98 | return CGAffineTransform(translationX: dx, y: min(dy, frame.width * minimumScaleFactor)) 99 | .scaledBy(x: scale, y: scale) 100 | } else { 101 | let dx = frictionCurve(translation.x, distance: frame.width, coefficient: 1) 102 | let dy = frictionCurve(translation.y, distance: frame.height, coefficient: 0.5) 103 | let scale = max(minimumScaleFactor, min(1 - (abs(dx) / frame.width), 1 - (abs(dy) / frame.height))) 104 | return CGAffineTransform(translationX: dx, y: dy * 0.25) 105 | .translatedBy(x: (1 - scale) * 0.5 * frame.width, y: (1 - scale) * 0.5 * frame.height) 106 | .scaledBy(x: scale, y: scale) 107 | } 108 | } 109 | 110 | open override func updateShadow(progress: Double) { 111 | super.updateShadow(progress: progress) 112 | dimmingView.isHidden = presentedViewShadow.shadowOpacity > 0 113 | } 114 | } 115 | 116 | /// An interactive transition built for the ``MatchedGeometryPresentationController``. 117 | /// 118 | /// ``` 119 | /// func animationController( 120 | /// forPresented presented: UIViewController, 121 | /// presenting: UIViewController, 122 | /// source: UIViewController 123 | /// ) -> UIViewControllerAnimatedTransitioning? { 124 | /// let transition = MatchedGeometryPresentationControllerTransition(...) 125 | /// transition.wantsInteractiveStart = false 126 | /// return transition 127 | /// } 128 | /// 129 | /// func animationController( 130 | /// forDismissed dismissed: UIViewController 131 | /// ) -> UIViewControllerAnimatedTransitioning? { 132 | /// guard let presentationController = dismissed.presentationController as? MatchedGeometryPresentationController else { 133 | /// return nil 134 | /// } 135 | /// let transition = MatchedGeometryPresentationControllerTransition(...) 136 | /// transition.wantsInteractiveStart = presentationController.wantsInteractiveTransition 137 | /// presentationController.transition(with: transition) 138 | /// return transition 139 | /// } 140 | /// 141 | /// func interactionControllerForDismissal( 142 | /// using animator: UIViewControllerAnimatedTransitioning 143 | /// ) -> UIViewControllerInteractiveTransitioning? { 144 | /// return animator as? MatchedGeometryPresentationControllerTransition 145 | /// } 146 | /// ``` 147 | /// 148 | @available(iOS 14.0, *) 149 | open class MatchedGeometryPresentationControllerTransition: MatchedGeometryViewControllerTransition { 150 | 151 | public override init( 152 | sourceView: UIView?, 153 | prefersScaleEffect: Bool, 154 | prefersZoomEffect: Bool, 155 | preferredFromCornerRadius: CornerRadiusOptions?, 156 | preferredToCornerRadius: CornerRadiusOptions.RoundedRectangle?, 157 | initialOpacity: CGFloat, 158 | isPresenting: Bool, 159 | animation: Animation? 160 | ) { 161 | super.init( 162 | sourceView: sourceView, 163 | prefersScaleEffect: prefersScaleEffect, 164 | prefersZoomEffect: prefersZoomEffect, 165 | preferredFromCornerRadius: preferredFromCornerRadius, 166 | preferredToCornerRadius: preferredToCornerRadius, 167 | initialOpacity: initialOpacity, 168 | isPresenting: isPresenting, 169 | animation: animation 170 | ) 171 | wantsInteractiveStart = true 172 | } 173 | 174 | open override func animatedStarted( 175 | transitionContext: UIViewControllerContextTransitioning 176 | ) { 177 | super.animatedStarted(transitionContext: transitionContext) 178 | 179 | if let presentationController = transitionContext.presentationController(isPresenting: isPresenting) as? PresentationController { 180 | presentationController.layoutBackgroundViews() 181 | } 182 | } 183 | } 184 | 185 | #endif 186 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/Presentation Controllers/PopoverPresentationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | 9 | open class PopoverPresentationController: UIPopoverPresentationController { 10 | 11 | } 12 | 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/Presentation Controllers/ToastPresentationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | /// A presentation controller that presents the view in its ideal size at the top or bottom 11 | @available(iOS 14.0, *) 12 | open class ToastPresentationController: InteractivePresentationController { 13 | 14 | public var edge: Edge { 15 | didSet { 16 | guard edge != oldValue else { return } 17 | switch edge { 18 | case .top, .leading: 19 | edges = .top 20 | case .bottom, .trailing: 21 | edges = .bottom 22 | } 23 | containerView?.setNeedsLayout() 24 | } 25 | } 26 | 27 | open override var frameOfPresentedViewInContainerView: CGRect { 28 | guard 29 | let containerView = containerView, 30 | let presentedView = presentedView 31 | else { 32 | return .zero 33 | } 34 | 35 | let inset: CGFloat = 16 36 | 37 | // Make sure to account for the safe area insets 38 | let safeAreaFrame = containerView.bounds 39 | .inset(by: containerView.safeAreaInsets) 40 | 41 | let targetWidth = safeAreaFrame.width - 2 * inset 42 | var sizeThatFits = CGSize( 43 | width: targetWidth, 44 | height: presentedView.idealHeight(for: targetWidth) 45 | ) 46 | if sizeThatFits.height <= 0 { 47 | sizeThatFits.height = targetWidth 48 | } 49 | var frame = safeAreaFrame 50 | frame.origin.x = (containerView.bounds.width - sizeThatFits.width) / 2 51 | switch edge { 52 | case .top, .leading: 53 | frame.origin.y = max(frame.origin.y, inset) 54 | case .bottom, .trailing: 55 | frame.origin.y += frame.size.height - sizeThatFits.height - inset 56 | frame.origin.y = min(frame.origin.y, containerView.frame.height - inset) 57 | } 58 | frame.size = sizeThatFits 59 | return frame 60 | } 61 | 62 | public init( 63 | edge: Edge = .top, 64 | presentedViewController: UIViewController, 65 | presenting presentingViewController: UIViewController? 66 | ) { 67 | self.edge = edge 68 | super.init( 69 | presentedViewController: presentedViewController, 70 | presenting: presentingViewController 71 | ) 72 | edges = Edge.Set(edge) 73 | } 74 | 75 | open override func presentedViewAdditionalSafeAreaInsets() -> UIEdgeInsets { 76 | .zero 77 | } 78 | } 79 | 80 | /// An interactive transition built for the ``ToastPresentationController``. 81 | /// 82 | /// ``` 83 | /// func animationController( 84 | /// forPresented presented: UIViewController, 85 | /// presenting: UIViewController, 86 | /// source: UIViewController 87 | /// ) -> UIViewControllerAnimatedTransitioning? { 88 | /// let transition = ToastPresentationControllerTransition(...) 89 | /// transition.wantsInteractiveStart = false 90 | /// return transition 91 | /// } 92 | /// 93 | /// func animationController( 94 | /// forDismissed dismissed: UIViewController 95 | /// ) -> UIViewControllerAnimatedTransitioning? { 96 | /// guard let presentationController = dismissed.presentationController as? ToastPresentationController else { 97 | /// return nil 98 | /// } 99 | /// let transition = ToastPresentationControllerTransition(...) 100 | /// transition.wantsInteractiveStart = presentationController.wantsInteractiveTransition 101 | /// presentationController.transition(with: transition) 102 | /// return transition 103 | /// } 104 | /// 105 | /// func interactionControllerForDismissal( 106 | /// using animator: UIViewControllerAnimatedTransitioning 107 | /// ) -> UIViewControllerInteractiveTransitioning? { 108 | /// return animator as? ToastPresentationControllerTransition 109 | /// } 110 | /// ``` 111 | /// 112 | @available(iOS 14.0, *) 113 | open class ToastPresentationControllerTransition: PresentationControllerTransition { 114 | 115 | public let edge: Edge 116 | 117 | public init( 118 | edge: Edge, 119 | isPresenting: Bool, 120 | animation: Animation? 121 | ) { 122 | self.edge = edge 123 | super.init(isPresenting: isPresenting, animation: animation) 124 | } 125 | 126 | open override func configureTransitionAnimator( 127 | using transitionContext: any UIViewControllerContextTransitioning, 128 | animator: UIViewPropertyAnimator 129 | ) { 130 | 131 | guard 132 | let presented = transitionContext.viewController(forKey: isPresenting ? .to : .from) 133 | else { 134 | transitionContext.completeTransition(false) 135 | return 136 | } 137 | 138 | if isPresenting { 139 | var presentedFrame = transitionContext.finalFrame(for: presented) 140 | transitionContext.containerView.addSubview(presented.view) 141 | presented.view.frame = presentedFrame 142 | presented.view.isHidden = true 143 | presented.view.layoutIfNeeded() 144 | 145 | (presented as? AnyHostingController)?.render() 146 | 147 | if let transitionReaderCoordinator = presented.transitionReaderCoordinator { 148 | transitionReaderCoordinator.update(isPresented: true) 149 | 150 | presented.view.setNeedsLayout() 151 | presented.view.layoutIfNeeded() 152 | 153 | if let presentationController = presented.presentationController as? PresentationController { 154 | presentedFrame = presentationController.frameOfPresentedViewInContainerView 155 | } 156 | 157 | transitionReaderCoordinator.update(isPresented: false) 158 | presented.view.setNeedsLayout() 159 | presented.view.layoutIfNeeded() 160 | transitionReaderCoordinator.update(isPresented: true) 161 | } 162 | 163 | switch edge { 164 | case .top, .leading: 165 | let transform = CGAffineTransform( 166 | translationX: 0, 167 | y: -presented.view.frame.maxY 168 | ) 169 | presented.view.frame = presentedFrame.applying(transform) 170 | case .bottom, .trailing: 171 | let transform = CGAffineTransform( 172 | translationX: 0, 173 | y: transitionContext.containerView.frame.height - presentedFrame.minY 174 | ) 175 | presented.view.frame = presentedFrame.applying(transform) 176 | } 177 | presented.view.isHidden = false 178 | animator.addAnimations { 179 | presented.view.frame = presentedFrame 180 | } 181 | } else { 182 | presented.view.layoutIfNeeded() 183 | 184 | let finalFrame: CGRect 185 | switch edge { 186 | case .top, .leading: 187 | let transform = CGAffineTransform(translationX: 0, y: -presented.view.frame.maxY) 188 | finalFrame = presented.view.frame.applying(transform) 189 | case .bottom, .trailing: 190 | let transform = CGAffineTransform( 191 | translationX: 0, 192 | y: transitionContext.containerView.frame.height - presented.view.frame.minY 193 | ) 194 | finalFrame = presented.view.frame.applying(transform) 195 | } 196 | animator.addAnimations { 197 | presented.view.frame = finalFrame 198 | } 199 | } 200 | animator.addCompletion { animatingPosition in 201 | switch animatingPosition { 202 | case .end: 203 | transitionContext.completeTransition(true) 204 | default: 205 | transitionContext.completeTransition(false) 206 | } 207 | } 208 | } 209 | } 210 | 211 | #endif 212 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/PresentationLink Transitions/CardPresentationLinkTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | extension PresentationLinkTransition { 12 | 13 | /// The card presentation style. 14 | public static let card: PresentationLinkTransition = .card() 15 | 16 | /// The card presentation style. 17 | public static func card( 18 | _ transitionOptions: CardPresentationLinkTransition.Options, 19 | options: PresentationLinkTransition.Options = .init( 20 | modalPresentationCapturesStatusBarAppearance: true 21 | ) 22 | ) -> PresentationLinkTransition { 23 | .custom( 24 | options: options, 25 | CardPresentationLinkTransition(options: transitionOptions) 26 | ) 27 | } 28 | 29 | /// The card presentation style. 30 | public static func card( 31 | preferredEdgeInset: CGFloat? = nil, 32 | preferredCornerRadius: CornerRadiusOptions.RoundedRectangle? = nil, 33 | preferredAspectRatio: CGFloat? = 1, 34 | isInteractive: Bool = true, 35 | preferredPresentationBackgroundColor: Color? = nil 36 | ) -> PresentationLinkTransition { 37 | .card( 38 | .init( 39 | preferredEdgeInset: preferredEdgeInset, 40 | preferredCornerRadius: preferredCornerRadius, 41 | preferredAspectRatio: preferredAspectRatio, 42 | preferredPresentationShadow: preferredPresentationBackgroundColor == .clear ? .clear : .minimal 43 | ), 44 | options: .init( 45 | isInteractive: isInteractive, 46 | modalPresentationCapturesStatusBarAppearance: true, 47 | preferredPresentationBackgroundColor: preferredPresentationBackgroundColor 48 | ) 49 | ) 50 | } 51 | } 52 | 53 | @available(iOS 14.0, *) 54 | extension PresentationLinkTransition { 55 | 56 | /// The card presentation style. 57 | @available(*, deprecated, message: "Use `CornerRadiusOptions`") 58 | public static func card( 59 | preferredEdgeInset: CGFloat? = nil, 60 | preferredCornerRadius: CGFloat?, 61 | preferredAspectRatio: CGFloat? = 1, 62 | isInteractive: Bool = true, 63 | preferredPresentationBackgroundColor: Color? = nil 64 | ) -> PresentationLinkTransition { 65 | .card( 66 | preferredEdgeInset: preferredEdgeInset, 67 | preferredCornerRadius: preferredCornerRadius.map { .rounded(cornerRadius: $0) }, 68 | preferredAspectRatio: preferredAspectRatio, 69 | isInteractive: isInteractive, 70 | preferredPresentationBackgroundColor: preferredPresentationBackgroundColor 71 | ) 72 | } 73 | } 74 | 75 | @frozen 76 | @available(iOS 14.0, *) 77 | public struct CardPresentationLinkTransition: PresentationLinkTransitionRepresentable { 78 | 79 | /// The transition options for a card transition. 80 | @frozen 81 | public struct Options { 82 | 83 | public var preferredEdgeInset: CGFloat? 84 | public var preferredCornerRadius: CornerRadiusOptions.RoundedRectangle? 85 | /// A `nil` aspect ratio will size the cards height to it's ideal size 86 | public var preferredAspectRatio: CGFloat? 87 | public var preferredPresentationShadow: ShadowOptions 88 | 89 | public init( 90 | preferredEdgeInset: CGFloat? = nil, 91 | preferredCornerRadius: CornerRadiusOptions.RoundedRectangle? = nil, 92 | preferredAspectRatio: CGFloat? = 1, 93 | preferredPresentationShadow: ShadowOptions = .minimal 94 | ) { 95 | self.preferredEdgeInset = preferredEdgeInset 96 | self.preferredCornerRadius = preferredCornerRadius 97 | self.preferredAspectRatio = preferredAspectRatio 98 | self.preferredPresentationShadow = preferredPresentationShadow 99 | } 100 | } 101 | public var options: Options 102 | 103 | public init(options: Options = .init()) { 104 | self.options = options 105 | } 106 | 107 | public static let defaultEdgeInset: CGFloat = 4 108 | public static let defaultCornerRadius: CGFloat = UIScreen.main.displayCornerRadius(min: 36) 109 | public static let defaultAdjustedCornerRadius: CornerRadiusOptions.RoundedRectangle = .rounded(cornerRadius: defaultCornerRadius - defaultEdgeInset, style: .continuous) 110 | 111 | public func makeUIPresentationController( 112 | presented: UIViewController, 113 | presenting: UIViewController?, 114 | context: Context 115 | ) -> CardPresentationController { 116 | let presentationController = CardPresentationController( 117 | preferredEdgeInset: options.preferredEdgeInset, 118 | preferredCornerRadius: options.preferredCornerRadius, 119 | preferredAspectRatio: options.preferredAspectRatio, 120 | presentedViewController: presented, 121 | presenting: presenting 122 | ) 123 | presentationController.presentedViewShadow = options.preferredPresentationShadow 124 | return presentationController 125 | } 126 | 127 | public func updateUIPresentationController( 128 | presentationController: CardPresentationController, 129 | context: Context 130 | ) { 131 | presentationController.preferredEdgeInset = options.preferredEdgeInset 132 | presentationController.preferredCornerRadius = options.preferredCornerRadius 133 | presentationController.preferredAspectRatio = options.preferredAspectRatio 134 | presentationController.presentedViewShadow = options.preferredPresentationShadow 135 | } 136 | 137 | public func updateHostingController( 138 | presenting: PresentationHostingController, 139 | context: Context 140 | ) where Content: View { 141 | presenting.tracksContentSize = true 142 | } 143 | 144 | public func animationController( 145 | forPresented presented: UIViewController, 146 | presenting: UIViewController, 147 | context: Context 148 | ) -> CardPresentationControllerTransition? { 149 | let transition = CardPresentationControllerTransition( 150 | preferredEdgeInset: options.preferredEdgeInset, 151 | preferredCornerRadius: options.preferredCornerRadius, 152 | isPresenting: true, 153 | animation: context.transaction.animation 154 | ) 155 | transition.wantsInteractiveStart = false 156 | return transition 157 | } 158 | 159 | public func animationController( 160 | forDismissed dismissed: UIViewController, 161 | context: Context 162 | ) -> CardPresentationControllerTransition? { 163 | guard let presentationController = dismissed.presentationController as? InteractivePresentationController else { 164 | return nil 165 | } 166 | let animation: Animation? = { 167 | guard context.transaction.animation == .default else { 168 | return context.transaction.animation 169 | } 170 | return presentationController.preferredDefaultAnimation() ?? context.transaction.animation 171 | }() 172 | let transition = CardPresentationControllerTransition( 173 | preferredEdgeInset: options.preferredEdgeInset, 174 | preferredCornerRadius: options.preferredCornerRadius, 175 | isPresenting: false, 176 | animation: animation 177 | ) 178 | transition.wantsInteractiveStart = presentationController.wantsInteractiveTransition 179 | presentationController.transition(with: transition) 180 | return transition 181 | } 182 | } 183 | 184 | #endif 185 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/PresentationLink Transitions/SlidePresentationLinkTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | extension PresentationLinkTransition { 12 | 13 | /// The slide presentation style. 14 | public static let slide: PresentationLinkTransition = .slide() 15 | 16 | /// The slide presentation style. 17 | public static func slide( 18 | _ transitionOptions: SlidePresentationLinkTransition.Options, 19 | options: PresentationLinkTransition.Options = .init( 20 | modalPresentationCapturesStatusBarAppearance: true 21 | ) 22 | ) -> PresentationLinkTransition { 23 | .custom( 24 | options: options, 25 | SlidePresentationLinkTransition(options: transitionOptions) 26 | ) 27 | } 28 | 29 | /// The slide presentation style. 30 | public static func slide( 31 | edge: Edge = .bottom, 32 | prefersScaleEffect: Bool = true, 33 | preferredFromCornerRadius: CornerRadiusOptions.RoundedRectangle? = nil, 34 | preferredToCornerRadius: CornerRadiusOptions.RoundedRectangle? = nil, 35 | isInteractive: Bool = true, 36 | preferredPresentationBackgroundColor: Color? = nil 37 | ) -> PresentationLinkTransition { 38 | .slide( 39 | .init( 40 | edge: edge, 41 | prefersScaleEffect: prefersScaleEffect, 42 | preferredFromCornerRadius: preferredFromCornerRadius, 43 | preferredToCornerRadius: preferredToCornerRadius, 44 | preferredPresentationShadow: preferredPresentationBackgroundColor == .clear ? .clear : .minimal 45 | ), 46 | options: .init( 47 | isInteractive: isInteractive, 48 | modalPresentationCapturesStatusBarAppearance: true, 49 | preferredPresentationBackgroundColor: preferredPresentationBackgroundColor 50 | ) 51 | ) 52 | } 53 | } 54 | 55 | @available(iOS 14.0, *) 56 | extension PresentationLinkTransition { 57 | 58 | /// The slide presentation style. 59 | @available(*, deprecated, message: "Use `CornerRadiusOptions`") 60 | public static func slide( 61 | edge: Edge = .bottom, 62 | prefersScaleEffect: Bool = true, 63 | preferredFromCornerRadius: CGFloat?, 64 | preferredToCornerRadius: CGFloat?, 65 | isInteractive: Bool = true, 66 | preferredPresentationBackgroundColor: Color? = nil 67 | ) -> PresentationLinkTransition { 68 | .slide( 69 | edge: edge, 70 | prefersScaleEffect: prefersScaleEffect, 71 | preferredFromCornerRadius: preferredFromCornerRadius.map { .rounded(cornerRadius: $0) }, 72 | preferredToCornerRadius: preferredToCornerRadius.map { .rounded(cornerRadius: $0) }, 73 | isInteractive: isInteractive, 74 | preferredPresentationBackgroundColor: preferredPresentationBackgroundColor 75 | ) 76 | } 77 | } 78 | 79 | @frozen 80 | @available(iOS 14.0, *) 81 | public struct SlidePresentationLinkTransition: PresentationLinkTransitionRepresentable { 82 | 83 | /// The transition options for a slide transition. 84 | @frozen 85 | public struct Options { 86 | 87 | public var edge: Edge 88 | public var prefersScaleEffect: Bool 89 | public var preferredFromCornerRadius: CornerRadiusOptions.RoundedRectangle? 90 | public var preferredToCornerRadius: CornerRadiusOptions.RoundedRectangle? 91 | public var preferredPresentationShadow: ShadowOptions 92 | 93 | public init( 94 | edge: Edge = .bottom, 95 | prefersScaleEffect: Bool = true, 96 | preferredFromCornerRadius: CornerRadiusOptions.RoundedRectangle? = nil, 97 | preferredToCornerRadius: CornerRadiusOptions.RoundedRectangle? = nil, 98 | preferredPresentationShadow: ShadowOptions = .minimal 99 | ) { 100 | self.edge = edge 101 | self.prefersScaleEffect = prefersScaleEffect 102 | self.preferredFromCornerRadius = preferredFromCornerRadius 103 | self.preferredToCornerRadius = preferredToCornerRadius 104 | self.preferredPresentationShadow = preferredPresentationShadow 105 | } 106 | } 107 | public var options: Options 108 | 109 | public init(options: Options = .init()) { 110 | self.options = options 111 | } 112 | 113 | public func makeUIPresentationController( 114 | presented: UIViewController, 115 | presenting: UIViewController?, 116 | context: Context 117 | ) -> SlidePresentationController { 118 | let presentationController = SlidePresentationController( 119 | edge: options.edge, 120 | presentedViewController: presented, 121 | presenting: presenting 122 | ) 123 | presentationController.presentedViewShadow = options.preferredPresentationShadow 124 | return presentationController 125 | } 126 | 127 | public func updateUIPresentationController( 128 | presentationController: SlidePresentationController, 129 | context: Context 130 | ) { 131 | presentationController.edge = options.edge 132 | presentationController.presentedViewShadow = options.preferredPresentationShadow 133 | } 134 | 135 | public func animationController( 136 | forPresented presented: UIViewController, 137 | presenting: UIViewController, 138 | context: Context 139 | ) -> SlidePresentationControllerTransition? { 140 | let transition = SlidePresentationControllerTransition( 141 | edge: options.edge, 142 | prefersScaleEffect: options.prefersScaleEffect, 143 | preferredFromCornerRadius: options.preferredFromCornerRadius, 144 | preferredToCornerRadius: options.preferredToCornerRadius, 145 | isPresenting: true, 146 | animation: context.transaction.animation 147 | ) 148 | transition.wantsInteractiveStart = false 149 | return transition 150 | } 151 | 152 | public func animationController( 153 | forDismissed dismissed: UIViewController, 154 | context: Context 155 | ) -> SlidePresentationControllerTransition? { 156 | guard let presentationController = dismissed.presentationController as? InteractivePresentationController else { 157 | return nil 158 | } 159 | let animation: Animation? = { 160 | guard context.transaction.animation == .default else { 161 | return context.transaction.animation 162 | } 163 | return presentationController.preferredDefaultAnimation() ?? context.transaction.animation 164 | }() 165 | let transition = SlidePresentationControllerTransition( 166 | edge: options.edge, 167 | prefersScaleEffect: options.prefersScaleEffect, 168 | preferredFromCornerRadius: options.preferredFromCornerRadius, 169 | preferredToCornerRadius: options.preferredToCornerRadius, 170 | isPresenting: false, 171 | animation: animation 172 | ) 173 | transition.wantsInteractiveStart = presentationController.wantsInteractiveTransition 174 | presentationController.transition(with: transition) 175 | return transition 176 | } 177 | } 178 | 179 | #endif 180 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/PresentationLink Transitions/ToastPresentationLinkTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | extension PresentationLinkTransition { 12 | 13 | /// The toast presentation style. 14 | public static let toast: PresentationLinkTransition = .toast() 15 | 16 | /// The toast presentation style. 17 | public static func toast( 18 | _ transitionOptions: ToastPresentationLinkTransition.Options, 19 | options: PresentationLinkTransition.Options = .init() 20 | ) -> PresentationLinkTransition { 21 | .custom( 22 | options: options, 23 | ToastPresentationLinkTransition(options: transitionOptions) 24 | ) 25 | } 26 | 27 | /// The toast presentation style. 28 | public static func toast( 29 | edge: Edge = .bottom, 30 | isInteractive: Bool = true, 31 | preferredPresentationBackgroundColor: Color? = .clear 32 | ) -> PresentationLinkTransition { 33 | .toast( 34 | .init( 35 | edge: edge, 36 | preferredPresentationShadow: preferredPresentationBackgroundColor == .clear ? .clear : .minimal 37 | ), 38 | options: .init( 39 | isInteractive: isInteractive, 40 | preferredPresentationBackgroundColor: preferredPresentationBackgroundColor 41 | ) 42 | ) 43 | } 44 | } 45 | 46 | @frozen 47 | @available(iOS 14.0, *) 48 | public struct ToastPresentationLinkTransition: PresentationLinkTransitionRepresentable { 49 | 50 | /// The transition options for a toast transition. 51 | @frozen 52 | public struct Options { 53 | 54 | public var edge: Edge 55 | public var preferredPresentationShadow: ShadowOptions 56 | 57 | public init( 58 | edge: Edge = .bottom, 59 | preferredPresentationShadow: ShadowOptions = .minimal 60 | ) { 61 | self.edge = edge 62 | self.preferredPresentationShadow = preferredPresentationShadow 63 | } 64 | } 65 | public var options: Options 66 | 67 | public init(options: Options = .init()) { 68 | self.options = options 69 | } 70 | 71 | public func makeUIPresentationController( 72 | presented: UIViewController, 73 | presenting: UIViewController?, 74 | context: Context 75 | ) -> ToastPresentationController { 76 | let presentationController = ToastPresentationController( 77 | edge: options.edge, 78 | presentedViewController: presented, 79 | presenting: presenting 80 | ) 81 | presentationController.presentedViewShadow = options.preferredPresentationShadow 82 | return presentationController 83 | } 84 | 85 | public func updateUIPresentationController( 86 | presentationController: ToastPresentationController, 87 | context: Context 88 | ) { 89 | presentationController.edge = options.edge 90 | presentationController.presentedViewShadow = options.preferredPresentationShadow 91 | } 92 | 93 | public func updateHostingController( 94 | presenting: PresentationHostingController, 95 | context: Context 96 | ) where Content: View { 97 | presenting.tracksContentSize = true 98 | } 99 | 100 | public func animationController( 101 | forPresented presented: UIViewController, 102 | presenting: UIViewController, 103 | context: Context 104 | ) -> ToastPresentationControllerTransition? { 105 | let transition = ToastPresentationControllerTransition( 106 | edge: options.edge, 107 | isPresenting: true, 108 | animation: context.transaction.animation 109 | ) 110 | transition.wantsInteractiveStart = false 111 | return transition 112 | } 113 | 114 | public func animationController( 115 | forDismissed dismissed: UIViewController, 116 | context: Context 117 | ) -> ToastPresentationControllerTransition? { 118 | guard let presentationController = dismissed.presentationController as? InteractivePresentationController else { 119 | return nil 120 | } 121 | let animation: Animation? = { 122 | guard context.transaction.animation == .default else { 123 | return context.transaction.animation 124 | } 125 | return presentationController.preferredDefaultAnimation() ?? context.transaction.animation 126 | }() 127 | let transition = ToastPresentationControllerTransition( 128 | edge: options.edge, 129 | isPresenting: false, 130 | animation: animation 131 | ) 132 | transition.wantsInteractiveStart = presentationController.wantsInteractiveTransition 133 | presentationController.transition(with: transition) 134 | return transition 135 | } 136 | } 137 | 138 | #endif 139 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/ShadowOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | import Engine 10 | 11 | @frozen 12 | @available(iOS 14.0, *) 13 | public struct ShadowOptions: Equatable, Sendable { 14 | public var shadowOpacity: Float 15 | public var shadowRadius: CGFloat 16 | public var shadowOffset: CGSize 17 | public var shadowColor: Color 18 | 19 | public init( 20 | shadowOpacity: Float, 21 | shadowRadius: CGFloat, 22 | shadowOffset: CGSize = CGSize(width: 0, height: -3), 23 | shadowColor: Color = Color.black 24 | ) { 25 | self.shadowOpacity = shadowOpacity 26 | self.shadowRadius = shadowRadius 27 | self.shadowOffset = shadowOffset 28 | self.shadowColor = shadowColor 29 | } 30 | 31 | public static let prominent = ShadowOptions( 32 | shadowOpacity: 0.4, 33 | shadowRadius: 40 34 | ) 35 | 36 | public static let minimal = ShadowOptions( 37 | shadowOpacity: 0.15, 38 | shadowRadius: 24 39 | ) 40 | 41 | public static let clear = ShadowOptions( 42 | shadowOpacity: 0, 43 | shadowRadius: 0, 44 | shadowColor: .clear 45 | ) 46 | 47 | public func apply(to layer: CALayer, progress: Double = 1) { 48 | layer.shadowOpacity = shadowOpacity * Float(progress) 49 | layer.shadowRadius = shadowRadius 50 | layer.shadowOffset = shadowOffset 51 | layer.shadowColor = shadowColor.toCGColor() 52 | } 53 | } 54 | 55 | #endif 56 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/View Controller Transitions/PresentationControllerTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | open class PresentationControllerTransition: ViewControllerTransition { 12 | 13 | public override init( 14 | isPresenting: Bool, 15 | animation: Animation? 16 | ) { 17 | super.init(isPresenting: isPresenting, animation: animation) 18 | wantsInteractiveStart = true 19 | } 20 | 21 | open override func animatedStarted( 22 | transitionContext: UIViewControllerContextTransitioning 23 | ) { 24 | if let presentationController = transitionContext.presentationController(isPresenting: isPresenting) as? PresentationController { 25 | presentationController.layoutBackgroundViews() 26 | } 27 | } 28 | 29 | open override func configureTransitionAnimator( 30 | using transitionContext: UIViewControllerContextTransitioning, 31 | animator: UIViewPropertyAnimator 32 | ) { 33 | 34 | guard 35 | let presented = transitionContext.viewController(forKey: isPresenting ? .to : .from), 36 | let presenting = transitionContext.viewController(forKey: isPresenting ? .from : .to) 37 | else { 38 | transitionContext.completeTransition(false) 39 | return 40 | } 41 | 42 | if isPresenting { 43 | let presentedFrame = transitionContext.finalFrame(for: presented) 44 | transitionContext.containerView.addSubview(presented.view) 45 | presented.view.frame = presentedFrame 46 | presented.view.layoutIfNeeded() 47 | 48 | let transform = CGAffineTransform( 49 | translationX: 0, 50 | y: presentedFrame.size.height + transitionContext.containerView.safeAreaInsets.bottom 51 | ) 52 | presented.view.transform = transform 53 | animator.addAnimations { 54 | presented.view.transform = .identity 55 | } 56 | } else { 57 | if presenting.view.superview == nil { 58 | transitionContext.containerView.insertSubview(presenting.view, at: 0) 59 | presenting.view.frame = transitionContext.finalFrame(for: presenting) 60 | presenting.view.layoutIfNeeded() 61 | } 62 | let frame = transitionContext.finalFrame(for: presented) 63 | let dy = transitionContext.containerView.frame.height - frame.origin.y 64 | let transform = CGAffineTransform( 65 | translationX: 0, 66 | y: dy 67 | ) 68 | presented.view.layoutIfNeeded() 69 | 70 | animator.addAnimations { 71 | presented.view.transform = transform 72 | } 73 | } 74 | animator.addCompletion { animatingPosition in 75 | switch animatingPosition { 76 | case .end: 77 | transitionContext.completeTransition(true) 78 | default: 79 | transitionContext.completeTransition(false) 80 | } 81 | } 82 | } 83 | } 84 | 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/Transitions/View Controller Transitions/ViewControllerTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import UIKit 8 | import SwiftUI 9 | 10 | @available(iOS 14.0, *) 11 | open class ViewControllerTransition: UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning { 12 | 13 | public let isPresenting: Bool 14 | public let animation: Animation? 15 | private var animator: UIViewPropertyAnimator? 16 | 17 | private var transitionDuration: CGFloat = 0 18 | open override var duration: CGFloat { 19 | transitionDuration 20 | } 21 | 22 | public init( 23 | isPresenting: Bool, 24 | animation: Animation? 25 | ) { 26 | self.isPresenting = isPresenting 27 | self.animation = animation 28 | super.init() 29 | wantsInteractiveStart = false 30 | } 31 | 32 | // MARK: - UIViewControllerAnimatedTransitioning 33 | 34 | open override func startInteractiveTransition( 35 | _ transitionContext: UIViewControllerContextTransitioning 36 | ) { 37 | super.startInteractiveTransition(transitionContext) 38 | if let presenting = transitionContext.viewController(forKey: isPresenting ? .to : .from) { 39 | presenting.transitionReaderAnimation = animation 40 | } 41 | transitionDuration = transitionDuration(using: transitionContext) 42 | } 43 | 44 | open func transitionDuration( 45 | using transitionContext: UIViewControllerContextTransitioning? 46 | ) -> TimeInterval { 47 | guard transitionContext?.isAnimated == true else { return 0 } 48 | return animation?.duration(defaultDuration: 0.35) ?? 0.35 49 | } 50 | 51 | public func animateTransition( 52 | using transitionContext: UIViewControllerContextTransitioning 53 | ) { 54 | transitionDuration = transitionDuration(using: transitionContext) 55 | let animator = makeTransitionAnimatorIfNeeded(using: transitionContext) 56 | let delay = animation?.delay ?? 0 57 | animatedStarted(transitionContext: transitionContext) 58 | animator.startAnimation(afterDelay: delay) 59 | 60 | if !transitionContext.isAnimated { 61 | animator.stopAnimation(false) 62 | animator.finishAnimation(at: .end) 63 | } 64 | } 65 | 66 | open func animatedStarted( 67 | transitionContext: UIViewControllerContextTransitioning 68 | ) { 69 | } 70 | 71 | open func animationEnded( 72 | _ transitionCompleted: Bool 73 | ) { 74 | animator = nil 75 | } 76 | 77 | public func interruptibleAnimator( 78 | using transitionContext: UIViewControllerContextTransitioning 79 | ) -> UIViewImplicitlyAnimating { 80 | let animator = makeTransitionAnimatorIfNeeded(using: transitionContext) 81 | return animator 82 | } 83 | 84 | open override func responds(to aSelector: Selector!) -> Bool { 85 | let responds = super.responds(to: aSelector) 86 | if aSelector == #selector(interruptibleAnimator(using:)) { 87 | return responds && wantsInteractiveStart 88 | } 89 | return responds 90 | } 91 | 92 | private func makeTransitionAnimatorIfNeeded( 93 | using transitionContext: UIViewControllerContextTransitioning 94 | ) -> UIViewPropertyAnimator { 95 | if let animator = animator { 96 | return animator 97 | } 98 | let animator = UIViewPropertyAnimator( 99 | animation: animation, 100 | defaultDuration: duration, 101 | defaultCompletionCurve: completionCurve 102 | ) 103 | configureTransitionAnimator(using: transitionContext, animator: animator) 104 | self.animator = animator 105 | return animator 106 | } 107 | 108 | open func configureTransitionAnimator( 109 | using transitionContext: UIViewControllerContextTransitioning, 110 | animator: UIViewPropertyAnimator 111 | ) { 112 | 113 | guard 114 | let presented = transitionContext.viewController(forKey: isPresenting ? .to : .from), 115 | let presenting = transitionContext.viewController(forKey: isPresenting ? .from : .to) 116 | else { 117 | transitionContext.completeTransition(false) 118 | return 119 | } 120 | 121 | let isPresenting = isPresenting 122 | if isPresenting { 123 | presented.view.alpha = 0 124 | let presentedFrame = transitionContext.finalFrame(for: presented) 125 | transitionContext.containerView.addSubview(presented.view) 126 | presented.view.frame = presentedFrame 127 | presented.view.layoutIfNeeded() 128 | } else { 129 | if presenting.view.superview == nil { 130 | transitionContext.containerView.insertSubview(presenting.view, at: 0) 131 | presenting.view.frame = transitionContext.finalFrame(for: presenting) 132 | presenting.view.layoutIfNeeded() 133 | } 134 | presented.view.layoutIfNeeded() 135 | } 136 | animator.addAnimations { 137 | presented.view.alpha = isPresenting ? 1 : 0 138 | } 139 | animator.addCompletion { animatingPosition in 140 | switch animatingPosition { 141 | case .end: 142 | transitionContext.completeTransition(true) 143 | default: 144 | transitionContext.completeTransition(false) 145 | } 146 | } 147 | } 148 | } 149 | 150 | extension UIViewControllerContextTransitioning { 151 | 152 | func presentationController(isPresenting: Bool) -> UIPresentationController? { 153 | viewController(forKey: isPresenting ? .to : .from)?._activePresentationController 154 | } 155 | } 156 | 157 | #endif 158 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/ViewControllerReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | @available(iOS 14.0, *) 10 | public struct ViewControllerReaderAdapter: View { 11 | 12 | let content: (UIViewController?) -> Content 13 | 14 | @WeakState var presentingViewController: UIViewController? 15 | 16 | public init( 17 | @ViewBuilder content: @escaping (UIViewController?) -> Content 18 | ) { 19 | self.content = content 20 | } 21 | 22 | public var body: some View { 23 | content(presentingViewController) 24 | .background(ViewControllerReaderAdapterBody(presentingViewController: $presentingViewController)) 25 | } 26 | } 27 | 28 | private struct ViewControllerReaderAdapterBody: UIViewRepresentable { 29 | var presentingViewController: Binding 30 | 31 | func makeUIView(context: Context) -> ViewControllerReader { 32 | let uiView = ViewControllerReader( 33 | onDidMoveToWindow: { viewController in 34 | withCATransaction { 35 | presentingViewController.wrappedValue = viewController 36 | } 37 | } 38 | ) 39 | return uiView 40 | } 41 | 42 | func updateUIView(_ uiView: ViewControllerReader, context: Context) { } 43 | } 44 | 45 | 46 | #endif 47 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/ViewControllerRepresentableAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | /// A wrapper for a `UIViewController` 10 | @frozen 11 | public struct ViewControllerRepresentableAdapter< 12 | Content: UIViewController 13 | >: UIViewControllerRepresentable { 14 | 15 | @usableFromInline 16 | var _makeUIViewController: (Context) -> Content 17 | 18 | @inlinable 19 | public init(_ makeUIViewController: @escaping () -> Content) { 20 | self._makeUIViewController = { _ in makeUIViewController() } 21 | } 22 | 23 | @inlinable 24 | public init(_ makeUIViewController: @escaping (Context) -> Content) { 25 | self._makeUIViewController = makeUIViewController 26 | } 27 | 28 | public func makeUIViewController(context: Context) -> Content { 29 | _makeUIViewController(context) 30 | } 31 | 32 | public func updateUIViewController(_ uiViewController: Content, context: Context) { } 33 | } 34 | 35 | #endif 36 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/ViewRepresentableAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A wrapper for a `UIView` 11 | @frozen 12 | public struct ViewRepresentableAdapter< 13 | Content: UIView 14 | >: UIViewRepresentable { 15 | 16 | @usableFromInline 17 | var _makeUIView: (Context) -> Content 18 | 19 | @inlinable 20 | public init(_ makeUIView: @escaping () -> Content) { 21 | self._makeUIView = { _ in makeUIView() } 22 | } 23 | 24 | @inlinable 25 | public init(_ makeUIView: @escaping (Context) -> Content) { 26 | self._makeUIView = makeUIView 27 | } 28 | 29 | public func makeUIView(context: Context) -> Content { 30 | _makeUIView(context) 31 | } 32 | 33 | public func updateUIView(_ uiView: Content, context: Context) { } 34 | 35 | public func _overrideSizeThatFits( 36 | _ size: inout CGSize, 37 | in proposedSize: _ProposedSize, 38 | uiView: UIViewType 39 | ) { 40 | let sizeThatFits = uiView.sizeThatFits(ProposedSize(proposedSize).toCoreGraphics()) 41 | if sizeThatFits != .zero { 42 | size = sizeThatFits 43 | } 44 | } 45 | } 46 | 47 | #endif 48 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/WindowLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | import Engine 9 | 10 | /// A button that presents a destination view in a new `UIWindow`. 11 | /// 12 | /// The destination view is presented with the provided `transition` 13 | /// and `level`. By default, the ``WindowLinkTransition/opacity`` 14 | /// transition and ``WindowLinkLevel/default`` are used. 15 | /// 16 | /// See Also: 17 | /// - ``WindowLinkTransition`` 18 | /// - ``WindowLinkLevel`` 19 | /// 20 | @available(iOS 14.0, *) 21 | @frozen 22 | public struct WindowLink< 23 | Label: View, 24 | Destination: View 25 | >: View { 26 | 27 | var label: Label 28 | var destination: Destination 29 | var level: WindowLinkLevel 30 | var transition: WindowLinkTransition 31 | var animation: Animation? 32 | 33 | @StateOrBinding var isPresented: Bool 34 | 35 | public init( 36 | level: WindowLinkLevel = .default, 37 | transition: WindowLinkTransition = .opacity, 38 | animation: Animation? = .default, 39 | @ViewBuilder destination: () -> Destination, 40 | @ViewBuilder label: () -> Label 41 | ) { 42 | self.label = label() 43 | self.destination = destination() 44 | self.level = level 45 | self.transition = transition 46 | self.animation = animation 47 | self._isPresented = .init(false) 48 | } 49 | 50 | public init( 51 | level: WindowLinkLevel = .default, 52 | transition: WindowLinkTransition = .opacity, 53 | animation: Animation? = .default, 54 | isPresented: Binding, 55 | @ViewBuilder destination: () -> Destination, 56 | @ViewBuilder label: () -> Label 57 | ) { 58 | self.label = label() 59 | self.destination = destination() 60 | self.level = level 61 | self.transition = transition 62 | self.animation = animation 63 | self._isPresented = .init(isPresented) 64 | } 65 | 66 | public var body: some View { 67 | Button { 68 | withAnimation(animation) { 69 | isPresented.toggle() 70 | } 71 | } label: { 72 | label 73 | } 74 | .modifier( 75 | WindowLinkModifier( 76 | level: level, 77 | transition: transition, 78 | isPresented: $isPresented, 79 | destination: destination 80 | ) 81 | ) 82 | } 83 | } 84 | 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/Transmission/Sources/WindowLinkTransition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | #if os(iOS) 6 | 7 | import SwiftUI 8 | 9 | /// The window level for a presented ``WindowLink`` destination 10 | @available(iOS 14.0, *) 11 | public struct WindowLinkLevel: Sendable { 12 | enum Value { 13 | case relative(Int) 14 | case fixed(CGFloat) 15 | } 16 | var rawValue: Value 17 | 18 | /// The default level, which is the same as the presenting window's level. 19 | public static let `default` = WindowLinkLevel(rawValue: .relative(0)) 20 | 21 | /// The overlay level, which is one unit higher than the presenting window's level. 22 | public static let overlay = WindowLinkLevel(rawValue: .relative(1)) 23 | 24 | /// The background level, which is one unit lower than the presenting window's level. 25 | public static let background = WindowLinkLevel(rawValue: .relative(-1)) 26 | 27 | /// The alert window level 28 | public static let alert = WindowLinkLevel(rawValue: .fixed(UIWindow.Level.alert.rawValue)) 29 | 30 | /// A custom level 31 | public static func custom(_ level: CGFloat) -> WindowLinkLevel { 32 | WindowLinkLevel(rawValue: .fixed(level)) 33 | } 34 | } 35 | 36 | /// The transition style for a ``WindowLink`` and ``WindowLinkModifier``. 37 | @available(iOS 14.0, *) 38 | public struct WindowLinkTransition: Sendable { 39 | indirect enum Value: Equatable, Sendable { 40 | case identity 41 | case opacity 42 | case move(edge: Edge) 43 | case scale(multiplier: CGFloat) 44 | case union(Value, Value) 45 | } 46 | var value: Value 47 | var options: Options 48 | 49 | /// The identity transition. 50 | public static let identity = WindowLinkTransition(value: .identity, options: .init()) 51 | 52 | /// The opacity transition. 53 | public static let opacity = WindowLinkTransition(value: .opacity, options: .init()) 54 | 55 | /// The move transition. 56 | public static func move(edge: Edge) -> WindowLinkTransition { 57 | WindowLinkTransition(value: .move(edge: edge), options: .init()) 58 | } 59 | 60 | /// The scale transition. 61 | public static func scale(_ multiplier: CGFloat) -> WindowLinkTransition { 62 | WindowLinkTransition(value: .scale(multiplier: multiplier), options: .init()) 63 | } 64 | } 65 | 66 | @available(iOS 14.0, *) 67 | extension WindowLinkTransition { 68 | 69 | /// The identity transition. 70 | public static func identity( 71 | options: Options 72 | ) -> WindowLinkTransition { 73 | WindowLinkTransition(value: .identity, options: options) 74 | } 75 | 76 | /// The opacity transition. 77 | public static func opacity( 78 | options: Options 79 | ) -> WindowLinkTransition { 80 | WindowLinkTransition(value: .opacity, options: options) 81 | } 82 | 83 | /// The move transition. 84 | public static func move( 85 | edge: Edge, 86 | options: Options 87 | ) -> WindowLinkTransition { 88 | WindowLinkTransition(value: .move(edge: edge), options: options) 89 | } 90 | 91 | /// The scale transition. 92 | public static func scale( 93 | _ multiplier: CGFloat, 94 | options: Options 95 | ) -> WindowLinkTransition { 96 | WindowLinkTransition(value: .scale(multiplier: multiplier), options: options) 97 | } 98 | } 99 | 100 | @available(iOS 14.0, *) 101 | extension WindowLinkTransition { 102 | public func combined(with other: WindowLinkTransition) -> WindowLinkTransition { 103 | WindowLinkTransition(value: .union(value, other.value), options: options) 104 | } 105 | } 106 | 107 | @available(iOS 14.0, *) 108 | extension WindowLinkTransition { 109 | /// The transition options. 110 | @frozen 111 | public struct Options: Sendable { 112 | /// When `true`, the destination will not be deallocated when dismissed and instead reused for subsequent presentations. 113 | public var isDestinationReusable: Bool 114 | /// When `true`, the destination will be dismissed when the presentation source is dismantled 115 | public var shouldAutomaticallyDismissDestination: Bool 116 | 117 | public init( 118 | isDestinationReusable: Bool = false, 119 | shouldAutomaticallyDismissDestination: Bool = true 120 | ) { 121 | self.isDestinationReusable = isDestinationReusable 122 | self.shouldAutomaticallyDismissDestination = shouldAutomaticallyDismissDestination 123 | } 124 | } 125 | } 126 | 127 | @available(iOS 14.0, *) 128 | extension WindowLinkTransition.Value { 129 | func toSwiftUIAlignment() -> Alignment { 130 | switch self { 131 | case .identity, .opacity, .scale: 132 | return .center 133 | case .move(let edge): 134 | switch edge { 135 | case .top: 136 | return .top 137 | case .bottom: 138 | return .bottom 139 | case .leading: 140 | return .leading 141 | case .trailing: 142 | return .trailing 143 | } 144 | case .union(let first, _): 145 | return first.toSwiftUIAlignment() 146 | } 147 | } 148 | 149 | func toUIKit( 150 | isPresented: Bool, 151 | window: UIWindow 152 | ) -> (alpha: CGFloat?, t: CGAffineTransform) { 153 | switch self { 154 | case .identity: 155 | return (nil, .identity) 156 | case .opacity: 157 | return (isPresented ? 1 : 0, .identity) 158 | case .move(let edge): 159 | let result: CGAffineTransform = { 160 | if !isPresented { 161 | let size = window.rootViewController.map { 162 | let frame = CGRect(origin: .zero, size: $0.view.intrinsicContentSize) 163 | return frame.inset(by: $0.view.safeAreaInsets).size 164 | } ?? window.frame.size 165 | switch edge { 166 | case .top: 167 | return CGAffineTransform(translationX: 0, y: -size.height) 168 | case .bottom: 169 | return CGAffineTransform(translationX: 0, y: size.height) 170 | case .leading: 171 | return CGAffineTransform(translationX: -size.width, y: 0) 172 | case .trailing: 173 | return CGAffineTransform(translationX: size.width, y: 0) 174 | } 175 | } else { 176 | return .identity 177 | } 178 | }() 179 | return (nil, result) 180 | case .scale(let multiplier): 181 | let result: CGAffineTransform = { 182 | if !isPresented { 183 | return CGAffineTransform(scaleX: multiplier, y: multiplier) 184 | } else { 185 | return .identity 186 | } 187 | }() 188 | return (nil, result) 189 | case .union(let first, let second): 190 | let first = first.toUIKit( 191 | isPresented: isPresented, 192 | window: window 193 | ) 194 | let second = second.toUIKit( 195 | isPresented: isPresented, 196 | window: window 197 | ) 198 | return (first.alpha ?? second.alpha, first.t.concatenating(second.t)) 199 | } 200 | } 201 | } 202 | 203 | @available(iOS 14.0, *) 204 | struct WindowBridgeAdapter: ViewModifier { 205 | var presentationCoordinator: PresentationCoordinator 206 | var transition: WindowLinkTransition.Value 207 | 208 | init( 209 | presentationCoordinator: PresentationCoordinator, 210 | transition: WindowLinkTransition.Value 211 | ) { 212 | self.presentationCoordinator = presentationCoordinator 213 | self.transition = transition 214 | } 215 | 216 | func body(content: Content) -> some View { 217 | content 218 | .modifier(PresentationBridgeAdapter(presentationCoordinator: presentationCoordinator)) 219 | .frame(maxHeight: .infinity, alignment: transition.toSwiftUIAlignment()) 220 | } 221 | } 222 | 223 | #endif 224 | -------------------------------------------------------------------------------- /Sources/Transmission/module.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Nathan Tannar 3 | // 4 | 5 | @_exported import Engine 6 | --------------------------------------------------------------------------------