xref: /expo/home/components/Camera.android.tsx (revision dec397fd)
1import { Camera as OriginalCamera } from 'expo-camera';
2import React, { useCallback, useState, useEffect } from 'react';
3import { View } from 'react-native';
4import type { LayoutChangeEvent } from 'react-native';
5
6type CameraRef = OriginalCamera;
7type CameraRefCallback = (node: CameraRef) => void;
8type Dimensions = { width: number; height: number };
9
10// This Camera component will automatically pick the appropriate ratio and
11// dimensions to fill the given layout properties, and it will resize according
12// to the same logic as resizeMode: cover. If somehow something goes wrong while
13// attempting to autosize, it will just fill the given layout and use the
14// default aspect ratio, likely resulting in skew.
15export function Camera(props: OriginalCamera['props']) {
16  const [dimensions, onLayout] = useComponentDimensions();
17  const [suggestedAspectRatio, suggestedDimensions, ref] = useAutoSize(dimensions);
18  const [cameraIsReady, setCameraIsReady] = useState(false);
19  const { style, ...rest } = props;
20  const { width, height } = suggestedDimensions || {};
21
22  return (
23    <View
24      onLayout={onLayout}
25      style={[
26        {
27          overflow: 'hidden',
28          backgroundColor: '#000',
29          alignItems: 'center',
30          justifyContent: 'center',
31        },
32        style,
33      ]}>
34      <OriginalCamera
35        onCameraReady={() => setCameraIsReady(true)}
36        ref={cameraIsReady ? ref : undefined}
37        ratio={suggestedAspectRatio ?? undefined}
38        style={
39          suggestedDimensions && width && height
40            ? {
41                position: 'absolute',
42                width,
43                height,
44                ...(height! > width!
45                  ? { top: -(height! - dimensions!.height) / 2 }
46                  : { left: -(width! - dimensions!.width) / 2 }),
47              }
48            : { flex: 1 }
49        }
50        {...rest}
51      />
52    </View>
53  );
54}
55
56function useAutoSize(
57  dimensions: Dimensions | null
58): [string | null, Dimensions | null, CameraRefCallback] {
59  const [supportedAspectRatios, ref] = useSupportedAspectRatios();
60  const [suggestedAspectRatio, setSuggestedAspectRatio] = useState<string | null>(null);
61  const [suggestedDimensions, setSuggestedDimensions] = useState<Dimensions | null>(null);
62
63  useEffect(() => {
64    const suggestedAspectRatio = findClosestAspectRatio(supportedAspectRatios, dimensions);
65    const suggestedDimensions = calculateSuggestedDimensions(dimensions, suggestedAspectRatio);
66
67    if (!suggestedAspectRatio || !suggestedDimensions) {
68      setSuggestedAspectRatio(null);
69      setSuggestedDimensions(null);
70    } else {
71      setSuggestedAspectRatio(suggestedAspectRatio);
72      setSuggestedDimensions(suggestedDimensions);
73    }
74  }, [dimensions, supportedAspectRatios]);
75
76  return [suggestedAspectRatio, suggestedDimensions, ref];
77}
78
79// Get the supported aspect ratios from the camera ref when the node is available
80// NOTE: this will fail if the camera isn't ready yet. So we need to avoid setting the
81// ref until the camera ready callback has fired
82function useSupportedAspectRatios(): [string[] | null, CameraRefCallback] {
83  const [aspectRatios, setAspectRatios] = useState<string[] | null>(null);
84
85  const ref = useCallback(
86    (node: CameraRef | null) => {
87      async function getSupportedAspectRatiosAsync(node: OriginalCamera) {
88        try {
89          const result = await node.getSupportedRatiosAsync();
90          setAspectRatios(result);
91        } catch (e) {
92          console.error(e);
93        }
94      }
95
96      if (node !== null) {
97        getSupportedAspectRatiosAsync(node);
98      }
99    },
100    [setAspectRatios]
101  );
102
103  return [aspectRatios, ref];
104}
105
106const useComponentDimensions = (): [Dimensions | null, (e: any) => void] => {
107  const [dimensions, setDimensions] = useState<Dimensions | null>(null);
108
109  const onLayout = useCallback(
110    (event: LayoutChangeEvent) => {
111      const { width, height } = event.nativeEvent.layout;
112      setDimensions({ width, height });
113    },
114    [setDimensions]
115  );
116
117  return [dimensions, onLayout];
118};
119
120function ratioStringToNumber(ratioString: string) {
121  const [a, b] = ratioString.split(':');
122  return parseInt(a, 10) / parseInt(b, 10);
123}
124
125function findClosestAspectRatio(
126  supportedAspectRatios: string[] | null,
127  dimensions: Dimensions | null
128) {
129  if (!supportedAspectRatios || !dimensions) {
130    return null;
131  }
132
133  try {
134    const dimensionsRatio =
135      Math.max(dimensions.height, dimensions.width) / Math.min(dimensions.height, dimensions.width);
136
137    const aspectRatios = [...supportedAspectRatios];
138    aspectRatios.sort((a: string, b: string) => {
139      const ratioA = ratioStringToNumber(a);
140      const ratioB = ratioStringToNumber(b);
141      return Math.abs(dimensionsRatio - ratioA) - Math.abs(dimensionsRatio - ratioB);
142    });
143
144    return aspectRatios[0];
145  } catch (e) {
146    // If something unexpected happens just bail out
147    console.error(e);
148    return null;
149  }
150}
151
152function calculateSuggestedDimensions(
153  containerDimensions: Dimensions | null,
154  ratio: string | null
155) {
156  if (!ratio || !containerDimensions) {
157    return null;
158  }
159
160  try {
161    const ratioNumber = ratioStringToNumber(ratio);
162    const width = containerDimensions.width;
163    const height = width * ratioNumber;
164    return { width, height };
165  } catch (e) {
166    // If something unexpected happens just bail out
167    console.error(e);
168    return null;
169  }
170}
171