Reading time: 3 min

This recipe shows how to convert a SwiftUI view to an image, i.e take its snapshot or screenshot. In SwiftUI 4, there's an awesome component dedicated to this called ImageRenderer, but the same can be achieved on any SwiftUI version.

Here's what the end result looks like. On the top, there's a simple view that constantly updated to show how long has SwiftUIRecipes.com been online, and right below it is its snapshot as an image, also updated in real time:

preview

Test View

Both solutions will make use of the test view that uses a timer to contiously update its state:

struct OnlineTrackerView: View {
  @State private var timeOnline = ""

  private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  private let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 11, day: 1))!
  private let formatter = DateComponentsFormatter()

  var body: some View {
    VStack {
      Text("**SwiftUIRecipes**")
      Image(decorative: "icon")
        .resizable()
        .frame(width: 128, height: 128)
      Text("Online for \(timeOnline)")
    }
    .onReceive(timer) { _ in
      let delta = Date().timeIntervalSince(startDate)
      timeOnline = formatter.string(from: delta)!
    }
  }
}

SwiftUI 4 (iOS 16, macOS 12.4)

In SwiftUI 4, ImageRenderer does all the heavy lifting for you. Simply initialize it with the view you wish to convert to an image. Here's the recipe:

  • ImageRenderer (and all of its methods and properties) runs on @MainActor, meaning it has to be wrapped in @StateObject. Alternatively, you can use it in a method that's annotated with @MainActor.
  • At runtime, you can access the wrapped view with the content property.
  • Similarly, you can access the view snapshot as UIImage with the uiImage property.
  • Whenever the underlying view changes, ImageRender publishes on its objectWillChange property. You can use this to, say, save a new snapshot to the file system.
struct ViewToImageTest: View {
  @StateObject private var renderer = ImageRenderer(content: OnlineTrackerView())

  var body: some View {
    VStack {
      Text("Original")
      renderer.content
        .padding(.bottom)

      Text("Image")
      Image(uiImage: renderer.uiImage ?? UIImage())
    }
    .onReceive(renderer.objectWillChange) { _ in
      print("View updated!")
    }
  }
}

SwiftUI 1-3 (iOS 13-15, macOS 10.15-12)

Since earlier SwiftUI versions don't support ImageRenderer, we have to resort to a trick in order to convert a view to an image. Basically, you use the UIKit way of using UIGraphicsImageRenderer, with the extra step of exposing the SwiftUI view in UIKit by instatiating a UIHostingController that holds it:

extension View {
  var snapshot: UIImage {
    let controller = UIHostingController(rootView: self.edgesIgnoringSafeArea(.top))
    let view = controller.view
    let targetSize = controller.view.intrinsicContentSize
    view?.bounds = CGRect(origin: CGPoint(x: 0, y: 0), size: targetSize)
    view?.backgroundColor = .clear

    let format = UIGraphicsImageRendererFormat()
    let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
    return renderer.image { _ in
      view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
    }
  }
}

There's a caveat to this, though. The above solution only works when snapshot is called from the view itself. If you call it from a parent view, you'll never get its latest rendered state. In other words, this DOESN'T WORK:

// Doesn't work, you only get the snapshot of the initial state of `OnlineTrackerView`.
struct ViewToImageOldTest: View {
  @State private var snapshotImage: UIImage?

  private let view = OnlineTrackerView()

  var body: some View {
    VStack {
      Text("Original")
      view
        .padding(.bottom)

      Text("Image")
      Image(uiImage: snapshotImage ?? UIImage())
    }
    .onReceive(view.timer) { _ in
      snapshotImage = view.snapshot
    }
  }
}

However, if you move the snapshot creation to OnlineTrackerView and publish it downstream, it does work:

struct OnlineTrackerView: View {
  @State private var timeOnline = ""

  let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  private let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 11, day: 1))!
  private let formatter = DateComponentsFormatter()

  private let subject = PassthroughSubject<UIImage, Never>()
  var publisher: AnyPublisher<UIImage, Never> {
    subject.eraseToAnyPublisher()
  }

  var body: some View {
    VStack {
      Text("**SwiftUIRecipes**")
      Image(decorative: "icon")
        .resizable()
        .frame(width: 128, height: 128)
      Text("Online for \(timeOnline)")
    }
    .onReceive(timer) { _ in
      formatter.calendar?.locale = Locale(identifier: "en-US")
      let delta = Date().timeIntervalSince(startDate)
      timeOnline = formatter.string(from: delta)!
      subject.send(asImage)
    }
  }
}

struct ViewToImageOldTest: View {
  @State private var snapshotImage: UIImage?
  let view = OnlineTrackerView()

  var body: some View {
    VStack {
      Text("Original")
      view
        .padding(.bottom)
      Text("Image")
      Image(uiImage: snapshotImage ?? UIImage())
    }
    .onReceive(view.publisher) {
      snapshotImage = $0
    }
  }
}

Next Post Previous Post