Skip to content

filipealva/swift-teleport

Repository files navigation

Teleport

Teleport

Portals and live view teleportation for SwiftUI and UIKit. Render content from one place in your view tree at another, and move a live view between two places — preserving its state (scroll offset, video playback, text input, animations) because the same instance is re-parented, never recreated.

CI Swift 6 Platforms SPM License

Inspired by react-native-teleport — this is a native Swift take on the same idea, built on UIKit view re-parenting.

Why Teleport?

UIKit already has the primitive: a UIView has exactly one superview, so moving it to a new parent keeps the same instance alive with all of its native state intact. SwiftUI, on its own, can't do this — it recreates view backing stores. Teleport bridges the gap.

  • One live instance, many places. Move a video player, web view, map, or any expensive view between hosts without re-initializing it. Playback, scroll position and input survive the move.
  • Declarative and imperative. A SwiftUI Portal / PortalHost API, and a matching UIKit PortalView / PortalHostView core underneath.
  • Local fallback, never a black hole. A portal whose host isn't mounted yet renders in place, then migrates the instant the host appears — and snaps back when it leaves.
  • Preload offscreen. Warm up a heavy view at launch and teleport it in instantly, fully initialized.
  • Shared-element transitions. Animate the move between hosts by re-flowing the live view itself — the Instagram-style "expand from the feed" effect, using only public API.
  • Swift 6, zero dependencies. @MainActor, @Observable, strict concurrency, one small library.

Installation

Swift Package Manager

dependencies: [
  .package(url: "https://github.com/filipealva/swift-teleport.git", from: "0.3.0")
]

Or in Xcode: File ▸ Add Package Dependencies… and paste the repository URL.

import Teleport

Quick Start

1. Install the provider

Wrap your root view once. This installs a default root host as a full-screen overlay, so overlays draw above everything.

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
        .portalProvider()   // or: PortalProvider { ContentView() }
    }
  }
}

2. Place a host

A PortalHost is a named destination. Put it wherever teleported content should appear.

ZStack {
  FeedScreen()
  PortalHost("overlay").ignoresSafeArea()
}

3. Teleport content into it

A Portal renders its content into the host named hostName. Toggle hostName to move the live instance back and forth.

struct PlayerCard: View {
  @State private var expanded = false

  var body: some View {
    Button("Toggle") { expanded.toggle() }

    Portal(hostName: expanded ? "overlay" : nil) {
      VideoPlayerView()   // keeps playing as it teleports
    }
  }
}

