CameraView

The device camera inside the app: a live preview and still photos, each a JPEG File. A photo taken in the app comes from CameraView; a photo the user already has in the library, from openFilePicker({ accept: 'image/*' }) (Files & share).

  • take a photo with the camera
  • open a full-screen camera with a shutter and a close button
  • show a live camera preview inside a card on a screen
  • switch between the front and the back camera for a selfie
  • show, upload or share the photo just taken
  • offer the camera only on devices that have one
  • send the user to the settings when the camera permission was denied

Platforms: iOS, Android, the web and the web player (https or localhost only); not on the desktop — check CameraView.isSupported.

At a glance

TypeScript
const camera = CameraView()                                   // the back camera
const photos = UIRow().style({ gap: 8, flexWrap: 'wrap' })

camera.widgets(                                               // the controls, over the preview
  UIWidget(UIButton(UIText('Close')).style({ bgColor: '#00000080', p: 10, borderRadius: 20 })
    .onClick(() => Router.pop())).style({ top: 'safe-top', left: 16 }),
  UIWidget(UIButton(UIText('Shoot')).style({ bgColor: '#00000080', p: 16, borderRadius: 32 })
    .onClick(async () => {
      try {
        const photo = await camera.takePhoto()                // a JPEG File
        Router.pop()                                          // back to the home screen
        photos.append(UIImage(photo).style({ width: 96, height: 128, borderRadius: 8 }))
      } catch (e) { toast(e.message) }                        // "failed": no frame yet
    })).style({ bottom: 'safe-bottom', left: 0, right: 0, alignItems: 'center' }),
)

Router.init(UIScreen(
  CameraView.isSupported
    ? UIButton(UIText('Take a photo')).onClick(async () => {
        try { await Router.push(camera) }                     // asks for the permission, then shows it
        catch (e) { toast(e.message) }                        // denied, or another screen came first
      })
    : UIText('No camera on this device'),
  photos,
).style({ p: 16, pt: 'safe-top', gap: 12 }))

Add the camera to the app: app.json

The camera is a plugin: list it in the project's app.json, or it is not there — in a shell built from the project, on the web and in the web player alike, where CameraView.isSupported is then false. The compile warns about a camera the app uses and does not list.

JSON
{ "plugins": { "camera": "*" } }

lecodes app plugin add camera writes the same entry. A shell built from the project takes the camera permission from the plugin (NSCameraUsageDescription on iOS, CAMERA on Android). The le.codes viewer app has the camera whatever the list says.

Full-screen camera with a shutter: Router.push

A camera that fills the screen is a destination of its own, as a screen is: CameraView() with its controls — a shutter, a close button, a flip — in .widgets(...), pushed with Router.push(camera); the At a glance is the whole flow. A preview goes among a screen's children only when it is a part of that screen (inside a screen).

TypeScript
CameraView(options?: { facingMode?: 'front' | 'back' }): CameraView   // default 'back'
camera.widgets(...widgets: UIWidget[]): CameraView   // the controls, laid out over the preview
Router.push(camera): Promise<void>                    // asks for the permission, then shows the preview
Router.pop(): void                                    // back: the preview leaves
camera.onOpen(cb) / camera.onClose(cb)                // shown / left (popped or covered)
camera.open(opts?: { transition }): Promise<void>     // the same without the Router: replaces what is shown
camera.close(opts?: { transition }): void             // after open(): leaves nothing on screen
  • Router.push(camera) asks for the camera permission while the current screen stays visible, then shows the preview. It rejects, and nothing is pushed, when the permission is denied, the host has no camera view or another navigation came first — catch it.
  • Back: a close button in the widgets calls Router.pop(); the system's back pops the camera too, as any pushed destination. The same CameraView() can be pushed again after a pop.
  • The widgets show while the camera is the destination on screen, positioned in its whole box: top: 'safe-top' and bottom: 'safe-bottom' keep them clear of the notch and the home bar.
  • camera.open() shows the camera without the Router, in place of what is shown; its camera.close() then leaves nothing on screen, and Router.restore() brings the router's screen back. With the Router, go back with Router.pop(), never close().

Live preview inside a screen or a card

A preview that is a part of a screen — a viewfinder in a card, beside a form — is an element among the screen's children, sized by its styles like any other; its buttons are ordinary children next to it (its widgets show only while it is a destination, pushed or opened). It does not ask for the permission itself: ask first with permissions.request('camera') — without it, Android shows a black preview.

TypeScript
const preview = CameraView().style({ height: 240, borderRadius: 16 })   // sized like any element
const shot = UIColumn()

const main = async () => {
  const allowed = CameraView.isSupported && (await permissions.request('camera')) === 'granted'
  UIScreen(
    allowed
      ? UIColumn(
          preview,                                            // the live viewfinder in the card
          UIButton(UIText('Capture')).onClick(() => preview.takePhoto().then(
            photo => shot.setContent([UIImage(photo).style({ height: 160, borderRadius: 8 })]),
            e => toast(e.message))),
        ).style({ p: 12, gap: 8, borderRadius: 16, bgColor: '#1c1c1e' })
      : UIText('Allow the camera to photograph the receipt'),
    shot,
  ).style({ p: 16, pt: 'safe-top', gap: 12 }).open()
}
main()

Front or back camera (selfie): setFacingMode

A selfie takes the front camera: create the view with CameraView({ facingMode: 'front' }), or switch a preview on screen with setFacingMode.

