DEV Community

Maxwell
Maxwell

Posted on

Crop Once, Post Everywhere: Batch Aspect-Ratio Crops for Social Images in Swift

If you ship photos from an iPhone app to more than one place, you eventually hit the same boring problem: Instagram wants 1:1 or 4:5, a shop banner wants 16:9, Stories want 9:16, and the camera roll is full of 4:3 and 3:4 stills. Doing that crop by hand for thirty product shots is how afternoons disappear.

This post walks through a small UIKit pipeline that takes one UIImage, a list of target aspect ratios, and returns one centre-weighted crop per ratio — suitable for running across a whole album. I’m writing it from the seat of someone who builds BatchMark, an on-device batch photo app, where “apply the same finishing step to many photos” is the whole product. The code below is a teaching extract, not a dump of the app.

What “same crop” should mean

People say “crop to 1:1” and mean two different things:

  1. Aspect-fill crop: keep the photo’s resolution as high as possible, cut off the edges that don’t fit the ratio.
  2. Aspect-fit pad: show the whole photo, add bars (or a mat) to reach the ratio.

For social uploads you almost always want (1). Padding changes the look of the feed thumbnail; a filled crop matches what creators already do in Instagram’s own editor.

Centre-weighted aspect-fill is a good default. Face-aware or subject-aware cropping is better for people photos, but it needs Vision and a policy for failure cases. Start with centre; add intelligence later.

The ratio list you’ll actually use

Keep ratios as width ÷ height in landscape-agnostic form, then decide orientation per photo:

import UIKit

struct SocialRatio: Hashable {
    let name: String
    let widthOverHeight: CGFloat

    static let feedSquare = SocialRatio(name: "1:1", widthOverHeight: 1)
    static let feedPortrait = SocialRatio(name: "4:5", widthOverHeight: 4.0 / 5.0)
    static let story = SocialRatio(name: "9:16", widthOverHeight: 9.0 / 16.0)
    static let landscapeBanner = SocialRatio(name: "16:9", widthOverHeight: 16.0 / 9.0)
    static let fourThree = SocialRatio(name: "4:3", widthOverHeight: 4.0 / 3.0)
}
Enter fullscreen mode Exit fullscreen mode

Store the name for export filenames (chair-blue-4x5.jpg). Store the math as a single CGFloat so you don’t thread two integers through every helper.

Pixel size, not point size

UIImage.size is in points. Drawing and JPEG export care about pixels. Multiply by scale, and prefer image.size over cgImage width/height so orientation flags are already respected in the size you lay out against:

func pixelSize(of image: UIImage) -> CGSize {
    CGSize(
        width: image.size.width * image.scale,
        height: image.size.height * image.scale
    )
}
Enter fullscreen mode Exit fullscreen mode

Centre aspect-fill rect

Given a source pixel size and a target width/height ratio, compute the crop rectangle in the source’s pixel space:

func aspectFillCropRect(
    sourcePixels: CGSize,
    targetWidthOverHeight: CGFloat
) -> CGRect {
    let sourceRatio = sourcePixels.width / sourcePixels.height
    let targetRatio = targetWidthOverHeight

    var cropSize = sourcePixels
    if sourceRatio > targetRatio {
        // Source is wider than target: crop left/right.
        cropSize.width = sourcePixels.height * targetRatio
    } else {
        // Source is taller than target: crop top/bottom.
        cropSize.height = sourcePixels.width / targetRatio
    }

    let origin = CGPoint(
        x: (sourcePixels.width - cropSize.width) / 2,
        y: (sourcePixels.height - cropSize.height) / 2
    )
    return CGRect(origin: origin, size: cropSize)
}
Enter fullscreen mode Exit fullscreen mode

That rect is the region of the oriented image you want to keep. Next you need to draw only that region into a new bitmap whose pixel size matches the crop.

Drawing the crop with UIGraphicsImageRenderer

A reliable approach: create a renderer whose pixel size equals the crop size, then draw the full image with an offset so the crop rect lands on the canvas.

func crop(_ image: UIImage, toWidthOverHeight ratio: CGFloat) -> UIImage {
    let srcPixels = pixelSize(of: image)
    let cropRect = aspectFillCropRect(
        sourcePixels: srcPixels,
        targetWidthOverHeight: ratio
    )

    let format = UIGraphicsImageRendererFormat.default()
    format.scale = 1
    format.opaque = true

    let renderer = UIGraphicsImageRenderer(size: cropRect.size, format: format)
    return renderer.image { _ in
        let drawRect = CGRect(
            x: -cropRect.origin.x,
            y: -cropRect.origin.y,
            width: srcPixels.width,
            height: srcPixels.height
        )
        image.draw(in: drawRect)
    }
}
Enter fullscreen mode Exit fullscreen mode

