Long Running task progress updates with SwiftUI & Combine

If you are targeting iOS 14+ you can utilize the ProgressView SwiftUI control to display progress updates. You may want to develop your own custom progress control if you want to target iOS 13 and below or if you would like a different UI than what the ProgressView provides.

End Result

By the end of this post you will have learned how to…

  • Wrap a long-running asynchronous task in a Combine powered service
  • Observe and respond to events sourced by the service
  • Update the SwiftUI control based on the observed events
  • Manage the execution state of the service by responding to user events from the control
  • Create both UI & Unit Tests for the SwiftUI control & Combine service

User Experience

The control UI consists of a tappable area which displays a title along with a progress indicator and status text.

Progress Control

The user can perform the following actions:

  • Start a task
  • Pause a task
  • Cancel a task

The UI will change based on the state of the control. The control can be in exactly one state at a time:

  • Idle
  • Active
  • Paused
  • Cancelled

When the user taps the control, a task will be started. This will in turn move the control from the Idle state to the Active state. Once in the Active state, the indicator and status text will display and animate once data has been received from the service. A single tap on the control in the Active state will update the state to Paused. Double tapping on the control in the Paused state will update the state to Cancelled

Current State Event New State
Idle Tap Active
Active Tap Paused
Active Double Tap Cancelled
Paused Tap Active
Cancelled Tap Idle

Progress Control Object Diagram

Progress Control

ProgressControlView

The ProgressControlView represents the graphic image that is updated in response to events sourced by the ProgressControlViewModel. The view consists of components that are stacked on each other within a SwiftUI ZStack. The background circle view is on the bottom of the stack while the status image including the dynamic arc are stacked on top of the circle view.

ProgressControlViewModel

This object emits events on a publisher that the ProgressControlView subscribes to. The view will then refresh itself when events are observed from the view model.

Service

Since this is SwiftUI, we can use the Combine framework to observe a service class that emits events while a long running task is performed. The events generated will contain:

  • status text
  • progress ratio, a decimal value from 0 to 1

The status text and ratio drive the UI of the control in the In Progress state.

Marble Diagram

Interface

The service emits events that are of type DownloadImageEvent via a property called events.

In addition to emitting events, the service provides a few functions that control the execution of the service:

  • downloadAllImages()
  • cancel()

The user will drive the state of the control by inovking either function.

Download

The downloadAllImages() function manages downloading data from a list of strings. The basic steps we need to perform are:

  1. Build a list of urls that should be downloaded
  2. Download each url in order
  3. Publish an event that the progress control consumes

Step 1 is straightforward; return an array of URL objects. Steps 2 & 3 are a bit more involved.

Downloading data at a particular url:

class ImageService: ObservableObject, UrlBuildable {
    @Published var currentImage: DownloadImageEvent?
    private var cancellables = Set<AnyCancellable>()
    
    func downloadAllImages() {
        func downloadImage(at: URL) -> AnyPublisher<UIImage, URLError> {
            return URLSession.shared.dataTaskPublisher(for: at)
                    .compactMap { UIImage(data: $0.data) }
                    .receive(on: RunLoop.main)
                    .eraseToAnyPublisher()
        }
        
        let urls = buildImagesUrls().compactMap{ URL(string: $0) }
        let pub: AnyPublisher<URL, Never> = urls.publisher.eraseToAnyPublisher()
        var index: Int = 0
        let delay: Int = 2
        
        pub.flatMap(maxPublishers: .max(1)) { url -> AnyPublisher<DownloadImageEvent, Never> in
            print("\(#function) \(url)")
            index += 1
            
            // download & create event
            return downloadImage(at: url)
                .print()
                .replaceError(with: UIImage())
                .map{ _ in DownloadImageEvent(
                        index: index,
                        total: urls.count,
                        url: url.absoluteString) }
                .delay(for: RunLoop.SchedulerTimeType.Stride(TimeInterval(delay)),
                       scheduler: RunLoop.main)
                .eraseToAnyPublisher()
        }
        .sink(receiveValue: {
            self.currentImage = $0
        }).store(in: &cancellables)
    }
}

This function performs the following:

  1. Retrieve a list of urls to download
  2. Get the publisher for the list
  3. Issue a demand and apply backpressure to restrict the number of publishers to 1. This is accomplished by using the .max(1) argument
  4. Create a DownloadImageEvent object that will update the currentImage property via a sink call.

As each url is processed, the service will emit an event on its’ currentImage property.

The event is of type

struct DownloadImageEvent: CustomStringConvertible {
    let index: Int
    let total: Int
    let url: String
    
    var completionRatio: Float {
        return (Float(index) / Float(total))
    }
    
    var description: String {
        return "\(index) of \(total): \(url)"
    }
}

The view

struct DownloadAllImagesView: View {
    @ObservedObject var viewModel: ImageService
    
    init(viewModel: ImageService) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        VStack {
            Text("DownloadAllImagesView")
            Button(action: { viewModel.downloadAllImages() } ) {
                Text("Download")
            }
            
            Text(viewModel.currentImage?.description ?? "")
        }
    }
}

The UrlBuildable protocol

protocol UrlBuildable {
    func buildImagesUrls() -> [String]
}

extension UrlBuildable {
    func buildImagesUrls() -> [String] {
        var ret: [String] = []
        
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/perceptivecopilot.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/landocalrissian.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/tantiveiv.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/chewbacca-crew-swz19.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/r7a7.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/moldycrow.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/fanatical.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/4lom.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/drk1probedroids.png")
        ret.append("https://pakirby1.github.io/images/XWing/upgrades/delayedfuses.png")

        return ret
    }
}

When the download button is tapped, the service will start downloading images and for each image retrieved, the service will emit a DownloadImageEvent on its currentImage property.

Adding a print() statement to the Combine logic that calls downloadAllImages() results in the following XCode console window:

downloadAllImages() https://pakirby1.github.io/images/XWing/upgrades/perceptivecopilot.png
receive subscription: (ReceiveOn)
request unlimited
receive value: (<UIImage:0x60000248db00 anonymous {700, 503}>)
receive finished
downloadAllImages() https://pakirby1.github.io/images/XWing/upgrades/landocalrissian.png
receive subscription: (ReceiveOn)
request unlimited
receive value: (<UIImage:0x6000024897a0 anonymous {700, 503}>)
receive finished

For each request, we request an unlimited demand, receive a value then finish. This request configuration is managed by the URLSession.shared.dataTaskPublisher utilized within the downloadImage(at: URL) function.