skip to Main Content

I have a square that is embedded within a scroll view inside a subview. When the square is clicked I want to animate it to the top of the view hierarchy and present it above everything else using a matched geometry effect. I cannot get the animation to work. When the square presented at the top is dismissed it should animate back down to the original view.

Problem
I present the square at the top of the view hierarchy through a window which makes the animation fail. How can I correctly animate with a window? How can I add basic match geometry effects, transitions, and other animations with windows?

struct SomeView: View {
    @EnvironmentObject var popRoot: PopToRoot
    @Namespace private var animation
    var body: some View {
        ScrollView {
            RoundedRectangle(cornerRadius: 10)
                .matchedGeometryEffect(id: "squareAnim", in: animation)
                .foregroundStyle(.blue).frame(width: 50, height: 50)
                .onTapGesture {
                    withAnimation {
                        popRoot.showOverlay = true
                    }
                }
            //other sub views
        }
    }
}
struct ContentView: View { //top of view hierarchy
     @EnvironmentObject var popRoot: PopToRoot
     @Namespace private var animation
     var body: some View {
          SomeView()
             .overlay {
                    if showOverlay {
                        TopViewWindow()
                            .matchedGeometryEffect(id: "squareAnim", in: animation)
                            // ----- HERE
                    }
             }
     }
}

class PopToRoot: ObservableObject { //envirnment object to be accessed anywhere
    @Published var showOverlay = false
}

video player that goes above everything

struct TopView: View {
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 10)
                .foregroundStyle(.blue).frame(width: 100, height: 100)
            
            //other views
        }.ignoresSafeArea()
    }
}

struct TopViewWindow: View {
    @State private var hostingController: UIHostingController<TopView>? = nil

    func showImage() {
        let swiftUIView = TopView()
        hostingController = UIHostingController(rootView: swiftUIView)
        hostingController?.view.backgroundColor = .clear
        hostingController?.view.frame = CGRect(
            x: 0,
            y: 0,
            width: UIScreen.main.bounds.width,
            height: UIScreen.main.bounds.height)

        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let window = windowScene.windows.first {
            // withAnimation { doesnt work here either
            window.addSubview(hostingController!.view)

            hostingController?.view.center.x = window.center.x
        }
    }
    func dismissImage() {
        hostingController?.view.removeFromSuperview()
        hostingController = nil
    }
    var body: some View {
        VStack {}.onAppear { showImage() }.onDisappear { dismissImage() }
    }
}

2

Answers


  1. You should make sure the animation namespace is shared correctly across the different views that participate in the animation. From your code, it appears that separate namespaces are being used in ContentView and BottomVideoView, which might be causing the issue with the animation not working as expected.

    The namespace used for the matched geometry effect should be declared in a common ancestor view and passed down to all child views that need it. That way, the same animation namespace is used across your views.
    The ID used with .matchedGeometryEffect (as described in "How to synchronize animations from one view to another with matchedGeometryEffect()" from Paul Hudson) needs to be consistent across both views participating in the animation. It looks like you are using popRoot.playID, which should work as long as it is correctly set before the animation starts.

    Your TopVideoView uses a UIHostingController to present the video player, which might complicate the matched geometry effect. SwiftUI animations work best within the SwiftUI view hierarchy, so consider integrating the TopVideo directly into the SwiftUI view hierarchy instead of adding it over a window.

    class PopToRoot: ObservableObject { // Environment object accessible anywhere
        @Published var player: AVPlayer? = nil
        @Published var playID: String = ""
    }
    
    struct ContentView: View { // Top of the view hierarchy
        @EnvironmentObject var popRoot: PopToRoot
        @Namespace private var animationNamespace
    
        var body: some View {
            ZStack {
                SomeView(animation: animationNamespace)
                
                if popRoot.player != nil {
                    TopVideo(player: $popRoot.player)
                        .matchedGeometryEffect(id: popRoot.playID, in: animationNamespace)
                        .ignoresSafeArea()
                }
            }
        }
    }
    
    struct SomeView: View { // Subview
        let example = "example URL"
        var animation: Namespace.ID
    
        var body: some View {
            ScrollView {
                BottomVideoView(url: URL(string: example)!, animation: animation)
                // Additional views
            }
        }
    }
    
    struct BottomVideoView: View {
        @EnvironmentObject var popRoot: PopToRoot
        let url: URL
        var animation: Namespace.ID
        @State var player: AVPlayer? = nil
    
        var body: some View {
            ZStack {
                if let vid = player {
                    VidPlayer(player: vid)
                        .matchedGeometryEffect(id: url.absoluteString, in: animation)
                        .onTapGesture {
                            withAnimation {
                                popRoot.playID = url.absoluteString
                                popRoot.player = player
                            }
                        }
                }
            }
            .onAppear {
                if player == nil {
                    player = AVPlayer(url: url)
                    self.player?.play()
                }
            }
        }
    }
    
    // No changes needed for VidPlayer and TopVideo
    

    The animationNamespace is declared in ContentView and passed to SomeView and BottomVideoView. That makes sure the matched geometry effect operates within the same namespace across these views. The TopVideo view is directly integrated into the SwiftUI view hierarchy, which should help with the animation.
    Make sure the environment object (PopToRoot) is correctly passed down to all views that require access to it.


    I think the issue is adding the view to the window. I need this functionality though to present the video player about everything including sheets and fullscreencovers.

    Matched geometry effect animations are designed to work within the SwiftUI view hierarchy, and using a UIHostingController to present a view on top of everything else complicates this.

    Since the matched geometry effect relies on both views being in the same view hierarchy and this requirement conflicts with your need to present the view above everything, you might consider implementing a custom animation. You can animate the frame of the UIHostingController‘s view manually to mimic the matched geometry effect. That involves calculating the starting and ending frames and using UIView animations to transition between them.

    For views within the SwiftUI hierarchy, you can use .zIndex() to manage which views are presented on top of others. While this will not help with presenting over fullscreen covers or sheets directly, it is useful for in-hierarchy presentation order management.

    Instead of adding the UIHostingController‘s view to the window directly, consider creating a new UIWindow that is presented on top of your current window. That overlay window can be used exclusively for presenting the video player. That approach makes it easier to manage the video player independently of the rest of your UI and can simplify animations since you are moving a window instead of a view within a window.

    To integrate this approach with SwiftUI and still trigger animations from SwiftUI views, you can use onTapGesture or similar triggers to initiate the presentation and dismissal of the overlay window. You would still use environment objects or shared state management to coordinate the data between your SwiftUI views and the video player being presented in the overlay window.

    Assuming you have a method to calculate the starting and ending frames based on your UI layout, your method would be:

    func presentVideoPlayerOverlay(player: AVPlayer, fromStartFrame startFrame: CGRect, toEndFrame endFrame: CGRect) {
        let swiftUIView = TopVideo(player: .constant(player))
        let hostingController = UIHostingController(rootView: swiftUIView)
        hostingController.view.frame = startFrame // Initial frame, possibly matching a SwiftUI view's frame
        
        let overlayWindow = UIWindow(frame: UIScreen.main.bounds)
        overlayWindow.rootViewController = hostingController
        overlayWindow.makeKeyAndVisible()
        
        UIView.animate(withDuration: 0.5, animations: {
            hostingController.view.frame = endFrame // Final frame, e.g., fullscreen
        }) { _ in
            // Animation completion actions if needed
        }
    }
    
    // You would call `presentVideoPlayerOverlay` with the appropriate player and frame parameters when you want to present the video player.
    
    Login or Signup to reply.
  2. enter image description here

    Here’s everything you need to get this working:

    SwiftUI isn’t going to be able to match matchedGeometryEffects if they’re wrapped inside of a UIHostingController

    Sorry, but you’re trying to inject a SwiftUI view (wrapped in a UIHostingController) into your UIApplication.shared.connectedScenes.first. There’s just no way SwiftUI will be able to match up your matchedGeometryEffects. If for some reason you really need to have a UIHostingController layer, I would recommend doing the matchedGeometryEffect with a placeholder and then once the animation is finished, quickly swapping the placeholder out with a UIHostingController.

    Put the .frame(width: 50, height: 50) lines after the matchedGeometryEffect

    This will create a smooth animation between the sizes. This is because you’re telling SwiftUI the core element that needs to stay the same. When you put the frame after, you’re saying "Transition this blue rectangle through these size changes"

    Pass your namespace around using this syntax let namespace: Namespace.ID

    This makes sure the view doesn’t get lost from SwiftUI’s perspective

    Use a top level if statement (optional)

    matchedGeometryEffect relies on one view being removed while another is inserted at the exact same time. I like to have the if statement at the top level so it’s clear where the transition happens. It makes it easier to see instead of burying the if statement inside low level views.

    Full working copy/pasteable example

    class PopToRoot: ObservableObject {
      static let shared = PopToRoot()
      private init() {}
      
      @Published var showOverlay = false
    }
    
    struct TopView: View {
    
      @ObservedObject var popToRoot = PopToRoot.shared
      let namespace: Namespace.ID // Pass namespace like this
      
      var body: some View {
        ZStack {
          RoundedRectangle(cornerRadius: 10)
            .foregroundStyle(.blue)
            .matchedGeometryEffect(id: "squareAnim", in: namespace)
            .frame(width: 100, height: 100) // Move frame here
            .onTapGesture {
              withAnimation {
                popToRoot.showOverlay = false
              }
            }
          
          //other views
        }
          .ignoresSafeArea()
      }
    }
    
    struct SomeView: View {
    
      @ObservedObject var popToRoot = PopToRoot.shared
      let namespace: Namespace.ID // Pass namespace like this
      
      var body: some View {
        ScrollView {
          RoundedRectangle(cornerRadius: 10)
            .foregroundStyle(.blue)
            .matchedGeometryEffect(id: "squareAnim", in: namespace)
            .frame(width: 50, height: 50) // Move frame here
            .onTapGesture {
              withAnimation {
                popToRoot.showOverlay = true
              }
            }
        }
      }
    }
    
    struct ContentView: View {
      @ObservedObject var popToRoot = PopToRoot.shared
      @Namespace private var namespace
      
      var body: some View {
        
        if popToRoot.showOverlay { // Top level if statement (optional)
          TopView(namespace: namespace)
        } else {
          SomeView(namespace: namespace)
        }
      }
    }
    
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search