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.

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 see the scanning results and configure whether to enable continuous scanning. At the scanning page, it opens the camera, scans the QR codes and draws the overlay.

The Capacitor plugin uses Dynamsoft Barcode Reader 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 blank --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 Info.plist:

<key>NSCameraUsageDescription</key>
<string>For barcode scanning</string>

For Android, add the following to AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />

Install dependencies

Install the capacitor plugins to use Dynamsoft Barcode Reader and open the camera.

npm install capacitor-plugin-dynamsoft-barcode-reader capacitor-plugin-camera

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 { MutableRefObject, useEffect, useRef, useState } from 'react';
import './QRCodeScanner.css';
import { DBR, TextResult } from 'capacitor-plugin-dynamsoft-barcode-reader';
import { CameraPreview } from 'capacitor-plugin-camera';
import { Capacitor, PluginListenerHandle } from '@capacitor/core';

export interface QRCodeScannerProps {
  torchOn?: boolean;
  onScanned?: (results:TextResult[]) => void;
  onPlayed?: (result:{orientation:"LANDSCAPE"|"PORTRAIT",resolution:string}) => void;
}

const QRCodeScanner: React.FC<QRCodeScannerProps> = (props:QRCodeScannerProps) => {
  const container:MutableRefObject<HTMLDivElement|null> = useRef(null);
  const decoding = useRef(false);
  const interval = useRef<any>();
  const onPlayedListener = useRef<PluginListenerHandle|undefined>();
  const [initialized,setInitialized] = useState(false);
  useEffect(() => {
    const init = async () => {
      if (container.current && Capacitor.isNativePlatform() === false) {
        await CameraPreview.setElement(container.current);
      }
      await CameraPreview.initialize();
      await CameraPreview.requestCameraPermission();
      await DBR.initialize();
      console.log("QRCodeScanner mounted");
      if (onPlayedListener.current) {
        onPlayedListener.current.remove();
      }
      onPlayedListener.current = await CameraPreview.addListener("onPlayed", async () => {
        startDecoding();
        const orientation = (await CameraPreview.getOrientation()).orientation;
        const resolution = (await CameraPreview.getResolution()).resolution;
        if (props.onPlayed) {
          props.onPlayed({orientation:orientation,resolution:resolution});
        }
      });
      await CameraPreview.startCamera();
      setInitialized(true);
    }
    init();
    return ()=>{
      console.log("unmount and stop scan");
      stopDecoding();
      CameraPreview.stopCamera();
    }
  }, []);

  const startDecoding = () => {
    stopDecoding();
    interval.current = setInterval(captureAndDecode,100);
  }
  
  const stopDecoding = () => {
    clearInterval(interval.current);
  }
  
  const captureAndDecode = async () => {
    if (decoding.current === true) {
      return;
    }
    let results = [];
    let dataURL;
    decoding.current = true;
    try {
      if (Capacitor.isNativePlatform()) {
        await CameraPreview.saveFrame();
        results = (await DBR.decodeBitmap({})).results;
      }else{
        let frame = await CameraPreview.takeSnapshot({quality:50});
        dataURL = "data:image/jpeg;base64,"+frame.base64;
        results = await readDataURL(dataURL);
      }
      if (props.onScanned) {
        props.onScanned(results);
      }
    } catch (error) {
      console.log(error);
    }
    decoding.current = false;
  }

  const readDataURL = async (dataURL:string) => {
    let response = await DBR.decode({source:dataURL});
    let results = response.results;
    return results;
  }

  useEffect(() => {
    if (initialized) {
      if (props.torchOn === true) {
        CameraPreview.toggleTorch({on:true});
      }else{
        CameraPreview.toggleTorch({on:false});
      }
    }
  }, [props.torchOn]);
  
  return (
    <>
      {!initialized && (
        <div>Initializing...</div>
      )}
      <div ref={container}>
        <div className="dce-video-container"></div>
      </div>
    </>
  );
};

export default QRCodeScanner;

Here, we’ve created a functional component. When the component is mounted, it will start the camera and a loop to read barcodes from the camera.

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>
         <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;
    

Implement the Home page

When the home page is mounted, initialize the license for Dynamsoft Barcode Reader. You can apply for your license here.

const initLicenseTried = useRef(false);
const [licenseInitialized, setLicenseInitialized] = useState(false);

