Build a macOS Barcode Scanner with SwiftUI and a C++ Barcode SDK

Developing a barcode scanner app for macOS comes with unique challenges, particularly due to the limited availability of barcode SDKs with a native Swift API for the platform. The Dynamsoft Capture Vision SDK covers that gap with a C++ core that ships macOS dylibs for barcode, MRZ, and document scanning. In this tutorial, we call the C++ shared libraries from an Objective-C++ wrapper and drive them from SwiftUI, moving step by step from C++ to Objective-C and finally to Swift.

What you’ll build: A native macOS barcode scanner app using SwiftUI that captures live camera frames and decodes 1D/2D barcodes in real time via the Dynamsoft Capture Vision C++ SDK, bridged through Objective-C++.

Key Takeaways

  • The Dynamsoft Capture Vision C++ SDK can be integrated into a SwiftUI macOS app by wrapping C++ calls in an Objective-C++ (.mm) file and exposing them through a bridging header.
  • Camera frames are captured with AVCaptureVideoDataOutput and passed to the C++ barcode engine as raw pixel buffers — no intermediate image conversion is needed.
  • macOS camera preview coordinates require a vertical flip before barcode bounding-box overlays align correctly with the live preview.
  • This approach extends to iOS with minimal changes because SwiftUI and the C++ SDK are cross-platform.

Common Developer Questions

How do I integrate a C++ barcode SDK into a SwiftUI macOS app?

Place the C++ SDK headers and dylibs in the Xcode project, add an Objective-C++ wrapper, and expose Swift-friendly methods through a bridging header. SwiftUI can then call the wrapper class without interacting with raw C++ types.

How do I capture camera frames in macOS SwiftUI and pass them to a C++ library for barcode detection?

Use AVCaptureVideoDataOutput to receive pixel buffers from the camera, extract the frame bytes in the Objective-C++ bridge, and pass those bytes directly to the C++ capture API. That avoids saving intermediate images and keeps the scan loop real time.

Why are barcode bounding boxes mirrored or flipped on the macOS camera preview?

The preview and the decoded result coordinates do not always share the same vertical origin, so the overlay often needs a Y-axis flip before drawing. Without that transform, the boxes appear mirrored or vertically offset from the actual barcode location.

macOS 1D/2D Barcode Scanner Demo Video

Prerequisites

  • Get a 30-day free trial license for the Dynamsoft Capture Vision SDK.
  • Obtain the C++ SDK headers and macOS dylibs. The Dynamsoft Capture Vision bundle on pypi packages them for Windows, Linux, and macOS; extract the *.h and *.dylib files. The sample repository already vendors version 3.6.50.9329 in examples/dcv, so you can skip this download if you start from the sample.
  • Xcode 16+ installed on macOS. The sample vendors the Dynamsoft Capture Vision C++ libraries 3.6.50.9329 (universal arm64 + x86_64 dylibs) in examples/dcv.

Step 1: Set Up the Xcode Project for macOS Barcode Scanning

Since the current target of the project is macOS, with potential future extension to iOS, we’ll select the Multiplatform template when creating the project in Xcode.

multiplatform template

Next, copy the.h and .dylib files to a new folder named dbr and drag the folder into the project.

macOS c++ barcode SDK

Navigate to Build Phases -> Link Binary With Libraries and add the *.dylib files. Ensure that the supported platform is set to macOS only.

link binary with libraries

Click the + button and select New Copy Files Phase. Set the destination to Frameworks and add the *.dylib files. This step bundles the *.dylib files with the app.

bundle dylib files in swiftui project

Finally, open the .entitlements file and add the camera permission for the macOS app.

macos camera permission

Step 2: Bridge the C++ Barcode Detection API to Objective-C

To invoke C++ functions in Swift, we need to create an Objective-C++ wrapper.

  1. Create an Objective-C bridging header file named DCV-Bridging-Header.h. The C++ includes and namespaces sit behind __cplusplus and TARGET_OS_IPHONE guards so the same header can be imported from Objective-C, Objective-C++, and Swift:

     #ifndef DCV_Bridging_Header_h
     #define DCV_Bridging_Header_h
    
     #include <TargetConditionals.h>
    
     #if defined(__APPLE__) && defined(__MACH__) && !TARGET_OS_IPHONE
     #ifdef __cplusplus
     #include "DynamsoftCaptureVisionRouter.h"
     #include "DynamsoftUtility.h"
    
     using namespace dynamsoft::license;
     using namespace dynamsoft::cvr;
     using namespace dynamsoft::dbr;
     using namespace dynamsoft::utility;
     using namespace dynamsoft::basic_structures;
     #endif /* __cplusplus */
    
     #import <Foundation/Foundation.h>
    
     @interface CaptureVisionWrapper : NSObject
     + (int)initializeLicense:(NSString *)licenseKey;
    
     - (NSArray *)captureImageWithData:(void *)baseAddress
                                 width:(int)width
                                height:(int)height
                                stride:(int)stride
                           pixelFormat:(OSType)pixelFormat;
    
     @end
     #endif
    
     #endif /* DCV_Bridging_Header_h */
    

    Explanation

    • The code inside #ifdef __cplusplus includes DynamsoftCaptureVisionRouter.h and DynamsoftUtility.h and pulls in the Dynamsoft namespaces. C++ syntax is not valid in plain Objective-C, so the guard keeps the header importable from both languages.
    • captureImageWithData: takes the raw void * base address of the pixel buffer. Reusing the camera buffer avoids copying a full frame on every detection pass.
    • TARGET_OS_IPHONE keeps the wrapper compiled for macOS only, so the iOS target of a multiplatform project can use the Swift package instead.
  2. In Build Settings, set the Objective-C Bridging Header to the created header file.

    Objective-C bridging header

  3. Create a dcv.mm file, which allows calling C++ functions, using C++ libraries, and interacting with C++ objects within Objective-C methods.

    • Import the bridging header file:

        #import <CoreVideo/CoreVideo.h>
        #import <Foundation/Foundation.h>
              
        #import "DCV-Bridging-Header.h"
              
        #if defined(__APPLE__) && defined(__MACH__) && !TARGET_OS_IPHONE
              
        @implementation CaptureVisionWrapper {
          CCaptureVisionRouter *cvr;
        }
      
    • Initialize the license key:

        + (int)initializeLicense:(NSString *)licenseKey {
          char errorMsgBuffer[512];
      
          const char *licenseCString = [licenseKey UTF8String];
      
          int ret = CLicenseManager::InitLicense(licenseCString, errorMsgBuffer,
                                                 sizeof(errorMsgBuffer));
      
          if (ret != 0) {
            NSString *errorMessage = [NSString stringWithUTF8String:errorMsgBuffer];
            NSLog(@"License initialization failed: %@", errorMessage);
          } else {
            NSLog(@"License initialized successfully");
          }
      
          return ret;
        }
      
    • Manage the lifecycle of the CCaptureVisionRouter object. No JSON template is loaded here because detection uses the built-in PT_READ_BARCODES preset:

        - (instancetype)init {
          self = [super init];
          if (self) {
            cvr = new CCaptureVisionRouter();
          }
          return self;
        }
      
        - (void)dealloc {
          if (cvr) {
            delete cvr;
            cvr = nullptr;
          }
        }
      
    • Convert the frame image format from OSType to ImagePixelFormat. The capture session is configured for 32BGRA:

        - (ImagePixelFormat)mapPixelFormat:(OSType)pixelFormat {
          switch (pixelFormat) {
          case kCVPixelFormatType_32BGRA:
            return IPF_ABGR_8888;
          default:
            return IPF_NV21;
          }
        }
      
    • Decode barcodes from an image and return an array of barcode results:

        - (NSArray *)captureImageWithData:(void *)baseAddress
                                    width:(int)width
                                   height:(int)height
                                   stride:(int)stride
                              pixelFormat:(OSType)pixelFormat {
          ImagePixelFormat sdkPixelFormat = [self mapPixelFormat:pixelFormat];
      
          CImageData *imageStruct =
              new CImageData(stride * height, (unsigned char *)baseAddress, width,
                             height, stride, sdkPixelFormat);
      
          CCapturedResult *result =
              cvr->Capture(imageStruct, CPresetTemplate::PT_READ_BARCODES);
      
          if (result->GetErrorCode() != 0) {
            NSLog(@"Error code: %d", result->GetErrorCode());
          }
          CDecodedBarcodesResult *barcodeResult = result->GetDecodedBarcodesResult();
          if (barcodeResult == nullptr || barcodeResult->GetItemsCount() == 0) {
            NSLog(@"No barcode found");
            delete imageStruct;
            return nil;
          }
      
          int barcodeResultItemCount = barcodeResult->GetItemsCount();
          NSLog(@"Total barcode(s) found: %d", barcodeResultItemCount);
          NSMutableArray *barcodeArray = [NSMutableArray array];
      
          for (int j = 0; j < barcodeResultItemCount; j++) {
            const CBarcodeResultItem *barcodeResultItem = barcodeResult->GetItem(j);
            const char *format = barcodeResultItem->GetFormatString();
            const char *text = barcodeResultItem->GetText();
            CQuadrilateral quad = barcodeResultItem->GetLocation(); // Keep alive before use
            CPoint *points = quad.points;
      
            NSDictionary *barcodeData = @{
              @"format" : [NSString stringWithUTF8String:format],
              @"text" : [NSString stringWithUTF8String:text],
              @"points" : @[
                @{@"x" : @(points[0][0]), @"y" : @(height - points[0][1])},
                @{@"x" : @(points[1][0]), @"y" : @(height - points[1][1])},
                @{@"x" : @(points[2][0]), @"y" : @(height - points[2][1])},
                @{@"x" : @(points[3][0]), @"y" : @(height - points[3][1])}
              ]
            };
      
            [barcodeArray addObject:barcodeData];
          }
      
          if (barcodeResult)
            barcodeResult->Release();
      
          result->Release();
      
          delete imageStruct;
          return [barcodeArray copy];
        }
      
        @end
      
        #endif
      

    Explanation

    • GetLocation() returns a CQuadrilateral by value, so the result is stored in a local variable before points is read. Calling barcodeResultItem->GetLocation().points directly would leave a pointer into a destroyed temporary.
    • The Y coordinate is flipped with height - points[i][1] because the C++ coordinate origin is the top-left of the frame while the AppKit overlay draws from the bottom-left.
    • Everything the C++ API allocates — the CImageData, the decoded result, and the captured result — is released before returning.