Setting format.scale = 1 matters. The renderer’s size is in points; with scale 1, points equal pixels, so a 1080×1350 canvas really is 1080×1350 pixels. Leave the default screen scale and a 3× phone quietly multiplies memory and dimensions by nine.

Batching across a set

For a whole album, map each photo across the ratio list. Keep work off the main actor, and don’t hold every output in memory at once if the set is large:

struct CroppedExport: Hashable {
    let sourceIndex: Int
    let ratio: SocialRatio
    let image: UIImage
}

func cropBatch(
    images: [UIImage],
    ratios: [SocialRatio]
) async -> [CroppedExport] {
    var results: [CroppedExport] = []
    results.reserveCapacity(images.count * ratios.count)

    for (index, image) in images.enumerated() {
        for ratio in ratios {
            let cropped = crop(image, toWidthOverHeight: ratio.widthOverHeight)
            results.append(CroppedExport(sourceIndex: index, ratio: ratio, image: cropped))
        }
        // Optional: encode and write to disk here, then drop `cropped` /
        // prune `results` so peak memory stays flat on large albums.
    }
    return results
}
Enter fullscreen mode Exit fullscreen mode

If you need parallelism, wrap the inner work in a TaskGroup, but cap concurrent renders (2–3 on older phones). Each render allocates a full bitmap; unbounded concurrency is a fast path to jetsam. A memory-budgeted exporter is a separate article — for this post, sequential per photo and parallel across a small capped group is enough.

Naming and exporting

Creators care about finding the right file later:

func filename(stem: String, ratio: SocialRatio) -> String {
    let safe = ratio.name.replacingOccurrences(of: ":", with: "x")
    return "\(stem)-\(safe).jpg"
}

func jpegData(_ image: UIImage, quality: CGFloat = 0.9) -> Data? {
    image.jpegData(compressionQuality: quality)
}
Enter fullscreen mode Exit fullscreen mode

Use 0.85–0.92 for social JPEGs unless you have a print path. Re-encoding already-compressed camera JPEGs at 1.0 still loses generation quality and inflates files for no visible gain on a feed.

Edge cases worth handling early

  • Tiny sources. A 640×640 screenshot cannot invent a sharp 1080×1920 story frame. Either upscale deliberately (usually looks bad) or export at the cropped native size and let the destination scale.
  • Panoramas. Centre crop on a 16000×3400 panorama often lands on empty content. For ultra-wide sources, consider pinning the crop to a focus rect the user tapped once.
  • Portrait vs landscape banners. A 16:9 crop of a tall portrait keeps a thin horizontal band through the middle — technically correct, often useless. Offer “skip this ratio” when the source ratio is extremely far from the target.
  • Orientation. Drawing with UIImage.draw(in:) uses the oriented image. If you bypass that and slice cgImage directly, you’ll crop the upright photo using the sideways buffer. Stick to UIImage drawing unless you also apply the orientation transform.

A minimal call site

let ratios: [SocialRatio] = [
    .feedSquare,
    .feedPortrait,
    .story,
    .landscapeBanner
]

let exports = await cropBatch(images: pickedImages, ratios: ratios)
for item in exports {
    let name = filename(stem: "product-\(item.sourceIndex)", ratio: item.ratio)
    if let data = jpegData(item.image) {
        // write `data` to a temp URL named `name`
        _ = name
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this belongs in a batch tool

The interesting product decision is not the crop math — it’s doing the same finishing step across a set so a shop owner or a creator isn’t trapped in a per-photo editor. Aspect-ratio packs are one of those steps, alongside watermarks, filters, and frames. If you’re solving the same class of problem on device, the crop helper above is a solid first brick.

I’m Maxwell — I build BatchMark, an iPhone app for finishing photos in batches without uploading them. This article is about the UIKit approach; the app is the longer-running experiment in making that approach usable for non-developers.

Top comments (1)

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

Storing the ratio as a single width over height value is a neat simplification, because it keeps the crop math in one place instead of threading two integers through every helper. The panorama edge case is the one I would handle next, since a centre crop can land on empty content even when the math is correct. Do you shift the crop toward detected content for ultra wide sources, or fall back to a different rule for those photos?