useEffect(() => {
  if (initLicenseTried.current === false) {
    initLicenseTried.current = true;
    const initLicense = async () => {
      try {
        await DBR.initLicense({license:"LICENSE-KEY"})  
        setLicenseInitialized(true);
      } catch (error) {
        alert(error);
      }
    }
    initLicense();
  }
  initLicenseTried.current = true;
}, []);

Then, in the home page, create a Start Scanning button and a checkbox.

const [continuousScan, setContinuousScan] = useState(false);

const handleOption = (e: any) => {
  let value = e.detail.value;
  let checked = e.detail.checked;
  setContinuousScan(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>
</IonList>

When the Start Scanning button is pressed, it will navigate to the scanner page with the continuous scan props.

const startScan = () => {
  props.history.push("scanner",{continuousScan:continuousScan})
}

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.

const [barcodeResults, setBarcodeResults] = useState([] as TextResult[]);


{(barcodeResults.length>0) &&
  <IonListHeader>
    <IonLabel>Results:</IonLabel>
  </IonListHeader>
}
{barcodeResults.map((tr,idx) => (
  <IonItem key={idx}>
    <IonLabel>{tr.barcodeFormat + ": " + tr.barcodeText}</IonLabel>
  </IonItem>
))}

Implement the Scanner page

  1. Add the QR code scanner component in JSX. It will start scanning when it is mounted.

    const [barcodeResults,setBarcodeResults] = useState([] as TextResult[]);
    const onScanned = (results:TextResult[]) => {
      setBarcodeResults(results);
    }
    return (
      <IonPage>
        <IonContent fullscreen>
          <QRCodeScanner 
            onScanned={onScanned}
          />
        </IonContent>
      </IonPage>
    );
    
  2. Get the continuousScan prop.

    const continuousScan = useRef(false);
    
    useEffect(() => {
      const state = props.location.state as { continuousScan?: boolean };
      if (state) {
        if (state.continuousScan) {
          continuousScan.current = state.continuousScan;
        }
      }
    }, [props.location.state]);
    
  3. Set the background color to transparent when the scanner page is mounted. We have to do this to reveal the camera preview which is behind the webview.

    const ionBackground = useRef("");
    useEffect(() => {
      ionBackground.current = document.documentElement.style.getPropertyValue('--ion-background-color');
      return () => {
        document.documentElement.style.setProperty('--ion-background-color', ionBackground.current);
      }
    }, []);
    
  4. 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.

       const onPlayed = (result:{orientation:"LANDSCAPE"|"PORTRAIT",resolution:string}) => {
         console.log(result);
         document.documentElement.style.setProperty('--ion-background-color', 'transparent');
         let width = parseInt(result.resolution.split("x")[0]);
         let height = parseInt(result.resolution.split("x")[1]);
         let box:string;
         if (result.orientation === "PORTRAIT") {
           box = "0 0 "+height+" "+width;
         }else{
           box = "0 0 "+width+" "+height;
         }
         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;
       }
      
  5. It continuous scan is set to false, return to the home page when the barcode is scanned.

    const scanned = useRef(false);
    const onScanned = (results:TextResult[]) => {
      console.log(results);
      if (continuousScan.current) {
        setBarcodeResults(results);
      }else{
        if (results.length>0 && scanned.current === false) {
          document.documentElement.style.setProperty('--ion-background-color', ionBackground.current);
          scanned.current = true;
          props.history.replace({ state: {results:results} });
          props.history.goBack();
        }
      }
    }
    
  6. Add floating action buttons to toggle the torch and stop scanning.

    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={() => {goBack()}}>
          <IonIcon icon={closeOutline} />
        </IonFabButton>
      </IonFabList>
    </IonFab>
    

    Functions:

    const toggleTorch = () => {
      if (torchOn == false) {
        setTorchOn(true);
      }else{
        setTorchOn(false);
      }
    }
       
    const goBack = () => {
      document.documentElement.style.setProperty('--ion-background-color', ionBackground.current);
      props.history.goBack();
    }
    

All right, we’ve now completed the demo.

Source Code

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

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

Disclaimer:

The wrappers and sample code on Dynamsoft Codepool are community editions, shared as-is and not fully tested. Dynamsoft is happy to provide technical support for users exploring these solutions but makes no guarantees.