33

I'm using Camera that comes from expo package and I'm having trouble with camera preview distortion. The preview makes images appear wider in landscape view and thinner in portrait view. Most of the solutions I have found are not using expo-camera.

Relevant Code:

camera.page.js:

import React from 'react';
import { View, Text } from 'react-native';
import { Camera } from 'expo-camera';
import * as Permissions from 'expo-permissions'
import { Platform } from 'react-native';

import styles from './styles';
import Toolbar from './toolbar.component';

const DESIRED_RATIO = "18:9";

export default class CameraPage extends React.Component {
    camera = null;

    state = {
        hasCameraPermission: null,
    };

    async componentDidMount() {
        const camera = await Permissions.askAsync(Permissions.CAMERA);
        const audio = await Permissions.askAsync(Permissions.AUDIO_RECORDING);
        const hasCameraPermission = (camera.status === 'granted' && audio.status === 'granted');

        this.setState({ hasCameraPermission });
    };


    render() {
        const { hasCameraPermission } = this.state;

        if (hasCameraPermission === null) {
            return <View />;
        } else if (hasCameraPermission === false) {
            return <Text>Access to camera has been denied.</Text>;
        }

        return (
          <React.Fragment>
            <View>
              <Camera
                ref={camera => this.camera = camera}
                style={styles.preview}
                />
            </View>
            <Toolbar/>
          </React.Fragment>

        );
    };
};

styles.js:

import { StyleSheet, Dimensions } from 'react-native';

const { width: winWidth, height: winHeight } = Dimensions.get('window');
export default StyleSheet.create({
    preview: {
        height: winHeight,
        width: winWidth,
        position: 'absolute',
        left: 0,
        top: 0,
        right: 0,
        bottom: 0,
        paddingBottom: 1000,
    },
    alignCenter: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'center',
    },
    bottomToolbar: {
        width: winWidth,
        position: 'absolute',
        height: 100,
        bottom: 0,
    },
    captureBtn: {
        width: 60,
        height: 60,
        borderWidth: 2,
        borderRadius: 60,
        borderColor: "#FFFFFF",
    },
    captureBtnActive: {
        width: 80,
        height: 80,
    },
    captureBtnInternal: {
        width: 76,
        height: 76,
        borderWidth: 2,
        borderRadius: 76,
        backgroundColor: "red",
        borderColor: "transparent",
    },
});

What can I do to fix this?

1
  • Where is get value this.cam?? Commented Oct 31, 2019 at 1:22

4 Answers 4

75

This one is kind of tedious.

Problem

Basically the problem is that the camera preview is a different width/height ratio from your screen. As far as I can tell, this is only a problem on Android where:

  1. Each camera manufacturer supports different aspect ratios
  2. Each phone manufacturer creates different screen aspect ratios

Theory

The way to solve this is essentially to:

  1. Figure out the aspect ratio (and orientation) of the screen
const { height, width } = Dimensions.get('window');
const screenRatio = height / width;
  1. Wait for camera to be ready
const [isRatioSet, setIsRatioSet] = useState(false);

// the camera must be loaded in order to 
// access the supported ratios
const setCameraReady = async() => {
  if (!isRatioSet) {
    await prepareRatio();
  }
};

return (
  <Camera
    onCameraReady={setCameraReady}
    ref={(ref) => {
      setCamera(ref);
    }}>
  </Camera>
);
  1. Figure out the supported aspect ratios of the camera
const ratios = await camera.getSupportedRatiosAsync();

This will return an array of strings with the format ['w:h'], so you might see something like this:

[ '4:3', '1:1', '16:9' ]
  1. Find the camera's closest aspect ratio to the screen where the height does not exceed the screen ratio (assuming you want a horizontal buffer, not a vertical buffer)

Essentially what you are trying to do here is to loop through the supported camera ratios and determine which of them are the closest in proportion to the screen. Any that are too tall we toss out since in this example we want to the preview to take up the entire width of the screen and we don't mind if the preview is shorter than the screen in portrait mode.

a) Get screen aspect ratio

So let's say that the screen is 480w x 800h, then the aspect ratio of the height / width is 1.666... If we were in landscape mode, we would do width / height.

