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:

Preview

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:

  1. Select camera
  2. Select camera resolution
  3. Set up a scan region
  4. Touch to focus
  5. Turn on torch
  6. Zoom in and zoom out
  7. Display QR code overlays

The Capacitor plugin uses Dynamsoft Barcode Reader and Dynamsoft Camera Enhancer as its backend.

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.

  1. 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.
  2. useEffect is used to monitor whether the component is mounted or updated.
  3. 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

  1. Create Home.tsx and Scanner.tsx under src\pages.
  2. The navigation is managed using IonReactRouter in App.tsx.

     const App: React.FC = () => {
       return (
         <IonApp style=>
         <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 the IonApp 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= slot="end" onClick={() =>{copyBarcode(tr.barcodeText)}}>copy</IonLabel>
  </IonItem>
))}

Home

Implement the Scanner page

Add the QR Code Scanner component

  1. Add the QR code scanner component in JSX:

     if (initialized == false) {
       return <IonPage><p>Initializing</p></IonPage>
     }
     return (
       <IonPage>
         <QRCodeScanner 
           isActive={isActive}
         />
       </IonPage>
     );
    
  2. 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.

  1. 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;
     }
    
  2. The viewBox attribute should match the video frame size. We can get the size from the onPlayed 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);
     }
    
  3. 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;
     }
    
  4. 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.

    1. 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);
       }
      
    2. 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;
         }
       }
      
    3. 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

  1. 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>
     }
    
  2. 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);
       }
     }
    
  3. Use the cameraID prop in the QR code scanner component to make it effective.

     <QRCodeScanner 
       isActive={isActive}
    +  cameraID={cameraID}
       />
    

Resolution selection

  1. 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.

  2. Update the current resolution label in the onPlayed event:

     onPlayedListener = await DBR.addListener("onPlayed", (result:{resolution:string}) => {
       const resolution: string = result.resolution;
       setResolutionLabel(resolution);
     });
    
  3. 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.

  1. 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>
    
  2. Add the zoom and torch props to the QR code scanner component:

     <QRCodeScanner 
       isActive={isActive}
    +  zoom={zoom}
    +  torchOn={torchOn}
     />
    
  3. 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:

ionic-floating-action-button

Source Code

Check out the source code to have a try on your own:

https://github.com/xulihang/Ionic-React-QR-Code-Scanner