Search code examples
iosswiftuiios15swiftui-geometryeffectswiftui-asyncimage

Matched Geometry Effect with AsyncImage iOS 15


Consider the following example:

    struct ContentView: View {

    @State var showSplash: Bool = true
    @Namespace var animationNamespace

    var body: some View {
        ZStack {
            if showSplash {
                GeometryReader { geometry in
                    AsyncImage(url: URL(string: "https://picsum.photos/seed/864a5875-6d8b-43d6-8d65-04c5cfb13f3b/1920/1440")) { image in
                        image.resizable()
                        .scaledToFill()
                        .matchedGeometryEffect(id: "SplashImage", in: animationNamespace)
                        .transition(.move(edge: .bottom))
                        .frame(width: geometry.size.width)
                        .transition(.move(edge: .bottom))
                        .edgesIgnoringSafeArea(.all)
                        .clipped()
                    } placeholder: {
                        Color.gray
                    }
                }
                .onTapGesture {
                    toggleSplashScreen(false)
                }
            } else {
                ScrollView {
                    GeometryReader { geometry in
                        AsyncImage(url: URL(string: "https://picsum.photos/seed/864a5875-6d8b-43d6-8d65-04c5cfb13f3b/1920/1440")) { image in
                            image
                            image
                                .resizable()
                                .scaledToFill()
                                .matchedGeometryEffect(id: "SplashImage", in: animationNamespace)
                                .transition(.move(edge: .bottom))
                        } placeholder: {
                            Color.gray
                        }
                        .frame(width: geometry.size.width, height: 400)
                        .clipped()
                    }
                    .edgesIgnoringSafeArea(.all)
                    .onTapGesture {
                        toggleSplashScreen(true)
                    }
                }
            }
        }
    }
}

With a helper method here:

private extension ContentView {
    func toggleSplashScreen(_ toggle: Bool) {
        withAnimation(.spring(response: 0.85, dampingFraction: 0.95)) {
            showSplash = toggle
        }
    }
}

This produces:

Demo

I noticed two things here that I would like to fix

  1. The flashing white effect when transitioning between the two states.
  2. I noticed since we are using AsyncImage, when showSplash changes the AsyncImages only sometimes hits the placeholder block. As a result, the transition becomes really choppy. I tested this with a static image from the assets file and the transition then became smooth. I also tried creating a Caching mechanism on the AsyncImage but still had issues with it hitting placeholder block sometimes.

Would love to hear any ideas :) Thanks!


Solution

  • There are a couple of things that I think you could do to improve this.

    First, You are fighting a little bit against the way SwiftUI maintains a view's identity. One of the ways that SwiftUI determines when it can reuse an existing structure as opposed to recreating a structure, is by it's location in the view hierarchy. So when you toggle your structure you go from:

    GeometryReader 
      AsyncImage
    

    to

    ScrollView
      GeometryReader
        AsyncImage
    

    As a result, the system thinks these are two AsyncImage views and so it's rebuilding the view (and reloading the image) every time. I think that's where your white flashes come from since you're seeing your gray placeholder in the middle of your animation. If you could leave the scroll view in place, possibly disabling scrolling when it's not needed (if that's possible) then the OS could maintain the identity of the AsyncImage. (see https://developer.apple.com/videos/play/wwdc2021/10022/)

    That leads to the second area of investigation for you. AsyncImage is wonderful in the convenience it gives you in loading content from the network. Unfortunately it doesn't make that communication faster. Your goal should be to have AsyncImage go to the network as few times as possible.

    Right now, your resizing strategy focuses on resizing the image. That means that for every transition you're "hitting the network" (read putting your code on the slow, dusty, dirt road path). Instead of resizing the image, you should just load the image once (the slow part) and resize the view that is displaying it. The general idea would be to let AsyncImage load the image, then control how the image is animated by animating the frame of the view.

    This is where I get less helpful. I don't know enough about AsyncImage to know if it's capable of implementing that strategy. It seems that it should be... but I don't know that it is. You might have to resort to downloading and storing the image as state separately from the view that presents it.

    So my advice is to limit the number of times AsyncImage has to reload the network data. That involves helping SwiftUI maintain the identity of the AsyncImage so it doesn't have to reload each time the view is created. And, try to implement your animations and scaling on the view, not the image, because rescaling the image also requires a network reload.