Building a Fully Custom Video Recording Interface with AVFoundation

Apple’s AVFoundation framework gives iOS developers complete control over audio and video capture. While the system camera app works for basic needs, many applications require a unique recording experience – think custom filters, branded overlays, manual controls, or non‑standard aspect ratios. This article walks through the complete process of building a production‑ready custom video recording interface from scratch, covering session configuration, live preview, custom UI elements, recording logic, permissions handling, and post‑capture workflows.

Understanding the Core Components of AVFoundation

Before writing any code, it helps to understand the key classes you’ll work with:

  • AVCaptureSession – The central orchestrator that routes media data from inputs to outputs. You configure its preset (e.g., .high, .1920x1080) to define capture quality.
  • AVCaptureDevice – Represents a physical camera or microphone. You query for devices (back, front, wide‑angle, telephoto) and configure properties like focus and exposure.
  • AVCaptureDeviceInput – Wraps an AVCaptureDevice so it can be added to the session.
  • AVCaptureVideoPreviewLayer – A CALayer subclass that renders the live camera feed in your UI. It must be added to your view’s layer hierarchy and configured with the session.
  • AVCaptureMovieFileOutput – The simplest way to write video to a file. For more control (e.g., custom audio tracks), you can use AVCaptureVideoDataOutput and AVCaptureAudioDataOutput in combination with AVAssetWriter.

Apple’s official AVFoundation documentation provides exhaustive detail, but for this guide we focus on practical implementation.

Setting Up the Capture Session

Creating the session is the first step. You’ll add a camera input and a microphone input, then add a video output. Here’s a structured setup:

import AVFoundation

class CameraController {
    private var session: AVCaptureSession?
    private var previewLayer: AVCaptureVideoPreviewLayer?
    private var movieOutput: AVCaptureMovieFileOutput?

    func configureSession() {
        session = AVCaptureSession()
        session?.sessionPreset = .high

        guard let session = session else { return }

        // Add camera input (back camera)
        guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
              let cameraInput = try? AVCaptureDeviceInput(device: camera),
              session.canAddInput(cameraInput) else {
            print("Unable to add camera input")
            return
        }
        session.addInput(cameraInput)

        // Add microphone input
        guard let microphone = AVCaptureDevice.default(for: .audio),
              let audioInput = try? AVCaptureDeviceInput(device: microphone),
              session.canAddInput(audioInput) else {
            print("Unable to add audio input")
            return
        }
        session.addInput(audioInput)

        // Add movie file output
        movieOutput = AVCaptureMovieFileOutput()
        guard let movieOutput = movieOutput, session.canAddOutput(movieOutput) else {
            print("Unable to add movie output")
            return
        }
        session.addOutput(movieOutput)

        // Configure preview layer
        previewLayer = AVCaptureVideoPreviewLayer(session: session)
        previewLayer?.videoGravity = .resizeAspectFill

        session.startRunning()
    }
}

Always call startRunning() on a background queue to avoid blocking the main thread. You can use DispatchQueue.global(qos: .userInitiated).async.

Selecting the Right Session Preset

The sessionPreset defines the output resolution and quality. Options include .low, .medium, .high, .hd1920x1080, .hd4K3840x2160, and more. Note that the preset must be compatible with the added inputs. If you need custom resolutions (e.g., square or ultra‑wide), you can use AVCaptureDevice.Format to override the preset.

Displaying a Live Camera Preview

To show the camera feed, you add the AVCaptureVideoPreviewLayer to your view’s layer. In UIKit, this typically goes inside a container view:

class PreviewView: UIView {
    override class var layerClass: AnyClass {
        return AVCaptureVideoPreviewLayer.self
    }

    var videoPreviewLayer: AVCaptureVideoPreviewLayer {
        return layer as! AVCaptureVideoPreviewLayer
    }
}

// Usage in your view controller
let previewView = PreviewView(frame: view.bounds)
view.addSubview(previewView)
previewView.videoPreviewLayer.session = session
previewView.videoPreviewLayer.videoGravity = .resizeAspectFill

The preview automatically updates as the session runs. You can change the videoGravity to .resizeAspect (letterboxed) or .resize (stretched).

Designing a Custom User Interface

With the live feed on screen, you can layer custom controls on top. Typical elements include:

  • Record / Stop button – A large circular button that changes color (red when recording, white when idle).
  • Camera switch button – Toggles between front and back cameras.
  • Flash toggle – Turns the LED flash on/off (for still photos or video recording).
  • Recording timer – A label that updates every second to show elapsed time.
  • Resolution selector – A picker or segmented control to change the session preset.
  • Focus / exposure indicator – A visual square that appears where the user taps to set focus and exposure.

Example: Record Button with State Handling

