Building Custom Camera Features in Mobile Apps

The camera is the most underutilised sensor in most mobile apps. Beyond basic photo capture, modern smartphone cameras enable barcode scanning, document digitisation, real-time text recognition, augmented reality overlays, and video processing. These capabilities, when integrated thoughtfully, transform functional apps into genuinely useful tools.

Building a custom camera experience is more complex than calling the system camera picker. You need to manage the camera session lifecycle, handle permissions gracefully, optimise for performance, and deliver a smooth user experience across a range of devices. This guide covers practical camera implementations for both iOS and Android.

iOS Camera with AVFoundation

AVFoundation provides full control over the camera on iOS. While UIImagePickerController and PHPickerViewController handle basic capture, custom experiences require AVFoundation directly.

Camera Session Setup

class CameraManager: NSObject, ObservableObject {
    @Published var capturedImage: UIImage?
    @Published var isSessionRunning = false

    let session = AVCaptureSession()
    private let sessionQueue = DispatchQueue(label: "camera.session")
    private var photoOutput = AVCapturePhotoOutput()
    private var videoOutput = AVCaptureVideoDataOutput()

    func configureSession() {
        sessionQueue.async { [weak self] in
            guard let self = self else { return }

            self.session.beginConfiguration()
            self.session.sessionPreset = .photo

            // Add camera input
            guard let camera = AVCaptureDevice.default(
                .builtInWideAngleCamera,
                for: .video,
                position: .back
            ),
            let input = try? AVCaptureDeviceInput(device: camera) else {
                return
            }

            if self.session.canAddInput(input) {
                self.session.addInput(input)
            }

            // Add photo output
            if self.session.canAddOutput(self.photoOutput) {
                self.session.addOutput(self.photoOutput)
                self.photoOutput.isHighResolutionCaptureEnabled = true
            }

            // Add video data output for real-time processing
            self.videoOutput.setSampleBufferDelegate(
                self,
                queue: DispatchQueue(label: "camera.videoOutput")
            )
            if self.session.canAddOutput(self.videoOutput) {
                self.session.addOutput(self.videoOutput)
            }

            self.session.commitConfiguration()
        }
    }

    func startSession() {
        sessionQueue.async { [weak self] in
            self?.session.startRunning()
            DispatchQueue.main.async {
                self?.isSessionRunning = true
            }
        }
    }

    func stopSession() {
        sessionQueue.async { [weak self] in
            self?.session.stopRunning()
            DispatchQueue.main.async {
                self?.isSessionRunning = false
            }
        }
    }

    func capturePhoto() {
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
}

extension CameraManager: AVCapturePhotoCaptureDelegate {
    func photoOutput(
        _ output: AVCapturePhotoOutput,
        didFinishProcessingPhoto photo: AVCapturePhoto,
        error: Error?
    ) {
        guard let data = photo.fileDataRepresentation(),
              let image = UIImage(data: data) else { return }

        DispatchQueue.main.async {
            self.capturedImage = image
        }
    }
}

SwiftUI Camera Preview

struct CameraPreview: UIViewRepresentable {
    let session: AVCaptureSession

    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        let previewLayer = AVCaptureVideoPreviewLayer(session: session)
        previewLayer.videoGravity = .resizeAspectFill
        view.layer.addSublayer(previewLayer)
        context.coordinator.previewLayer = previewLayer
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        context.coordinator.previewLayer?.frame = uiView.bounds
    }

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

    class Coordinator {
        var previewLayer: AVCaptureVideoPreviewLayer?
    }
}

struct CameraView: View {
    @StateObject private var camera = CameraManager()