Step 3: Create a Camera View Controller in Swift

Create a CameraViewController.swift file and add the following code to set up a camera view:

import AVFoundation
import Accelerate
import SwiftUI
import Cocoa

typealias ViewController = NSViewController
typealias ImageType = NSImage

class CameraViewController: ViewController,
    AVCapturePhotoCaptureDelegate, AVCaptureVideoDataOutputSampleBufferDelegate
{
    var captureSession: AVCaptureSession!
    var photoOutput: AVCapturePhotoOutput!
    var previewLayer: AVCaptureVideoPreviewLayer!
    var overlayView: BarcodeOverlayView!
    let cv = CaptureVisionWrapper()

    override func viewDidLoad() {
        super.viewDidLoad()

        let licenseKey = "LICENSE-KEY"
        let result = CaptureVisionWrapper.initializeLicense(licenseKey)
        if result != 0 {
            print("Failed to initialize license with error code: \(result)")
        }

        setupCamera()
    }

    func setupCamera() {
        captureSession = AVCaptureSession()
        captureSession.sessionPreset = .photo

        guard let camera = AVCaptureDevice.default(for: .video) else {
            print("Unable to access the camera!")
            return
        }

        do {
            let input = try AVCaptureDeviceInput(device: camera)
            photoOutput = AVCapturePhotoOutput()
            let videoOutput = AVCaptureVideoDataOutput()

            videoOutput.videoSettings = [
                kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
            ]
            videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))

            if captureSession.canAddInput(input) && captureSession.canAddOutput(photoOutput) {
                captureSession.addInput(input)
                captureSession.addOutput(photoOutput)
                captureSession.addOutput(videoOutput)

                previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
                previewLayer.videoGravity = .resizeAspectFill
                previewLayer.frame = view.bounds

                view.layer = CALayer()
                view.wantsLayer = true
                view.layer?.insertSublayer(previewLayer, at: 0)

                DispatchQueue.global(qos: .userInitiated).async {
                    self.captureSession.startRunning()
                }
            }
        } catch {
            print("Error Unable to initialize camera: \(error.localizedDescription)")
        }
    }

    func captureOutput(
        _ output: AVCaptureOutput,
        didOutput sampleBuffer: CMSampleBuffer,
        from connection: AVCaptureConnection
    ) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }

        processCameraFrame(pixelBuffer)
    }

    override func viewDidLayout() {
        super.viewDidLayout()
        previewLayer?.frame = view.bounds
        overlayView?.frame = view.bounds
    }
}

