Skip to content

Instantly share code, notes, and snippets.

@nartc
Last active August 3, 2023 08:44
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save nartc/7a05860825bba5ee433e4d05a1329ec9 to your computer and use it in GitHub Desktop.
Save nartc/7a05860825bba5ee433e4d05a1329ec9 to your computer and use it in GitHub Desktop.
ReactNative Camera Barcode Mask (from react-native-barcode-mask) rewritten using Hooks and Reanimated
import React, { FC, memo } from 'react';
import { LayoutChangeEvent, StyleSheet, View, ViewStyle } from 'react-native';
import Animated, { Easing } from 'react-native-reanimated';
const { Value, Clock, block, cond, set, startClock, timing, eq } = Animated;
type DimensionUnit = string | number;
type EdgePosition = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
interface Props {
width?: DimensionUnit;
height?: DimensionUnit;
edgeWidth?: number;
edgeHeight?: DimensionUnit;
edgeColor?: string;
edgeRadius?: number;
edgeBorderWidth?: DimensionUnit;
backgroundColor?: string;
maskOpacity?: number;
showAnimatedLine?: boolean;
animatedLineThickness?: number;
animatedLineOrientation?: 'vertical' | 'horizontal';
animatedLineColor?: string;
animationDuration?: number;
/**
* This is very specific to my use-case which is the hook below
*/
onLayoutChange: (event: LayoutChangeEvent) => void;
}
const runTiming = (clock: Animated.Clock, value: number, destination: number, duration: number) => {
const timingState: Animated.TimingState = {
finished: new Value(0),
position: new Value(0),
time: new Value(0),
frameTime: new Value(0),
};
const timingConfig: Animated.TimingConfig = {
duration,
toValue: new Value(destination),
easing: Easing.inOut(Easing.ease),
};
return block([
startClock(clock),
timing(clock, timingState, timingConfig),
cond(timingState.finished, [
set(timingState.finished, 0),
set(timingState.time, 0),
set(timingState.frameTime, 0),
set(timingState.position, cond(eq(timingState.position, destination), destination, 0)),
set(timingConfig.toValue as Animated.Value<number>, cond(eq(timingState.position, destination), 0, destination)),
]),
timingState.position,
]);
};
const BarcodeMask: FC<Props> = memo(
({
width,
height,
backgroundColor,
edgeBorderWidth,
edgeColor,
edgeHeight,
edgeWidth,
edgeRadius,
maskOpacity,
animatedLineColor,
animatedLineOrientation,
animatedLineThickness,
animationDuration,
showAnimatedLine,
onLayoutChange,
}) => {
const _animatedLineStyle = () => {
if (animatedLineOrientation === 'horizontal') {
return {
...styles.animatedLine,
height: animatedLineThickness,
width: (width as number) * 0.9,
backgroundColor: animatedLineColor,
top: runTiming(
new Clock(),
0,
(height as number) - (animatedLineThickness as number),
animationDuration as number,
),
};
}
return {
...styles.animatedLine,
width: animatedLineThickness,
height: (height as number) * 0.9,
backgroundColor: animatedLineColor,
left: runTiming(
new Clock(),
0,
(width as number) - (animatedLineThickness as number),
animationDuration as number,
),
};
};
const _applyMaskFrameStyle = () => {
return { backgroundColor, opacity: maskOpacity, flex: 1
};
};
const _renderEdge = (edgePosition: EdgePosition) => {
const defaultStyle = {
width: edgeWidth,
height: edgeHeight,
borderColor: edgeColor,
zIndex: 2,
};
const borderWidth = edgeBorderWidth as number;
const borderRadius = edgeRadius as number;
const edgeBorderStyle: { [position in typeof edgePosition]: ViewStyle } = {
topRight: {
borderRightWidth: borderWidth,
borderTopWidth: borderWidth,
borderTopRightRadius: borderRadius,
top: -borderWidth,
right: -borderWidth,
},
topLeft: {
borderTopWidth: borderWidth,
borderLeftWidth: borderWidth,
borderTopLeftRadius: borderRadius,
top: -borderWidth,
left: -borderWidth,
},
bottomRight: {
borderBottomWidth: borderWidth,
borderRightWidth: borderWidth,
borderBottomRightRadius: borderRadius,
bottom: -borderWidth,
right: -borderWidth,
},
bottomLeft: {
borderBottomWidth: borderWidth,
borderLeftWidth: borderWidth,
borderBottomLeftRadius: borderRadius,
bottom: -borderWidth,
left: -borderWidth,
},
};
return <View style={{ ...defaultStyle, ...styles[edgePosition], ...edgeBorderStyle[edgePosition] }} />;
};
return (
<View style={styles.container}>
<View style={{ ...styles.finder, width, height }} onLayout={onLayoutChange}>
{_renderEdge('topLeft')}
{_renderEdge('topRight')}
{_renderEdge('bottomLeft')}
{_renderEdge('bottomRight')}
{showAnimatedLine && <Animated.View style={_animatedLineStyle()} />}
</View>
<View style={styles.maskOuter}>
<View style={{ ...styles.maskRow, ..._applyMaskFrameStyle() }} />
<View style={{ height, ...styles.maskCenter }}>
<View style={_applyMaskFrameStyle()} />
<View style={{ ...styles.maskInner, width, height, borderRadius: edgeRadius }} />
<View style={_applyMaskFrameStyle()} />
</View>
<View style={{ ...styles.maskRow, ..._applyMaskFrameStyle() }} />
</View>
</View>
);
},
);
BarcodeMask.defaultProps = {
width: 280,
height: 230,
edgeWidth: 20,
edgeHeight: 20,
edgeColor: 'white',
edgeBorderWidth: 4,
edgeRadius: 0,
backgroundColor: '#ccc',
maskOpacity: 1,
animatedLineColor: 'white',
animatedLineOrientation: 'horizontal',
animatedLineThickness: 2,
animationDuration: 2000,
showAnimatedLine: true,
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
...StyleSheet.absoluteFillObject,
},
finder: {
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
maskOuter: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'space-around',
},
maskInner: {
backgroundColor: 'transparent',
},
maskRow: {
width: '100%',
},
maskCenter: {
display: 'flex',
flexDirection: 'row',
},
topLeft: {
position: 'absolute',
top: 0,
left: 0,
},
topRight: {
position: 'absolute',
top: 0,
right: 0,
},
bottomLeft: {
position: 'absolute',
bottom: 0,
left: 0,
},
bottomRight: {
position: 'absolute',
bottom: 0,
right: 0,
},
animatedLine: {
position: 'absolute',
zIndex: 1,
},
});
export default BarcodeMask;
const Scan = ({isFocused, navigation}) => {
const {barcodeRead, onBarcodeRead, onBarcodeFinderLayoutChange} = useBarcodeRead(isFocused, data => data.split('|'), scannedData => {
fetchData(scannedData);
navigation.navigate('Detail');
});
return (
<RNCamera barCodeTypes={barcodeRead ? [] : [RNCamera.Constants.BarCodeTypes.QR]} onBarcodeRead={onBarcodeRead}>
<BarcodeMask onLayout={onBarcodeFinderLayoutChange} />
</RNCamera>
)
}
import { useCallback, useRef, useState } from 'react';
import { LayoutChangeEvent } from 'react-native';
/**
* This hook is meant to be used by useBarcodeReadIOS and useBarcodeReadAndroid internally
* since they share some data about the Barcode Finder Area
*/
export default (dataProcessor: (data: string) => string, onScannedData: (processed: string) => void) => {
const [barcodeRead, setBarcodeRead] = useState(false);
const [isFinderBoundingInitialized, setIsFinderBoundingInitialized] = useState(false);
const finderWidth = useRef(0);
const finderHeight = useRef(0);
const finderX = useRef(0);
const finderY = useRef(0);
const _onBarcodeFinderLayoutChange = useCallback((event: LayoutChangeEvent) => {
const {
nativeEvent: {
layout: { height, width, x, y },
},
} = event;
finderWidth.current = width;
finderHeight.current = height;
finderX.current = x;
finderY.current = y;
setIsFinderBoundingInitialized(true);
}, []);
const processingReadBarcode = (data: string) => {
setBarcodeRead(true);
const processed = dataProcessor(data);
if (processed) {
onScannedData(processed);
}
setBarcodeRead(false);
};
return {
barcodeRead,
finderX: finderX.current,
finderY: finderY.current,
finderWidth: finderWidth.current,
finderHeight: finderHeight.current,
isFinderBoundingInitialized,
onBarcodeFinderLayoutChange: _onBarcodeFinderLayoutChange,
processingReadBarcode,
};
};
import { useCallback, useRef, useState } from 'react';
import { LayoutChangeEvent, Platform } from 'react-native';
import { Point, Size } from 'react-native-camera';
/**
* This hook is to make sure the Scanner to grab the data from the barcode
* when the barcode dimensions is within the Scan Area (Barcode Finder area)
*
* This hook takes in:
* 1. isFocused: this is ReactNavigation's isFocused. If you're using isFocused then pass it in, otherwise you can ignore it.
* 2. dataProcessor: usually barcode's data is a string. This is a callback that will have the barcode's data passed in as argument, you then can process the data how you want.
* 3. onScannedData: a callback that will get called with the processed data. The hook makes you to pass in the callback so you can dictate what you want to do with the processed data outside of the hooks (maybe fetch data based on the scan then navigate to a new screen)
*
* This hook returns:
* 1. barcodeRead: has the barcode been read once. This is used to prevent the scanner to read the barcode too many times.
* 2. onBarcodeRead: callback to be bound to onBarcodeRead on RNCamera. The internal implementation will handle scan area bounding box.
* 3. onBarcodeFinderLayoutChange: callback to be bound to onLayout on BarcodeMask. This is to grab the Barcode Finder Area bounding box to be calculated for onBarcodeRead
*
* The hook differs in Android vs in iOS because of how the bounds that the RNCamera returns for the barcode upon scanned.
*/
import { Platform } from 'react-native';
import useBarcodeReadAndroid from './useBarcodeReadAndroid';
import useBarcodeReadIOS from './useBarcodeReadIOS';
export default Platform.select({
ios: useBarcodeReadIOS,
android: useBarcodeReadAndroid,
});
import { useCallback } from 'react';
import { PixelRatio } from 'react-native';
import { BarCodeType, Point, RNCamera, Size } from 'react-native-camera';
import useBarcodeFinder from './useBarcodeFinder';
/**
* Android returns different bounds upon scan event. And the origin array is different for each BarcodeType as well.
* In this case, I use QR and PDF417 so I only handle those two types. I also put comment above the conditiion check
* to annotate how the origins are laid out for each type. The numbers on the comments are Indices.
*
* Note: It's confusing how Android returns the bounds for the detected barcode on the Preview so it's pretty unstable.
* However, for most cases, it works well enough for me.
*/
export default (
isFocused: boolean,
dataProcessor: (data: string) => string,
onScannedData: (processed: string) => void,
) => {
const {
barcodeRead,
onBarcodeFinderLayoutChange,
isFinderBoundingInitialized,
finderY,
finderX,
finderWidth,
finderHeight,
processingReadBarcode,
} = useBarcodeFinder(dataProcessor, onScannedData);
const _onBarcodeRead = useCallback(
(event: { data: string; bounds: any | { origin: Point<string>; size: Size<string> }; type: keyof BarCodeType }) => {
if (!isFinderBoundingInitialized) {
return;
}
const _bounds = event.bounds as { width: number; height: number; origin: Point<string>[] };
const _pointBounds = _bounds.origin.map(point => ({
x: Number(point.x) / PixelRatio.get(),
y: Number(point.y) / PixelRatio.get(),
}));
const _insideBox = (point: { x: number; y: number }) => {
const { x, y } = point;
return x >= finderX && x <= finderX + finderWidth && y >= finderY && y <= finderY + finderHeight;
};
/**
* 0 --------------- 2
* | PDF417 |
* | /////////////// |
* 1 --------------- 3
*/
if (event.type === RNCamera.Constants.BarCodeType.pdf417) {
const [topLeft, bottomLeft, topRight, bottomRight] = _pointBounds;
if (_insideBox(topLeft) && _insideBox(bottomLeft) && _insideBox(topRight) && _insideBox(bottomRight)) {
processingReadBarcode(event.data);
return;
}
}
/**
* 2 ------ 3
* |
* | QR Code
* |
* 1 ------ 0
*/
if (event.type === RNCamera.Constants.BarCodeType.qr) {
const [bottomRight, bottomLeft, topLeft, topRight] = _pointBounds;
if (_insideBox(bottomRight) && _insideBox(bottomLeft) && _insideBox(topLeft) && _insideBox(topRight)) {
processingReadBarcode(event.data);
return;
}
}
},
[isFocused, isFinderBoundingInitialized, finderX, finderY, finderWidth, finderHeight],
);
return { barcodeRead, onBarcodeRead: _onBarcodeRead, onBarcodeFinderLayoutChange };
};
import { useCallback } from 'react';
import { BarCodeType, Point, Size } from 'react-native-camera';
import useBarcodeFinder from './useBarcodeFinder';
/**
* Mostly the same as before revision, the iOS works a bit better in terms of scanning within the scan area
*/
export default (
isFocused: boolean,
dataProcessor: (data: string) => string,
onScannedData: (processed: string) => void,
) => {
const {
barcodeRead,
onBarcodeFinderLayoutChange,
isFinderBoundingInitialized,
finderY,
finderX,
finderWidth,
finderHeight,
processingReadBarcode,
} = useBarcodeFinder(dataProcessor, onScannedData);
const _onBarcodeRead = useCallback(
(event: { data: string; bounds: any | { origin: Point<string>; size: Size<string> }; type: keyof BarCodeType }) => {
if (!isFinderBoundingInitialized) {
return;
}
const {
origin: { x, y },
size: { width, height },
} = event.bounds as { origin: Point<string>; size: Size<string> };
if (
Number(x) >= finderX &&
Number(x) + Number(width) <= finderX + finderWidth &&
Number(y) >= finderY &&
Number(y) + Number(height) <= finderY + finderHeight
) {
processingReadBarcode(event.data);
return;
}
},
[isFocused, isFinderBoundingInitialized, finderX, finderY, finderWidth, finderHeight],
);
return { barcodeRead, onBarcodeRead: _onBarcodeRead, onBarcodeFinderLayoutChange };
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment