Live Activity: Remote Images

Part 4: Downloading and displaying an image from a URL

Shawn Roller

November 29, 2024

GuideSuccess

We can’t make a network request in a widget extension…

But we can download the image in our main app when we’re configuring the initial Live Activity. So let’s see how it’s done.

Configuring the Live Activity

First let’s get our Live Activity configured and started in the main app. For the purpose of keeping things simple, I’m using a simple form with some actions that trigger a couple Live Activity life cycle events: create and end.

Creating the activity requires us to pass in some attributes which remain constant, and state which we could update via push notification or when the app is reopened while the activity is active.

ContentView.swift
1func createLiveActivity() async {
2    // attempt to download the image
3    var localImageUrl: URL? = nil
4    if let imageUrl = URL(string: "https://picsum.photos/200") {
5      localImageUrl = try? await downloadImage(from: imageUrl)
6      print("saved resized image successfully")
7    }
8    
9    let startTime = Date.now + 10
10    let endTime = .now + 50
11    let attributes = LiveActivityExtensionAttributes(startTime: startTime, endTime: endTime, name: "smiley", image: localImageUrl?.absoluteString)
12    let state = LiveActivityExtensionAttributes.LiveActivityState(emoji: "😄")
13    let content = ActivityContent(state: state, staleDate: endTime)
14    
15    do {
16        let _ = try Activity<LiveActivityExtensionAttributes>.request(
17            attributes: attributes,
18            content: content
19        )
20    } catch(let error) {
21        print(error.localizedDescription)
22    }
23}

We’ll dive into the downloadImage function here shortly. Here’s the code for ending all the live activities that are active. It will iterate through our state and request immediate end to the activities. Note that we have to provide a state for how our activities look directly before they’re ended.

ContentView.swift
1func endAllActivities() {
2    Task {
3        for activity in Activity<LiveActivityExtensionAttributes>.activities{
4            // We need to set a "final" state before ending the activity
5            let state = LiveActivityExtensionAttributes.LiveActivityState(emoji: "😄")
6            let content = ActivityContent(state: state, staleDate: .now)
7            await activity.end(content, dismissalPolicy: .immediate)
8        }
9    }
10}

Downloading the remote image

I’m going to show some code which will download a remote image from a URL, specify a size to which the image should fill, and then scales and draws to fill the size that was specified. Finally the image data is written to a shared space where our extension will have access to it. If we’ve downloaded an image with the same file name before, it will return the path to the cached image instead of downloading it again.

ImageUtils.swift
1import UIKit
2
3func resizeAndCropImage(at url: URL) throws -> UIImage? {
4    let originalImage = UIImage(contentsOfFile: url.path)
5    guard let image = originalImage else { return nil }
6    
7    let targetSize = CGSize(width: 48, height: 48)
8    let widthRatio = targetSize.width / image.size.width
9    let heightRatio = targetSize.height / image.size.height
10    let scaleFactor = max(widthRatio, heightRatio)
11    
12    let scaledImageSize = CGSize(
13        width: image.size.width * scaleFactor,
14        height: image.size.height * scaleFactor
15    )
16    
17    let renderer = UIGraphicsImageRenderer(size: scaledImageSize)
18    let scaledImage = renderer.image { _ in
19        image.draw(in: CGRect(origin: .zero, size: scaledImageSize))
20    }
21    
22    return scaledImage
23}
24
25
26func downloadImage(from url: URL) async throws -> URL? {
27    guard var destination = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.riff-tech.laappgroup") else { return nil }
28    
29    destination = destination.appendingPathComponent(url.lastPathComponent)
30    
31    guard !FileManager.default.fileExists(atPath: destination.path()) else {
32        print("File already exists: \(destination)")
33        return destination
34    }
35    
36    let (source, _) = try await URLSession.shared.download(from: url)
37    try FileManager.default.moveItem(at: source, to: destination)
38    
39    if let resizedAndCroppedImage = try resizeAndCropImage(at: destination),
40       let imageData = resizedAndCroppedImage.pngData() {
41        try imageData.write(to: destination)
42    }
43    
44    print("Downloaded \(url.lastPathComponent)")
45    return destination
46}

Displaying the image in the Live Activity

Now that the image is downloaded and in a shared container, we can display it in our Live Activity. Here’s the content of our ThumbnailView.swift file:

ThumbnailView.swift
1import SwiftUI
2
3struct ThumbnailView: View {
4  var image: String?
5  
6  var body: some View {
7    GeometryReader { proxy in
8      if let image, let url = URL(string: image), let imageData = try? Data(contentsOf: url),
9         let uiImage = UIImage(data: imageData) {
10        Image(uiImage: uiImage)
11          .resizable()
12          .aspectRatio(1, contentMode: .fill)
13          .scaledToFit()
14          .clipShape(RoundedRectangle(cornerRadius: 4))
15      } else {
16        Image(systemName: "car")
17          .resizable()
18          .aspectRatio(1, contentMode: .fill)
19          .scaledToFill()
20          .clipShape(RoundedRectangle(cornerRadius: 4))
21      }
22    }
23  }
24}
25
26#Preview {
27    VStack {
28      ThumbnailView()
29    }.frame(width: 48)   
30}

You can see that we try to access the image URL (file path) from the downloaded image, create a new UIImage from the data, and then finally display it in an Image view. If for some reason we can’t get the cached image data, we just use a system image.

Logo Overlay

In our design we also have an image overlaid onto the thumbnail which we can accomplish by applying an overlay modifier to the image:

LiveActivityExtension.swift
1.overlay(alignment: .topLeading) {
2  LogoView()
3    .frame(width: proxy.size.width / 2)
4    .alignmentGuide(.top) { dim in
5      dim.height / 4
6    }
7    .alignmentGuide(.leading) { dim in
8      dim.width / 4
9    }
10    .shadow(radius: 3, y: 3)
11}

Next up

We’ll put it all together and work on the remainder of the Live Activity!

All the source code for this series is on Github!