SwiftUI: animate changes that depend on @ObjectBinding

Viewed 3141

SwiftUI has implicit animations with .animate(), and explicit ones using .withAnimation(). However, I can't figure out how to animate an image change:

struct ImageViewWidget : View {
  @ObjectBinding var imageLoader: ImageLoader

  init(imageURL: URL) {
    imageLoader = ImageLoader(imageURL: imageURL)
  }

  var body: some View {
    Image(uiImage:
      (imageLoader.data.count == 0) ? UIImage(named: "logo-old")! :  UIImage(data: imageLoader.data)!)
        .resizable()
        .cornerRadius(5)
        .frame(width: 120, height:120)
  }
}

This Image's uiImage argument is passed the old-logo (placeholder) if there's no data in imageLoader (a BindableObject), and replaces it with the correct one once that's asynchronously loaded:

class ImageLoader : BindableObject {
  let didChange = PassthroughSubject<Data, Never>()

  var data = Data() {
    didSet {
      didChange.send(data)
    }
  }

  init(imageURL: URL) {
    print("Image loader being initted!")
    let url = imageURL

    URLSession.shared.dataTask(with: url) { (data, _, _) in
      guard let data = data else { return }
      DispatchQueue.main.async {
        self.data = data
      }
      }.resume()

  }
}

How can I animate this change, the moment where data.count stops being 0, and we have the image? say I want a fade out-in animation..

2 Answers

If you want to use explicit animations based on environment objects (or observable objects), you need to create some state in your view.

You can react to changes to an observable in your view using onReceive, and then modify your state using explicit animation.

struct ImageViewWidget: View {
    @ObservedObject var imageLoader: ImageLoader
    @State var uiImage: UIImage = UIImage(named: "logo-old")!

    init(imageURL: URL) {
        imageLoader = ImageLoader(imageURL: imageURL)
    }

    var body: some View {
        Image(uiImage: uiImage)
            .resizable()
            .cornerRadius(5)
            .frame(width: 120, height: 120)
            .onReceive(imageLoader.$data) { data in
                if data.count != 0 {
                    withAnimation {
                        self.uiImage = UIImage(data: data)!
                    }
                }
            }
    }
}

You don't necessarily have to call .animate() or .withAnimation() because you are simply switching the images, you can use .transition() instead. Assuming you have already successfully updated your image with your @ObjectBinding(@ObservedObject in Beta5), you can do this:

var body: some View {
    if imageLoader.data.count == 0 {
        Image(uiImage: UIImage(named: "logo-old")!)
        .resizable()
        .cornerRadius(5)
        .frame(width: 120, height:120)
        .transition(.opacity)
        .animation(.easeInOut(duration:1))
    } else {
        Image(uiImage: UIImage(data: imageLoader.data)!)
        .resizable()
        .cornerRadius(5)
        .frame(width: 120, height:120)
        .transition(.opacity)
        .animation(.easeInOut(duration:1))
    }
}

or you can use a custom view modifier if you want to make the transition fancier:

struct ScaleAndFade: ViewModifier {
    /// True when the transition is active.
    var isEnabled: Bool

    // fade the content view while transitioning in and
    // out of the container.
    func body(content: Content) -> some View {
        return content
            .scaleEffect(isEnabled ? 0.1 : 1)
            .opacity(isEnabled ? 0 : 1)
            //any other properties you want to transition
    }
}


extension AnyTransition {
    static let scaleAndFade = AnyTransition.modifier(
        active: ScaleAndFade(isEnabled: true),
        identity: ScaleAndFade(isEnabled: false))
}

and then inside your ImageViewWidget, add .transition(.scaleAndFade) to your Image as its view modifier

Related