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.
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
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.
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:
- Build a list of urls that should be downloaded
- Download each url in order
- 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:
- Retrieve a list of urls to download
- Get the publisher for the list
- Issue a demand and apply backpressure to restrict the number of publishers to 1. This is accomplished by using the
.max(1)
argument - Create a
DownloadImageEvent
object that will update thecurrentImage
property via asink
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.