class RecordButton: UIButton {
    var isRecording = false {
        didSet {
            backgroundColor = isRecording ? .systemRed : .white
            setTitle(isRecording ? "Stop" : "Record", for: .normal)
            setTitleColor(isRecording ? .white : .systemRed, for: .normal)
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        layer.cornerRadius = frame.width / 2
        clipsToBounds = true
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}

Layout these controls using Auto Layout or a stack view, ensuring they don’t interfere with the preview layer’shitTest. You may also want to add a semi‑transparent gradient view at the bottom of the screen for better readability of the controls.

Recording Video

When the user taps the record button, you start/stop the AVCaptureMovieFileOutput. The output writes to a temporary file URL. After recording finishes, you can move the file to a permanent location or present it in a player.

func toggleRecording() {
    guard let movieOutput = movieOutput else { return }
    guard let session = session, session.isRunning else { return }

    if movieOutput.isRecording {
        movieOutput.stopRecording()
        recordButton.isRecording = false
        timer.invalidate()
    } else {
        let outputPath = NSTemporaryDirectory() + "\(UUID().uuidString).mov"
        let outputURL = URL(fileURLWithPath: outputPath)
        movieOutput.startRecording(to: outputURL, recordingDelegate: self)
        recordButton.isRecording = true
        startTimer()
    }
}

Implement AVCaptureFileOutputRecordingDelegate to handle completion and errors:

extension CameraController: AVCaptureFileOutputRecordingDelegate {
    func fileOutput(_ output: AVCaptureFileOutput,
                    didFinishRecordingTo outputFileURL: URL,
                    from connections: [AVCaptureConnection],
                    error: Error?) {
        if let error = error {
            // Handle recording failure (e.g., disk full)
            print("Recording error: \(error.localizedDescription)")
            return
        }
        // Success – save to Photos library or present the video
        DispatchQueue.main.async {
            self.presentVideo(at: outputFileURL)
        }
    }
}

Managing the Recording Timer

Use a Timer fired every second to update the label. Format elapsed seconds into MM:SS:

var elapsedSeconds = 0
func startTimer() {
    elapsedSeconds = 0
    timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
        guard let self = self else { return }
        self.elapsedSeconds += 1
        let minutes = self.elapsedSeconds / 60
        let seconds = self.elapsedSeconds % 60
        self.timerLabel.text = String(format: "%02d:%02d", minutes, seconds)
    }
}

Handling Permissions and User Privacy

Before using camera or microphone, you must request authorization. Handling denial gracefully is critical for a good user experience.

  • CameraAVCaptureDevice.requestAccess(for: .video)
  • MicrophoneAVCaptureDevice.requestAccess(for: .audio)
func requestPermissions() {
    let group = DispatchGroup()

    group.enter()
    AVCaptureDevice.requestAccess(for: .video) { granted in
        if !granted { print("Camera access denied") }
        group.leave()
    }

    group.enter()
    AVCaptureDevice.requestAccess(for: .audio) { granted in
        if !granted { print("Microphone access denied") }
        group.leave()
    }

    group.notify(queue: .main) {
        self.configureSession()
    }
}

If the user denies permission, consider showing a tailored message and a link to Settings using UIApplication.openSettingsURLString. Apple tightly enforces privacy, so never attempt to bypass the permission dialog.

Handling Interruptions (Phone Calls, Alerts)

AVFoundation sessions can be interrupted by phone calls, alarms, or other apps. You need to register for AVCaptureSessionWasInterruptedNotification and AVCaptureSessionInterruptionEndedNotification. When an interruption starts, stop the recording gracefully (if in progress) and update the UI. After interruption ends, restart the session if desired.

NotificationCenter.default.addObserver(
    self,
    selector: #selector(sessionWasInterrupted(_:)),
    name: .AVCaptureSessionWasInterrupted,
    object: session
)

@objc func sessionWasInterrupted(_ notification: Notification) {
    if movieOutput?.isRecording == true {
        movieOutput?.stopRecording()
        recordButton.isRecording = false
    }
    // Show a message: "Recording paused"
}

Adding Manual Focus and Exposure

Professional video apps allow users to tap to set focus and exposure. AVFoundation supports this through AVCaptureDevice methods. First, convert the tap point from view coordinates to preview layer coordinates:

@IBAction func handleTap(_ gesture: UITapGestureRecognizer) {
    let location = gesture.location(in: previewView)
    guard let device = currentCameraInput?.device,
          let previewLayer = previewLayer else { return }

    let pointOfInterest = previewLayer.captureDevicePointConverted(
        fromLayerPoint: location
    )

    do {
        try device.lockForConfiguration()
        if device.isFocusPointOfInterestSupported {
            device.focusPointOfInterest = pointOfInterest
            device.focusMode = .autoFocus
        }
        if device.isExposurePointOfInterestSupported {
            device.exposurePointOfInterest = pointOfInterest
            device.exposureMode = .autoExpose
        }
        device.unlockForConfiguration()
    } catch {
        print("Could not lock device for configuration: \(error)")
    }
}

For a polished look, animate a small focus square at the tap location that fades after a second.

Saving Recorded Video to the Photo Library

After a successful recording, you may want to save the video to the user’s Camera Roll. Use the PHPhotoLibrary class from Photos framework. First, request write authorization:

import Photos

func saveVideoToLibrary(_ videoURL: URL) {
    PHPhotoLibrary.requestAuthorization { status in
        guard status == .authorized else { return }
        PHPhotoLibrary.shared().performChanges({
            PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: videoURL)
        }) { success, error in
            DispatchQueue.main.async {
                if success {
                    // Show success alert
                } else if let error = error {
                    print("Save failed: \(error.localizedDescription)")
                }
            }
        }
    }
}

