DEV Community

Daniel Pertu
Daniel Pertu

Posted on

expo-camera has no tap to focus, so we patched it in Kotlin, Swift and the compiled JS

Munchable tells you whether a packaged food fits your gut condition. The fast path is the barcode, but when a product is not in our catalogue yet, the app asks you to photograph the ingredients panel, reads it, and scores the result. The conditions it reasons about are at munchable.app/conditions and the ingredient reasoning is on display at munchable.app/answers.

Everything about that capture flow comes back to one number: pixels per letter, in focus. An ingredients panel is 5pt type wrapped around a curved bottle, usually in a shop, usually by someone holding a basket. If the photo is soft, no amount of cleverness downstream recovers it.

expo-camera gives you continuous autofocus. What it does not give you is a way to say "focus here", and on a 5pt ingredients list that is the control that matters most. So we patched it in.

Three levers, and the one that was missing

For sharpness on a label, our capture screen has exactly three things to work with:

  1. Continuous autofocus, which expo-camera exposes (through a prop whose values read backwards, which is its own small adventure).
  2. Which camera, on iOS. The bare wide lens never hands over to the ultra wide's macro mode, so up close it simply cannot focus. The virtual multi-lens devices switch lenses with distance the way the stock Camera app does, so the app asks for one of those rather than taking the default.
  3. Tap to focus, which does not exist in the library.

There is deliberately no zoom control. Digital zoom gives the reader nothing that cropping the full-resolution still does not, and it costs the user a gesture that competes with the one that matters. The crop is drawn by the user instead, which I wrote about in six percent of the box, with an eight point floor.

Tap to focus is the one that turns a soft panel into a readable one, because the scene the autofocus algorithm picks is almost never the small block of text you care about.

Patching, not forking

The whole feature is a pnpm patch, pinned to an exact version:

patchedDependencies:
  expo-camera@57.0.5: patches/expo-camera@57.0.5.patch
Enter fullscreen mode Exit fullscreen mode

The exact version is the point. When Expo bumps the package, the patch does not quietly stop applying: the install fails and tells me to go and look. A patch that can silently disappear is worse than no patch, because the native method vanishes and you find out from a user whose photos went blurry.

What it costs: the app needs real native builds rather than Expo Go, which we already do through EAS. What it saves: no fork to rebase, no custom native module to keep in sync with the library's view lifecycle, and a diff that a reviewer can read in one sitting.

The coordinate space is the whole bug

The naive implementation of tap to focus is to take the touch position, divide by the view's width and height, and hand the camera a normalised point. It looks right in a simulator and it is wrong on a phone.

Camera previews are aspect-fill. The preview is cropped to fill the view, so a sizeable band of what the sensor sees is off screen on one axis. Dividing by the view's dimensions assumes the preview and the view are the same rectangle, and they are not, so every tap lands a little off, by an amount that depends on the device's aspect ratio and the orientation. It is the kind of bug that reads as "autofocus is a bit unreliable" rather than as a coordinate error.

Both platforms ship the conversion, and the patch does nothing but call it. Android:

fun focusAt(x: Float, y: Float) {
  val cam = camera ?: return
  val point = previewView.meteringPointFactory.createPoint(x, y)
  val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE)
    .setAutoCancelDuration(4, java.util.concurrent.TimeUnit.SECONDS)
    .build()
  cam.cameraControl.startFocusAndMetering(action)
}
Enter fullscreen mode Exit fullscreen mode

previewView.meteringPointFactory knows what the FILL_CENTER scale type cropped away, because it belongs to the view that did the cropping. iOS has the same idea under a different name:

func focusAt(x: Double, y: Double) {
  let devicePoint = previewLayer.captureDevicePointConverted(fromLayerPoint: CGPoint(x: x, y: y))
  sessionManager.focus(at: devicePoint, resetAfter: 4)
}
Enter fullscreen mode Exit fullscreen mode

So the JS API can take plain view coordinates, in the same units a React Native touch event reports, and the native side converts. The alternative, normalising in JS, would force every caller to know about the crop.

A tap has to expire

Both platforms also point exposure at the tapped spot, not just focus. On a label that is often the bigger win: packaging is glossy, the panel is usually in shadow under a shelf, and metering for the whole frame underexposes exactly the region you are trying to read.

And both reset. Android gets it for free from setAutoCancelDuration. On iOS it is manual, with one guard worth copying:

DispatchQueue.main.asyncAfter(deadline: .now() + resetAfter) { [weak self] in
  guard let self, let current = self.captureDeviceInput?.device, current == device else {
    return
  }
  self.setFocusMode()
  // ... restore continuous auto exposure
}
Enter fullscreen mode Exit fullscreen mode

The current == device check is there because the capture device can be swapped between the tap and the reset, including by our own lens selection. Restoring focus mode on a device that is no longer the active one is a write to the wrong object four seconds after anybody was looking at it. Deferred work that touches hardware state has to re-verify that the hardware it captured is still the hardware in use.

Four seconds, not forever, because a user who taps the wrong spot should not have to restart the camera. They point somewhere else and continuous focus takes over again.

The patch has to edit the compiled output too

Here is the unglamorous part. The same ten-line method appears four times in the diff:

  • src/CameraView.tsx, the source nobody ships.
  • build/CameraView.js, the compiled JavaScript that is actually imported at runtime.
  • src/Camera.types.ts and build/CameraView.d.ts, so TypeScript knows the method exists.

expo-camera publishes compiled output, like most libraries do. Patch only src/ and you get a method that typechecks, imports cleanly, and does not exist at runtime. That failure mode is quiet in the worst way: the optional call at the call site simply does nothing, and tap to focus looks like it is working because the focus ring still animates.

Which brings up the call site:

/** 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

Two deliberate pieces of defensiveness. focusAtAsync?. because a patched method is a method that might not be there, and a missing camera control should degrade to continuous autofocus rather than crash the capture screen. And isNative, because expo-camera on the web is a getUserMedia preview with a canvas grab behind it, with no focus control wired to it. A control that exists and does nothing is worse than an absent one, so the browser gets a plain shutter.

The focus ring is keyed by timestamp rather than by position, so tapping the same spot twice still draws a fresh ring. Confirming the tap matters more than it sounds: without it, a user who taps and sees no change concludes the camera ignored them and taps three more times.

Try the capture flow

Sign in at app.munchable.app, scan the barcode of something obscure enough that we do not have it, and the app will ask for the label. Tap the part of the panel you want sharp before you shoot, then check the photo on the review page at full size. For the browser version you get the plain shutter described above, which is itself worth seeing next to the native one.

If you want to see what the reading is for before installing anything, the 373 pages at munchable.app/answers are the same engine's output on individual ingredients, served as static pages.

Top comments (0)