Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Matched geometry effect doesn't work with window

Tags:

ios

swift

swiftui

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, including .sheets, 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() }
    }
}
like image 270
Ahmed Zaidan Avatar asked Mar 03 '26 05:03

Ahmed Zaidan


1 Answers

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. If you do this instantly with no animations, the user won't know the difference.

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)
    }
  }
}

like image 99
joshuakcockrell Avatar answered Mar 04 '26 18:03

joshuakcockrell