DEV Community

Daniel Pertu
Daniel Pertu

Posted on

autofocus='off' is the one that keeps focusing, and two other reasons our label photos were blurry

Munchable is a gut-health scanner. You scan a barcode, and the app tells you whether that packaged food fits your conditions, with the reasons: munchable.app. When a product is not in our database yet, the app asks for a photo of the ingredients panel instead, and a reader turns those pixels into an ingredients list.

That makes the whole feature a function of one thing: pixels per readable letter. A photo that is 20 percent blurry is not 20 percent worse, it is a failed scan and an annoyed user standing in an aisle. So when our capture screen was producing soft photos, the fix was not a better reader. It was three separate levers on the camera, and only one of them was in our own code.

Lever one: the prop whose values read backwards

expo-camera exposes an autofocus prop with values 'on' and 'off'. The obvious reading is wrong.

'on' means focus once and then lock. 'off' means keep focusing as the scene moves.

Reading small print off a pack that somebody is still steadying in one hand needs the continuous one, so our native path sets the value that looks like it turns the feature off:

<CameraView
  // 'on' focuses ONCE and then locks, 'off' keeps focusing as the scene
  // moves. Native wants continuous; web keeps 'on' because the browser
  // implementation has nothing continuous to ask for.
  autofocus={Platform.OS === 'web' ? 'on' : 'off'}
  selectedLens={lens}
  enableTorch={isNative ? torch : undefined}
  pictureSize={pictureSize}
/>
Enter fullscreen mode Exit fullscreen mode

The comment above that line is longer than the line. It stays, because the next person to read it will otherwise "fix" it.

Lever two: on iOS, which camera you opened

This one cost the most time, because the photo was not blurry in a way that looked like a focus problem. It was blurry only up close, which is the only distance anybody photographs an ingredients list from.

expo-camera opens the plain wide angle lens unless you tell it otherwise. On a modern iPhone that lens has a minimum focus distance of roughly a hand's width, and on its own it never hands over to the ultra wide, which is the lens that can actually focus on something held close. The stock Camera app does not have this problem because it does not open a single physical lens. It opens one of the virtual multi-lens devices, a single logical camera that switches lens with distance, macro included.

So we ask for one of those:

/**
 * The back camera that focuses like the stock Camera app, from the names iOS
 * reports. Matched by substring, case insensitively, because these are
 * localised strings and an exact match would silently stop working in
 * another language. A miss is harmless: the camera keeps its default lens.
 */
function pickLens(lenses: string[]): string | undefined {
  const lower = lenses.map((name) => [name, name.toLowerCase()] as const);
  const find = (needle: string) => lower.find(([, l]) => l.includes(needle))?.[0];
  return find('triple') ?? find('dual wide') ?? find('dual');
}
Enter fullscreen mode Exit fullscreen mode

Triple first, then dual wide, then plain dual, which has no macro but still hands over sensibly. Anything else means a single lens phone, where the default is already the only option.

Two details worth stealing. The names come back from getAvailableLensesAsync() as localised display strings, so substring and case-insensitive matching is not laziness, it is the only thing that survives a device set to French. And the fallback chain ends in undefined, which means "keep whatever you had", so a device we have never seen degrades to the old behaviour rather than to a crash.

Lever three: tap to focus, which does not exist

Continuous autofocus decides for itself what the subject is. Point a phone at a bottle with a busy background and it will sometimes pick the background. Every camera app on earth solves this the same way: you tap the thing you care about.

expo-camera does not ship tap to focus. Not as a prop, not as a method.

The nuclear options are forking the module or writing a config plugin that rewrites native source at prebuild time. We took the smaller one: a pinned patch, applied by the package manager at install.

# pnpm-workspace.yaml
patchedDependencies:
  expo-camera@57.0.5: patches/expo-camera@57.0.5.patch
Enter fullscreen mode Exit fullscreen mode

The patch adds one async function on each native side and one method on the JS side. On Android it is CameraX, and the interesting part is that the preview view will do the coordinate maths for you:

/**
 * Tap to focus at a point in this view's own coordinates (pixels, origin
 * top left, as a touch event reports it). The preview's own metering point
 * factory maps it through whatever FILL_CENTER cropped away. Auto-cancel
 * returns the camera to continuous focus so the next scene is tracked again
 * rather than stuck on the last tap.
 */
fun focusAt(x: Float, y: Float) {
  val cam = camera ?: return
  val point = previewView.meteringPointFactory.createPoint(x, y)
  val action = FocusMeteringAction.Builder(point, FLAG_AF or FLAG_AE)
    .setAutoCancelDuration(4, TimeUnit.SECONDS)
    .build()
  cam.cameraControl.startFocusAndMetering(action)
}
Enter fullscreen mode Exit fullscreen mode

On iOS the equivalent conversion is previewLayer.captureDevicePointConverted(fromLayerPoint:), which takes the touch point in view coordinates and returns the 0 to 1 device point that focusPointOfInterest wants. Both platforms have the same trap: a preview is aspect-fill, so the visible rectangle is a crop of the sensor. Scaling the touch point by the view's width and height yourself produces a focus point that is correct in the middle of the screen and progressively wrong towards the edges. Let the platform convert.

The other half of the patch is the part that is easy to forget. Focus and exposure are pointed at the tap in single-shot mode, and then the device is put back into its configured continuous mode a few seconds later. Without that, a tap does not mean "focus here", it means "stop focusing, forever", and the next pack the user points at is soft.

The JS surface is deliberately optional:

async focusAtAsync(x: number, y: number): Promise<void> {
  await this._cameraRef.current?.focusAt?.(x, y);
}
Enter fullscreen mode Exit fullscreen mode

Two optional chains, so an unpatched install, a web build or a native module that is one version behind quietly does nothing instead of throwing. Our call site is equally unbothered:

/** Tap to focus: point the lens where the finger landed, and show a ring there. */
function focusAt(x: number, y: number) {
  if (!isNative) return;
  setFocusRing({ x, y, at: Date.now() });
  void camRef.current?.focusAtAsync?.(x, y).catch(() => {});
}
Enter fullscreen mode Exit fullscreen mode

The focus ring is drawn from React state at the touch coordinates, not by the native layer. It is a tiny thing, but a tap with no visible response feels broken even when the lens did move, and the ring is the only part of this feature the user can actually perceive.

What we chose not to add

There is no zoom control on the capture screen. Digital zoom gives the reader nothing that a crop does not, and it gives the user one more control to fiddle with while their arm is getting tired. The user draws a crop box around the ingredients panel instead, and only the crop is uploaded, which is what actually raises pixels per letter.

The cost of a patch

Being honest about the trade-off: a patch is a diff against specific files in a specific version. expo-camera went from 57.0.4 to 57.0.5 and the patch had to be re-cut, and one comment in our own code still points at the old filename. Package managers fail loudly when a patch no longer applies, which is the behaviour you want, but it does mean every upgrade of that one dependency is a small job rather than no job.

We took it anyway, because the alternative was a fork we would have to keep in step with a fast-moving module, for about 60 lines of Kotlin and Swift.

See it

The scan flow, the seven conditions and the per-condition rules are all live:

If you have built label or document capture in Expo, I would like to know whether you fought the lens selection too, or whether you found a way to get macro focus out of the plain wide lens that we missed.

Top comments (0)