b) Get supported camera aspect ratios

Then we look at each camera aspect ratio and calculate the width / height. The reason we calculate this and not the height / width like we do the screen is that the camera aspect ratios are always in landscape mode.

So:

  • Aspect => calculation
  • 4:3 => 1.3333
  • 1:1 => 1
  • 16:9 => 1.77777

c) Calculate supported camera aspect ratios

For each one, we subtract from the aspect ratio of the screen to find the difference. Any that exceed the aspect ratio of the screen on the long side are discarded:

  • Aspect => calculation => difference from screen
  • 4:3 => 1.333... => 0.333... (closest without going over!)
  • 1:1 => 1 => 0.666... (worst match)
  • 16:9 => 1.777... => -0.111... (too wide)

d) closest shortest camera aspect ratio matching screen aspect ratio

So we pick the 4:3 aspect ratio for this camera on this screen.

e) Calculate difference between camera aspect ratio and screen aspect ratio for padding and positioning.

To position the preview in the center of the screen, we can calculate half the difference between the screen height and the scaled height of the camera preview.

verticalPadding = (screenHeight - bestRatio * screenWidth) / 2

All together:

let distances = {};
let realRatios = {};
let minDistance = null;
for (const ratio of ratios) {
  const parts = ratio.split(':');
  const realRatio = parseInt(parts[0]) / parseInt(parts[1]);
  realRatios[ratio] = realRatio;
  // ratio can't be taller than screen, so we don't want an abs()
  const distance = screenRatio - realRatio; 
  distances[ratio] = distance;
  if (minDistance == null) {
    minDistance = ratio;
  } else {
    if (distance >= 0 && distance < distances[minDistance]) {
      minDistance = ratio;
    }
  }
}
// set the best match
desiredRatio = minDistance;
//  calculate the difference between the camera width and the screen height
const remainder = Math.floor(
  (height - realRatios[desiredRatio] * width) / 2
);
// set the preview padding and preview ratio
setImagePadding(remainder / 2);
  1. Style the <Camera> component to have the appropriate scaled height to match the applied camera aspect ratio and to be centered or whatever in the screen.
<Camera
  style={[styles.cameraPreview, {marginTop: imagePadding, marginBottom: imagePadding}]}
  onCameraReady={setCameraReady}
  ratio={ratio}
  ref={(ref) => {
    setCamera(ref);
  }}
/>

Something to note is that the camera aspect ratios are always width:height in landscape mode, but your screen might be in either portrait or landscape.

Execution

This example only supports a portrait-mode screen. To support both screen types, you'll have to check the screen orientation and change the calculations based on which orientation the device is in.

import React, { useEffect, useState } from 'react';
import {StyleSheet, View, Text, Dimensions, Platform } from 'react-native';
import { Camera } from 'expo-camera';

