Introduction
An animated loading circle gives users immediate feedback while an image is being fetched, decoded, and displayed. Without that feedback, blank image areas look broken and users may retry actions unnecessarily. A production-ready implementation must handle cancellation, cell reuse, and main-thread UI updates correctly.
Add a Spinner Overlay to an Image View
In UIKit, the standard indicator is UIActivityIndicatorView. Place it inside the image view container and keep it centered. Start animation before network work, stop it for every completion path.
1import UIKit
2
3final class PhotoViewController: UIViewController {
4 private let imageView = UIImageView()
5 private let spinner = UIActivityIndicatorView(style: .large)
6 private var task: URLSessionDataTask?
7
8 override func viewDidLoad() {
9 super.viewDidLoad()
10
11 view.backgroundColor = .systemBackground
12 imageView.contentMode = .scaleAspectFit
13 imageView.frame = view.bounds
14 imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
15 view.addSubview(imageView)
16
17 spinner.translatesAutoresizingMaskIntoConstraints = false
18 spinner.hidesWhenStopped = true
19 view.addSubview(spinner)
20
21 NSLayoutConstraint.activate([
22 spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
23 spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor)
24 ])
25 }
26
27 func loadImage(from url: URL) {
28 task?.cancel()
29 imageView.image = nil
30 spinner.startAnimating()
31
32 task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
33 DispatchQueue.main.async {
34 guard let self = self else { return }
35 defer { self.spinner.stopAnimating() }
36
37 if let nsError = error as NSError?, nsError.code == NSURLErrorCancelled {
38 return
39 }
40
41 guard
42 let http = response as? HTTPURLResponse,
43 200...299 ~= http.statusCode,
44 let data,
45 let image = UIImage(data: data)
46 else {
47 return
48 }
49
50 self.imageView.image = image
51 }
52 }
53
54 task?.resume()
55 }
56}
This snippet is runnable in a UIKit app and covers the main lifecycle requirements.
Prevent Wrong Images in Reusable Cells
For table and collection views, the biggest issue is stale responses. A cell can start one request, get reused, and then receive the old response. Use a representation token such as URL value to confirm that the response still belongs to the current item.
1import UIKit
2
3final class FeedImageCell: UICollectionViewCell {
4 private let imageView = UIImageView()
5 private let spinner = UIActivityIndicatorView(style: .medium)
6
7 private var representedURL: URL?
8 private var task: URLSessionDataTask?
9
10 override init(frame: CGRect) {
11 super.init(frame: frame)
12
13 imageView.frame = contentView.bounds
14 imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
15 imageView.contentMode = .scaleAspectFill
16 imageView.clipsToBounds = true
17 contentView.addSubview(imageView)
18
19 spinner.center = contentView.center
20 spinner.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin]
21 spinner.hidesWhenStopped = true
22 contentView.addSubview(spinner)
23 }
24
25 required init?(coder: NSCoder) {
26 fatalError("init(coder:) has not been implemented")
27 }
28
29 override func prepareForReuse() {
30 super.prepareForReuse()
31 task?.cancel()
32 task = nil
33 representedURL = nil
34 imageView.image = nil
35 spinner.stopAnimating()
36 }
37
38 func configure(with url: URL) {
39 representedURL = url
40 spinner.startAnimating()
41
42 task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
43 DispatchQueue.main.async {
44 guard let self = self else { return }
45 defer { self.spinner.stopAnimating() }
46 guard self.representedURL == url else { return }
47 guard let data, let image = UIImage(data: data) else { return }
48 self.imageView.image = image
49 }
50 }
51
52 task?.resume()
53 }
54}
This check avoids visual flicker and incorrect image placement while fast scrolling.
Improve UX Beyond a Spinner
The loading circle should not be the only strategy. Add placeholder imagery, crossfade transitions, and optional caching for smoother results.
1extension UIImageView {
2 func setImageWithCrossfade(_ image: UIImage?) {
3 UIView.transition(
4 with: self,
5 duration: 0.2,
6 options: .transitionCrossDissolve,
7 animations: { self.image = image }
8 )
9 }
10}
A short crossfade makes image appearance feel intentional instead of abrupt. Combined with cached responses, it can eliminate spinners for frequently viewed items.
Consider a Library for Advanced Cases
If your app heavily depends on remote images, a library such as SDWebImage or Kingfisher can reduce boilerplate. You still need to understand lifecycle rules so you can configure caching and cancellation correctly.
Even with a library, keep UI ownership local. Cells should still reset state in prepareForReuse and not assume requests always succeed.
Common Pitfalls
Updating UIImageView from a background callback thread, which causes random UI glitches or crashes. Always dispatch UI work to main thread.
Forgetting to stop the spinner on failure and cancel paths. Use defer to guarantee cleanup.
Not canceling previous requests before starting a new one for the same view, leading to race conditions.
Ignoring cell reuse, so outdated requests write images into recycled cells.
Using spinner-only feedback with no placeholder or transition, which makes the interface feel jumpy during slow networks.
Summary
Start spinner before request and stop it in every completion path.
Keep all UI updates on the main thread.
Cancel in-flight tasks when views are reused or replaced.
Validate response ownership in reusable cells with a URL token.
Pair spinners with placeholders, caching, and subtle transitions for better perceived performance.