├── .gitignore
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── Portal
│ ├── AnchorKey.swift
│ ├── CrossModel.swift
│ ├── Examples
│ ├── DifferExample.swift
│ ├── NavigationExample.swift
│ └── SheetExample.swift
│ ├── PassThroughWindow.swift
│ ├── PortalContainer.swift
│ ├── PortalDestination.swift
│ ├── PortalInfo.swift
│ ├── PortalLayerView.swift
│ ├── PortalSource.swift
│ ├── PortalTransition.swift
│ ├── SheetShow.swift
│ └── View+OnChangeCompat.swift
├── Tests
└── PortalTests
│ └── SheetShowTests.swift
└── assets
├── example1.gif
└── icon.png
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Aether
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Portal",
8 | platforms: [.iOS(.v15)/*, .macOS(.v13)*/],
9 | products: [
10 | .library(
11 | name: "Portal",
12 | targets: ["Portal"]),
13 | ],
14 | targets: [
15 | .target(
16 | name: "Portal",
17 | path: "Sources/Portal"
18 | ),
19 | .testTarget(
20 | name: "PortalTests",
21 | dependencies: ["Portal"]
22 | ),
23 | ]
24 | )
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
Portal
4 |
5 | Portal is a SwiftUI package for seamless element transitions between views—including across sheets and navigation pushes (NavigationStack, .navigationDestination, etc)—using a portal metaphor for maximum flexibility.
6 |
7 | Compatible with iOS 15.0 and later
8 |
9 |
10 |
11 |
23 |
24 | ## **Demo**
25 |
26 | 
27 |
28 |
29 | Real Examples
30 |
31 | https://github.com/user-attachments/assets/1658216e-dabd-442f-a7fe-7c2a19bf427d
32 |
33 | https://github.com/user-attachments/assets/7bba5836-f6e0-4d0b-95d7-f2c44c86c80a
34 |
35 |
36 | ## Features
37 |
38 | - **`PortalContainer(hideStatusBar:) { ... }`**
39 | Manages overlay window logic for floating portal animations. The `hideStatusBar` parameter controls whether the status bar is hidden when the overlay is active.
40 |
41 | - **`.portalContainer(hideStatusBar:)`**
42 | View extension for easily wrapping any view in a portal container, with optional status bar hiding.
43 |
44 | - **`.portalSource(id:)`**
45 | Marks a view as the source anchor for portal transitions.
46 |
47 | - **`.portalDestination(id:)`**
48 | Marks a view as the destination anchor for portal transitions.
49 |
50 | - **`.portalTransition(id: animate: animation: animationDuration: delay: layer: completion:)`**
51 | Drives the floating overlay animation, with options for animation type, duration, delay, layering, and completion handling.
52 |
53 | - **No custom presentation modifiers required**
54 | Works directly with standard SwiftUI views.
55 |
56 | - **iOS 15+ support**
57 |
58 |
59 | ## Installation
60 |
61 | In Xcode: File → Add Packages → `https://github.com/Aeastr/Portal.git`
62 |
63 | Or in `Package.swift`:
64 |
65 | ```swift
66 | .package(url: "https://github.com/Aeastr/Portal.git", from: "0.0.1")
67 | ```
68 |
69 | ## Usage
70 |
71 | Wrap your root view with `PortalContainer`:
72 |
73 | ```swift
74 | import SwiftUI
75 | import Portal
76 |
77 | struct ExampleView: View {
78 | @State private var showDetail = false
79 |
80 | var body: some View {
81 | PortalContainer {
82 | VStack {
83 | // Source image in the main view
84 | Image("cover")
85 | .onTapGesture {
86 | showDetail = true
87 | }
88 | }
89 | // Present the destination in a sheet
90 | .sheet(isPresented: $showDetail) {
91 | // Destination image in the sheet
92 | Image("cover")
93 | .portalDestination(id: "Book1")
94 | }
95 | }
96 | }
97 | }
98 | ```
99 |
100 | Mark the source view (the element to animate from):
101 |
102 | ```swift
103 | Image("cover")
104 | .portalSource(id: "Book1")
105 | ```
106 |
107 | Mark the destination view (the element to animate to, typically in a sheet or detail view):
108 |
109 | ```swift
110 | Image("cover")
111 | .portalDestination(id: "Book1")
112 | ```
113 |
114 | Kick off the portal transition:
115 |
116 | ```swift
117 | .portalTransition(
118 | id: "Book1",
119 | animate: $showDetail, // your binding
120 | animation: .smooth(duration: 0.6), // customizable
121 | animationDuration: 0.6, // required as the animation duration isn't exposed, and transition requires it
122 | delay: 0.1 // optional
123 | ) {
124 | FloatingLayerView()
125 | }
126 | ```
127 |
128 | All together,
129 |
130 | ```swift
131 | import SwiftUI
132 | import Portal
133 |
134 | struct ExampleView: View {
135 | @State private var showDetail = false
136 |
137 | var body: some View {
138 | PortalContainer {
139 | VStack {
140 | // Source image in the main view
141 | Image("cover")
142 | .portalSource(id: "Book1")
143 | .onTapGesture {
144 | showDetail = true
145 | }
146 | }
147 | // Present the destination in a sheet
148 | .sheet(isPresented: $showDetail) {
149 | // Destination image in the sheet
150 | Image("cover")
151 | .portalDestination(id: "Book1")
152 | }
153 | // Attach the portal transition to the parent container
154 | .portalTransition(
155 | id: "Book1",
156 | animate: $showDetail, // your binding
157 | animation: .smooth(duration: 0.6), // customizable
158 | animationDuration: 0.6, // required as the animation duration isn't exposed, and transition requires it
159 | delay: 0.1 // optional
160 | ) {
161 | // The floating overlay content during the transition
162 | Image("cover")
163 | }
164 | }
165 | }
166 | }
167 | ```
168 |
169 | See the `Sources/Portal` folder for full API details and example usage.
170 |
171 | ### Summary:
172 |
173 | - Wrap your root view in PortalContainer.
174 | - Attach .portalSource(id:) to the view you want to animate out.
175 | - Attach .portalDestination(id:) to the view you want to animate in.
176 | - Use .portalTransition(id:animate:animation:...) to provide the floating layer and control the animation.
177 | You can use any SwiftUI view as your source/destination/floating layer—images, shapes, custom views, etc.
178 |
179 | ## 🌀 Custom Portal Transitions
180 |
181 | ### Creating a Scale Effect Transition
182 |
183 | This guide shows how to create a custom transition that scales up and bounces when the portal activates. We'll create a reusable view that handles the scale animation.
184 |
185 | #### 1. **Define Animation Constants**
186 |
187 | First, set up your animation parameters:
188 |
189 | ```swift
190 | let transitionDuration: TimeInterval = 0.4
191 |
192 | let scaleAnimation = Animation.smooth(
193 | duration: transitionDuration,
194 | extraBounce: 0.25
195 | )
196 |
197 | let bounceAnimation = Animation.smooth(
198 | duration: transitionDuration + 0.12,
199 | extraBounce: 0.55
200 | )
201 | ```
202 |
203 | #### 2. **Create a Custom Transition View**
204 |
205 | Create a view that manages the scale animation:
206 |
207 | ```swift
208 | struct ScaleTransitionView: View {
209 | @EnvironmentObject private var portalModel: CrossModel
210 | let id: String
211 | @ViewBuilder let content: () -> Content
212 |
213 | @State private var scale: CGFloat = 1
214 |
215 | var body: some View {
216 | let isActive = portalModel.info
217 | .first(where: { $0.infoID == id })?
218 | .animateView ?? false
219 |
220 | content()
221 | .scaleEffect(scale)
222 | .onAppear { scale = 1 }
223 | .onChangeCompat(of: isActive) { newValue in
224 | if newValue {
225 | // Scale up
226 | withAnimation(scaleAnimation) {
227 | scale = 1.25
228 | }
229 |
230 | // Bounce back
231 | DispatchQueue.main.asyncAfter(
232 | deadline: .now() + (transitionDuration / 2) - 0.1
233 | ) {
234 | withAnimation(bounceAnimation) {
235 | scale = 1
236 | }
237 | }
238 | } else {
239 | // Reset scale
240 | withAnimation { scale = 1 }
241 | }
242 | }
243 | }
244 | }
245 | ```
246 |
247 | #### 3. **Use the Transition in Your Views**
248 |
249 | Apply the transition to your portal source, destination, and transition views:
250 |
251 | ```swift
252 | // Source view
253 | ScaleTransitionView(id: "myPortal") {
254 | RoundedRectangle(cornerRadius: 16)
255 | .fill(gradient)
256 | }
257 | .frame(width: 100, height: 100)
258 | .portalSource(id: "myPortal")
259 |
260 | // Destination view
261 | ScaleTransitionView(id: "myPortal") {
262 | RoundedRectangle(cornerRadius: 16)
263 | .fill(gradient)
264 | }
265 | .frame(width: 220, height: 220)
266 | .portalDestination(id: "myPortal")
267 |
268 | // Transition
269 | .portalTransition(
270 | id: "myPortal",
271 | animate: $isShowing,
272 | animation: scaleAnimation,
273 | animationDuration: transitionDuration
274 | ) {
275 | ScaleTransitionView(id: "myPortal") {
276 | RoundedRectangle(cornerRadius: 16)
277 | .fill(gradient)
278 | }
279 | }
280 | ```
281 |
282 | ### Summary
283 |
284 | 1. The `ScaleTransitionView` observes the portal's state through the `portalModel`.
285 | 2. When the portal activates:
286 | - The view scales up to 1.25x with a smooth animation
287 | - After a brief delay, it bounces back to 1.0x with extra bounce
288 | 3. When the portal deactivates, the scale smoothly returns to 1.0x
289 |
290 | ### Tips for Customization
291 |
292 | - Adjust the `scale` values (1.25) to change the intensity of the effect
293 | - Modify the animation timing and bounce parameters
294 | - Add additional transforms like rotation or opacity
295 | - Combine with other effects for more complex transitions
296 |
297 | > **Note:**
298 | > This is just one way to create custom transitions. The portal system is flexible and allows for many different animation approaches. A more declarative API for transitions is in development - this is temporary.
299 |
300 | ---
301 |
302 | ## Contributing
303 |
304 | Contributions are welcome! Please feel free to submit a Pull Request.
305 |
306 | ## Support
307 |
308 | If you like this project, please consider giving it a ⭐️
309 |
310 | ## Where to find me:
311 | - here, obviously.
312 | - [Twitter](https://x.com/AetherAurelia)
313 | - [Threads](https://www.threads.net/@aetheraurelia)
314 | - [Bluesky](https://bsky.app/profile/aethers.world)
315 | - [LinkedIn](https://www.linkedin.com/in/willjones24)
316 |
317 | ---
318 |
319 | Built with <3 by Aether
320 |
321 |
322 |
--------------------------------------------------------------------------------
/Sources/Portal/AnchorKey.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// PreferenceKey for collecting Portal anchors
4 | public struct AnchorKey: PreferenceKey {
5 | public static var defaultValue: [String: Anchor] = [:]
6 | public static func reduce(value: inout [String : Anchor], nextValue: () -> [String : Anchor]) {
7 | value.merge(nextValue()) { $1 }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Sources/Portal/CrossModel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Shared model for Portal animations
4 | public class CrossModel: ObservableObject {
5 | @Published public var info: [PortalInfo] = []
6 | public init() {}
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/Portal/Examples/DifferExample.swift:
--------------------------------------------------------------------------------
1 | #if DEBUG
2 | import SwiftUI
3 |
4 | @available(iOS 18.0, *)
5 | /// A demo view to showcase SheetShow Portal transitions and iOS 18 transitions
6 | public struct Portal_DifferExample: View {
7 | @State private var showDetailRed = false
8 | @State private var showDetailPurple = false
9 | @State private var useMatchingColors = true
10 |
11 | @Namespace private var transitionNamespace
12 |
13 | private let redGradient = [
14 | Color(red: 0.98, green: 0.36, blue: 0.35),
15 | Color(red: 0.92, green: 0.25, blue: 0.48)
16 | ]
17 | private let purpleGradient = [
18 | Color(red: 0.6, green: 0.4, blue: 0.9),
19 | Color(red: 0.4, green: 0.2, blue: 0.8)
20 | ]
21 | private let alternateGradient1 = [
22 | Color(red: 0.3, green: 0.8, blue: 0.5),
23 | Color(red: 0.1, green: 0.6, blue: 0.4)
24 | ]
25 |
26 | public init() {}
27 |
28 | @available(iOS 18.0, *)
29 | public var body: some View {
30 | NavigationView {
31 | PortalContainer {
32 | ScrollView {
33 | VStack(spacing: 24) {
34 |
35 | Text("Demo: Custom Portal (Red) & iOS 18 Zoom (Purple)")
36 | .font(.subheadline)
37 | .foregroundColor(.secondary)
38 | .padding(.horizontal)
39 | .multilineTextAlignment(.center)
40 |
41 | Text("Tap either shape to expand it")
42 | .font(.subheadline)
43 | .foregroundColor(.secondary)
44 |
45 | HStack(spacing: 30) {
46 | VStack {
47 | AnimatedLayer(id: "demo1") {
48 | RoundedRectangle(cornerRadius: 16)
49 | .fill(
50 | LinearGradient(
51 | gradient: Gradient(colors: redGradient),
52 | startPoint: .topLeading,
53 | endPoint: .bottomTrailing
54 | )
55 | )
56 | }
57 | .frame(width: 100, height: 100)
58 | .portalSource(id: "demo1")
59 | .onTapGesture { withAnimation(animationExample) { showDetailRed.toggle() } }
60 |
61 | Text("Cross-View (Portal)")
62 | .font(.caption)
63 | .foregroundColor(.secondary)
64 | }
65 |
66 |
67 | VStack {
68 |
69 | RoundedRectangle(cornerRadius: 16)
70 | .fill(
71 | LinearGradient(
72 | gradient: Gradient(colors: purpleGradient),
73 | startPoint: .topLeading,
74 | endPoint: .bottomTrailing
75 | )
76 | )
77 |
78 | .frame(width: 100, height: 100)
79 |
80 | .matchedTransitionSource(id: "demo2_shape", in: transitionNamespace)
81 | .onTapGesture {
82 | withAnimation(.smooth) { showDetailPurple.toggle() }
83 | }
84 |
85 | Text("navigationTransition (SwiftUI)")
86 | .font(.caption)
87 | .foregroundColor(.secondary)
88 | }
89 | }
90 |
91 | }
92 | .frame(maxWidth: .infinity)
93 | }
94 | .safeAreaInset(edge: .bottom, content: {
95 | // Toggle section - Keep for red portal example
96 | VStack(alignment: .leading, spacing: 12) {
97 | Toggle(isOn: $useMatchingColors) {
98 | VStack(alignment: .leading){
99 | Text("Use matching colors for Red Portal")
100 | Text(
101 | useMatchingColors
102 | ? "Red elements match for smooth portal transition"
103 | : "Red elements differ to show portal transition break"
104 | )
105 | .font(.caption)
106 | .foregroundColor(.secondary)
107 | }
108 | }
109 | }
110 | .padding(.vertical, 18)
111 | .padding(.horizontal, 20)
112 | .padding(.bottom, 12)
113 | .background{
114 | Color(.systemGray6)
115 | .clipShape(.rect(cornerRadius: 20))
116 | .ignoresSafeArea()
117 | }
118 | })
119 |
120 | // First sheet (red square) - Unchanged (Uses Custom Portal)
121 | .sheet(isPresented: $showDetailRed) {
122 | ScrollView {
123 | VStack(spacing: 24) {
124 | Text("Red Square Expanded (Custom Portal)")
125 | .font(.title2)
126 | .fontWeight(.bold)
127 | .padding(.top, 16)
128 | Spacer().frame(height: 30)
129 | AnimatedLayer(id: "demo1") {
130 | RoundedRectangle(cornerRadius: 16)
131 | .fill(
132 | LinearGradient(
133 | gradient: Gradient(colors: redGradient),
134 | startPoint: .topLeading,
135 | endPoint: .bottomTrailing
136 | )
137 | )
138 | }
139 | .frame(width: 220, height: 220)
140 | .portalDestination(id: "demo1") // Use custom portal destination
141 | .onTapGesture { withAnimation(animationExample) { showDetailRed.toggle() } }
142 | Spacer().frame(height: 30)
143 | Text("Tap to collapse")
144 | .font(.subheadline)
145 | .foregroundColor(.secondary)
146 | .padding(.bottom, 40)
147 | }
148 | .padding()
149 | .frame(maxWidth: .infinity)
150 | }
151 | .background(Color(UIColor.systemGroupedBackground))
152 | }
153 |
154 | // Second sheet (purple square) - MODIFIED for iOS 18 Transition
155 | .sheet(isPresented: $showDetailPurple) {
156 | ScrollView {
157 | VStack(spacing: 24) {
158 | Text("Purple Square Expanded (iOS 18 Zoom)")
159 | .font(.title2)
160 | .fontWeight(.bold)
161 | .padding(.top, 16)
162 | Spacer().frame(height: 30)
163 |
164 | RoundedRectangle(cornerRadius: 16)
165 | .fill(
166 | LinearGradient(
167 | gradient: Gradient(colors: purpleGradient),
168 | startPoint: .topLeading,
169 | endPoint: .bottomTrailing
170 | )
171 | )
172 |
173 | .frame(width: 220, height: 220)
174 | .onTapGesture {
175 | withAnimation(.smooth) { showDetailPurple.toggle() }
176 | }
177 |
178 | Spacer().frame(height: 30)
179 | Text("Tap to collapse")
180 | .font(.subheadline)
181 | .foregroundColor(.secondary)
182 | .padding(.bottom, 40)
183 | }
184 | .padding()
185 | .frame(maxWidth: .infinity)
186 | }
187 | .background(Color(UIColor.systemGroupedBackground))
188 | .navigationTransition(.zoom(sourceID: "demo2_shape", in: transitionNamespace))
189 | }
190 |
191 | .portalTransition(
192 | id: "demo1",
193 | animate: $showDetailRed,
194 | animation: animationExample,
195 | animationDuration: animationDuration
196 | ) {
197 | AnimatedLayer(id: "demo1") {
198 | RoundedRectangle(cornerRadius: 16)
199 | .fill(
200 | LinearGradient(
201 | gradient: Gradient(colors: useMatchingColors ? redGradient : alternateGradient1),
202 | startPoint: .topLeading,
203 | endPoint: .bottomTrailing
204 | )
205 | )
206 | }
207 | }
208 |
209 |
210 | }
211 |
212 | .navigationTitle("Mixed Transitions Demo")
213 | }
214 | }
215 | }
216 |
217 | #endif
218 |
--------------------------------------------------------------------------------
/Sources/Portal/Examples/NavigationExample.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationExample.swift
3 | // Portal
4 | //
5 | // Created by Aether on 21/04/2025.
6 | //
7 |
8 |
9 | #if DEBUG
10 | import SwiftUI
11 |
12 | private enum DemoSelection: Hashable {
13 | case red, purple
14 | }
15 |
16 |
17 | @available(iOS 16.0, *)
18 | public struct Portal_NavigationExample: View {
19 | // MARK: – 1) Use an array for the stack
20 | @State private var path: [DemoSelection] = []
21 | @State private var useMatchingColors = true
22 |
23 | // MARK: – 2) Drive portal animations from `path`
24 | private var isShowingRed: Binding {
25 | Binding(
26 | get: { path.contains(.red) },
27 | set: { new in
28 | if !new { path.removeAll(where: { $0 == .red }) }
29 | }
30 | )
31 | }
32 | private var isShowingPurple: Binding {
33 | Binding(
34 | get: { path.contains(.purple) },
35 | set: { new in
36 | if !new { path.removeAll(where: { $0 == .purple }) }
37 | }
38 | )
39 | }
40 |
41 | // MARK: – 3) Gradients
42 | private let redGradient = [
43 | Color(red: 0.98, green: 0.36, blue: 0.35),
44 | Color(red: 0.92, green: 0.25, blue: 0.48),
45 | ]
46 | private let purpleGradient = [
47 | Color(red: 0.6, green: 0.4, blue: 0.9),
48 | Color(red: 0.4, green: 0.2, blue: 0.8),
49 | ]
50 | private let alt1 = [
51 | Color(red: 0.3, green: 0.8, blue: 0.5),
52 | Color(red: 0.1, green: 0.6, blue: 0.4),
53 | ]
54 | private let alt2 = [
55 | Color(red: 0.95, green: 0.6, blue: 0.2),
56 | Color(red: 0.9, green: 0.4, blue: 0.1),
57 | ]
58 |
59 | public init() {}
60 |
61 | public var body: some View {
62 | PortalContainer {
63 | NavigationStack(path: $path) {
64 | ScrollView {
65 | VStack(spacing: 24) {
66 | Text("Portal Transition Demo")
67 | .font(.title).bold()
68 | .padding(.top, 16)
69 |
70 | Text("Tap a shape to push it")
71 | .font(.subheadline)
72 | .foregroundColor(.secondary)
73 |
74 | HStack(spacing: 30) {
75 | // RED source
76 | AnimatedLayer(id: "demo1") {
77 | RoundedRectangle(cornerRadius: 16)
78 | .fill(
79 | LinearGradient(
80 | gradient: .init(colors:
81 | useMatchingColors ? redGradient : alt1),
82 | startPoint: .topLeading,
83 | endPoint: .bottomTrailing
84 | )
85 | )
86 | }
87 | .frame(width: 100, height: 100)
88 | .portalSource(id: "demo1")
89 | .onTapGesture {
90 | withAnimation { path.append(.red) }
91 | }
92 |
93 | // PURPLE source
94 | AnimatedLayer(id: "demo2") {
95 | RoundedRectangle(cornerRadius: 16)
96 | .fill(
97 | LinearGradient(
98 | gradient: .init(colors:
99 | useMatchingColors ? purpleGradient : alt2),
100 | startPoint: .topLeading,
101 | endPoint: .bottomTrailing
102 | )
103 | )
104 | }
105 | .frame(width: 100, height: 100)
106 | .portalSource(id: "demo2")
107 | .onTapGesture {
108 | withAnimation { path.append(.purple) }
109 | }
110 | }
111 |
112 | Toggle("Use matching colors", isOn: $useMatchingColors)
113 | .padding()
114 |
115 | Spacer()
116 | }
117 | .frame(maxWidth: .infinity)
118 | }
119 | .navigationTitle("Portals + Nav")
120 | // MARK: – 5) set up destinations
121 | .navigationDestination(for: DemoSelection.self) { sel in
122 | switch sel {
123 | case .red: DetailView(colorSet: useMatchingColors ? redGradient : alt1,
124 | id: "demo1")
125 | case .purple: DetailView(colorSet: useMatchingColors ? purpleGradient : alt2,
126 | id: "demo2")
127 | }
128 | }
129 | // MARK: – 6) portal transitions remain exactly the same
130 | .portalTransition(
131 | id: "demo1",
132 | animate: isShowingRed,
133 | animation: animationExample,
134 | animationDuration: animationDuration
135 | ) {
136 | AnimatedLayer(id: "demo1") {
137 | RoundedRectangle(cornerRadius: 16)
138 | .fill(
139 | LinearGradient(
140 | gradient: .init(colors:
141 | useMatchingColors ? redGradient : alt1),
142 | startPoint: .topLeading,
143 | endPoint: .bottomTrailing
144 | )
145 | )
146 | }
147 | }
148 | } // NavigationStack
149 |
150 | .portalTransition(
151 | id: "demo2",
152 | animate: isShowingPurple,
153 | animation: animationExample,
154 | animationDuration: animationDuration
155 | ) {
156 | AnimatedLayer(id: "demo2") {
157 | RoundedRectangle(cornerRadius: 16)
158 | .fill(
159 | LinearGradient(
160 | gradient: .init(colors:
161 | useMatchingColors ? purpleGradient : alt2),
162 | startPoint: .topLeading,
163 | endPoint: .bottomTrailing
164 | )
165 | )
166 | }
167 | }
168 | } // PortalContainer
169 | }
170 | }
171 |
172 | fileprivate struct DetailView: View {
173 | @Environment(\.dismiss) private var dismiss
174 | let colorSet: [Color]
175 | let id: String
176 |
177 | var body: some View {
178 | ScrollView {
179 | VStack(spacing: 24) {
180 | Text("\(id.capitalized) Expanded")
181 | .font(.title2).bold().padding(.top, 16)
182 |
183 | AnimatedLayer(id: id) {
184 | RoundedRectangle(cornerRadius: 16)
185 | .fill(
186 | LinearGradient(
187 | gradient: .init(colors: colorSet),
188 | startPoint: .topLeading,
189 | endPoint: .bottomTrailing
190 | )
191 | )
192 | }
193 | .frame(width: 220, height: 220)
194 | .portalDestination(id: id)
195 | .onTapGesture {
196 | withAnimation { dismiss() }
197 | }
198 |
199 | Text("Tap to go back")
200 | .font(.subheadline).foregroundColor(.secondary)
201 | }
202 | .padding()
203 | }
204 | }
205 | }
206 | #endif
207 |
--------------------------------------------------------------------------------
/Sources/Portal/Examples/SheetExample.swift:
--------------------------------------------------------------------------------
1 | #if DEBUG
2 | import SwiftUI
3 |
4 | let animationDuration: TimeInterval = 0.4
5 | let animationExample: Animation = Animation.smooth(duration: animationDuration, extraBounce: 0.25)
6 | let animationExampleExtraBounce: Animation = Animation.smooth(duration: animationDuration + 0.12, extraBounce: 0.55)
7 |
8 | /// A demo view to showcase SheetShow Portal transitions
9 | public struct Portal_SheetExample: View {
10 | @State private var showDetailRed = false
11 | @State private var showDetailPurple = false
12 | @State private var useMatchingColors = true
13 |
14 | // Different gradient sets
15 | private let redGradient = [
16 | Color(red: 0.98, green: 0.36, blue: 0.35),
17 | Color(red: 0.92, green: 0.25, blue: 0.48)
18 | ]
19 | private let purpleGradient = [
20 | Color(red: 0.6, green: 0.4, blue: 0.9),
21 | Color(red: 0.4, green: 0.2, blue: 0.8)
22 | ]
23 | private let alternateGradient1 = [
24 | Color(red: 0.3, green: 0.8, blue: 0.5),
25 | Color(red: 0.1, green: 0.6, blue: 0.4)
26 | ]
27 | private let alternateGradient2 = [
28 | Color(red: 0.95, green: 0.6, blue: 0.2),
29 | Color(red: 0.9, green: 0.4, blue: 0.1)
30 | ]
31 |
32 | public init() {}
33 |
34 | public var body: some View {
35 | NavigationView{
36 | PortalContainer {
37 | ScrollView {
38 | VStack(spacing: 24) {
39 |
40 | Text("This demo shows how multiple portal transitions can work simultaneously.")
41 | .font(.subheadline)
42 | .foregroundColor(.secondary)
43 | .padding(.horizontal)
44 | .multilineTextAlignment(.center)
45 |
46 | Text("Tap either shape to expand it")
47 | .font(.subheadline)
48 | .foregroundColor(.secondary)
49 |
50 | // Two squares side by side
51 | HStack(spacing: 30) {
52 | VStack {
53 | AnimatedLayer(id: "demo1") {
54 | RoundedRectangle(cornerRadius: 16)
55 | .fill(
56 | LinearGradient(
57 | gradient: Gradient(colors: redGradient),
58 | startPoint: .topLeading,
59 | endPoint: .bottomTrailing
60 | )
61 | )
62 | }
63 | .frame(width: 100, height: 100)
64 | .portalSource(id: "demo1")
65 | .onTapGesture { withAnimation { showDetailRed.toggle() } }
66 |
67 | Text("Portal 1")
68 | .font(.caption)
69 | .foregroundColor(.secondary)
70 | }
71 |
72 | VStack {
73 | AnimatedLayer(id: "demo2") {
74 | RoundedRectangle(cornerRadius: 16)
75 | .fill(
76 | LinearGradient(
77 | gradient: Gradient(colors: purpleGradient),
78 | startPoint: .topLeading,
79 | endPoint: .bottomTrailing
80 | )
81 | )
82 | }
83 | .frame(width: 100, height: 100)
84 | .portalSource(id: "demo2")
85 | .onTapGesture { withAnimation { showDetailPurple.toggle() } }
86 |
87 | Text("Portal 2")
88 | .font(.caption)
89 | .foregroundColor(.secondary)
90 | }
91 | }
92 |
93 | }
94 | .frame(maxWidth: .infinity)
95 | }
96 | .safeAreaInset(edge: .bottom, content: {
97 |
98 | // Toggle for matching/different colors
99 | VStack(alignment: .leading, spacing: 12) {
100 | Toggle(isOn: $useMatchingColors) {
101 | VStack(alignment: .leading){
102 | Text("Use matching colors for all elements")
103 | Text(
104 | useMatchingColors
105 | ? "All elements have the same appearance for smooth transitions"
106 | : "Elements have different colors to show how transitions can break"
107 | )
108 | .font(.caption)
109 | .foregroundColor(.secondary)
110 | }
111 | }
112 | }
113 | .padding(.vertical, 18)
114 | .padding(.horizontal, 20)
115 | .padding(.bottom, 12)
116 | .background{
117 | Color(.systemGray6)
118 | .clipShape(.rect(cornerRadius: 20))
119 | .ignoresSafeArea()
120 | }
121 |
122 | })
123 |
124 | // First sheet (red square)
125 | .sheet(isPresented: $showDetailRed) {
126 | ScrollView {
127 | VStack(spacing: 24) {
128 | Text("Red Square Expanded")
129 | .font(.title2)
130 | .fontWeight(.bold)
131 | .padding(.top, 16)
132 |
133 | Spacer().frame(height: 30)
134 |
135 | AnimatedLayer(id: "demo1") {
136 | RoundedRectangle(cornerRadius: 16)
137 | .fill(
138 | LinearGradient(
139 | gradient: Gradient(colors: redGradient),
140 | startPoint: .topLeading,
141 | endPoint: .bottomTrailing
142 | )
143 | )
144 | }
145 | .frame(width: 220, height: 220)
146 | .portalDestination(id: "demo1")
147 | .onTapGesture { withAnimation { showDetailRed.toggle() } }
148 |
149 | Spacer().frame(height: 30)
150 |
151 | Text("Tap to collapse")
152 | .font(.subheadline)
153 | .foregroundColor(.secondary)
154 | .padding(.bottom, 40)
155 | }
156 | .padding()
157 | .frame(maxWidth: .infinity)
158 | }
159 | .background(Color(UIColor.systemGroupedBackground))
160 | }
161 |
162 | // Second sheet (purple square)
163 | .sheet(isPresented: $showDetailPurple) {
164 | ScrollView {
165 | VStack(spacing: 24) {
166 | Text("Purple Square Expanded")
167 | .font(.title2)
168 | .fontWeight(.bold)
169 | .padding(.top, 16)
170 |
171 | Spacer().frame(height: 30)
172 |
173 | AnimatedLayer(id: "demo2") {
174 | RoundedRectangle(cornerRadius: 16)
175 | .fill(
176 | LinearGradient(
177 | gradient: Gradient(colors: purpleGradient),
178 | startPoint: .topLeading,
179 | endPoint: .bottomTrailing
180 | )
181 | )
182 | }
183 | .frame(width: 220, height: 220)
184 | .portalDestination(id: "demo2")
185 | .onTapGesture { withAnimation { showDetailPurple.toggle() } }
186 |
187 | Spacer().frame(height: 30)
188 |
189 | Text("Tap to collapse")
190 | .font(.subheadline)
191 | .foregroundColor(.secondary)
192 | .padding(.bottom, 40)
193 | }
194 | .padding()
195 | .frame(maxWidth: .infinity)
196 | }
197 | .background(Color(UIColor.systemGroupedBackground))
198 | }
199 |
200 | // Transition for first square (red)
201 | .portalTransition(
202 | id: "demo1",
203 | animate: $showDetailRed,
204 | animation: animationExample,
205 | animationDuration: animationDuration
206 | ) {
207 | AnimatedLayer(id: "demo1") {
208 | RoundedRectangle(cornerRadius: 16)
209 | .fill(
210 | LinearGradient(
211 | gradient: Gradient(colors: useMatchingColors ? redGradient : alternateGradient1),
212 | startPoint: .topLeading,
213 | endPoint: .bottomTrailing
214 | )
215 | )
216 | }
217 | }
218 |
219 | // Transition for second square (purple)
220 | .portalTransition(
221 | id: "demo2",
222 | animate: $showDetailPurple,
223 | animation: animationExample,
224 | animationDuration: animationDuration
225 | ) {
226 | AnimatedLayer(id: "demo2") {
227 | RoundedRectangle(cornerRadius: 16)
228 | .fill(
229 | LinearGradient(
230 | gradient: Gradient(colors: useMatchingColors ? purpleGradient : alternateGradient2),
231 | startPoint: .topLeading,
232 | endPoint: .bottomTrailing
233 | )
234 | )
235 | }
236 | }
237 | }
238 | .navigationTitle("Portal Transition Demo")
239 | }
240 | }
241 | }
242 | #endif
243 |
244 | struct AnimatedLayer: View {
245 | @EnvironmentObject private var portalModel: CrossModel
246 | let id: String
247 | @ViewBuilder let content: () -> Content
248 |
249 | @State private var layerScale: CGFloat = 1
250 |
251 | var body: some View {
252 | let idx = portalModel.info.firstIndex { $0.infoID == id }
253 | let isActive = idx.flatMap { portalModel.info[$0].animateView } ?? false
254 |
255 | content()
256 | .scaleEffect(layerScale)
257 | .onAppear {
258 | // Ensure scale is correct on appear
259 | layerScale = 1
260 | }
261 | .onChangeCompat(of: isActive) { newValue in
262 | if newValue {
263 | // 1) bump up
264 | withAnimation(animationExample) {
265 | layerScale = 1.25
266 | }
267 | // 2) bounce back down
268 | DispatchQueue.main.asyncAfter(deadline: .now() + (animationDuration / 2) - 0.1) {
269 | withAnimation(animationExampleExtraBounce) {
270 | layerScale = 1
271 | }
272 | }
273 | } else {
274 | // Reset scale when not active
275 | withAnimation {
276 | layerScale = 1
277 | }
278 | }
279 | }
280 | .overlay(
281 | Group {
282 | if idx == nil {
283 | Image(systemName: "exclamationmark.triangle")
284 | .foregroundColor(.yellow)
285 | }
286 | }
287 | )
288 | }
289 | }
290 |
--------------------------------------------------------------------------------
/Sources/Portal/PassThroughWindow.swift:
--------------------------------------------------------------------------------
1 | #if canImport(UIKit)
2 | import UIKit
3 | import SwiftUI
4 |
5 | /// A window that lets touches pass through non-content areas
6 | internal class PassThroughWindow: UIWindow {
7 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
8 | guard let view = super.hitTest(point, with: event) else { return nil }
9 | // If the hit is on the root view controller's background, pass it through
10 | return rootViewController?.view == view ? nil : view
11 | }
12 | }
13 | #else
14 | import SwiftUI
15 |
16 | /// Stub for non-UIKit platforms
17 | internal class PassThroughWindow { }
18 | #endif
19 |
--------------------------------------------------------------------------------
/Sources/Portal/PortalContainer.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | #if canImport(UIKit)
3 | import UIKit
4 | #endif
5 |
6 | /// A SwiftUI container that overlays a transparent window above your app's UI,
7 | /// optionally hiding the status bar in the overlay.
8 | ///
9 | /// Use this to inject a portal layer for cross-view communication or overlays.
10 | /// The overlay is managed automatically as the app's scene becomes active/inactive.
11 | ///
12 | /// - Parameters:
13 | /// - hideStatusBar: Whether the overlay should hide the status bar. Default is `true`.
14 | /// - content: The main content of your view hierarchy.
15 | /// - Example:
16 | /// ```swift
17 | /// PortalContainer(hideStatusBar: false) {
18 | /// MyMainView()
19 | /// }
20 | /// ```
21 | public struct PortalContainer: View {
22 | @ViewBuilder public var content: Content
23 | @Environment(\.scenePhase) private var scene
24 | @StateObject private var portalModel = CrossModel()
25 | private let hideStatusBar: Bool
26 |
27 | /// Creates a new PortalContainer.
28 | /// - Parameters:
29 | /// - hideStatusBar: Whether the overlay should hide the status bar.
30 | /// - content: The main content view.
31 | public init(
32 | hideStatusBar: Bool = false,
33 | @ViewBuilder content: () -> Content
34 | ) {
35 | self.hideStatusBar = hideStatusBar
36 | self.content = content()
37 | }
38 |
39 | public var body: some View {
40 | content
41 | .onChange(of: scene) { newValue in
42 | #if canImport(UIKit)
43 | if newValue == .active {
44 | OverlayWindowManager.shared.addOverlayWindow(
45 | with: portalModel,
46 | hideStatusBar: hideStatusBar
47 | )
48 | } else {
49 | OverlayWindowManager.shared.removeOverlayWindow()
50 | }
51 | #endif
52 | }
53 | .environmentObject(portalModel)
54 | }
55 | }
56 |
57 | /// Adds a portal container overlay to the view, optionally hiding the status bar.
58 | ///
59 | /// - Parameter hideStatusBar: Whether the overlay should hide the status bar. Default is `true`.
60 | /// - Returns: A view wrapped in a `PortalContainer`.
61 | /// - Example:
62 | /// ```swift
63 | /// MyView()
64 | /// .portalContainer(hideStatusBar: false)
65 | /// ```
66 | extension View {
67 | @ViewBuilder
68 | public func portalContainer(hideStatusBar: Bool = true) -> some View {
69 | PortalContainer(hideStatusBar: hideStatusBar) {
70 | self
71 | }
72 | }
73 | }
74 |
75 | #if canImport(UIKit)
76 | import UIKit
77 |
78 | /// Manages the overlay window for the portal layer.
79 | final class OverlayWindowManager {
80 | static let shared = OverlayWindowManager()
81 | private var overlayWindow: PassThroughWindow?
82 |
83 | /// Adds the overlay window to the active scene.
84 | /// - Parameters:
85 | /// - portalModel: The shared portal model.
86 | /// - hideStatusBar: Whether the overlay should hide the status bar.
87 | func addOverlayWindow(
88 | with portalModel: CrossModel,
89 | hideStatusBar: Bool
90 | ) {
91 | guard overlayWindow == nil else { return }
92 | DispatchQueue.main.async {
93 | for scene in UIApplication.shared.connectedScenes {
94 | guard let windowScene = scene as? UIWindowScene,
95 | scene.activationState == .foregroundActive else { continue }
96 |
97 | let window = PassThroughWindow(windowScene: windowScene)
98 | window.backgroundColor = .clear
99 | window.isUserInteractionEnabled = false
100 | window.isHidden = false
101 |
102 | let root: UIViewController
103 | if hideStatusBar {
104 | root = HiddenStatusHostingController(
105 | rootView: PortalLayerView()
106 | .environmentObject(portalModel)
107 | )
108 | } else {
109 | root = UIHostingController(
110 | rootView: PortalLayerView()
111 | .environmentObject(portalModel)
112 | )
113 | }
114 | root.view.backgroundColor = .clear
115 | root.view.frame = windowScene.screen.bounds
116 |
117 | window.rootViewController = root
118 | self.overlayWindow = window
119 | break
120 | }
121 | }
122 | }
123 |
124 | /// Removes the overlay window from the scene.
125 | func removeOverlayWindow() {
126 | DispatchQueue.main.async {
127 | self.overlayWindow?.isHidden = true
128 | self.overlayWindow = nil
129 | }
130 | }
131 | }
132 |
133 | /// A HostingController that always hides the status bar.
134 | final class HiddenStatusHostingController: UIHostingController {
135 | override var prefersStatusBarHidden: Bool { true }
136 | override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .slide }
137 | }
138 | #endif
139 |
--------------------------------------------------------------------------------
/Sources/Portal/PortalDestination.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A view wrapper that marks its content as a portal destination (arriving view).
4 | ///
5 | /// Used internally by the `.portalDestination(id:)` view modifier to identify the destination
6 | /// (target) of a portal transition animation. You typically do not use this type directly;
7 | /// instead, use the `.portalDestination(id:)` modifier on your view.
8 | ///
9 | /// - Parameters:
10 | /// - id: A unique string identifier for this portal destination. This should match the `id` used for the corresponding portal source and transition.
11 | /// - content: The view content to be marked as the destination.
12 | public struct PortalDestination: View {
13 | public let id: String
14 | @ViewBuilder public let content: Content
15 | @EnvironmentObject private var portalModel: CrossModel
16 |
17 | public init(id: String, @ViewBuilder content: () -> Content) {
18 | self.id = id
19 | self.content = content()
20 | }
21 |
22 | public var body: some View {
23 | content
24 | .opacity(opacity)
25 | .anchorPreference(key: AnchorKey.self, value: .bounds) { anchor in
26 | if let idx = index, portalModel.info[idx].isActive {
27 | return [destKey: anchor]
28 | }
29 | return [:]
30 | }
31 | .onPreferenceChange(AnchorKey.self) { prefs in
32 | if let idx = index, portalModel.info[idx].isActive {
33 | portalModel.info[idx].destinationAnchor = prefs[destKey]
34 | }
35 | }
36 | }
37 |
38 | private var destKey: String { "\(id)DEST" }
39 |
40 | private var index: Int? {
41 | portalModel.info.firstIndex { $0.infoID == id }
42 | }
43 |
44 | private var opacity: CGFloat {
45 | guard let idx = index else { return 1 }
46 | return portalModel.info[idx].isActive ? (portalModel.info[idx].hideView ? 1 : 0) : 1
47 | }
48 | }
49 |
50 | public extension View {
51 | /// Marks this view as a portal destination (arriving view).
52 | ///
53 | /// Attach this modifier to the view that should act as the destination for a portal transition.
54 | ///
55 | /// - Parameter id: A unique string identifier for this portal destination. This should match the `id` used for the corresponding portal source and transition.
56 | ///
57 | /// Example usage:
58 | /// ```swift
59 | /// Image("cover")
60 | /// .portalDestination(id: "Book1")
61 | /// ```
62 | func portalDestination(id: String) -> some View {
63 | PortalDestination(id: id) { self }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Sources/Portal/PortalInfo.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Data record for a portal animation
4 | public struct PortalInfo: Identifiable {
5 | public let id = UUID()
6 | public let infoID: String
7 | public var isActive = false
8 | public var layerView: AnyView? = nil
9 | public var animateView = false
10 | public var animationDuration: TimeInterval = 0.55
11 | public var hideView = false
12 | public var sourceAnchor: Anchor? = nil
13 | public var destinationAnchor: Anchor? = nil
14 | public var sourceProgress: CGFloat = 0
15 | public var destinationProgress: CGFloat = 0
16 | public var completion: (Bool) -> Void = { _ in }
17 |
18 | public init(id: String) {
19 | self.infoID = id
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/Portal/PortalLayerView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Internal overlay view that renders and animates portal layers
4 | internal struct PortalLayerView: View {
5 | @EnvironmentObject private var portalModel: CrossModel
6 |
7 | var body: some View {
8 | GeometryReader { proxy in
9 | ForEach($portalModel.info) { $info in
10 | ZStack {
11 | if let source = info.sourceAnchor,
12 | let destination = info.destinationAnchor,
13 | let layer = info.layerView,
14 | !info.hideView {
15 | let sRect = proxy[source]
16 | let dRect = proxy[destination]
17 | let animate = info.animateView
18 | let width = animate ? dRect.size.width : sRect.size.width
19 | let height = animate ? dRect.size.height : sRect.size.height
20 | let x = animate ? dRect.minX : sRect.minX
21 | let y = animate ? dRect.minY : sRect.minY
22 |
23 | layer
24 | .frame(width: width, height: height)
25 | .offset(x: x, y: y)
26 | .transition(.identity)
27 | }
28 | }
29 | .onChangeCompat(of: info.animateView) { newValue in
30 | // Delay to allow animation to finish
31 | DispatchQueue.main.asyncAfter(deadline: .now() + info.animationDuration + 0.2) {
32 | if !newValue {
33 | info.isActive = false
34 | info.layerView = nil
35 | info.sourceAnchor = nil
36 | info.destinationAnchor = nil
37 | info.sourceProgress = 0
38 | info.destinationProgress = 0
39 | info.completion(false)
40 | } else {
41 | info.hideView = true
42 | info.completion(true)
43 | }
44 | }
45 | }
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/Portal/PortalSource.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A view wrapper that marks its content as a portal source (leaving view).
4 | ///
5 | /// Used internally by the `.portalSource(id:)` view modifier to identify the source (origin)
6 | /// of a portal transition animation. You typically do not use this type directly; instead,
7 | /// use the `.portalSource(id:)` modifier on your view.
8 | ///
9 | /// - Parameters:
10 | /// - id: A unique string identifier for this portal source. This should match the `id` used for the corresponding portal destination and transition.
11 | /// - content: The view content to be marked as the source.
12 | public struct PortalSource: View {
13 | public let id: String
14 | @ViewBuilder public let content: Content
15 | @EnvironmentObject private var portalModel: CrossModel
16 |
17 | public init(id: String, @ViewBuilder content: () -> Content) {
18 | self.id = id
19 | self.content = content()
20 | }
21 |
22 | public var body: some View {
23 | content
24 | .opacity(opacity)
25 | .anchorPreference(key: AnchorKey.self, value: .bounds) { anchor in
26 | if let idx = index, portalModel.info[idx].isActive {
27 | return [id: anchor]
28 | }
29 | return [:]
30 | }
31 | .onPreferenceChange(AnchorKey.self) { prefs in
32 | if let idx = index, portalModel.info[idx].isActive, portalModel.info[idx].sourceAnchor == nil {
33 | portalModel.info[idx].sourceAnchor = prefs[id]
34 | }
35 | }
36 | }
37 |
38 | private var index: Int? {
39 | portalModel.info.firstIndex { $0.infoID == id }
40 | }
41 |
42 | private var opacity: CGFloat {
43 | guard let idx = index else { return 1 }
44 | return portalModel.info[idx].destinationAnchor == nil ? 1 : 0
45 | }
46 | }
47 |
48 | public extension View {
49 | /// Marks this view as a portal source (leaving view).
50 | ///
51 | /// Attach this modifier to the view that should act as the source for a portal transition.
52 | ///
53 | /// - Parameter id: A unique string identifier for this portal source. This should match the `id` used for the corresponding portal destination and transition.
54 | ///
55 | /// Example usage:
56 | /// ```swift
57 | /// Image("cover")
58 | /// .portalSource(id: "Book1")
59 | /// ```
60 | func portalSource(id: String) -> some View {
61 | PortalSource(id: id) { self }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Sources/Portal/PortalTransition.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Drives the Portal floating layer for a given id.
4 | ///
5 | /// Use this view modifier to trigger and control a portal transition animation between
6 | /// a source and destination view. The modifier manages the floating overlay layer,
7 | /// animation timing, and transition state for the specified `id`.
8 | ///
9 | /// - Parameters:
10 | /// - id: A unique string identifier for the portal transition. This should match the `id` used for the corresponding portal source and destination.
11 | /// - animate: A binding that triggers the transition when set to `true`.
12 | /// - sourceProgress: The progress value for the source view (default: 0).
13 | /// - destinationProgress: The progress value for the destination view (default: 0).
14 | /// - animation: The animation to use for the transition (default: `.bouncy(duration: 0.3)`).
15 | /// - animationDuration: The duration of the transition animation (default: 0.3).
16 | /// - delay: The delay before starting the animation (default: 0.06).
17 | /// - layer: A closure that returns the floating overlay view to animate.
18 | /// - completion: A closure called when the transition completes, with a `Bool` indicating success.
19 | ///
20 | /// Example usage:
21 | /// ```swift
22 | /// .portalTransition(
23 | /// id: "Book1",
24 | /// animate: $showDetail,
25 | /// animation: .smooth(duration: 0.6),
26 | /// animationDuration: 0.6
27 | /// ) {
28 | /// Image("cover")
29 | /// }
30 | /// ```
31 | @available(iOS 15.0, macOS 13.0, *)
32 | public struct PortalTransitionModifier: ViewModifier {
33 | public let id: String
34 | @Binding public var animate: Bool
35 | public let sourceProgress: CGFloat
36 | public let destinationProgress: CGFloat
37 | public let animation: Animation
38 | public let animationDuration: TimeInterval
39 | public let delay: TimeInterval
40 | public let layer: () -> Layer
41 | public let completion: (Bool) -> Void
42 |
43 | @EnvironmentObject private var portalModel: CrossModel
44 | public init(
45 | id: String,
46 | animate: Binding,
47 | sourceProgress: CGFloat = 0,
48 | destinationProgress: CGFloat = 0,
49 | animation: Animation = .bouncy(duration: 0.3),
50 | animationDuration: TimeInterval = 0.3,
51 | delay: TimeInterval = 0.06,
52 | layer: @escaping () -> Layer,
53 | completion: @escaping (Bool) -> Void = { _ in }
54 | ) {
55 | self.id = id
56 | self._animate = animate
57 | self.sourceProgress = sourceProgress
58 | self.destinationProgress = destinationProgress
59 | self.animation = animation
60 | self.animationDuration = animationDuration
61 | self.delay = delay
62 | self.layer = layer
63 | self.completion = completion
64 | }
65 |
66 | public func body(content: Content) -> some View {
67 | content
68 | .onAppear {
69 | if !portalModel.info.contains(where: { $0.infoID == id }) {
70 | portalModel.info.append(PortalInfo(id: id))
71 | }
72 | }
73 | .onChangeCompat(of: animate) { newValue in
74 | guard let idx = portalModel.info.firstIndex(where: { $0.infoID == id }) else { return }
75 | // activate and configure
76 | portalModel.info[idx].isActive = true
77 | portalModel.info[idx].layerView = AnyView(layer())
78 | portalModel.info[idx].animationDuration = animationDuration
79 | portalModel.info[idx].sourceProgress = sourceProgress
80 | portalModel.info[idx].destinationProgress = destinationProgress
81 | portalModel.info[idx].completion = completion
82 |
83 | if newValue {
84 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
85 | withAnimation(animation) {
86 | portalModel.info[idx].animateView = true
87 | }
88 | }
89 | } else {
90 | portalModel.info[idx].hideView = false
91 | withAnimation(animation) {
92 | portalModel.info[idx].animateView = false
93 | }
94 | }
95 | }
96 | }
97 | }
98 |
99 | /// Drives the portal animation for the given id.
100 | ///
101 | /// Attach this modifier to a container view to drive a portal transition between
102 | /// a source and destination. The modifier manages the floating overlay, animation,
103 | /// and transition state for the specified `id`.
104 | ///
105 | /// - Parameters:
106 | /// - id: A unique string identifier for the portal transition. This should match the `id` used for the corresponding portal source and destination.
107 | /// - animate: A binding that triggers the transition when set to `true`.
108 | /// - sourceProgress: The progress value for the source view (default: 0).
109 | /// - destinationProgress: The progress value for the destination view (default: 0).
110 | /// - animation: The animation to use for the transition (default: `.smooth(duration: 0.42, extraBounce: 0.2)`).
111 | /// - animationDuration: The duration of the transition animation (default: 0.72).
112 | /// - delay: The delay before starting the animation (default: 0.06).
113 | /// - layer: A closure that returns the floating overlay view to animate.
114 | /// - completion: A closure called when the transition completes, with a `Bool` indicating success.
115 | ///
116 | /// Example usage:
117 | /// ```swift
118 | /// .portalTransition(
119 | /// id: "Book1",
120 | /// animate: $showDetail,
121 | /// animation: .smooth(duration: 0.6),
122 | /// animationDuration: 0.6
123 | /// ) {
124 | /// Image("cover")
125 | /// }
126 | /// ```
127 | @available(iOS 15.0, macOS 13.0, *)
128 | public extension View {
129 | /// Triggers a portal animation for the given id
130 | func portalTransition(
131 | id: String,
132 | animate: Binding,
133 | sourceProgress: CGFloat = 0,
134 | destinationProgress: CGFloat = 0,
135 | animation: Animation = .smooth(duration: 0.42, extraBounce: 0.2),
136 | animationDuration: TimeInterval = 0.72,
137 | delay: TimeInterval = 0.06,
138 | @ViewBuilder layer: @escaping () -> Layer,
139 | completion: @escaping (Bool) -> Void = { _ in }
140 | ) -> some View {
141 | self.modifier(
142 | PortalTransitionModifier(
143 | id: id,
144 | animate: animate,
145 | sourceProgress: sourceProgress,
146 | destinationProgress: destinationProgress,
147 | animation: animation,
148 | animationDuration: animationDuration,
149 | delay: delay,
150 | layer: layer,
151 | completion: completion
152 | )
153 | )
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/Sources/Portal/SheetShow.swift:
--------------------------------------------------------------------------------
1 | // The Swift Programming Language
2 | // https://docs.swift.org/swift-book
3 |
--------------------------------------------------------------------------------
/Sources/Portal/View+OnChangeCompat.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Compatibility onChange for iOS/macOS
4 | public extension View {
5 | /// Like onChange(of:), but works uniformly across iOS 17+/macOS 14+ and earlier.
6 | @ViewBuilder
7 | func onChangeCompat(of value: Value, perform action: @escaping (Value) -> Void) -> some View {
8 | if #available(iOS 17, macOS 14, *) {
9 | self.onChange(of: value) { oldValue, newValue in
10 | action(newValue)
11 | }
12 | } else {
13 | self.onChange(of: value) { newValue in
14 | action(newValue)
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Tests/PortalTests/SheetShowTests.swift:
--------------------------------------------------------------------------------
1 | import Testing
2 | @testable import Portal
3 |
4 | @Test func example() async throws {
5 | // Write your test here and use APIs like `#expect(...)` to check expected conditions.
6 | }
7 |
--------------------------------------------------------------------------------
/assets/example1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aeastr/Portal/444af7a131e0ba18ee09551077c985bcce2425dd/assets/example1.gif
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aeastr/Portal/444af7a131e0ba18ee09551077c985bcce2425dd/assets/icon.png
--------------------------------------------------------------------------------