Portal SwiftUI Toolkit

repository·main·Indexed 22 days ago

https://github.com/aeastr/portal

A modular SwiftUI toolkit for advanced UI transitions. It includes PortalTransitions for animating elements across navigation contexts (iOS 17+), PortalHeaders for scroll-based flowing headers (iOS 18+), and _PortalPrivate for view mirroring using private UIKit APIs. Supports version v4.0.0+ with legacy support for iOS 15/16 in v2.1.0.

Tokens
13.5K
Snippets
43
Records
55
Agent score
77%

What's inside Portal

  1. Overview of Portal modules

    main

    Portal provides three distinct capabilities for SwiftUI developers:

    PortalTransitions

    Used to animate elements across different navigation contexts like sheets, navigation stacks, or tabs. It uses a floating overlay layer to facilitate these transitions.

    • Requirement: iOS 17+
    • Safety: Uses standard SwiftUI APIs; safe for App Store.

    PortalHeaders

    Provides scroll-based header transitions that flow into the navigation bar, mimicking the behavior found in Apple's Music or Photos apps.

    • Requirement: iOS 18+
    • Safety: Uses advanced scroll tracking APIs; safe for App Store.

    _PortalPrivate

    Provides true view mirroring by leveraging Apple's private _UIPortalView instead of layer snapshots.

    • Requirement: iOS 17+
    • Safety: WARNING: Uses private UIKit APIs. This may lead to App Store rejection.
  2. How PortalHeaders snapping triggers

    main

    Snapping behavior is not continuous; it is an event-driven correction that occurs only when two conditions are met:

    1. Scrolling stops: The user lifts their finger or the view's momentum ends.
    2. Header is mid-transition: The header's progress value is currently between 0.0 (inline header) and 1.0 (nav bar).

    When these conditions are met, the component executes a smooth animation curve to move the header to its snapped destination.

  3. Compare PortalTransitions vs _PortalPrivate

    main

    Choose between PortalTransitions and _PortalPrivate based on your requirements for state preservation and implementation method:

    FeaturePortalTransitions
    ImplementationLayer snapshots
    State preservationSnapshot at transition start
    View sizeCan differ between source/dest
    API typePublic SwiftUI APIs
    Feature_PortalPrivate
    Implementation_UIPortalView mirroring
    State preservationLive view instance
    View sizeMust match at source/dest
    API typePrivate UIKit API (obfuscated)
  4. Configure PortalTransition layer levels

    main

    The configuration closure allows you to customize the appearance and layout of the transition layer. There are three levels of control depending on how much manual layout work you want to perform:

    Level 1: Styling Only

    Use this to modify appearance (like corner radius or shadows) without affecting positioning. The system automatically applies frame and offset after your configuration.

    Level 2: Full Control (Interpolated Values)

    Use this when you need to control the order of modifiers (e.g., applying a clip shape after a frame). You must manually apply the frame and offset using the provided size and position parameters.

    Level 3: Raw Source/Destination Values

    Use this for complex custom interpolation. You receive both the source and destination values for size and position, allowing you to calculate custom paths or logic.

    // Level 1: Styling Only
    .portalTransition(item: $selectedItem, in: namespace) { item in
        ItemView(item: item)
    } configuration: { content, isActive in
        content
            .clipShape(.rect(cornerRadius: isActive ? 24 : 12, style: .continuous))
            .shadow(radius: isActive ? 10 : 2)
    }
    
    // Level 2: Full Control (Interpolated Values)
    .portalTransition(item: $selectedItem, in: namespace) { item in
        ItemView(item: item)
    } configuration: { content, isActive, size, position in
        content
            .frame(width: size.width, height: size.height)
            .clipShape(.rect(cornerRadius: isActive ? 24 : 12, style: .continuous))
            .offset(x: position.x, y: position.y)
    }
    
    // Level 3: Raw Source/Destination Values
    .portalTransition(item: $selectedItem, in: namespace) { item in
        ItemView(item: item)
    } configuration: { content, isActive, sourceSize, destinationSize, sourcePosition, destinationPosition in
        let size = isActive ? destinationSize : sourceSize
        let position = isActive ? destinationPosition : sourcePosition
        return content
            .frame(width: size.width, height: size.height)
            .offset(x: position.x, y: position.y)
    }
  5. Separate sheet and portal state to prevent premature dismissal

    main

    To prevent a sheet from dismissing automatically when the portal item changes during paging, you must maintain two separate state variables:

    1. A state variable to control the sheet/modal presentation (e.g., selectedItem).
    2. A state variable to control the portal animation/transfer (e.g., portalItem).

    Pass the portalItem binding into the detail view and use it with the .portalTransition modifier.

    @State private var selectedItem: CarouselItem?   // Controls sheet presentation
    @State private var portalItem: CarouselItem?     // Controls portal animation
    
    // ...
    
    .fullScreenCover(item: $selectedItem) { item in
        CarouselDetailView(
            items: items,
            initialItem: item,
            portalItem: $portalItem
        )
    }
    .portalTransition(item: $portalItem) { item in
        GridItemView(item: item)
    }
  6. Warning: Using _PortalPrivate (Private API)

    main

    The _PortalPrivate module uses Apple's private _UIPortalView API to achieve true view mirroring where the view instance is shared rather than recreated via snapshots.

    CRITICAL WARNING:

    • Using private APIs may result in App Store rejection.
    • Use this module at your own discretion.
    • Class names are obfuscated at compile-time to avoid detection.
  7. Animate multiple portals as a coordinated group

    main

    Use Group Animations when multiple elements need to transition together to the same destination (e.g., multiple thumbnails expanding into a single detail view or a stack of cards spreading out). All portals in a group animate simultaneously with synchronized timing, and the completion handler fires once for the entire group rather than per-portal.

    To implement a group transition, you must:

    1. Mark both source and destination elements with the same groupID using the .portal modifier.
    2. Trigger the transition using the .portalTransition modifier on a parent view, specifying the groupID and the collection of elements involved.
    // 1. Mark sources and destinations with a shared groupID
    Image("photo1")
        .portal(id: "photo1", .source, groupID: "photoStack", in: namespace)
    
    Image("photo1")
        .portal(id: "photo1", .destination, groupID: "photoStack", in: namespace)
    
    // 2. Trigger the group transition
    .portalTransition(
        ids: ["photo1"],
        groupID: "photoStack",
        in: namespace,
        isActive: $showDetail
    ) { id in
        Image(id)
    }
  8. Understand the PortalTransitions lifecycle

    main

    PortalTransitions follows a specific lifecycle for both opening and closing transitions:

    Opening Transition

    1. The source view's position is captured.
    2. The source view is hidden.
    3. A Layer view is created at the source position on the overlay window.
    4. The destination view is prepared (initially invisible).
    5. The destination view's position is captured.
    6. The Layer animates from the source position to the destination position.
    7. Upon completion, the layer is hidden and the destination view becomes visible.

    Closing Transition

    1. The destination view is hidden.
    2. The Layer reappears at the destination position.
    3. The Layer animates back to the source position.
    4. Upon completion, the layer is hidden and the source view becomes visible.
  9. Understand PortalTransitions debug overlay colors and indicators

    main

    When debug overlays are active, they use specific colors to identify portal components:

    • Blue overlay: Represents Portal source views.
    • Orange overlay: Represents Portal destination views.
    • Green overlay: Represents the Animated layer (the view moving during the transition).
    • "PortalContainerOverlay" indicator: Confirms that the overlay window is currently active.
  10. Use _PortalPrivate for true view mirroring

    main

    _PortalPrivate provides the same API as PortalTransitions but utilizes Apple's private _UIPortalView API. Unlike PortalTransitions, which uses layer snapshots, _PortalPrivate performs true view mirroring. This means it maintains a live view instance rather than a static snapshot, but it requires the view size to match exactly between the source and destination.

    import _PortalPrivate
    
    // Source
    PortalPrivate(id: "item") {
        MyView()
    }
    
    // Destination
    PortalPortalDestination(id: "item")
    
    // Transition modifier
    .portalPrivateTransition(item: $selectedItem)
  11. How PortalTransitions works

    main

    PortalTransitions enables smooth view animations between two different positions (e.g., from a grid thumbnail to a fullscreen view) by using a transparent overlay window.

    Instead of moving the actual view, the system uses three concepts:

    1. Source: The starting view.
    2. Destination: The ending view.
    3. Layer: A temporary copy of the view rendered on a transparent overlay window that animates between the source and destination positions.

    This approach allows the animation to cross different view hierarchies and presentation contexts (like moving from a standard view into a modal sheet) because the animation happens on a top-level PassThroughWindow that sits above the app's UI.