Explanation

  • The CameraViewController class subclasses NSViewController and conforms to AVCaptureVideoDataOutputSampleBufferDelegate, so it receives every camera frame.
  • photoOutput is created before the canAddOutput check. The session is configured for 32BGRA frames because the wrapper maps that format to IPF_ABGR_8888.
  • overlayView is declared here and attached in Step 6, above the preview layer, so the barcode boxes are drawn on top of the video.

Step 4: Create a SwiftUI Camera View

Create a CameraView.swift file to integrate the CameraViewController into a SwiftUI view hierarchy:

import AVFoundation
import SwiftUI

struct CameraView: NSViewControllerRepresentable {

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeNSViewController(context: Context) -> CameraViewController {
        let cameraViewController = CameraViewController()
        context.coordinator.cameraViewController = cameraViewController
        return cameraViewController
    }

    func updateNSViewController(_ nsViewController: CameraViewController, context: Context) {
    }

    class Coordinator: NSObject {
        var parent: CameraView
        var cameraViewController: CameraViewController?

        init(_ parent: CameraView) {
            self.parent = parent
        }
    }
}

Explanation

  • makeCoordinator creates a Coordinator instance to facilitate communication between CameraView and CameraViewController.
  • makeNSViewController initializes the CameraViewController to be embedded in the SwiftUI view.
  • updateNSViewController allows dynamic updates to CameraViewController when the CameraView state changes.

Step 5: Decode Barcodes from Camera Frames in Swift

Swift and Objective-C interoperate directly, so processCameraFrame can call the Objective-C++ wrapper with the pixel buffer address and let C++ do the decoding.

func processCameraFrame(_ pixelBuffer: CVPixelBuffer) {
    let previewWidth = CVPixelBufferGetWidth(pixelBuffer)
    let previewHeight = CVPixelBufferGetHeight(pixelBuffer)

    DispatchQueue.main.async { [weak self] in
        self?.overlayView.cameraPreviewSize = CGSize(width: previewWidth, height: previewHeight)
    }

    CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
    defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }

    guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }

    let width = CVPixelBufferGetWidth(pixelBuffer)
    let height = CVPixelBufferGetHeight(pixelBuffer)
    let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
    let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)

    let barcodeArray = cv.captureImage(withData: baseAddress,
                                       width: Int32(width),
                                       height: Int32(height),
                                       stride: Int32(bytesPerRow),
                                       pixelFormat: pixelFormat) as? [[String: Any]] ?? []

    DispatchQueue.main.async { [weak self] in
        self?.overlayView.barcodeData = barcodeArray
        self?.overlayView.setNeedsDisplay(self?.overlayView.bounds ?? .zero)
    }
}

The selector captureImageWithData:width:height:stride:pixelFormat: is imported into Swift as captureImage(withData:width:height:stride:pixelFormat:), which is why the call spells out withData: instead of with:.

Step 6: Draw Barcode Results with an Overlay View

