Skip to main content

Downsample an Image in Swift

swift

Decode an image directly at its display size to reduce memory use without changing its aspect ratio.

Use ImageIO to create a thumbnail without first decoding the full-resolution image. Pass a bounding size in points and the display scale separately. The function preserves the source’s aspect ratio, accounts for EXIF orientation, and does not upscale smaller images.

import ImageIO
import UIKit

func downsampleImage(
    at url: URL,
    to pointSize: CGSize,
    scale: CGFloat
) -> UIImage? {
    guard pointSize.width.isFinite,
          pointSize.height.isFinite,
          scale.isFinite,
          pointSize.width > 0,
          pointSize.height > 0,
          scale > 0
    else {
        return nil
    }

    let sourceOptions = [
        kCGImageSourceShouldCache: false,
    ] as CFDictionary

    guard let source = CGImageSourceCreateWithURL(
        url as CFURL,
        sourceOptions
    ) else {
        return nil
    }

    guard let properties = CGImageSourceCopyPropertiesAtIndex(
        source,
        0,
        nil
    ) as? [CFString: Any],
          let widthNumber = properties[kCGImagePropertyPixelWidth] as? NSNumber,
          let heightNumber = properties[kCGImagePropertyPixelHeight] as? NSNumber
    else {
        return nil
    }

    let orientation = (
        properties[kCGImagePropertyOrientation] as? NSNumber
    )?.intValue ?? 1
    let swapsDimensions = (5...8).contains(orientation)

    let storedWidth = CGFloat(truncating: widthNumber)
    let storedHeight = CGFloat(truncating: heightNumber)
    let sourceWidth = swapsDimensions ? storedHeight : storedWidth
    let sourceHeight = swapsDimensions ? storedWidth : storedHeight
    let targetWidth = pointSize.width * scale
    let targetHeight = pointSize.height * scale

    guard sourceWidth > 0,
          sourceHeight > 0,
          sourceWidth.isFinite,
          sourceHeight.isFinite,
          targetWidth.isFinite,
          targetHeight.isFinite
    else {
        return nil
    }

    let fitScale = min(
        targetWidth / sourceWidth,
        targetHeight / sourceHeight,
        1
    )
    let maxPixelSize = floor(
        max(sourceWidth, sourceHeight) * fitScale
    )
    guard maxPixelSize >= 1 else {
        return nil
    }

    let downsampleOptions = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
    ] as CFDictionary

    guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(
        source,
        0,
        downsampleOptions
    ) else {
        return nil
    }

    return UIImage(cgImage: thumbnail, scale: scale, orientation: .up)
}

For a 120-point image view rendered at 3x:

let thumbnail = downsampleImage(
    at: imageURL,
    to: CGSize(width: 120, height: 120),
    scale: 3
)

Pass the scale of the destination display, not the source image’s scale. The supplied CGSize is a bounding box. Nonsquare images retain their aspect ratio and fit inside it.

Unlike resizing an existing UIImage, this function accepts a file URL so ImageIO can avoid caching and decoding the full-size bitmap first. If you already created the full-resolution UIImage, its largest memory cost has already occurred.