Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/content/docs/orientation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@ output.outputOrientation = orientationManager.currentOrientation
</Tab>
</Tabs>

### Rotate UI controls

When the [`orientationSource`](/api/react-native-vision-camera/interfaces/CameraProps#orientationsource) is [`'device'`](/api/react-native-vision-camera/type-aliases/OrientationSource), the Camera's outputs can rotate while the App's interface stays locked.
Use [`onUIRotationChanged`](/api/react-native-vision-camera/interfaces/CameraProps#onuirotationchanged) to keep controls such as the shutter button aligned with the output orientation:

```tsx
const [uiRotation, setUIRotation] = useState(0)

return (
<View>
<Camera
{...props}
orientationSource="device"
onUIRotationChanged={setUIRotation}
/>
<CaptureButton
style={{ transform: [{ rotate: `${uiRotation}deg` }] }}
/>
</View>
)
```

The callback reports the shortest signed rotation in degrees relative to the current interface orientation.
Animate changes to this value for a smooth transition.
It is not called when `orientationSource="custom"`, because each output can have a different custom orientation.

### Manually set Orientation

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type CameraVideoOutput,
type FocusOptions,
type Frame,
getUIRotation,
type MeteringMode,
type MirrorMode,
type PixelFormat,
Expand Down Expand Up @@ -249,7 +250,8 @@ function SkiaCameraImpl({
enablePreviewSizedOutputBuffers,
targetResolution,
device,
orientationSource,
orientationSource = 'device',
onUIRotationChanged,
warnIfRenderSkipped = true,
...props
}: SkiaCameraProps): React.ReactElement {
Expand Down Expand Up @@ -357,6 +359,13 @@ function SkiaCameraImpl({
const otherOutputsOrientation =
useOrientation(orientationSourceOrUndefined) ?? 'up'
const skiaFrameOutputOrientation = useOrientation('interface') ?? 'up'
const onUIRotationChangedRef = useRef(onUIRotationChanged)
onUIRotationChangedRef.current = onUIRotationChanged
const hasOnUIRotationChanged = onUIRotationChanged != null
const uiRotation =
orientationSource === 'custom'
? undefined
: getUIRotation(otherOutputsOrientation, skiaFrameOutputOrientation)
useEffect(() => {
for (const output of outputs) {
output.outputOrientation = otherOutputsOrientation
Expand All @@ -368,6 +377,10 @@ function SkiaCameraImpl({
otherOutputsOrientation,
outputs,
])
useEffect(() => {
if (!hasOnUIRotationChanged || uiRotation == null) return
onUIRotationChangedRef.current?.(uiRotation)
}, [hasOnUIRotationChanged, uiRotation])

useImperativeHandle(ref, () => ({
convertViewPointToNormalizedPoint(viewPoint) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class HybridDeviceOrientationManager : HybridOrientationManagerSpec() {

override fun startOrientationUpdates(onChanged: (orientation: CameraOrientation) -> Unit) {
orientationListener?.disable()
currentOrientation?.let(onChanged)
orientationListener =
object : OrientationEventListener(context) {
override fun onOrientationChanged(rotationDegrees: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class HybridInterfaceOrientationManager : HybridOrientationManagerSpec() {
listener?.let { listener ->
displayManager.unregisterDisplayListener(listener)
}
currentOrientation?.let(onChanged)
val listener =
object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ final class HybridDeviceOrientationManager: HybridOrientationManagerSpec {
if motionManager.isAccelerometerActive {
motionManager.stopAccelerometerUpdates()
}
if let currentOrientation {
onChanged(currentOrientation)
}

if motionManager.isAccelerometerAvailable {
motionManager.startAccelerometerUpdates(to: operationQueue) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ final class HybridInterfaceOrientationManager: HybridOrientationManagerSpec {
// Start new listener (beginGeneratingDeviceOrientationNotifications() can be nested)
UIDevice.current.beginGeneratingDeviceOrientationNotifications()

let interfaceOrientation = UIApplication.shared.interfaceOrientation
if interfaceOrientation != .unknown {
let orientation = CameraOrientation(interfaceOrientation: interfaceOrientation)
self.currentOrientation = orientation
onChanged(orientation)
}

self.observer = NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: nil,
Expand All @@ -60,6 +67,7 @@ final class HybridInterfaceOrientationManager: HybridOrientationManagerSpec {
if let observer = self.observer {
logger.info("Stopping interface orientation updates...")
NotificationCenter.default.removeObserver(observer)
self.observer = nil
UIDevice.current.endGeneratingDeviceOrientationNotifications()
}
}
Expand Down
50 changes: 43 additions & 7 deletions packages/react-native-vision-camera/src/hooks/useCamera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ import type {
import type { CameraSessionConfig } from '../specs/session/CameraSessionConfig.nitro'
import type { CameraSessionConfiguration } from '../specs/session/CameraSessionConfiguration'
import type { CameraSessionConnection } from '../specs/session/CameraSessionConnection'
import { getUIRotation } from '../utils/getUIRotation'
import { useCameraController } from './internal/useCameraController'
import { useCameraControllerConfiguration } from './internal/useCameraControllerConfiguration'
import { useCameraSession } from './internal/useCameraSession'
import { useCameraSessionIsRunning } from './internal/useCameraSessionIsRunning'
import { useExposureUpdater } from './internal/useExposureUpdater'
import { useListenerSubscription } from './internal/useListenerSubscription'
import { useStableCallback } from './internal/useStableCallback'
import { useTorchModeUpdater } from './internal/useTorchModeUpdater'
import { useZoomUpdater } from './internal/useZoomUpdater'
import { useCameraDevices } from './useCameraDevices'
Expand Down Expand Up @@ -205,6 +207,19 @@ export interface CameraProps
* @platform iOS
*/
onSubjectAreaChanged?: () => void
/**
* Called whenever the rotation that UI controls should apply changes.
*
* The value is the shortest signed rotation in degrees that keeps
* controls aligned with the automatically selected output orientation,
* relative to the app's interface orientation.
*
* This callback is not called when {@linkcode orientationSource} is
* `'custom'`, because individual outputs can use different orientations.
*
* @see {@linkcode CameraProps.orientationSource}
*/
onUIRotationChanged?: (rotationDegrees: number) => void
}

function defaultOnErrorHandler(error: Error) {
Expand Down Expand Up @@ -253,6 +268,7 @@ export function useCamera({
onInterruptionStarted,
onInterruptionEnded,
onSubjectAreaChanged,
onUIRotationChanged,
enableDistortionCorrection,
enableLowLightBoost,
enableSmoothAutoFocus,
Expand All @@ -274,11 +290,31 @@ export function useCamera({
}
}, [orientation, outputs])

// 3. Update UI rotation
const interfaceOrientation = useOrientation(
orientationSource === 'device' && onUIRotationChanged != null
? 'interface'
: undefined,
)
const stableOnUIRotationChanged = useStableCallback(onUIRotationChanged)
const uiRotation =
orientationSource === 'interface'
? 0
: orientationSource === 'device' &&
orientation != null &&
interfaceOrientation != null
? getUIRotation(orientation, interfaceOrientation)
: undefined
useEffect(() => {
if (uiRotation == null) return
stableOnUIRotationChanged?.(uiRotation)
}, [stableOnUIRotationChanged, uiRotation])

// TODO: Make `CameraSessionConnection.input` also accept
// a `TargetCameraPosition` so we don't need to do `useCameraDevices()` here
// so we don't need to always re-render, and we can actually use `getDefaultCamera(position)`
// on the native side for better selection!
// 3. Get the input - either find one via position, or use the user provided one
// 4. Get the input - either find one via position, or use the user provided one
const devices = useCameraDevices()
const input = useMemo(() => {
if (typeof device === 'string') {
Expand All @@ -295,7 +331,7 @@ export function useCamera({
}
}, [device, devices])

// 4. Configure the session with the input + outputs to create a `CameraController`
// 5. Configure the session with the input + outputs to create a `CameraController`
const controller = useCameraController(session, input, outputs, {
mirrorMode: mirrorMode,
onConfigured: onConfigured,
Expand All @@ -307,18 +343,18 @@ export function useCamera({
allowHapticsAndSystemSoundsPlayback: allowHapticsAndSystemSoundsPlayback,
})

// 5. Configure the Controller with some settings
// 6. Configure the Controller with some settings
useCameraControllerConfiguration(controller, {
enableSmoothAutoFocus: enableSmoothAutoFocus,
enableDistortionCorrection: enableDistortionCorrection,
enableLowLightBoost: enableLowLightBoost,
})

// 6. Start (or stop) the Session if we have a Controller and `isActive` is true.
// 7. Start (or stop) the Session if we have a Controller and `isActive` is true.
const hasController = controller != null
useCameraSessionIsRunning(session, isActive && hasController)

// 7. Set up listeners and delegate to JS
// 8. Set up listeners and delegate to JS
useListenerSubscription(session, 'addOnStartedListener', onStarted)
useListenerSubscription(session, 'addOnStoppedListener', onStopped)
useListenerSubscription(session, 'addOnErrorListener', onError)
Expand All @@ -338,11 +374,11 @@ export function useCamera({
onSubjectAreaChanged,
)

// 8. Update CameraController props
// 9. Update CameraController props
useZoomUpdater(controller, zoom, onError)
useExposureUpdater(controller, exposure, onError)
useTorchModeUpdater(controller, torchMode, onError)

// 9. Give the user the controller
// 10. Give the user the controller
return controller
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function useOrientation(
): CameraOrientation | undefined {
const orientationManager = useOrientationManager(source)
const currentOrientation = useRef(orientationManager?.currentOrientation)
currentOrientation.current = orientationManager?.currentOrientation

const subscribe = useCallback(
(onStoreChange: () => void) => {
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-vision-camera/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export * from './threading/RuntimeThreadProvider'
export * from './utils/CommonDynamicRanges'
export * from './utils/CommonResolutions'
export * from './utils/FrameConverter'
export * from './utils/getUIRotation'
// Main factory
export * from './VisionCamera'
// Views
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ export interface OrientationManager
*/
readonly currentOrientation: CameraOrientation | undefined
/**
* Starts listening to orientation changes.
* Starts listening to orientation updates.
*
* If an orientation is already known, {@linkcode onChanged} is immediately
* called with the current orientation. It is then called whenever the
* orientation changes.
*/
startOrientationUpdates(
onChanged: (orientation: CameraOrientation) => void,
Expand Down
39 changes: 39 additions & 0 deletions packages/react-native-vision-camera/src/utils/getUIRotation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { CameraOrientation } from '../specs/common-types/CameraOrientation'

function orientationToDegrees(orientation: CameraOrientation): number {
switch (orientation) {
case 'up':
return 0
case 'right':
return 90
case 'down':
return 180
case 'left':
return 270
}
}

/**
* Gets the short rotation that UI controls need to apply to match the target
* output orientation while remaining relative to the interface orientation.
*
* @param targetOrientation The orientation that Camera outputs target.
* @param interfaceOrientation The current app interface orientation.
* @returns The shortest signed rotation in degrees.
*/
export function getUIRotation(
targetOrientation: CameraOrientation,
interfaceOrientation: CameraOrientation,
): number {
const targetDegrees = orientationToDegrees(targetOrientation)
const interfaceDegrees = orientationToDegrees(interfaceOrientation)
const rotation = (interfaceDegrees - targetDegrees) % 360

if (rotation < -180) {
return rotation + 360
} else if (rotation > 180) {
return rotation - 360
} else {
return rotation
}
}
Loading