Remember that writing to the Photos library requires the NSPhotoLibraryAddUsageDescription key in Info.plist.

Switching Between Front and Back Cameras

To toggle cameras, you remove the existing camera input and add the new one. Keep in mind that the session must be reconfigured while it’s not running, or you can use beginConfiguration() / commitConfiguration() to change inputs without stopping the session.

func switchCamera() {
    guard let session = session else { return }
    session.beginConfiguration()

    // Remove existing camera input
    guard let currentCameraInput = session.inputs.first(where: { input in
        guard let input = input as? AVCaptureDeviceInput else { return false }
        return input.device.hasMediaType(.video)
    }) else { return }
    session.removeInput(currentCameraInput)

    // Determine new position
    let newPosition: AVCaptureDevice.Position = currentCameraInput.device.position == .back ? .front : .back
    guard let newCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition),
          let newInput = try? AVCaptureDeviceInput(device: newCamera),
          session.canAddInput(newInput) else {
        // Fall back to old input
        session.addInput(currentCameraInput)
        session.commitConfiguration()
        return
    }
    session.addInput(newInput)
    session.commitConfiguration()
}

When switching to the front camera, you may also want to mirror the preview layer for a more natural selfie experience. Set videoPreviewLayer.connection?.automaticallyAdjustsVideoMirroring = true.

Advanced Considerations: Custom Video Data Output and Asset Writer

For apps that need real‑time video processing (filters, augmented reality, custom compression), use AVCaptureVideoDataOutput and AVCaptureAudioDataOutput instead of AVCaptureMovieFileOutput. You feed the sample buffers into an AVAssetWriter. This approach gives you full control over the encoding process but adds complexity. A typical pipeline:

  1. Add AVCaptureVideoDataOutput and set its delegate to receive sample buffer callbacks.
  2. Add AVCaptureAudioDataOutput similarly.
  3. Create an AVAssetWriter with a .mov output file.
  4. Add an AVAssetWriterInput for video and audio with appropriate settings.
  5. In the delegate callbacks, append sample buffers to the writer inputs.

This pattern is essential for integrating Core Image filters or applying custom metadata. For a detailed walkthrough, refer to Ray Wenderlich’s AVFoundation tutorial.

Performance and Battery Optimization

Video capture is resource‑intensive. To keep your app smooth:

  • Use the lowest resolution preset that meets your quality requirements.
  • Stop the session when the view controller disappears (override viewWillDisappear).
  • Prefer DispatchQueue.global(qos: .background) for sample buffer processing if using data outputs.
  • Avoid excessive UI updates during recording – batch timer updates or use a custom drawing layer.
  • Test on real devices; the simulator does not provide accurate performance metrics for camera work.

Testing Your Implementation

Before shipping, verify these scenarios:

  • Permission flow – First launch, denied, allowed then later revoked in Settings.
  • Interruptions – Incoming call, alarm, or app switching while recording.
  • Save to library – Ensure the video is correctly written and plays back in the Photos app.
  • Camera switch – Verify focus and exposure still work after switching.
  • Memory leaks – Use Instruments to check that the session and outputs are deallocated.

Conclusion

Building a custom video recording interface with AVFoundation unlocks a world of possibilities beyond Apple’s default camera UI. By mastering session configuration, preview layers, custom controls, recording logic, and permissions handling, you can create a feature‑rich recording experience tailored exactly to your app’s use case. Whether you’re building a social video app, a security camera viewer, or a content creation tool, the techniques covered here form the foundation. Start small, test on real devices, and gradually add polish with focus indicators, flash controls, and advanced processing. For more details, Apple’s AVFoundation Programming Guide and Hacking with Swift offer excellent supplementary reading.