TypeScript
CameraView({ facingMode: 'front' | 'back' })                 // the camera it starts on; default 'back'
camera.setFacingMode(mode: 'front' | 'back'): Promise<void>  // switch the preview on screen
TypeScript
let facing: CameraFacing = 'front'
const camera = CameraView({ facingMode: facing })             // a selfie camera
camera.widgets(
  UIWidget(UIButton(UIText('Close')).onClick(() => Router.pop())).style({ top: 'safe-top', left: 16 }),
  UIWidget(UIButton(UIText('Flip')).onClick(() => {
    const next = facing === 'front' ? 'back' : 'front'
    camera.setFacingMode(next).then(() => { facing = next }, e => toast(e.message))   // switch the preview
  })).style({ top: 'safe-top', right: 16 }),
)

Router.init(UIScreen(UIButton(UIText('Selfie')).onClick(async () => {
  try { await Router.push(camera) } catch (e) { toast(e.message) }
})))

setFacingMode switches a preview that is on screen; to start on the front camera, pass facingMode when you create the view.

Show, upload or share the photo: takePhoto()

takePhoto() resolves with the current frame as a JPEG File named image.jpg — a handle to bytes the host holds in memory; nothing saves it to the photo library. Hand it to what takes a File: UIImage(photo) shows it, a FormData part uploads it, share(photo) opens the OS share sheet (where the user can save it), Texture2D.load(photo) makes a 2D texture, and a server function keeps it on the project's backend (Stored files).

TypeScript
camera.takePhoto(): Promise<File>   // rejects with "failed" when the camera has no frame to give
TypeScript
const camera = CameraView()
const send = async (photo: File) => {
  Router.pop()
  const form = new FormData()
  form.append('photo', photo)                                 // a file part named image.jpg
  const res = await fetch('https://api.example.com/photos', { method: 'POST', body: form })
  toast(res.status === 200 ? 'Sent' : `Upload failed: ${res.status}`)
}
camera.widgets(UIWidget(
  UIButton(UIText('Send')).onClick(() => camera.takePhoto().then(send, e => toast(e.message))),
  UIButton(UIText('Share')).onClick(() =>
    camera.takePhoto().then(photo => share(photo, 'My shot'), e => toast(e.message))),
).style({ bottom: 'safe-bottom', left: 0, right: 0, flexDirection: 'row', justifyContent: 'center', gap: 16 }))

Router.init(UIScreen(UIButton(UIText('Send a photo')).onClick(async () => {
  try { await Router.push(camera) } catch (e) { toast(e.message) }
})))
Note

photos only — there is no video recording; openFilePicker({ accept: 'video/*' }) picks a video the user already has.

Note

there is no flash, flashlight (torch) or zoom control — a CameraView is the preview, takePhoto() and the facing mode.

No camera or permission denied: isSupported

Offer the camera only where there is one, and tell the user why it did not open.

TypeScript
CameraView.isSupported: boolean                            // static — does this host have the camera view?
camera.on('error', (e: { message: string }) => void)       // the browser's preview could not start
  • isSupported is false where the host has no camera view: on the desktop, on a web page that is not https or localhost, in an app whose app.json does not list the plugin, and in headless tests (below).
  • Router.push(camera) and camera.open() reject, and nothing is shown, with the reason as the message: the permission denied (The camera permission was denied), no camera view on this host (CameraView isn't available on this host — …), another navigation first (… superseded …).
  • After a denial permissions.query('camera') is 'denied', and permissions.openSettings() opens the app's page of the system settings, where the user turns the camera back on: iOS never asks twice, and the web has no such page (Permissions).
  • The error event comes from the browser only (the web and the web player): no camera, or the browser refused the stream. iOS and Android send none.
  • Headless tests (lecodes render / lecodes test) have no camera: isSupported is false unless the run names the view, --native-view camera. The preview is then a labelled placeholder, Router.push works, and takePhoto() / setFacingMode() reject.
TypeScript
const camera = CameraView()
camera.widgets(UIWidget(UIButton(UIText('Close')).onClick(() => Router.pop()))
  .style({ top: 'safe-top', left: 16 }))

const openCamera = async () => {
  try {
    await Router.push(camera)                                 // asks for the permission first
  } catch (e) {
    if ((await permissions.query('camera')) === 'denied') {
      toast('Turn the camera on in Settings')
      permissions.openSettings()                              // the app's page of the settings
    } else toast(e.message)
  }
}

Router.init(UIScreen(
  CameraView.isSupported
    ? UIButton(UIText('Open the camera')).onClick(openCamera)
    : UIText('This device has no camera'),
))

When the camera turns on and off

The camera turns on when its preview is shown. On iOS and Android it turns off when the preview leaves the screen — popped, closed, covered by another screen, or gone with the screen that embeds it — and on again when the preview comes back: there is nothing to release by hand.

Note

on the web the camera, once on, stays on after the preview leaves — the browser's camera indicator stays lit — until the page is closed (in the web player: until the CameraView is freed).

  • Make one CameraView() and push it again each time: on the web every instance keeps its own camera stream.
  • The device lends its camera to one preview: show one CameraView at a time.

Pitfalls

  • A full-screen preview among a screen's children, with buttons positioned absolutely over it, is a web habit; the camera is a destination with its controls in its widgets:

    TypeScript
    UIScreen(camera.style({ flexGrow: 1 }), shutter.style({ position: 'absolute', bottom: 40 })).open()  // ✗
    Router.push(camera.widgets(UIWidget(shutter).style({ bottom: 'safe-bottom' })))                         // ✓
  • camera.close() on a pushed camera leaves an empty screen; go back with Router.pop():

    TypeScript
    camera.close()   // ✗ after Router.push: nothing is on screen
    Router.pop()     // ✓

See also