To display barcode detection results in real-time, overlay the bounding boxes on the camera preview.

  1. Create a BarcodeOverlayView.swift file and add the following code:

     import SwiftUI
     import Cocoa
     typealias OverlayView = NSView
     typealias OverlayColor = NSColor
        
     class BarcodeOverlayView: OverlayView {
         var barcodeData: [[String: Any]] = []  
         var cameraPreviewSize: CGSize = .zero
        
         override func draw(_ rect: CGRect) {
             guard cameraPreviewSize != .zero else { return }
             guard let context = NSGraphicsContext.current?.cgContext else { return }
        
             let overlaySize = bounds.size
        
             for barcode in barcodeData {
                 guard let points = barcode["points"] as? [[String: NSNumber]],
                     let format = barcode["format"] as? String,
                     let text = barcode["text"] as? String
                 else { continue }
        
                 let convertedPoints = points.map { point -> CGPoint in
                     let x = CGFloat(point["x"]!.doubleValue)
                     let y = CGFloat(point["y"]!.doubleValue)
                     return convertToOverlayCoordinates(
                         cameraPoint: CGPoint(x: x, y: y), overlaySize: overlaySize)
                 }
        
                 context.setStrokeColor(OverlayColor.red.cgColor)
                 context.setLineWidth(2.0)
        
                 if let firstPoint = convertedPoints.first {
                     context.beginPath()
                     context.move(to: firstPoint)
                     for point in convertedPoints.dropFirst() {
                         context.addLine(to: point)
                     }
                     context.closePath()
                     context.strokePath()
                 }
        
                 let labelRect = CGRect(
                     x: convertedPoints[0].x,
                     y: convertedPoints[0].y - 20,
                     width: 200,
                     height: 20
                 )
                 let paragraphStyle = NSMutableParagraphStyle()
                 paragraphStyle.alignment = .left
        
                 let attributes: [NSAttributedString.Key: Any] = [
                         .font: NSFont.systemFont(ofSize: 14),
                         .foregroundColor: OverlayColor.yellow,
                         .paragraphStyle: paragraphStyle,
                     ]
        
                 let attributedText = NSAttributedString(
                     string: "\(format): \(text)", attributes: attributes)
                 attributedText.draw(in: labelRect)
             }
         }
        
         private func convertToOverlayCoordinates(cameraPoint: CGPoint, overlaySize: CGSize)
         -> CGPoint
         {
             let cameraSize = cameraPreviewSize
        
             let scaleX = overlaySize.width / cameraSize.width
             let scaleY = overlaySize.height / cameraSize.height
        
             if scaleX < scaleY {
                 let deltaX = CGFloat((cameraSize.width * scaleY - overlaySize.width) / 2)
                 return CGPoint(x: cameraPoint.x * scaleY - deltaX, y: cameraPoint.y * scaleY)
             } else {
                 let deltaY = CGFloat((cameraSize.height * scaleX - overlaySize.height) / 2)
                 return CGPoint(x: cameraPoint.x * scaleX, y: cameraPoint.y * scaleX - deltaY)
             }
         }
     }
    

    Explanation

    • The draw method strokes the four corners of every barcode and writes the format and text above the first corner.
    • convertToOverlayCoordinates maps camera pixel space into the overlay bounds, matching the aspect-fill gravity of the preview layer.
  2. In CameraViewController.viewDidLoad, create the overlay above the preview layer:

     overlayView = BarcodeOverlayView()
     overlayView.wantsLayer = true
     overlayView.layer?.backgroundColor = NSColor.clear.cgColor
     overlayView.frame = view.bounds
     view.addSubview(overlayView, positioned: .above, relativeTo: nil)
    

    The Y flip is already applied inside the Objective-C++ wrapper (height - points[i][1]), so the overlay draws the returned points as-is. No extra frame mirroring is needed.

Step 7: Run the macOS Barcode Scanner App

Run the macOS barcode scanner app in Xcode to see the results:

macOS barcode scanner built with SwiftUI and C++ SDK

Common Issues and Edge Cases

  • Barcode overlays appear flipped or offset: The C++ result uses a top-left origin while AppKit draws from the bottom-left, so the wrapper flips each Y value with height - points[i][1]. If overlays still drift, verify that cameraPreviewSize matches the actual pixel-buffer dimensions, not the display-point size.
  • dylib not found at runtime: Ensure the .dylib files are added in both Link Binary With Libraries and a Copy Files build phase targeting Frameworks. Without the copy phase, the app compiles but crashes on launch with Library not loaded.
  • Camera permission denied with no prompt: On macOS 14+, the camera entitlement must be present in the .entitlements file and the Info.plist must include NSCameraUsageDescription. Missing either one silently blocks access.

Source Code

Get the complete sample project source code on GitHub