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
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
andBottomVideoView
, 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 usingpopRoot.playID
, which should work as long as it is correctly set before the animation starts.Your
TopVideoView
uses aUIHostingController
to present the video player, which might complicate the matched geometry effect. SwiftUI animations work best within the SwiftUI view hierarchy, so consider integrating theTopVideo
directly into the SwiftUI view hierarchy instead of adding it over a window.The
animationNamespace
is declared inContentView
and passed toSomeView
andBottomVideoView
. That makes sure the matched geometry effect operates within the same namespace across these views. TheTopVideo
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.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 newUIWindow
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:
Here’s everything you need to get this working:
SwiftUI isn’t going to be able to match
matchedGeometryEffect
s if they’re wrapped inside of aUIHostingController
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 yourmatchedGeometryEffect
s. If for some reason you really need to have aUIHostingController
layer, I would recommend doing thematchedGeometryEffect
with a placeholder and then once the animation is finished, quickly swapping the placeholder out with aUIHostingController
.Put the
.frame(width: 50, height: 50)
lines after thematchedGeometryEffect
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