Ionic React QR Code Scanner with Capacitor
In the previous article, we’ve given a brief demonstration on how to use the capacitor-plugin-dynamsoft-barcode-reader to create a cross-platform QR code scanner using web technologies with Capacitor. In this article, we are going to take a step further to create an Ionic React QR Code Scanner.
A preview of the final result:
Online demo on netlify: link.
The app uses IonReactRouter to manage navigation. It has two pages: a home page and a scanning page. At the home page, users can copy the scanning result to the clipboard and configure whether to scan QR code only and whether to enable continuous scanning. At the scanning page, it can do the following:
- Select camera
- Select camera resolution
- Set up a scan region
- Touch to focus
- Turn on torch
- Zoom in and zoom out
- Display QR code overlays
The Capacitor plugin uses Dynamsoft Barcode Reader and Dynamsoft Camera Enhancer as its backend.
Getting started with Dynamsoft Barcode Reader
DOWNLOAD THE SDK WITH A 30-DAY LICENSE
REQUEST A 30-DAY LICENSE
Building an Ionic React QR Code Scanner
Let’s build an Ionic React QR code scanner in steps.
New project
Create a new Ionic React app:
ionic start qr-code-scanner tabs --type=react --capacitor
We can start a server to have a live test in browser:
ionic serve
To run it on Android:
ionic capacitor add android
ionic capacitor copy android // sync files
ionic capacitor run android
To run it on iOS:
ionic capacitor add ios
ionic capacitor copy ios // sync files
ionic capacitor open ios // use XCode to open the project
Add camera permission
For iOS, add the following to ios\App\App\Info.plist
:
<key>NSCameraUsageDescription</key>
<string>For barcode scanning</string>
Install dependencies
There are some extra packages we need to install for this project:
npm install capacitor-plugin-dynamsoft-barcode-reader copy-to-clipboard
Create a QR code scanner component
React is declarative while the plugin only has imperative APIs. We need to create a QR code scanner component so that we can use the plugin in a declarative manner.
Here is the complete code of the component (the file is saved as src\components\QRCodeScanner.tsx
):
import { DBR, ScanRegion } from 'capacitor-plugin-dynamsoft-barcode-reader';
import { useEffect } from 'react';
const QRCodeScanner = (props: { isActive: boolean;
cameraID?: string;
resolution?: number;
torchOn?: boolean;
zoom?: number;
scanRegion?:ScanRegion}) => {
useEffect(() => {
return ()=>{
console.log("unmount and stop scan");
DBR.stopScan();
}
}, []);
useEffect(() => {
console.log("update active");
if (props.isActive) {
DBR.startScan();
}else{
DBR.stopScan();
}
}, [props.isActive]);
useEffect(() => {
if (props.torchOn != undefined) {
if (props.torchOn == true) {
console.log("torch on");
DBR.toggleTorch({"on":true});
}else{
console.log("torch off");
DBR.toggleTorch({"on":false});
}
}
}, [props.torchOn]);
useEffect(() => {
if (props.zoom != undefined) {
DBR.setZoom({factor:props.zoom});
}
}, [props.zoom]);
useEffect(() => {
const selectCamera = async () => {
if (props.cameraID != undefined && props.cameraID != "") {
let result = await DBR.getSelectedCamera();
if (result.selectedCamera) {
if (result.selectedCamera == props.cameraID){
return;
}
}
DBR.selectCamera({cameraID:props.cameraID});
}
}
selectCamera();
}, [props.cameraID]);
useEffect(() => {
if (props.scanRegion != undefined) {
DBR.setScanRegion(props.scanRegion);
}
}, [props.scanRegion]);
useEffect(() => {
if (props.resolution != undefined) {
let res:number = Math.floor(props.resolution);
DBR.setResolution({resolution:res});
}
}, [props.resolution]);
return (
<div></div>
);
}
export default QRCodeScanner;
Here, we’ve created a functional component. There are some points to notice.
- Since the plugin will create a native camera view behind the webview and set the background of the webview to transparent so that we can customize the UI, we only return
<div></div>
as a placeholder. useEffect
is used to monitor whether the component is mounted or updated.- Some imperative APIs like
setFocus
are more suitable to be used in an imperative way, so they are not added as props of the component.
Create a Home page and a Scanner page
- Create
Home.tsx
andScanner.tsx
undersrc\pages
. -
The navigation is managed using
IonReactRouter
inApp.tsx
.const App: React.FC = () => { return ( <IonApp style={{zIndex:999}}> <IonReactRouter> <IonRouterOutlet> <Route path="/home" component={Home} exact={true} /> <Route path="/scanner" component={Scanner} exact={true} /> <Route exact path="/" render={() => <Redirect to="/home" />} /> </IonRouterOutlet> </IonReactRouter> </IonApp> ); } export default App;
We need to set
z-index
for theIonApp
since the scanner will display a full screen camera preview with absolute position which covers other elements.
Implement the Home page
In the home page, create a Start Scanning
button and two checkboxes.
const [continuousScan, setContinuousScan] = useState(false);
const [QRcodeOnly, setQRcodeOnly] = useState(true);
const handleOption = (e: any) => {
let value = e.detail.value;
let checked = e.detail.checked;
if (value == "Continuous Scan") {
setContinuousScan(checked)
} else if (value == "Scan QR Code Only") {
setQRcodeOnly(checked);
}
}
//......
<IonButton expand="full" onClick={startScan}>Start Scanning</IonButton>
<IonList>
<IonItem>
<IonLabel>Continuous Scan</IonLabel>
<IonCheckbox slot="end" value="Continuous Scan" checked={continuousScan} onIonChange={(e) => handleOption(e)}/>
</IonItem>
<IonItem>
<IonLabel>Scan QR Code Only</IonLabel>
<IonCheckbox slot="end" value="Scan QR Code Only" checked={QRcodeOnly} onIonChange={(e) => handleOption(e)}/>
</IonItem>
</IonList>
When the Start Scanning
button is pressed, it will navigate to the scanner page with the continuous scan
and qrcodeonly
props and an active
property.
const startScan = () => {
props.history.push("scanner",{continuousScan:continuousScan,qrcodeOnly:QRcodeOnly,active:true})
}
If continuous scan is disabled, the scanner will return the barcode results to the home page if it detects barcodes. We can display them in a list and make them copiable.
const [present, dismiss] = useIonToast();
const [barcodeResults, setBarcodeResults] = useState([] as TextResult[]);
const copyBarcode = (text:string) => {
if (copy(text)){
present("copied",500);
}
}
//......
{(barcodeResults.length>0) &&
<IonListHeader>
<IonLabel>Results:</IonLabel>
</IonListHeader>
}
{barcodeResults.map((tr,idx) => (
<IonItem key={idx}>
<IonLabel>{tr.barcodeFormat + ": " + tr.barcodeText}</IonLabel>
<IonLabel style={{color:"green"}} slot="end" onClick={() =>{copyBarcode(tr.barcodeText)}}>copy</IonLabel>
</IonItem>
))}
Implement the Scanner page
Add the QR Code Scanner component
-
Add the QR code scanner component in JSX:
if (initialized == false) { return <IonPage><p>Initializing</p></IonPage> } return ( <IonPage> <QRCodeScanner isActive={isActive} /> </IonPage> );
-
Initialize the plugin when the page is mounted and stop the scanner is the page is unmounted. You may need to apply for a trial license to use it.
const [initialized,setInitialized] = useState(false); const [isActive,setIsActive] = useState(false); useEffect(() => { console.log("on mount"); const state = props.location.state as { continuousScan: boolean; qrcodeOnly: boolean; active: boolean; }; if (state && state.active != true) { return; } async function init() { let result = await DBR.initialize(); // To use your license: DBR.initialize({license: <your license>}) if (result) { if (result.success == true) { setQRCodeRuntimeSettings(state.qrcodeOnly); setInitialized(true); setIsActive(true); } } } init(); return ()=>{ console.log("unmount"); setIsActive(false); } }, []);
Modify the runtime settings to scan QR Code only
Dynamsoft Barcode Reader provides rich parameters to adjust its behavior and it is very convenient to modify it with a JSON template.
If the scan QR only
option is enabled, we can do the following to modify the runtime settings:
const setQRCodeRuntimeSettings = async (qrcodeOnly:boolean) => {
if (qrcodeOnly == true) {
let template = "{\"ImageParameter\":{\"BarcodeFormatIds\":[\"BF_QR_CODE\"],\"Description\":\"\",\"Name\":\"Settings\"},\"Version\":\"3.0\"}"; //decode QR code only
await DBR.initRuntimeSettingsWithString({template:template})
} else{
let template = "{\"ImageParameter\":{\"BarcodeFormatIds\":[\"BF_ALL\"],\"Description\":\"\",\"Name\":\"Settings\"},\"Version\":\"3.0\"}"; //decode all barcode formats
await DBR.initRuntimeSettingsWithString({template:template})
}
}
You can learn more about runtime settings here.
Add the onFrameRead and onPlayed listeners
Add the onFrameRead
listener which is triggered when barcodes are found and add the onPlayed
listener which is triggered when the camera is opened, switched or its resolution changed.
The listeners have to be removed when the page is unmounted.
let scanned = false;
let frameReadListener:PluginListenerHandle|undefined;
let onPlayedListener:PluginListenerHandle|undefined;
const Scanner = (props:RouteComponentProps) => {
useEffect(() => {
console.log("on mount");
async function init() {
let result = await DBR.initialize();
if (result) {
if (result.success == true) {
removeListeners();
frameReadListener = await DBR.addListener('onFrameRead', async (scanResult:ScanResult) => {
let results = scanResult["results"];
if (state.continuousScan == true) {
setBarcodeResults(results);
}else{
if (results.length>0 && scanned == false) {
scanned = true;
props.history.replace({ state: {results:results,active:false} });
props.history.goBack();
}
}
});
onPlayedListener = await DBR.addListener("onPlayed", (result:{resolution:string}) => {
console.log(resolution);
});
setQRCodeRuntimeSettings(state.qrcodeOnly);
setInitialized(true);
setIsActive(true);
}
}
}
init();
scanned = false;
return ()=>{
console.log("unmount");
setIsActive(false);
removeListeners();
}
}, []);
const removeListeners = () => {
if (frameReadListener) {
frameReadListener.remove();
}
if (onPlayedListener) {
onPlayedListener.remove();
}
}
}
Draw QR code overlays
In continuous scan
mode, we can draw QR code overlays to show which QR codes are detected.
We can do this using SVG which has been discussed in a previous article.
-
Add an SVG element in JSX:
const [viewBox,setViewBox] = useState("0 0 1920 1080"); //...... <svg viewBox={viewBox} className="overlay" xmlns="<http://www.w3.org/2000/svg>" > </svg>
The style of the SVG element:
.overlay { top: 0; left: 0; position: absolute; width: 100%; height: 100%; z-index: 998; }
-
The
viewBox
attribute should match the video frame size. We can get the size from theonPlayed
event.let currentWidth = 1920; let currentHeight = 1080; onPlayedListener = await DBR.addListener("onPlayed", (result:{resolution:string}) => { const resolution: string = result.resolution; //1920x1080 currentWidth = parseInt(resolution.split("x")[0]); currentHeight = parseInt(resolution.split("x")[1]); updateViewBox(); }); const updateViewBox = () => { let box:string = "0 0 "+currentWidth+" "+currentHeight; setViewBox(box); }
-
Add polygon and text SVG elements
<svg viewBox={viewBox} className="overlay" xmlns="<http://www.w3.org/2000/svg>" > {barcodeResults.map((tr,idx) => ( <polygon key={"poly-"+idx} xmlns="<http://www.w3.org/2000/svg>" points={getPointsData(tr)} className="barcode-polygon" /> ))} {barcodeResults.map((tr,idx) => ( <text key={"text-"+idx} xmlns="<http://www.w3.org/2000/svg>" x={tr.x1} y={tr.y1} fill="red" fontSize="20" >{tr.barcodeText}</text> ))} </svg>
The barcode result has the following localization attributes which we can use to determine the location of the polygon and text elements:
x1
,x2
,x3
,x4
,y1
,y2
,y3
,y4
.The
getPointsData
function:const getPointsData = (lr:TextResult) => { let pointsData = lr.x1 + "," + lr.y1 + " "; pointsData = pointsData + lr.x2+ "," + lr.y2 + " "; pointsData = pointsData + lr.x3+ "," + lr.y3 + " "; pointsData = pointsData + lr.x4+ "," + lr.y4; return pointsData; }
-
Handle rotation
Another problem we need to handle is the rotation problem. The natural image sensor’s orientation is landscape while the device is often portrait. The video frame is often rotated by 90 degress for display.
The
onFrameReadListener
will return the barcode results as well as the orientation of the device and the rotation degrees of the frame. We can use these info to handle rotatoin.-
Switch viewBox’s height and width if the device is portrait.
const updateViewBox = (deviceOrientation?:string) => { let box:string = "0 0 "+currentWidth+" "+currentHeight; if (deviceOrientation && deviceOrientation == "portrait") { box = "0 0 "+currentHeight+" "+currentWidth; } setViewBox(box); }
-
Update the localization results based on frame orientation and device orientation.
const handleRotation = (result:any, orientation: string, rotation:number) => { let width,height; if (orientation == "portrait") { width = currentHeight; height = currentWidth; }else{ width = currentWidth; height = currentHeight; } const frontCam:boolean = isFront(); console.log("front cam: "+frontCam); for (let i = 1; i < 5; i++) { let x = result["x"+i]; let y = result["y"+i]; let rotatedX; let rotatedY; switch (rotation) { case 0: rotatedX = x; rotatedY = y; if (frontCam == true){ //front cam landscape rotatedX = width - rotatedX; } break; case 90: rotatedX = width - y; rotatedY = x; if (frontCam == true){ //front cam portrait rotatedY = height - rotatedY; } break; case 180: rotatedX = width - x; rotatedY = height - y; if (frontCam == true){ //front cam landscape rotatedX = width - rotatedX; } break; case 270: rotatedX = height - y; rotatedY = width - x; if (frontCam == true){ //front cam portrait rotatedY = height - rotatedY; } break; default: rotatedX = x; rotatedY = y; } result["x"+i] = rotatedX; result["y"+i] = rotatedY; } }
-
The above functions will be run in the
onFrameRead
event.frameReadListener = await DBR.addListener('onFrameRead', async (scanResult:ScanResult) => { let results = scanResult["results"]; if (scanResult.deviceOrientation) { updateViewBox(scanResult.deviceOrientation); } if (state.continuousScan == true) { if (scanResult.frameOrientation != undefined && scanResult.deviceOrientation != undefined) { for (let index = 0; index < results.length; index++) { handleRotation(results[index], scanResult.deviceOrientation, scanResult.frameOrientation); } } setBarcodeResults(results); } });
-
Camera selection
-
Add a camera select in JSX:
let selectedCam = ""; const Scanner = (props:RouteComponentProps) => { const [cameras,setCameras] = useState([] as string[]); const [cameraID,setCameraID] = useState(""); //...... {isActive && <div> <select value={cameraID} className="camera-select controls" onChange={(e) => onCameraSelected(e)}> {cameras.map((camera,idx) => ( <option key={idx} value={camera}> {camera} </option> ))} </select> </div> }
-
Load the camera list upon initialization:
async function init() { let result = await DBR.initialize(); console.log(result); if (result) { if (result.success == true) { //...... loadCameras(); setInitialized(true); setIsActive(true); } } } //...... const loadCameras = async () => { let result = await DBR.getAllCameras(); if (result.cameras){ setCameras(result.cameras); } }
-
Use the
cameraID
prop in the QR code scanner component to make it effective.<QRCodeScanner isActive={isActive} + cameraID={cameraID} />
Resolution selection
-
Add a resolution select in JSX:
let presetResolutions = [{label:"ask 480P",value:EnumResolution.RESOLUTION_480P}, {label:"ask 720P",value:EnumResolution.RESOLUTION_720P}, {label:"ask 1080P",value:EnumResolution.RESOLUTION_1080P}] const Scanner = (props:RouteComponentProps) => { const [cameraResolution,setCameraResolution] = useState(undefined); //do not set up resolution at the beginning const [resolutionLabel,setResolutionLabel] = useState(""); //...... {isActive && <div> <select value={resolutionLabel} className="resolution-select controls" onChange={(e) => onResolutionSelected(e)}> <option> {"got "+resolutionLabel} </option> {presetResolutions.map((res,idx) => ( <option key={idx} value={res.value}> {res.label} </option> ))} </select> </div> }
The select will display a preset resolution list with an “ask” label prefix and an option which shows the current resolution with a “got” label prefix.
-
Update the current resolution label in the
onPlayed
event:onPlayedListener = await DBR.addListener("onPlayed", (result:{resolution:string}) => { const resolution: string = result.resolution; setResolutionLabel(resolution); });
-
Use the
resolution
prop in the QR code scanner component to make it effective.<QRCodeScanner isActive={isActive} + resolution={cameraResolution} />
Set up a scan region
We can set up a scan region (or recognition area) to guide the user to aim the camera to the QR code.
It is fairly simple to use:
const [scanRegion,setScanRegion] = useState({left:10,
top:20,
right:90,
bottom:65,
measuredByPercentage:1
});
<QRCodeScanner
isActive={isActive}
scanRegion={scanRegion}/>
Tap to focus
The plugin also has an API to set focus. The unit of x and y is percentage, ranging from 0.0 to 1.0.
setFocus(options: { x: number; y: number; }) => Promise<{ success?: boolean; message?: string; }>
We can set up an onClick event to the overlay SVG element to set focus. If the user taps the screen, set focus, draw a polygon around the tapped point and delete it after 1.5 seconds.
const Scanner = (props:RouteComponentProps) => {
const overlayRef = useRef<SVGSVGElement>(null);
const [pressedX,setPressedX] = useState<number|undefined>(undefined);
const [pressedY,setPressedY] = useState<number|undefined>(undefined);
const onOverlayClicked = (e:any) => {
if (overlayRef.current) {
let x = e.clientX / overlayRef.current?.clientWidth;
let y = e.clientY / overlayRef.current?.clientHeight;
setPressedX(x);
setPressedY(y);
DBR.setFocus({x:x,y:y});
setTimeout(() => {
setPressedX(undefined);
setPressedY(undefined);
}, 1000);
}
}
//......
<svg
viewBox={viewBox}
className="overlay"
ref={overlayRef}
xmlns="<http://www.w3.org/2000/svg>"
onClick={(e) => {onOverlayClicked(e)}}
>
{(pressedX!=undefined && pressedY!=undefined) &&
<polygon xmlns="<http://www.w3.org/2000/svg>"
points={getPointsDataForFocusHint(pressedX,pressedY)}
className="focus-polygon"
/>
</svg>
}
Other actions
There are other actions we can add to control the camera.
We can use a floating action button to perform these actions.
-
Add the elements in JSX:
<IonFab vertical="bottom" horizontal="start" slot="fixed"> <IonFabButton> <IonIcon icon={ellipsisHorizontalOutline} /> </IonFabButton> <IonFabList side="top"> <IonFabButton onClick={toggleTorch}> <IonIcon icon={flashlightOutline} /> </IonFabButton> <IonFabButton onClick={() => {setZoom(1)}}> <IonIcon icon={removeOutline} /> </IonFabButton> <IonFabButton onClick={() => {setZoom(2.5)}}> <IonIcon icon={addOutline} /> </IonFabButton> </IonFabList> </IonFab>
-
Add the zoom and torch props to the QR code scanner component:
<QRCodeScanner isActive={isActive} + zoom={zoom} + torchOn={torchOn} />
-
Update the torch status when the torch button is clicked
const toggleTorch = () => { if (torchOn == false) { setTorchOn(true); }else{ setTorchOn(false); } }
Screenshot of the floating action button:
Source Code
Check out the source code to have a try on your own: