├── .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 |
--------------------------------------------------------------------------------