    var body: some View {
        ZStack {
            CameraPreview(session: camera.session)
                .ignoresSafeArea()

            VStack {
                Spacer()

                HStack(spacing: 40) {
                    // Flash toggle
                    Button(action: { camera.toggleFlash() }) {
                        Image(systemName: "bolt.fill")
                            .font(.title2)
                            .foregroundColor(.white)
                    }

                    // Capture button
                    Button(action: { camera.capturePhoto() }) {
                        Circle()
                            .fill(.white)
                            .frame(width: 70, height: 70)
                            .overlay(
                                Circle()
                                    .stroke(.white, lineWidth: 3)
                                    .frame(width: 80, height: 80)
                            )
                    }

                    // Switch camera
                    Button(action: { camera.switchCamera() }) {
                        Image(systemName: "camera.rotate")
                            .font(.title2)
                            .foregroundColor(.white)
                    }
                }
                .padding(.bottom, 40)
            }
        }
        .onAppear { camera.configureSession(); camera.startSession() }
        .onDisappear { camera.stopSession() }
    }
}

Android Camera with C

ameraX

CameraX simplifies Android camera development significantly compared to the Camera2 API:

class CameraFragment : Fragment() {
    private var imageCapture: ImageCapture? = null
    private var imageAnalysis: ImageAnalysis? = null

    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())

        cameraProviderFuture.addListener({
            val cameraProvider = cameraProviderFuture.get()

            // Preview
            val preview = Preview.Builder()
                .build()
                .also {
                    it.setSurfaceProvider(binding.viewFinder.surfaceProvider)
                }

            // Image capture
            imageCapture = ImageCapture.Builder()
                .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
                .setFlashMode(ImageCapture.FLASH_MODE_AUTO)
                .build()

            // Image analysis for real-time processing
            imageAnalysis = ImageAnalysis.Builder()
                .setTargetResolution(Size(1280, 720))
                .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                .build()
                .also { analysis ->
                    analysis.setAnalyzer(
                        ContextCompat.getMainExecutor(requireContext()),
                        BarcodeAnalyzer { barcode -> handleBarcode(barcode) }
                    )
                }

            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

            try {
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(
                    viewLifecycleOwner,
                    cameraSelector,
                    preview,
                    imageCapture,
                    imageAnalysis
                )
            } catch (e: Exception) {
                Log.e("Camera", "Binding failed", e)
            }
        }, ContextCompat.getMainExecutor(requireContext()))
    }

    private fun takePhoto() {
        val imageCapture = imageCapture ?: return

        val outputOptions = ImageCapture.OutputFileOptions.Builder(
            createTempFile()
        ).build()

        imageCapture.takePicture(
            outputOptions,
            ContextCompat.getMainExecutor(requireContext()),
            object : ImageCapture.OnImageSavedCallback {
                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    val savedUri = output.savedUri
                    handleCapturedImage(savedUri)
                }

                override fun onError(exception: ImageCaptureException) {
                    Log.e("Camera", "Capture failed", exception)
                }
            }
        )
    }
}

Barcode and QR Code Sc

anning

One of the most common custom camera features. Both platforms offer built-in barcode detection:

iOS with Vision Framework

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

        let request = VNDetectBarcodesRequest { [weak self] request, error in
            guard let results = request.results as? [VNBarcodeObservation] else { return }

            for barcode in results {
                if let payload = barcode.payloadStringValue {
                    DispatchQueue.main.async {
                        self?.onBarcodeDetected?(payload, barcode.symbology)
                    }
                }
            }
        }

        request.symbologies = [.qr, .ean13, .ean8, .code128, .code39]

        let handler = VNImageRequestHandler(
            cvPixelBuffer: pixelBuffer,
            orientation: .right,
            options: [:]
        )
        try? handler.perform([request])
    }
}

Android with ML Kit

class BarcodeAnalyzer(
    private val onBarcodeDetected: (String) -> Unit
) : ImageAnalysis.Analyzer {

    private val scanner = BarcodeScanning.getClient(
        BarcodeScannerOptions.Builder()
            .setBarcodeFormats(
                Barcode.FORMAT_QR_CODE,
                Barcode.FORMAT_EAN_13,
                Barcode.FORMAT_EAN_8,
                Barcode.FORMAT_CODE_128
            )
            .build()
    )

    @OptIn(ExperimentalGetImage::class)
    override fun analyze(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image ?: run {
            imageProxy.close()
            return
        }

        val inputImage = InputImage.fromMediaImage(
            mediaImage,
            imageProxy.imageInfo.rotationDegrees
        )

        scanner.process(inputImage)
            .addOnSuccessListener { barcodes ->
                barcodes.firstOrNull()?.rawValue?.let { value ->
                    onBarcodeDetected(value)
                }
            }
            .addOnCompleteListener {
                imageProxy.close()
            }
    }
}

