├── .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 | Portal Logo 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 |
12 | 13 | 14 | Swift Version 15 | 16 | 17 | iOS 18 | 19 | 20 | License: MIT 21 | 22 |
23 | 24 | ## **Demo** 25 | 26 | ![Example](/assets/example1.gif) 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 --------------------------------------------------------------------------------