Search code examples
iosswiftuiuikit

UIViewRepresentable with an animation layer doesn't animate inside of a sheet


I have a legacy UIImageView that animates using a CABasicAnimation. I wrapped that in a UIViewRepresentable in order to embed it inside a SwiftUI view. It works great for the main view, but in a view that is presented using a .sheet modifier, it doesn't animate. My entire code is below (compiled with Xcode 15.3).

import SwiftUI

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

struct Microphone: UIViewRepresentable {
  func updateUIView(_ uiView: UIImageView, context: Context) {
  }
  
  func makeUIView(context: Context) -> UIImageView {
    let view = UIImageView(
      image:
        UIImage(
          systemName: "mic.circle.fill",
          withConfiguration: UIImage.SymbolConfiguration(pointSize: 50,weight: .bold, scale: .large)
        )?
        .withRenderingMode(.alwaysTemplate)
    )
    view.translatesAutoresizingMaskIntoConstraints = false
    view.tintColor = .green

    let animation = CABasicAnimation(keyPath: "transform.scale")
    animation.duration = 1.3
    animation.repeatCount = .greatestFiniteMagnitude
    animation.autoreverses = true
    animation.fromValue = 0.9
    animation.toValue = 1.1

    view.layer.add(animation, forKey: "scale")
    return view
  }
}

struct ContentView: View {
  @State var showSheet = false

  var body: some View {
    VStack {
      Microphone() // <-- animates perfectly
        .frame(width: 50, height: 50)

      Button("Show Sheet") { showSheet = true }
        .padding(.top)
    }
    .sheet(isPresented: $showSheet) {
      VStack {
        Microphone() // <-- does not animate
          .frame(width: 50, height: 50)
      }
    }
  }
}

#Preview {
    ContentView()
}

The reason I'm going with UIViewRepresentable is

  1. That code has already been written, tested and accepted.
  2. I was unable to make a native SwiftUI view that animated as smoothly, which is likely because my SwiftUI knowledge is still pretty rudimentary. I would gladly take a solution that gives me a smooth animation in pure SwiftUI.

Solution

  • Checking the memory graph debugger, the CABasicAnimation created for the image view in the sheet is deallocated for some reason. From my experiments, I also found that the animation does get added if layer.add(animation) is called after some delay. I think this is either a SwiftUI bug or perhaps the view somehow isn't "ready" for an animation to be added.

    Adding the animation in layoutSubviews works, at least:

    class ImageViewSubclass: UIImageView {
        override func layoutSubviews() {
            super.layoutSubviews()
            // optionally, check layer.animation(forKey: "scale") == nil first
            let animation = CABasicAnimation(keyPath: "transform.scale")
            animation.duration = 1.3
            animation.repeatCount = .greatestFiniteMagnitude
            animation.autoreverses = true
            animation.fromValue = 0.9
            animation.toValue = 1.1
            layer.add(animation, forKey: "scale")
        }
    }
    
    // in makeUIView:
    
    let view = ImageViewSubclass(...)
    ...
    return view
    

    There are probably other life cycle methods you can do this in.


    A pure SwiftUI way to do such an animation would be:

    struct Microphone: View {
        @State private var scale = 0.9
        
        var body: some View {
            Image(systemName: "mic.circle.fill")
                .imageScale(.large)
                .font(.system(size: 50, weight: .bold))
                .foregroundStyle(.green)
                .scaleEffect(scale)
                .onAppear {
                    withAnimation(.linear(duration: 1.3).repeatForever(autoreverses: true)) {
                        scale = 1.1
                    }
                }
                
        }
    }