DEV Community

Maxwell
Maxwell

Posted on Edited on

Stitching Photos into One Long Image in Swift: Common Width, a Height Cap, and One Photo in Memory at a Time

A long image is the simplest way to post a set of photos as one thing: a trip, a recipe, a product from every angle. Stack the photos top to bottom, export one file, done. The first version most of us write is also simple: load every photo as a UIImage, add up the heights, draw them into a UIGraphicsImageRenderer, save.

It works on the simulator with five screenshots. Then someone picks twelve 48-megapixel photos, two of them shot sideways, and the app either dies or produces a 9,000 × 60,000 pixel file that no social app will show at full size.

I build BatchMark, an on-device photo app for iPhone, and its Photo Stories feature joins a set of finished photos into one long image. This post is a teaching extract of the problems you hit when you build that kind of stitcher, not the app's source. Everything here is ImageIO and Core Graphics, no third-party code.

The approach has four parts:

  1. Read each photo's pixel size without decoding it.
  2. Plan the layout: one common width, heights scaled to match, total height capped.
  3. Draw each photo into a single bitmap context, decoding one at a time at the size it will actually appear.
  4. Encode the result, or split it into parts if it's still too tall.

1. Read sizes without decoding

You need every photo's aspect ratio before you can plan anything. Loading full images just to read their size is the most expensive way to get two integers. ImageIO can read them from the file header:

import ImageIO
import CoreGraphics
import UniformTypeIdentifiers

func displaySize(of url: URL) -> CGSize? {
    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
          let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
          let w = props[kCGImagePropertyPixelWidth] as? Int,
          let h = props[kCGImagePropertyPixelHeight] as? Int
    else { return nil }

    // Orientations 5...8 are rotated by 90 degrees, so width and height swap on screen.
    let orientation = props[kCGImagePropertyOrientation] as? UInt32 ?? 1
    return (5...8).contains(orientation)
        ? CGSize(width: h, height: w)
        : CGSize(width: w, height: h)
}
Enter fullscreen mode Exit fullscreen mode

The orientation check is the bit people skip. A portrait photo from an iPhone is often stored as a landscape bitmap plus an orientation flag. If you plan with the stored size, your sideways photo gets a slot that's the wrong shape, and the draw step squashes it.

2. Plan the layout before touching pixels

Every photo gets the same width. Its height follows from its aspect ratio. Then you check the total against a cap and, if it's over, shrink the width until it fits.

struct StitchPlan {
    let width: Int
    let heights: [Int]
    let spacing: Int

    var totalHeight: Int {
        heights.reduce(0, +) + spacing * max(0, heights.count - 1)
    }
}

func makePlan(sizes: [CGSize],
              preferredWidth: Int,
              spacing: Int,
              maxTotalHeight: Int) -> StitchPlan? {
    guard !sizes.isEmpty else { return nil }

    func heights(at width: Int) -> [Int] {
        sizes.map { Int((Double(width) * Double($0.height / $0.width)).rounded()) }
    }

    var width = preferredWidth
    var hs = heights(at: width)
    let gaps = spacing * (sizes.count - 1)
    let imageHeight = hs.reduce(0, +)

    if imageHeight + gaps > maxTotalHeight {
        let available = Double(maxTotalHeight - gaps)
        guard available > 0 else { return nil }
        width = Int((Double(width) * available / Double(imageHeight)).rounded(.down))
        hs = heights(at: width)
    }
    return StitchPlan(width: width, heights: hs, spacing: spacing)
}
Enter fullscreen mode Exit fullscreen mode

Why cap the height at all? Three reasons:

  • Memory. An 8-bit RGBA bitmap costs width × height × 4 bytes. At 1,080 × 16,000 that's about 69 MB, which is fine. At 4,000 × 60,000 it's close to a gigabyte, which is not.
  • Format limits. JPEG stores width and height as 16-bit values, so 65,535 pixels is a hard wall. You'll hit memory trouble long before that, but it's worth knowing the wall exists.
  • Where it's going. Social apps and chat apps resize large images on upload. A long image that's only 400 pixels wide after their resize is unreadable. Check the current limits of wherever you're posting rather than trusting a number from a blog post, including this one.

Pick a sensible preferred width (around the width of the screens people will scroll it on) and a cap that keeps memory comfortable. The plan is just integers, so you can show it in the UI ("12 photos, 1,080 × 15,400") before doing any real work.

3. Draw one photo at a time

The core idea: allocate one bitmap of the final size, then decode each source photo at its final size straight into it. Never hold more than one decoded photo at once.

ImageIO's thumbnail API does the downsampled decode. I covered why this matters for memory in an earlier post on batch exports, so here's just the function:

func decode(_ url: URL, longSide: Int) -> CGImage? {
    let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil }

    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceCreateThumbnailWithTransform: true,   // applies the orientation for us
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceThumbnailMaxPixelSize: longSide
    ]
    return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
}
Enter fullscreen mode Exit fullscreen mode