When hostName is nil (or its host isn't mounted), the content renders locally. When the host is available, the same VideoPlayerView keeps running as it appears over the host.

Core Concepts

Type Role
PortalProvider / .portalProvider() Installs the default root host overlay above your content.
PortalHost(_:) A named destination that teleported content tracks. Transparent to touches on empty areas.
Portal(_:hostName:alignment:content:) Hosts its content once and re-positions it over the named host, preserving state.
usePortal(_:)PortalController Query isHostAvailable and call removePortal(_:) — the Swift take on RN's usePortal hook.
PortalRegistry.shared The process-wide directory mapping host names to hosts. Usually you don't touch it directly.

The default host is named PortalRegistry.rootHostName ("root") — pass hostName: nil to render locally, or reference the constant when you want the root host by name.

Sizing & touch passthrough

By default (alignment: .center) the content is sized to itself and anchored to the host, so empty areas of the host pass touches through to whatever is beneath them — floating banners and toasts won't block your UI. Use a different alignment (e.g. .top) to anchor it elsewhere, or alignment: nil to fill the host (for full-bleed content like video or a map, which should receive touches across the whole host).

The portal's original slot collapses to zero once teleported, so wrap it in a fixed-size container if you need to avoid reflow.

Note: the SwiftUI Portal hosts its content in a per-presentation overlay and positions it over the host with Auto Layout — it never moves the content in the view hierarchy, which is what preserves the content's state. The host must therefore live in the same presentation as the portal (the same screen, or the same modal — see the modal recipe).

Ordering & duplicate names

Two hosts active under the same name at once is undefined — the most recently mounted one wins, so use unique names. When several portals target one host their relative stacking is not guaranteed and may change as portals re-sync; prefer a single portal per host when exact z-order matters.

usePortal — availability & removal

struct Toast: View {
  var body: some View {
    let portal = usePortal("overlay")
    if portal.isHostAvailable {                 // re-renders when the host mounts/unmounts
      Portal("toast", hostName: "overlay") { ToastView() }
    }
    Button("Dismiss") { portal.removePortal("toast") }   // one-shot hide of the current toast
  }
}

Recipes

Preload a heavy component offscreen

Mount the portal somewhere persistent (e.g. your root view), parked offscreen. It loads immediately and teleports in the moment a matching host appears.

ZStack {
  AppTabs()
  Portal(hostName: "editor") { ExpensiveWebView() }
    .frame(width: 320, height: 480)
    .offset(x: -10_000)            // offscreen, but real-sized so it loads
    .allowsHitTesting(false)
}

// Later, on some screen:
PortalHost("editor")               // the warmed-up web view teleports in instantly

Shared-element transition (UIKit)

The UIKit PortalView animates the move by re-flowing the live view from its source frame to the destination — so subviews stay pinned by their own constraints (labels re-flow instead of stretching) and layer properties like cornerRadius hold their true value the whole way:

let card = PortalView(name: "hero")
card.contentView.addSubview(videoView)          // your live content

// Expand into a full-screen host, animating from the card to full-screen:
card.setHostName("fullscreen", animated: true)

// Collapse back to the feed slot:
card.setHostName(nil, animated: true)

Tune the animation with PortalTransition(duration:options:).

Portals above a modal

A modal is its own presentation, so the Portal and the PortalHost it targets must both live inside the modal:

.fullScreenCover(isPresented: $showModal) {
  ZStack {
    ModalContent()
    Portal(hostName: "modal.overlay", alignment: .top) { FloatingBadge() }
    PortalHost("modal.overlay").ignoresSafeArea()   // lives in the modal's presentation
  }
}

Pure UIKit

The UIKit PortalView physically re-parents its content view (which preserves all native state), so it works the same whether content fills the host or floats:

let host = PortalHostView(name: "overlay")
view.addSubview(host)                 // registers once it's in a window

let portal = PortalView(name: "player", hostName: "overlay")
portal.contentView.addSubview(playerView)
// `playerView` migrates into `host` once the host is mounted in a window (not synchronously
// here). Call portal.setHostName(nil) to bring it back to the local slot.

Example App

The Example/ app is a SwiftUI + UIKit showcase of every feature:

  • Portal — declarative teleport with local fallback
  • Teleport (preserve state) — a live card whose timer and text survive the move
  • Preloading — a WKWebView warmed up at launch, teleported in already running
  • Modals — a host inside a fullScreenCover
  • usePortal — host availability + removePortal
  • Shared-element transition — the animated UIKit PortalView flow

See Example/README.md to run it (xcodegen generate).

Compared to react-native-teleport

react-native-teleport Teleport (Swift)
<PortalProvider> PortalProvider { } / .portalProvider()
<PortalHost name="x" /> PortalHost("x") / PortalHostView(name: "x")
<Portal hostName="x"> Portal(hostName: "x") { } / PortalView(hostName: "x")
usePortal(host){ isHostAvailable, removePortal } usePortal("host").isHostAvailable, .removePortal(_:)
Local fallback when host is missing Same
Teleport preserves state Same — the content is hosted once and re-positioned, never rebuilt
ScrollView.Context propagation Not needed — UIScrollView handles direction natively
Web (createPortal) Out of scope (native iOS only)

How it works

The SwiftUI Portal hosts your content once in a single UIHostingController that lives in a box-none overlay attached to the current presentation. It never moves that controller in the view hierarchy — it only retargets Auto Layout constraints so the content tracks the resolved host (or the local slot). Because the content never leaves the hierarchy, SwiftUI never tears it down, so @State, scroll position, focus and in-flight animations are preserved. The box-none overlay lets empty areas pass touches through.

The UIKit PortalView is simpler: it physically re-parents a plain content UIView between the local slot and the host. A UIView keeps all of its state when it changes superview, so video playback, web-view scroll position and text input survive the move directly.

Native edge cases

The parts that make a portal feel native rather than pasted on — z-order over sheets and modals, keyboard & safe area, animation timing, and VoiceOver/accessibility — are documented with their actual behavior and recommended patterns in Docs/native-edge-cases.md.

Contributing

Contributions are welcome — see CONTRIBUTING.md for how to build, test (on an iOS simulator), and submit changes. CI builds and tests every pull request.

Requirements

  • iOS 17.0+
  • Swift 6.0
  • Xcode 16 or later

License

Teleport is available under the MIT license. See LICENSE for details.

About

Inspired by react-native-teleport, this is a native Swift take on the same idea, built on UIKit view re-parenting.

Topics

Resources

License

Contributing

Stars

8 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages