Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 026 / 100

Images and SF Symbols: a Swift & iOS guide for developers

Icons and images in SwiftUI.

Expona Admin7 min read

Images and SF Symbols: a Swift & iOS guide for developers

Mobile apps live or die by their visual presentation, and handling images correctly separates polished products from rough prototypes. Whether you're loading system icons or fetching user avatars from a server, SwiftUI gives us powerful tools to manage images efficiently. In this guide, we'll walk through the essentials of working with SF Symbols, remote images, and the layout properties that make them work seamlessly in production apps.

Why images matter in production apps

Images aren't just decoration—they're a core part of user experience. A loading state without visual feedback feels broken. An icon that doesn't scale properly looks unprofessional. A remote image that blocks the UI while fetching will tank your app rating. Getting images right means understanding when to use system icons versus custom assets, how to handle network requests, and how to make everything responsive across screen sizes.

Image(systemName:) for SF Symbols

Apple's SF Symbols library provides thousands of system icons ready to use. Instead of bundling custom icon packs, you tap into a consistent, accessible, and automatically updated symbol set. We use Image(systemName:) to access these symbols by name.

swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "star.fill")
            Image(systemName: "heart")
            Image(systemName: "arrow.right.circle")
            Image(systemName: "checkmark.seal.fill")
        }
    }
}

SF Symbols scale automatically with the font size of their context, which is why they're ideal for buttons and list items. You can find the complete symbol library by downloading the SF Symbols app from Apple's developer website—it's indispensable when building iOS apps.

In real production workflows, we often wrap SF Symbols in buttons for user interactions. Here's a practical example from an e-commerce checkout flow:

swift
struct CartButton: View {
    @State private var itemCount = 3
    
    var body: some View {
        Button(action: { /* navigate to cart */ }) {
            ZStack(alignment: .topTrailing) {
                Image(systemName: "bag.fill")
                    .font(.system(size: 20))
                
                if itemCount > 0 {
                    Text("\(itemCount)")
                        .font(.system(size: 10, weight: .bold))
                        .foregroundColor(.white)
                        .frame(width: 18, height: 18)
                        .background(Color.red)
                        .cornerRadius(9)
                        .offset(x: 8, y: -8)
                }
            }
        }
    }
}

This pattern appears everywhere: the red notification badge on the cart icon. The SF Symbol gives us the base visual, and we layer additional content on top.

AsyncImage for remote images

When you need to display user-generated content or images from an API, AsyncImage handles the async loading, error states, and caching for you. Introduced in iOS 15, it's the modern way to fetch and display images without blocking the main thread.

swift
struct UserProfileView: View {
    let imageURL: URL
    
    var body: some View {
        AsyncImage(url: imageURL) { phase in
            switch phase {
            case .empty:
                ProgressView()
            case .success(let image):
                image
                    .resizable()
                    .scaledToFill()
            case .failure:
                Image(systemName: "photo.fill")
                    .foregroundColor(.gray)
            @unknown default:
                EmptyView()
            }
        }
        .frame(width: 100, height: 100)
        .clipShape(Circle())
    }
}

The three phases—empty, success, and failure—let you handle each state differently. The empty phase shows a loading spinner. On success, we display the downloaded image. On failure, we show a fallback icon or placeholder.

In a SaaS dashboard or ride-hailing app, you might be displaying dozens of user avatars. AsyncImage handles this efficiently by only keeping loaded images in memory and rendering the loading state for images not yet fetched.

For more control over caching and network behavior, many teams still reach for URLSession and ImageCache wrappers, but for straightforward cases, AsyncImage works well out of the box.

resizable() and aspectRatio

By default, images render at their intrinsic size. That's fine for SF Symbols which scale with font size, but for remote images or custom assets, you almost always need to resize them. The resizable() modifier tells SwiftUI the image can be resized, and aspectRatio() controls how it scales.

swift
struct ProductCard: View {
    let imageURL: URL
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            AsyncImage(url: imageURL) { phase in
                switch phase {
                case .success(let image):
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                case .empty:
                    ProgressView()
                case .failure:
                    Color.gray.opacity(0.3)
                @unknown default:
                    EmptyView()
                }
            }
            .frame(height: 200)
            .clipped()
            
            Text("Premium Widget")
                .font(.headline)
            Text("$29.99")
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
        .cornerRadius(8)
    }
}

Notice we use contentMode: .fill rather than .fit. Fill crops the image to match the frame, while fit scales it to display entirely. For product images in e-commerce, fill usually looks better because you control the composition. We also call clipped() to ensure the image respects the corner radius.

A common mistake here is forgetting to add resizable() before setting a frame. Without it, the frame will be ignored and the image renders at its original size, potentially breaking your layout.

Symbol rendering modes and variants

SF Symbols support different rendering modes that change how they're colored and styled. You can use symbolRenderingMode() to control this behavior, which is especially useful when building themed UIs or when you need symbols to respect system colors.

swift
struct SymbolVariantsView: View {
    var body: some View {
        VStack(spacing: 20) {
            // Monochrome: single color
            Image(systemName: "heart.fill")
                .symbolRenderingMode(.monochrome)
                .foregroundColor(.red)
            
            // Hierarchical: multiple opacities of one color
            Image(systemName: "heart.fill")
                .symbolRenderingMode(.hierarchical)
                .foregroundColor(.blue)
            
            // Palette: multiple colors (requires explicit colors)
            Image(systemName: "heart.fill")
                .symbolRenderingMode(.palette)
                .foregroundStyle(.red, .pink)
            
            // Multicolor: predefined colors
            Image(systemName: "checkmark.seal.fill")
                .symbolRenderingMode(.multicolor)
        }
        .font(.system(size: 40))
    }
}

Each mode serves different purposes. Monochrome works for toolbar icons where you want consistency with the app's color scheme. Hierarchical is great for complex icons where you want visual emphasis—the primary element gets full opacity, supporting elements get reduced opacity. Palette and multicolor are for decorative or branded symbols.

In production, we often set a default rendering mode globally and override where needed. A travel app might render transport icons (car, plane, train) in multicolor for visual richness, while using monochrome for navigation tabs.

SF Symbols also support variants like .fill, .slash, and weight variations. You stack these to create the exact symbol you need:

swift
Image(systemName: "heart.fill")      // filled heart
Image(systemName: "heart")           // outline heart
Image(systemName: "heart.slash.fill") // crossed-out filled heart

// You can also modify weight
Image(systemName: "circle.fill")
    .font(.system(size: 20, weight: .thin))

Image(systemName: "circle.fill")
    .font(.system(size: 20, weight: .black))

This system gives you enormous flexibility without needing custom assets. A notification icon can be "bell.fill" normally but "bell.slash.fill" when notifications are disabled.

Common mistakes

  • Forgetting resizable() on remote images: Frame sizes get ignored if you don't call resizable() first. Always add it before using frame() or aspectRatio().
  • Not handling AsyncImage failure states: Ignoring the .failure case leaves users staring at nothing when a network request fails. Always provide a fallback.
  • Using .fit when .fill makes more sense: For product images and user content, .fill usually looks better and gives you more control. Test both for your use case.
  • Rendering too many AsyncImages at once: If you're showing a list of 100 user avatars, SwiftUI will fetch all of them simultaneously. Consider pagination or lazy loading.
  • Mismatching symbol names: A typo in a symbol name ("heart.fil" instead of "heart.fill") results in nothing rendering. Use the SF Symbols app to verify names.
  • Overcomplicating palette rendering: Remember that symbolRenderingMode(.palette) requires explicit foregroundStyle() colors. Without them, it falls back to the default rendering.

Key takeaways

  • Use Image(systemName:) for SF Symbols—they're consistent, accessible, and automatically scaled based on context.
  • AsyncImage handles remote images efficiently by managing loading, error, and success states, eliminating the need to build these from scratch.
  • Always call resizable() before applying frame constraints to images, then use aspectRatio(contentMode:) to control scaling behavior.
  • Symbol rendering modes (.monochrome, .hierarchical, .palette, .multicolor) let you match your app's design system without custom assets.
  • Combine symbol names with variants (like .fill, .slash) to create semantic icons that respond to state changes.
  • Always provide fallback UI for failed image loads—null states break the user experience.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles