Swift and SwiftUI tutorials for Swift Developers

How to display an image from URL in SwiftUI

In modern mobile app development, it is almost impossible to find an app that lives 100% “offline.” Whether you are building an e-commerce platform, a social network, a news app, or a simple catalog, sooner or later you will face one of the most common—and sometimes frustrating—requirements of iOS development: loading images from the internet.

If you come from the old world of UIKit, you will remember that UIImageView had no native way to load a URL. We had to deal with URLSession, manually dispatch tasks to the Main Thread, or rely on massive third-party libraries to do something that should be trivial.

With the arrival of SwiftUI, Apple promised to simplify our lives. But have they really succeeded? Is AsyncImage the definitive solution, or should we still use external libraries?

In this massive tutorial, we are going to break down everything you need to know to handle and load remote images in Xcode with SwiftUI. You won’t just learn how to display a photo; you will learn about caching, memory management, error handling,and how to create a fluid user experience.


Part 1: The Native Solution (iOS 15+) – AsyncImage

With the release of iOS 15, Apple introduced AsyncImage. This is the standard and recommended way for most simple use cases.

1.1 Basic Usage

The simplest implementation requires just one line of code. AsyncImage takes care of:

  1. Making the asynchronous network request.
  2. Downloading the data.
  3. Decoding the image.
  4. Displaying it on the screen.
import SwiftUI

struct BasicImageView: View {
    // Example URL (make sure to use HTTPS)
    let imageUrl = URL(string: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e")

    var body: some View {
        VStack {
            Text("My Remote Image")
                .font(.headline)
            
            // Basic load
            AsyncImage(url: imageUrl)
        }
    }
}

The problem with this approach: If you run this code, you’ll notice something strange. If the image is larger than the iPhone screen, it will overflow the edges. If you try to add the .resizable() modifier, Xcode will throw an error.

Why? Because AsyncImage is a wrapper, not the image itself, until it loads.

1.2 Customization and Resizing

To manipulate the image (make it resizable, change the aspect ratio, crop it), we need to use the initializer that provides a closure.

AsyncImage(url: imageUrl) { image in
    image
        .resizable()
        .scaledToFill() // Or .scaledToFit()
} placeholder: {
    // What is shown while loading
    ProgressView()
}
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 20))

Code Analysis:

  • image: This is the actual Image object once downloaded. This is where we apply .resizable().
  • placeholder: This is a temporary view. Using a ProgressView (the native spinner) is a UX best practice.
  • External modifiers: .frame() and .clipShape() are applied to the AsyncImage container, ensuring that both the placeholder and the final image respect the size constraints.

Part 2: State Management (Loading, Success, and Error)

In the real world, connections fail. URLs break. Servers go down. A professional app cannot just leave a blank space if something goes wrong.

For this, AsyncImage offers us granular control through AsyncImagePhase.

2.1 Implementing Phases

This initializer gives us access to an enum that tells us exactly what is happening.

struct AdvancedImageView: View {
    let url = URL(string: "https://example.com/image-that-might-fail.jpg")

    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .empty:
                // Phase 1: Loading
                ZStack {
                    Color.gray.opacity(0.1)
                    ProgressView()
                }
                
            case .success(let image):
                // Phase 2: Success
                image
                    .resizable()
                    .scaledToFit()
                    .transition(.opacity.animation(.easeIn)) // Smooth animation
                
            case .failure(let error):
                // Phase 3: Error
                VStack {
                    Image(systemName: "wifi.slash")
                        .font(.largeTitle)
                        .foregroundColor(.red)
                    Text("Error loading")
                        .font(.caption)
                        .foregroundColor(.secondary)
                    // Optional: Print error to console for debug
                    let _ = print(error.localizedDescription) 
                }
                
            @unknown default:
                // Future Apple cases
                EmptyView()
            }
        }
        .frame(height: 250)
        .background(Color(.systemGray6))
        .cornerRadius(12)
    }
}

Advantages of this method:

  1. Visual Feedback: The user always knows what is happening.
  2. Error Handling: You can show a “retry” icon or a default placeholder if the image fails.
  3. Transitions: Note how we added .transition(.opacity). This prevents the image from “popping in” abruptly, making the app feel more elegant.

Part 3: The Great Debate – To Cache or Not to Cache?

Here is where we enter real “Software Engineering” territory.

AsyncImage uses iOS’s default URL caching system (URLCache). This means:

  • If the server sends the correct HTTP headers (Cache-Control), iOS will temporarily save the image.
  • However, this cache is volatile. If you close the app and reopen it, or if the system needs RAM, the image will be downloaded again.
  • In a list (List/LazyVStack) with hundreds of images, AsyncImage might re-download images you saw 10 seconds ago if you scroll up and down quickly.

3.1 When is AsyncImage enough?

  • Prototypes.
  • Apps with few images.
  • Images that change very frequently and shouldn’t be saved.

3.2 When do you need something more powerful?

  • Social networks (infinite feeds).
  • Apps that must work with poor connections.
  • When you need to optimize CPU and battery performance.

Part 4: The Professional Solution (Kingfisher)

If you need persistent cache (on disk) and high performance, the iOS community has a gold standard: Kingfisher. Although it is an external library, it is so common that it is practically considered part of the ecosystem.

4.1 Installation

In Xcode:

  1. Go to File > Add Packages...
  2. Paste the URL: https://github.com/onevcat/Kingfisher
  3. Select your project and click “Add Package”.

4.2 Using Kingfisher in SwiftUI (KFImage)

Kingfisher simplifies everything we saw in Part 2 and adds automatic disk and RAM caching.

import SwiftUI
import Kingfisher

struct KingfisherExampleView: View {
    let url = URL(string: "https://images.unsplash.com/photo-1550258987-190a2d41a8ba")

    var body: some View {
        KFImage(url)
            .placeholder {
                // What is shown while loading
                ProgressView()
            }
            .onFailure { error in
                print("Load failed: \(error)")
            }
            .resizable() // Kingfisher allows putting this directly
            .loadDiskFileSynchronously() // Load optimization
            .cacheMemoryOnly() // Optional: Cache configuration
            .fade(duration: 0.25) // Integrated transition
            .aspectRatio(contentMode: .fill)
            .frame(width: 300, height: 300)
            .clipShape(Circle())
    }
}

4.3 Why use Kingfisher?

  1. Downsampling: If you download a 4K image but display it in a 50x50px icon, Kingfisher can resize it in memory to avoid wasting RAM. This prevents your app from crashing due to memory pressure.
  2. Smart Caching: It automatically manages the expiration of old files so as not to fill up the user’s iPhone.
  3. Prefetching: You can download images before they appear on the screen for an instant experience.

Part 5: Creating Your Own ImageLoader (Advanced)

To finish this tutorial, we’re going to see how to do it “by hand.” Why? Because in a technical interview you might be asked not to use libraries, or maybe you need very specific control that AsyncImage doesn’t provide.

We will use the ObservableObject and Combine pattern.

5.1 The ViewModel (ImageLoader)

import Combine

class ImageLoader: ObservableObject {
    @Published var image: UIImage? = nil
    @Published var isLoading: Bool = false
    
    private var cancellables = Set<AnyCancellable>()
    private static let cache = NSCache<NSString, UIImage>() // Simple memory cache

    func load(from urlString: String) {
        guard let url = URL(string: urlString) else { return }
        
        // 1. Check Cache
        if let cachedImage = ImageLoader.cache.object(forKey: urlString as NSString) {
            self.image = cachedImage
            return
        }
        
        self.isLoading = true
        
        // 2. Network Request
        URLSession.shared.dataTaskPublisher(for: url)
            .map { UIImage(data: $0.data) }
            .replaceError(with: nil)
            .receive(on: DispatchQueue.main) // Important: Return to main thread
            .sink { [weak self] loadedImage in
                self?.isLoading = false
                if let validImage = loadedImage {
                    // 3. Save to Cache
                    ImageLoader.cache.setObject(validImage, forKey: urlString as NSString)
                    self?.image = validImage
                }
            }
            .store(in: &cancellables)
    }
}

5.2 The Custom View

struct CustomRemoteImage: View {
    @StateObject private var loader = ImageLoader()
    let url: String

    var body: some View {
        Group {
            if loader.isLoading {
                ProgressView()
            } else if let image = loader.image {
                Image(uiImage: image)
                    .resizable()
            } else {
                Image(systemName: "photo") // Error placeholder
            }
        }
        .onAppear {
            loader.load(from: url)
        }
    }
}

This code gives you a deep understanding of how asynchronous data works in SwiftUI. Although it is more verbose than AsyncImage, it gives you total control over cache logic and the network session.


Conclusion

We have come a long way. To summarize, here is my final recommendation as a senior developer:

  1. Use AsyncImage if you are just starting out, if your app is simple, or if you only need to display a few static images (like a user profile or a detail header). It is native, lightweight, and requires no dependencies.
  2. Use Kingfisher (or SDWebImage) if you are building a complex commercial app with long lists (LazyVStack), grids, or if the loading experience must be instant and persistent.
  3. Implement your own Loader only if you have very strict security requirements (such as authentication in image headers) or for educational purposes.

Mastering image loading is the first step to mastering iOS performance. A poorly optimized image can ruin even the most beautiful user experience.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

SwiftUI Best Practices

Next Article

Best SwiftUI Packages

Related Posts