Skip to content

Commit 19b7eca

Browse files
authored
feat: Add onUIRotationChanged(..) (#4147)
* feat: Add `onUIRotationChanged(..)` * ok? * fix logic * animated ? * fix: Get rotation working * Add docs * rename `getUIRotation` * simplify to use `getUIRotation`
1 parent bb2c5c2 commit 19b7eca

8 files changed

Lines changed: 203 additions & 5 deletions

File tree

apps/simple-camera/__tests__/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Tests are split by domain. Each file tests one slice of the imperative `VisionCa
2828
| [visioncamera.constraints.harness.ts](visioncamera.constraints.harness.ts) | `VisionCamera.resolveConstraints` + `onSessionConfigSelected`, FPS / HDR / stabilization / binned / pixelFormat / resolutionBias constraints |
2929
| [visioncamera.controller.harness.ts](visioncamera.controller.harness.ts) | `CameraController` — zoom, torch, exposure bias, focus metering, low-light boost, subject area listener |
3030
| [visioncamera.hooks.harness.tsx](visioncamera.hooks.harness.tsx) | React hook reactivity for `useCameraDevice(...)` position and physical-device filter changes |
31+
| [visioncamera.utils.harness.ts](visioncamera.utils.harness.ts) | Pure public utilities such as `getUIRotation(...)` across every output/interface orientation pair |
3132
| [visioncamera.coordinates.harness.ts](visioncamera.coordinates.harness.ts) | `Frame.convertFramePointToCameraPoint` / `convertCameraPointToFramePoint`, `PreviewView.convertViewPointToCameraPoint` / `convertCameraPointToViewPoint`, `PreviewView.createMeteringPoint`, `convertScannedObjectCoordinatesToViewCoordinates`, end-to-end Frame → Camera → View round-trip |
3233
| [visioncamera.nativepreviewview.harness.tsx](visioncamera.nativepreviewview.harness.tsx) | Bare `NativePreviewView` lifecycle, layout-sensitive preview regression coverage, `resizeMode`, Android `implementationMode`, gesture controllers, multi-preview mounting, `PreviewView` ref methods, Android `takeSnapshot()` dimensions |
3334
| [visioncamera.camera-view.harness.tsx](visioncamera.camera-view.harness.tsx) | High-level `<Camera>` preview lifecycle, photo output integration, controller props, native gestures, `CameraRef` methods, `isActive`, mount / unmount / replacement behavior |
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'react-native-harness'
2+
import type { CameraOrientation } from 'react-native-vision-camera'
3+
import { getUIRotation } from 'react-native-vision-camera'
4+
5+
describe('VisionCamera - Utils', () => {
6+
it('calculates UI rotation for every output and interface orientation', () => {
7+
const expectedRotations = [
8+
{ output: 'up', interface: 'up', rotation: 0 },
9+
{ output: 'up', interface: 'right', rotation: 90 },
10+
{ output: 'up', interface: 'down', rotation: 180 },
11+
{ output: 'up', interface: 'left', rotation: -90 },
12+
{ output: 'right', interface: 'up', rotation: -90 },
13+
{ output: 'right', interface: 'right', rotation: 0 },
14+
{ output: 'right', interface: 'down', rotation: 90 },
15+
{ output: 'right', interface: 'left', rotation: 180 },
16+
{ output: 'down', interface: 'up', rotation: 180 },
17+
{ output: 'down', interface: 'right', rotation: -90 },
18+
{ output: 'down', interface: 'down', rotation: 0 },
19+
{ output: 'down', interface: 'left', rotation: 90 },
20+
{ output: 'left', interface: 'up', rotation: 90 },
21+
{ output: 'left', interface: 'right', rotation: 180 },
22+
{ output: 'left', interface: 'down', rotation: -90 },
23+
{ output: 'left', interface: 'left', rotation: 0 },
24+
] satisfies {
25+
output: CameraOrientation
26+
interface: CameraOrientation
27+
rotation: number
28+
}[]
29+
30+
const reportedRotations = expectedRotations.map(
31+
({ output, interface: interfaceOrientation }) => ({
32+
output,
33+
interface: interfaceOrientation,
34+
rotation: getUIRotation(output, interfaceOrientation),
35+
}),
36+
)
37+
38+
expect(reportedRotations).toEqual(expectedRotations)
39+
})
40+
})

apps/simple-camera/src/components/CameraSelectorButton.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ import {
55
} from '@react-native-menu/menu'
66
import type React from 'react'
77
import { useCallback, useMemo } from 'react'
8+
import { Animated } from 'react-native'
89
import type { CameraDevice, CameraPosition } from 'react-native-vision-camera'
910
import { IconButton } from './IconButton'
1011

1112
interface Props {
1213
devices: CameraDevice[]
1314
setDevice: (device: CameraDevice) => void
15+
uiRotation: Animated.Value
1416
}
1517

1618
export function CameraSelectorButton({
1719
devices,
1820
setDevice,
21+
uiRotation,
1922
}: Props): React.ReactElement {
2023
const menuActions = useMemo<MenuAction[]>(() => {
2124
const positions = ['back', 'front', 'external'].filter<CameraPosition>(
@@ -49,9 +52,24 @@ export function CameraSelectorButton({
4952
[devices, setDevice],
5053
)
5154

55+
const rotate = uiRotation.interpolate({
56+
inputRange: [0, 360],
57+
outputRange: ['0deg', '360deg'],
58+
})
59+
5260
return (
5361
<MenuView actions={menuActions} onPressAction={onMenuItemPressed}>
54-
<IconButton iconName="camera" onPress={() => {}} />
62+
<Animated.View
63+
style={{
64+
transform: [
65+
{
66+
rotate: rotate,
67+
},
68+
],
69+
}}
70+
>
71+
<IconButton iconName="camera" onPress={() => {}} />
72+
</Animated.View>
5573
</MenuView>
5674
)
5775
}

apps/simple-camera/src/screens/CameraScreen.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { useIsFocused, useNavigation } from '@react-navigation/native'
22
import { useCallback, useEffect, useRef, useState } from 'react'
3-
import { StatusBar, StyleSheet, Text, View } from 'react-native'
3+
import {
4+
Animated,
5+
StatusBar,
6+
StyleSheet,
7+
Text,
8+
useAnimatedValue,
9+
View,
10+
} from 'react-native'
411
import {
512
type Recorder,
613
useCameraDeviceExtensions,
@@ -30,6 +37,7 @@ export function CameraScreen() {
3037
const [enableVideo, setEnableVideo] = useState(false)
3138
const [enableFrameStream, setEnableFrameStream] = useState(false)
3239
const [enableDepthStream, setEnableDepthStream] = useState(false)
40+
const uiRotation = useAnimatedValue(0, { useNativeDriver: true })
3341

3442
const devices = useCameraDevices()
3543
const defaultDevice = devices[0]
@@ -234,6 +242,13 @@ export function CameraScreen() {
234242
device={device}
235243
outputs={[photoOutput]}
236244
mirrorMode={device.position === 'front' ? 'on' : 'off'}
245+
orientationSource="device"
246+
onUIRotationChanged={(rotation) => {
247+
Animated.spring(uiRotation, {
248+
toValue: rotation,
249+
useNativeDriver: true,
250+
}).start()
251+
}}
237252
constraints={
238253
[
239254
// Session Constraints
@@ -250,6 +265,7 @@ export function CameraScreen() {
250265
<Row>
251266
<View style={styles.flex} />
252267
<CameraSelectorButton
268+
uiRotation={uiRotation}
253269
devices={devices}
254270
setDevice={(d) => {
255271
setDevice(d)

docs/content/docs/orientation.mdx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
88
A Camera has a fixed sensor orientation in which Frames are streamed in.
99
If you rotate your phone, the Camera doesn't physically rotate alongside with it, so rotation has to be applied to the Frames dynamically - and each [`CameraOutput`](/api/react-native-vision-camera/hybrid-objects/CameraOutput) applies orientation differently.
1010

11-
### Automatically set Orientation
11+
### Set Orientation
12+
13+
#### Automatically set Orientation
1214

1315
In most cases, your [`Orientation`](/api/react-native-vision-camera/type-aliases/CameraOrientation) should be automatically set to either the App's Interface Orientation, or your Phone's Device Orientation:
1416

15-
- App Interface Orientation ([`'interface'`](/api/react-native-vision-camera/type-aliases/OrientationSource)): Changes output orientation only when the UI rotates. If the UI is locked to `portrait` and the phone is held sideways, the output orientation will still stick to `portrait` ([`'up'`](/api/react-native-vision-camera/type-aliases/CameraOrientation)). This is how apps like Snapchat or Instagram work.
17+
- App Interface Orientation ([`'interface'`](/api/react-native-vision-camera/type-aliases/OrientationSource)): Changes output orientation only when the UI rotates. If the UI is locked to `portrait` and the phone is held sideways, the output orientation will still stick to `portrait` ([`'up'`](/api/react-native-vision-camera/type-aliases/CameraOrientation)). This is how apps like Snapchat or Instagram work - they are effectively locked to `portrait`.
1618
- Phone Device Orientation ([`'device'`](/api/react-native-vision-camera/type-aliases/OrientationSource)): Changes output orientation when the phone physically rotates. If the UI is locked to `portrait` and the phone is held sideways, the output orientation will change to be sideways - even if the UI doesn't rotate to `landscape`. This is how most photography apps (including the stock iOS Camera app) work.
1719

1820
VisionCamera exposes two [`OrientationManager`](/api/react-native-vision-camera/hybrid-objects/OrientationManager)s - one for monitoring [`'interface'`](/api/react-native-vision-camera/type-aliases/OrientationSource) orientation, and one for monitoring [`'device'`](/api/react-native-vision-camera/type-aliases/OrientationSource) orientation:
@@ -61,10 +63,54 @@ output.outputOrientation = orientationManager.currentOrientation
6163
</Tab>
6264
</Tabs>
6365

64-
### Manually set Orientation
66+
#### Manually set Orientation
6567

6668
For full manual control over orientation, set [`orientationSource`](/api/react-native-vision-camera/interfaces/CameraProps#orientationsource) to [`'custom'`](/api/react-native-vision-camera/type-aliases/OrientationSource) (if you are using `<Camera />` or `useCamera(...)`), and set a custom [`CameraOutput.outputOrientation`](/api/react-native-vision-camera/hybrid-objects/CameraOutput#outputorientation) for your outputs.
6769

70+
#### Rotate UI Elements based on Camera Orientation
71+
72+
If your Camera's [`orientationSource`](/api/react-native-vision-camera/interfaces/CameraProps#orientationsource) is set to [`'device'`](/api/react-native-vision-camera/type-aliases/OrientationSource) (or [`'custom'`](/api/react-native-vision-camera/type-aliases/OrientationSource)), your UI will not rotate alongside with the Camera pipeline.
73+
To then visually rotate individual Camera controls (such as the Flip Camera button, a Flash button, or other controls), listen to the [`onUIRotationChanged`](/api/react-native-vision-camera/interfaces/CameraProps#onuirotationchanged) callback and rotate accordingly:
74+
75+
```tsx
76+
function App() {
77+
const device = useCameraDevice('back')
78+
// [!code ++]
79+
const uiRotation = useAnimatedValue(0)
80+
// [!code ++:4]
81+
const rotate = uiRotation.interpolate({
82+
inputRange: [0, 360],
83+
outputRange: ['0deg', '360deg'],
84+
})
85+
86+
return (
87+
<View>
88+
<Camera
89+
style={StyleSheet.absoluteFill}
90+
isActive={true}
91+
device={device}
92+
orientationSource="device"
93+
// [!code ++:6]
94+
onUIRotationChanged={(rotation) => {
95+
Animated.spring(uiRotation, {
96+
toValue: rotation,
97+
useNativeDriver: true,
98+
}).start()
99+
}}
100+
/>
101+
// [!code ++]
102+
<Animated.View style={{ transform: [{ rotate: rotate }] }}>
103+
<FlashButton />
104+
// [!code ++]
105+
</Animated.View>
106+
</View>
107+
)
108+
}
109+
```
110+
111+
> [!TIP]
112+
> Use [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) for more control over animations.
113+
68114
### How Orientation is handled
69115

70116
Since Camera sensors have fixed orientations, rotation has to be applied to Frames dynamically. The Camera pipeline does not physically rotate buffers, as this is computationally expensive and would introduce latency.

packages/react-native-vision-camera/src/hooks/useCamera.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ import type {
2222
import type { CameraSessionConfig } from '../specs/session/CameraSessionConfig.nitro'
2323
import type { CameraSessionConfiguration } from '../specs/session/CameraSessionConfiguration'
2424
import type { CameraSessionConnection } from '../specs/session/CameraSessionConnection'
25+
import { getUIRotation } from '../utils/getUIRotation'
2526
import { useCameraController } from './internal/useCameraController'
2627
import { useCameraControllerConfiguration } from './internal/useCameraControllerConfiguration'
2728
import { useCameraSession } from './internal/useCameraSession'
2829
import { useCameraSessionIsRunning } from './internal/useCameraSessionIsRunning'
2930
import { useExposureUpdater } from './internal/useExposureUpdater'
3031
import { useListenerSubscription } from './internal/useListenerSubscription'
32+
import { useStableCallback } from './internal/useStableCallback'
3133
import { useTorchModeUpdater } from './internal/useTorchModeUpdater'
3234
import { useZoomUpdater } from './internal/useZoomUpdater'
3335
import { useOrientation } from './useOrientation'
@@ -86,6 +88,16 @@ export interface CameraProps
8688
* @see {@linkcode CameraOutput.outputOrientation}
8789
*/
8890
orientationSource?: OrientationSource | 'custom'
91+
/**
92+
* Called when the Camera Output orientation (driven
93+
* by {@linkcode orientationSource}) or the interface
94+
* orientation changes with a {@linkcode rotation} value
95+
* that specifies the degrees needed to rotate UI elements
96+
* such as Camera controls (flash button, Camera flip button)
97+
* so they appear upright.
98+
* @param rotation The degrees that UI elements need to be rotated by to appear up-right.
99+
*/
100+
onUIRotationChanged?: (rotation: number) => void
89101
/**
90102
* Sets whether the {@linkcode CameraOutput}s are mirrored along
91103
* the vertical axis. {@linkcode MirrorMode | 'auto'} mirrors
@@ -252,6 +264,7 @@ export function useCamera({
252264
onInterruptionStarted,
253265
onInterruptionEnded,
254266
onSubjectAreaChanged,
267+
onUIRotationChanged,
255268
enableDistortionCorrection,
256269
enableLowLightBoost,
257270
enableSmoothAutoFocus,
@@ -265,6 +278,12 @@ export function useCamera({
265278
onError: onError,
266279
})
267280

281+
// TODO: Refactor our orientation logic here because it is problematic for multiple reasons;
282+
// 1. Avoid going through re-renders/React state to change orientation (2x useOrientation(..)) (slow)
283+
// 2. Avoid going through multiple setter calls here in a useEffect to set output orientation (possible race condition)
284+
// 3. Avoid having a static useOrientation(...) hook - instead, have a UI element (`<NativePreviewView />`) fire interface orientation listeners (multi-display support)
285+
// 4. orientationSource="custom" currently resorts back to 'up', which is not true - not sure if we just skip the callback or ignore instead?
286+
// Instead, have orientation source be native/declarative so we can use `AVCaptureDevice.RotationCoordinator` and drive orientation from a preview without re-renders.
268287
// 2. Update output orientations
269288
const orientationSourceOrUndefined =
270289
orientationSource === 'custom' ? undefined : orientationSource
@@ -276,6 +295,20 @@ export function useCamera({
276295
}
277296
}, [orientation, outputs])
278297

298+
// 2.1. Call onUIRotationChanged listener
299+
const interfaceOrientation = useOrientation(
300+
onUIRotationChanged != null ? 'interface' : undefined,
301+
)
302+
const uiRotation = getUIRotation(
303+
orientation ?? 'up',
304+
interfaceOrientation ?? 'up',
305+
)
306+
const stableOnUIRotationChanged = useStableCallback(onUIRotationChanged)
307+
useEffect(() => {
308+
if (stableOnUIRotationChanged == null) return
309+
stableOnUIRotationChanged(uiRotation)
310+
}, [stableOnUIRotationChanged, uiRotation])
311+
279312
// 4. Configure the session with the input + outputs to create a `CameraController`
280313
const controller = useCameraController(session, device, outputs, {
281314
mirrorMode: mirrorMode,

packages/react-native-vision-camera/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export * from './threading/RuntimeThreadProvider'
9393
export * from './utils/CommonDynamicRanges'
9494
export * from './utils/CommonResolutions'
9595
export * from './utils/FrameConverter'
96+
export * from './utils/getUIRotation'
9697
// Main factory
9798
export * from './VisionCamera'
9899
// Views
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { CameraOrientation } from '../specs/common-types/CameraOrientation'
2+
3+
function cameraOrientationToDegrees(
4+
orientation: CameraOrientation,
5+
): 0 | 90 | 180 | 270 {
6+
switch (orientation) {
7+
case 'up':
8+
return 0
9+
case 'right':
10+
return 90
11+
case 'down':
12+
return 180
13+
case 'left':
14+
return 270
15+
}
16+
}
17+
18+
/**
19+
* Gets the signed rotation needed to keep UI elements upright relative to the
20+
* Camera output orientation.
21+
*
22+
* The result is normalized to the shortest cardinal rotation, with opposite
23+
* orientations represented as `180`.
24+
*/
25+
export function getUIRotation(
26+
outputOrientation: CameraOrientation,
27+
interfaceOrientation: CameraOrientation,
28+
): number {
29+
// Convert to degrees
30+
const outputOrientationDegrees = cameraOrientationToDegrees(outputOrientation)
31+
const interfaceOrientationDegrees =
32+
cameraOrientationToDegrees(interfaceOrientation)
33+
// Calculate difference, not overshooting 360°
34+
const rotation =
35+
(interfaceOrientationDegrees - outputOrientationDegrees + 360) % 360
36+
const normalizedRotation = rotation % 360
37+
if (normalizedRotation > 180) {
38+
// Converts 270° to -90°
39+
return normalizedRotation - 360
40+
} else {
41+
return normalizedRotation
42+
}
43+
}

0 commit comments

Comments
 (0)