Document Sc

anning

Build a document scanner that detects edges, corrects perspective, and enhances contrast:

import VisionKit

class DocumentScannerDelegate: NSObject, VNDocumentCameraViewControllerDelegate {
    var onScanComplete: (([UIImage]) -> Void)?

    func documentCameraViewController(
        _ controller: VNDocumentCameraViewController,
        didFinishWith scan: VNDocumentCameraScan
    ) {
        var images: [UIImage] = []

        for pageIndex in 0 ..< scan.pageCount {
            let image = scan.imageOfPage(at: pageIndex)
            images.append(image)
        }

        onScanComplete?(images)
        controller.dismiss(animated: true)
    }
}

// VisionKit provides a complete document scanning UI
// For custom implementations, use Vision framework:
func detectDocumentEdges(in image: CIImage) {
    let request = VNDetectRectanglesRequest { request, error in
        guard let results = request.results as? [VNRectangleObservation],
              let rectangle = results.first else { return }

        // Apply perspective correction
        let correctedImage = image.applyingFilter(
            "CIPerspectiveCorrection",
            parameters: [
                "inputTopLeft": CIVector(cgPoint: rectangle.topLeft),
                "inputTopRight": CIVector(cgPoint: rectangle.topRight),
                "inputBottomLeft": CIVector(cgPoint: rectangle.bottomLeft),
                "inputBottomRight": CIVector(cgPoint: rectangle.bottomRight)
            ]
        )
    }

    request.maximumObservations = 1
    request.minimumConfidence = 0.8

    let handler = VNImageRequestHandler(ciImage: image, options: [:])
    try? handler.perform([request])
}

Real-Time Text Recognition (OCR)

Extract text from the camera feed in real time:

func recogniseText(in pixelBuffer: CVPixelBuffer) {
    let request = VNRecognizeTextRequest { request, error in
        guard let results = request.results as? [VNRecognizedTextObservation] else { return }

        let recognisedText = results.compactMap { observation in
            observation.topCandidates(1).first?.string
        }.joined(separator: "\n")

        DispatchQueue.main.async {
            self.recognisedText = recognisedText
        }
    }

    request.recognitionLevel = .accurate
    request.recognitionLanguages = ["en-AU"]
    request.usesLanguageCorrection = true

    let handler = VNImageRequestHandler(
        cvPixelBuffer: pixelBuffer,
        orientation: .right,
        options: [:]
    )
    try? handler.perform([request])
}

Performance Best Practices

Camera processing is CPU and GPU intensive. Follow these guidelines:

  1. Process frames selectively. You do not need to analyse every frame. Skip frames when the previous analysis is still running.

  2. Use appropriate resolutions. Full-resolution capture for photos, lower resolution for real-time analysis. A 720p feed is sufficient for barcode scanning.

  3. Offload processing. Run Vision and ML Kit analysis on background queues. Never block the camera preview.

  4. Release resources promptly. Stop the camera session when the view disappears. Release analysis objects when they are no longer needed.

  5. Test on older devices. An iPhone SE or budget Android device reveals performance issues that flagships hide.

  6. Monitor battery impact. Camera usage drains battery quickly. Provide clear UI indicating when the camera is active and offer ways to dismiss it.

Custom camera features differentiate your app in ways that stock camera pickers cannot. Whether scanning barcodes at a warehouse, digitising receipts, or enabling visual search, the camera is your app’s window to the physical world.


Need custom camera features in your mobile app? Our team at eawesome builds intelligent camera experiences for Australian businesses.