ondelva

← Blog

A Live Camera Preview in SwiftUI: Frame Pacing and Gestures That End Early

MTKView drawing at 60 fps over a 30 fps camera, a shutter that froze the preview, a DragGesture that fired onEnded while the finger was still down, and a TabView that built a Metal renderer for every page. Notes from the screens around a live camera.

Yunseon Hong6 min read

Part four of notes from building Thirdstop, an iOS camera app. The first three covered orientation, settings that don’t apply and saving RAW with PhotoKit. This one is about the screens: a Metal preview that has to keep pace with the camera, and SwiftUI views that redraw far more often than SwiftUI examples assume.

Measured on an iPhone 17 Pro with iOS 26.6.1.

The camera delivers 30 frames a second. MTKView by default draws on its own timer at 60. The result is that every camera frame gets drawn twice, which wastes power and shows nothing new.

Setting the view to 30 fps looked like the fix and was worse in a subtler way. The view’s clock and the camera’s clock drift against each other, so the phase slides. For a while each frame gets drawn twice. Then for a while a new frame arrives and is replaced by the next one before it is ever drawn. The preview stutters in a pattern that comes and goes.

The fix is to take the timer away and draw when the camera says so:

mtkView.isPaused = true
mtkView.enableSetNeedsDisplay = true

// in the video data output delegate, after storing the new frame
DispatchQueue.main.async { mtkView.setNeedsDisplay() }

Both flags, together. The MTKView reference lists this combination as its “draw notifications” mode. With only one of them set, the view either stops drawing altogether or keeps running its link as before. A redraw that has nothing to do with a new frame, like a size change, happens once in mtkView(_:drawableSizeWillChange:).

Get the shutter off the main thread

Every shutter press froze the preview for a moment. The capture path was reading the 12 MP buffer with getBytes and encoding on the main thread, and along the way a 48 MB buffer was being copied twice.

Two changes cleared it. Buffers go to the GPU through CVMetalTextureCache, which wraps the pixel buffer as a texture without copying it. Full-resolution rendering and encoding run on their own queue.

A smaller note in the same area: from Xcode 26, building shaders needs the Metal toolchain as a separate download.

xcodebuild -downloadComponent MetalToolchain

DragGesture ended while my finger was still down

The camera screen reads a lot of camera state, so its body is evaluated dozens of times a second. On that screen a DragGesture would call onEnded before I lifted my finger, between 0.1 and 3 ms after onChanged. My reading is that the gesture was being rebuilt along with the view, though I never proved it.

I tried the usual fixes. Holding gesture state in @State. Holding it in a reference type. Removing every other gesture that might compete. None of them changed anything.

What worked was leaving SwiftUI’s gesture system for that one control. A UIViewRepresentable creates a UILongPressGestureRecognizer with minimumPressDuration = 0 in makeUIView, once. After that, body can be re-evaluated as often as it likes and the recognizer is not affected, because it is not part of body. A UIPanGestureRecognizer looks like the obvious choice and is not, because it needs a minimum movement before it begins and so never sees a press that stays in place.

DragGesture inside a ScrollView eats the scroll

Put a DragGesture on content inside a ScrollView and scrolling stops. It stops even when the gesture’s callbacks are empty, and simultaneousGesture did not help.

The version that worked is a UIKit recognizer attached to the scroll view itself, deciding in gestureRecognizerShouldBegin by direction. Getting there had its own traps.

The direction check has to happen before calling super.touchesMoved. If super runs first, the recognizer is already in .began, it can no longer move to .failed, and the scroll view’s pan, which was told to require(toFail:) this recognizer, never starts at all.

Adding a recognizer broke the system’s drag-to-dismiss on that screen. Returning true from shouldRecognizeSimultaneouslyWith for it brought it back.

location(in: scrollView) is in content coordinates, not screen coordinates, because a scroll view’s bounds.origin is its contentOffset.

Auto-scroll at the edges has to be specified as points per second multiplied by the frame interval, not points per frame. 14 points a frame sounds modest. At 120 Hz it is 1,680 points a second.

And one bug that looked like a gesture bug: wherever I tapped, focus landed in the top left corner. The recognizer was already normalizing the location to 0 to 1, and the code receiving it divided by the view size again. Normalize in one place.

Heavy objects built per page and per cell

The gallery opened a second late. A TabView over a ForEach builds the model for every page up front, not only the visible one, and each page model was creating its own Metal renderer. Sharing one renderer across all pages fixed it.

Scrolling the grid stalled. The cells decoded 12 MP HEIC thumbnails inside .task, and in a SwiftUI view that task runs on the main actor. Moving the decode into Task.detached with an NSCache for results fixed the stall. The cache has to be read synchronously where the cell draws. If the cell waits for an async lookup, every recreated cell flashes gray for a frame.

The last one was a loop. I passed layout positions up with a PreferenceKey and back down into @State, inside a LazyVStack. Each state change caused cells to be created, which changed the preference, which changed the state. Writing those positions into a reference object instead, which SwiftUI does not observe, broke the loop.

The iOS 18 zoom transition has sharp edges

The zoom navigation transition from iOS 18 gives no progress value. You get ZoomNavigationTransition and the destination view scaled as a whole, nothing in between.

Changing navigationTransition later does not remove the drag-to-dismiss gesture it already attached. That gesture is a different recognizer from interactivePopGestureRecognizer, so disabling the pop gesture does not reach it either.

It also fights pinnedViews: .sectionHeaders. While the destination scales, the pinned header recomputes its position every frame and leaves horizontal streaks across the screen. The clue was that it never happened with the list scrolled to the top, where nothing was pinned. A bar with .regularMaterial has a similar problem, since it keeps blurring whatever passes underneath during the scale and produces stripes.

Cover the camera switch with a blur

Switching between front and back cameras reconfigures the session, and for that long the preview was black. I cover the preview with a UIVisualEffectView when the switch starts and remove it when the first frame from the new camera arrives. The blur works on the live layers behind it, so there is no snapshot to take and nothing to keep in sync.

That last detail is the one I would repeat to anyone building on a camera: the switch is over when the first frame from the new camera arrives. Timing things off the frames themselves fixed the black screen here, and the mirrored frames in part one.

Filed under Build log