kCGImageSourceCreateThumbnailWithTransform rotates the result upright, which is why step 1 had to plan with the rotated size.

Now the stitcher:

func stitch(urls: [URL], plan: StitchPlan, background: CGColor) -> CGImage? {
    guard urls.count == plan.heights.count,
          let space = CGColorSpace(name: CGColorSpace.sRGB),
          let ctx = CGContext(data: nil,
                              width: plan.width,
                              height: plan.totalHeight,
                              bitsPerComponent: 8,
                              bytesPerRow: 0,
                              space: space,
                              bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
    else { return nil }

    ctx.interpolationQuality = .high
    ctx.setFillColor(background)
    ctx.fill(CGRect(x: 0, y: 0, width: plan.width, height: plan.totalHeight))

    var top = 0
    for (url, height) in zip(urls, plan.heights) {
        autoreleasepool {
            let longSide = max(plan.width, height)
            if let image = decode(url, longSide: longSide) {
                // Core Graphics puts the origin at the bottom-left; the layout is top-down.
                let y = plan.totalHeight - top - height
                ctx.draw(image, in: CGRect(x: 0, y: y, width: plan.width, height: height))
            }
        }
        top += height + plan.spacing
    }
    return ctx.makeImage()
}
Enter fullscreen mode Exit fullscreen mode

A few details that are easy to get wrong:

  • The y flip. A raw CGContext is bottom-up. If you forget, photo 1 ends up at the bottom and your story reads backwards. Converting from a top-down top offset at the moment you draw keeps the layout code readable.
  • longSide. The thumbnail API takes the longest edge, not the width. For a portrait photo the longest edge is its height in the plan, so max(width, height) is right for both orientations.
  • Colour space. I've used sRGB here because the output is headed for social apps. If you want to keep wide-gamut colour, create the context in Display P3 instead and make sure the encoder writes the profile. Mixing them silently is how reds go flat.
  • autoreleasepool. ImageIO hands back objects that can sit in an autorelease pool until the loop ends. Draining each iteration keeps peak memory near "one bitmap plus one photo" instead of "one bitmap plus all photos".

4. Encode, or split

Encoding a CGImage with ImageIO:

func writeJPEG(_ image: CGImage, to url: URL, quality: Double = 0.88) -> Bool {
    guard let dest = CGImageDestinationCreateWithURL(
        url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else { return false }
    let props = [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
    CGImageDestinationAddImage(dest, image, props)
    return CGImageDestinationFinalize(dest)
}
Enter fullscreen mode Exit fullscreen mode

Sometimes the honest answer is that one file is the wrong output. If the cap forces the width down to something unreadable, split the set into parts instead. Splitting the plan, not the pixels, keeps whole photos together:

func split(sizes: [CGSize], width: Int, spacing: Int, maxPartHeight: Int) -> [Range<Int>] {
    var parts: [Range<Int>] = []
    var start = 0, running = 0
    for (i, size) in sizes.enumerated() {
        let h = Int((Double(width) * Double(size.height / size.width)).rounded())
        let added = (i == start ? h : h + spacing)
        if running + added > maxPartHeight, i > start {
            parts.append(start..<i)
            start = i
            running = h
        } else {
            running += added
        }
    }
    parts.append(start..<sizes.count)
    return parts
}
Enter fullscreen mode Exit fullscreen mode

Each range becomes its own StitchPlan and its own file: "Part 1 of 3". No photo is ever cut in half across two images, which is what happens if you render one huge image and slice it.

Putting it together off the main thread

func makeLongImage(from urls: [URL], output: URL) async -> Bool {
    await Task.detached(priority: .userInitiated) {
        let sizes = urls.compactMap(displaySize(of:))
        guard sizes.count == urls.count,
              let plan = makePlan(sizes: sizes, preferredWidth: 1080,
                                  spacing: 24, maxTotalHeight: 16_000),
              let image = stitch(urls: urls, plan: plan,
                                 background: CGColor(gray: 1, alpha: 1))
        else { return false }
        return writeJPEG(image, to: output)
    }.value
}
Enter fullscreen mode Exit fullscreen mode

If a size can't be read, this bails out rather than silently dropping a photo. In a story, a missing photo is worse than an error message, because the order is the point.

What I'd check before shipping

  • Mix portrait, landscape and square photos in one test set, including at least one stored sideways.
  • Test the biggest selection you allow, on the oldest device you support, and watch memory in Instruments rather than guessing.
  • Look at the output after uploading it somewhere real. A long image that looks great in your app and turns to mush after a chat app's resize isn't finished.

The stitching itself is twenty lines. The planning around it, what size, what cap, when to split, is where the real decisions are. If you're building something similar, I'd love to hear how you picked your numbers.

Top comments (0)