export default function App() {
  //  camera permissions
  const [hasCameraPermission, setHasCameraPermission] = useState(null);
  const [camera, setCamera] = useState(null);

  // Screen Ratio and image padding
  const [imagePadding, setImagePadding] = useState(0);
  const [ratio, setRatio] = useState('4:3');  // default is 4:3
  const { height, width } = Dimensions.get('window');
  const screenRatio = height / width;
  const [isRatioSet, setIsRatioSet] =  useState(false);

  // on screen  load, ask for permission to use the camera
  useEffect(() => {
    async function getCameraStatus() {
      const { status } = await Camera.requestPermissionsAsync();
      setHasCameraPermission(status == 'granted');
    }
    getCameraStatus();
  }, []);

  // set the camera ratio and padding.
  // this code assumes a portrait mode screen
  const prepareRatio = async () => {
    let desiredRatio = '4:3';  // Start with the system default
    // This issue only affects Android
    if (Platform.OS === 'android') {
      const ratios = await camera.getSupportedRatiosAsync();

      // Calculate the width/height of each of the supported camera ratios
      // These width/height are measured in landscape mode
      // find the ratio that is closest to the screen ratio without going over
      let distances = {};
      let realRatios = {};
      let minDistance = null;
      for (const ratio of ratios) {
        const parts = ratio.split(':');
        const realRatio = parseInt(parts[0]) / parseInt(parts[1]);
        realRatios[ratio] = realRatio;
        // ratio can't be taller than screen, so we don't want an abs()
        const distance = screenRatio - realRatio; 
        distances[ratio] = distance;
        if (minDistance == null) {
          minDistance = ratio;
        } else {
          if (distance >= 0 && distance < distances[minDistance]) {
            minDistance = ratio;
          }
        }
      }
      // set the best match
      desiredRatio = minDistance;
      //  calculate the difference between the camera width and the screen height
      const remainder = Math.floor(
        (height - realRatios[desiredRatio] * width) / 2
      );
      // set the preview padding and preview ratio
      setImagePadding(remainder);
      setRatio(desiredRatio);
      // Set a flag so we don't do this 
      // calculation each time the screen refreshes
      setIsRatioSet(true);
    }
  };

  // the camera must be loaded in order to access the supported ratios
  const setCameraReady = async() => {
    if (!isRatioSet) {
      await prepareRatio();
    }
  };

  if (hasCameraPermission === null) {
    return (
      <View style={styles.information}>
        <Text>Waiting for camera permissions</Text>
      </View>
    );
  } else if (hasCameraPermission === false) {
    return (
      <View style={styles.information}>
        <Text>No access to camera</Text>
      </View>
    );
  } else {
    return (
      <View style={styles.container}>
        {/* 
        We created a Camera height by adding margins to the top and bottom, 
        but we could set the width/height instead 
        since we know the screen dimensions
        */}
        <Camera
          style={[styles.cameraPreview, {marginTop: imagePadding, marginBottom: imagePadding}]}
          onCameraReady={setCameraReady}
          ratio={ratio}
          ref={(ref) => {
            setCamera(ref);
          }}>
        </Camera>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  information: { 
    flex: 1,
    justifyContent: 'center',
    alignContent: 'center',
    alignItems: 'center',
  },
  container: {
    flex: 1,
    backgroundColor: '#000',
    justifyContent: 'center'
  },
  cameraPreview: {
    flex: 1,
  }
});

You can play with the Expo Snack here

Results

And finally, a camera preview with preserved proportions, which uses padding on the top and bottom to center the preview:

Android screenshot

You can also try this code out online or in your Android on Expo Snack.

5
  • 1
    This is perfect!
    – solarnz
    Commented Oct 11, 2020 at 2:20
  • 1
    This is a great answer which works well, but there seems to be some differences in how the Snack works vs a real app. I found that when using the code in my own Expo Project the camera view appeared slightly warped (circles were oval shaped). Seems if you have the translucent key in your app.json set to true (which it is by default as of SDK38) then you may need to add StatusBar.currentHeight to the marginTop of the camera preview styles. Commented Dec 29, 2020 at 10:19
  • 1
    I think you should replace setImagePadding(remainder / 2); by setImagePadding(remainder);
    – Arnaud
    Commented Jan 31, 2021 at 17:45
  • 1
    Suggested edit queue is full. There is an issue with the distance check in the loop, which compares distance with ratio. Will almost always return the last ratio of the supported ratios, which can give the impression that the code work when it doesn't. Fix is to distances[ratio] = realRatio; with distances[ratio] = distance; Commented Jan 25, 2022 at 9:02
  • This works but the ratio doesn't get updated to the calculated one on first render.
    – Nima
    Commented Jan 2 at 8:40
8

A simple solution in portrait mode:

import * as React from "react";
import { Camera } from "expo-camera";
import { useWindowDimensions } from "react-native";

const CameraComponent = () => {
  const {width} = useWindowDimensions();
  const height = Math.round((width * 16) / 9);
  return (
    <Camera
      ratio="16:9"
      style={{
        height: height,
        width: "100%",
      }}
    ></Camera>
  );
};

export default CameraComponent;
3
  • this solution is to calculate the ratio of screen. Does not equate to camera preview texture ratio Commented Jun 14, 2021 at 15:10
  • Be careful with this approach. If 16/9 > aspect ratio of the device, the camera will overflow in the vertical direction.
    – OGreeni
    Commented Jan 10 at 21:29
  • Yes @OGreeni, This solution is for portrait mode only. If you want to use it in paysage it probably needs more work.
    – abumalick
    Commented Jan 12 at 10:40
1

This fits the camera preview inside its container and adds black bars whenever it is either too tall or too wide. Works for portrait mode

const CAMERA_ASPECT_RATIO = [16, 9] as const;

...

const [cameraWidth, cameraHeight] = useMemo<[DimensionValue, DimensionValue]>(() => {
    const { width: containerWidth, height: containerHeight } = Dimensions.get('window'); 

    const width = Math.round(containerHeight / (CAMERA_ASPECT_RATIO[0] / CAMERA_ASPECT_RATIO[1]));
    const height = Math.round(containerWidth * (CAMERA_ASPECT_RATIO[0] / CAMERA_ASPECT_RATIO[1]));

    return [Math.min(width, containerWidth), Math.min(height, containerHeight)];
  }, []);

...
<View style={{ flex: 1, backgroundColor: 'black' }}>
  <CameraView
    ratio={`${CAMERA_ASPECT_RATIO[0]}:${CAMERA_ASPECT_RATIO[1]}`}
    style={{
      width: cameraWidth,
      height: cameraHeight,
      marginTop: 'auto',
      marginRight: 'auto',
      marginBottom: 'auto',
      marginLeft: 'auto',
    }}
    ...
  />
</View>

If your camera's container's dimensions are not the same as the screen's, you can set them via onLayout

<View
  onLayout={(e) => {
    containerDimensionsRef.current = [e.nativeEvent.layout.width, e.nativeEvent.layout.height];
  }}
> ... </View>

If you don't want to have a fixed camera aspect ratio, you can also loop through the available ratios given by getSupportedRatiosAsync() and find the one that best fits the container, by finding the minimum difference availableRatio.height / availableRatio.width - containerHeight / containerWidth

-1

This way also works: Here we try to set Camera component dimensions based on the size of pictures the current device can take depending on a ratio using the async function getAvailablePictureSizesAsync("ratio")

//First we import necessary components
import React, { useEffect, useState, useRef } from "react";
import { Camera, FlashMode } from "expo-camera";
import {Alert, View, StyleSheet, Dimensions} from "react-native";  

const CameraComp = () => {
//we declare state to start camera and another to set Camera component dimensions  
const [startCamera, setStartCamera] = useState(false);
const [dimCamera, setDimCamera] = useState({flex: 1});  

//we use a ref to refer to camera component object
const cameraRef = useRef();  

//function to start camera
const startCamera = async () => {
  const { status } = await Camera.requestCameraPermissionsAsync();
  if (status === "granted") {
    // start the camera
    setStartCamera(true);
  } else {
    Alert.alert("Access denied");
  }
};  

//function to define dimensions of camera component based on a ratio 4:3
const definingDimCamera = async () => {
  let sizes = await cameraRef.current.getAvailablePictureSizesAsync("4:3");
  console.log(sizes);
  let width = Dimensions.get("window").width;
  console.log(width);

  let closest = null;
  console.log(parseInt(sizes[0].toLowerCase().split("x")[0]));
  if((sizes.length > 0) && (sizes[0].toLowerCase().indexOf("x") > 0)){
    closest = {height: parseInt(sizes[0].toLowerCase().split("x")[0]), width: parseInt(sizes[0].toLowerCase().split("x")[1])};
    for (let i=1; i<sizes.length; i++){
      let size = sizes[i];
      if(Math.abs(width - closest.width) > Math.abs(width - parseInt(size.toLowerCase().split("x")[1]))) {
        closest = {height: parseInt(size.toLowerCase().split("x")[0]), width: parseInt(size.toLowerCase().split("x")[1])};
      }
    }
  }
  if(closest && closest.height && closest.width){
    setDimCamera({height: parseInt((closest.height * width)/closest.width), width: width});
  }
}  

//call functions once after the first render to set things
useEffect(() => {
  startCamera();
  definingDimCamera();

}, []);

return(
<View style={{
        flex: 1,
      }}>
 

      <Camera
        type={cameraType}
        flashMode={flashMode}
        style={{...dimCamera}}
        ref={cameraRef}
        ratio="4:3"
      >

        
      </Camera>

</View> );
}

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.