Home > Software engineering >  How do I get a replacement view to fade in over top of the old one rather than crossfading with it?
How do I get a replacement view to fade in over top of the old one rather than crossfading with it?

Time:08-19

By default, SwiftUI transitions seem to use .opacity so when a view is appearing/disappearing during an animation it will fade in/out. This creates a crossfade that is usually quite nice but can sometimes be undesirable when one overlapping view is replacing another.

I have a situation where I’d rather have the new view fade in over top of the view that is about to be replaced. The old view should not change opacity at all but simply disappear when the transition is finished.

I do not want there to be any point during the transition where I'm seeing the old view at anything but 100% opacity. For example, the default way this is handled results in a point in the middle of the transition where where we're seeing 50% opacity versions of both the back and front view composited over top of each other.

To be clear, I have one workaround already: I can get the effect I want if I use a ZStack to keep the background view there at all times - this way only the new view fades in (since it's the only view that changes). My solution feels wrong, wasteful, and inelegant, though. (The view in the background is going to get composited by the system constantly despite it being totally invisible and unwanted after the actual image loads in. I only want that background view to exist until the transition is complete, but I can't figure out how to make it do that.)

Here's some code that shows what I mean. The top view appears using the default transition and crossfading but with the code the way I'd expect - when the new view exists, we use it and only it. The bottom appears the way I want it to - but it does so at the expense of keeping the background view there at all times so that only the new view fades when it first appears:

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    let transaction = Transaction(animation: .linear(duration: 10))
    let imageURL = URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/main_image_star-forming_region_carina_nircam_final-5mb.jpg")!
    
    var body: some View {
        VStack(spacing: 10) {
            AsyncImage(url: imageURL, transaction: transaction) { phase in
                if let img = phase.image {
                    img.resizable()
                } else {
                    Color.red
                }
            }
            .aspectRatio(CGSize(width: 3600, height: 2085), contentMode: .fit)

            AsyncImage(url: imageURL, transaction: transaction) { phase in
                ZStack {
                    Color.red
                    
                    if let img = phase.image {
                        img.resizable()
                    }
                }
            }
            .aspectRatio(CGSize(width: 3600, height: 2085), contentMode: .fit)
        }
        .frame(width: 500)
        .padding(10)
        .background(Color.yellow)
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Since I apparently cannot embed a video, here's a link to one of the playground running since it helps to see it to make sense of what I mean here, I think: https://www.dropbox.com/s/scwxfoa9pojo0yq/SwiftUICrossfade.mov?dl=0

CodePudding user response:

One way might be to apply a custom Transition to the background view. I only tested this briefly but with this added it looks like the top and bottom views match during their transitions.

import SwiftUI
import PlaygroundSupport

struct AllOrNothingTransition: Animatable, ViewModifier {
    
    var animatableData: CGFloat = 0
    
    func body(content: Content) -> some View {
        // The goal is for `content` to remain in place, unchanged,
        // until the transition completes and it is removed, so we
        // simply pass it back unchanged regardless of the value
        // of `animatableData`.
        content
    }
}

extension AnyTransition {
    static var allOrNothing: AnyTransition {
        // For this use case, the specific values passed in as `animatableData`
        // are not important, as long as they differ from each other.
        // SwiftUI needs to have differing values to interpolate between,
        // otherwise it will assume there is nothing to animate and
        // remove the transitioning view immediately at the start of
        // of the transition (this is based on observation, not a knowledge
        // of the inner workings of SwiftUI).
        AnyTransition.modifier(
            active: AllOrNothingTransition(animatableData: 0),
            identity: AllOrNothingTransition(animatableData: 1)
        )
    }
}

struct ContentView: View {
    let transaction = Transaction(animation: .linear(duration: 10))
    let imageURL = URL(string: "https://www.nasa.gov/sites/default/files/thumbnails/image/main_image_star-forming_region_carina_nircam_final-5mb.jpg")!
    
    var body: some View {
        VStack(spacing: 10) {
            AsyncImage(url: imageURL, transaction: transaction) { phase in
                
                if let img = phase.image {
                    img.resizable()
                } else {
                    Color.red
                        .transition(.allOrNothing)
                }
            }
            .aspectRatio(CGSize(width: 3600, height: 2085), contentMode: .fit)

            AsyncImage(url: imageURL, transaction: transaction) { phase in
                ZStack {
                    Color.red
                    
                    if let img = phase.image {
                        img.resizable()
                    }
                }
            }
            .aspectRatio(CGSize(width: 3600, height: 2085), contentMode: .fit)
        }
        .frame(width: 500)
        .padding(10)
        .background(Color.yellow)
    }
}

PlaygroundPage.current.setLiveView(ContentView())

  • Related