The video recording feature allows users to capture short-form videos using the device camera with an animated record button and real-time preview.
Architecture
The recording system consists of:
- MediaViewController: Main controller for the recording interface
- CameraManager: Manages AVCaptureSession and video recording
- RecordButton: Custom animated button for recording control
Camera Setup
The CameraManager initializes the capture session with both video and audio inputs:
CameraManager.swift:96-156
fileprivate func setupCamera(completion: @escaping RegularCompletionBlock){
captureSession = AVCaptureSession()
guard let device = AVCaptureDevice.default(AVCaptureDevice.DeviceType.builtInWideAngleCamera, for: .video, position: .back) else { return }
captureDevice = device
guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return }
var deviceInput: AVCaptureDeviceInput!
var audioDeviceInput: AVCaptureDeviceInput!
do {
deviceInput = try AVCaptureDeviceInput(device: captureDevice)
guard deviceInput != nil else {
print("error: cant get deviceInput")
return
}
audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice)
guard audioDeviceInput != nil else {
print("error: cant get audioDeviceInput")
return
}
sessionQueue.async {
if let session = self.captureSession {
session.beginConfiguration()
session.sessionPreset = .high
// Add video
if session.canAddInput(deviceInput){
session.addInput(deviceInput)
}
// Add audio
if session.canAddInput(audioDeviceInput){
session.addInput(audioDeviceInput)
}
self.movieOutput = AVCaptureMovieFileOutput()
if session.canAddOutput(self.movieOutput!){
session.addOutput(self.movieOutput!)
}
if let connection = self.movieOutput!.connection(with: .video) {
if connection.isVideoStabilizationSupported {
connection.preferredVideoStabilizationMode = .auto
}
}
self.setupPreviewLayer()
session.commitConfiguration()
session.startRunning()
completion()
}
}
} catch let error as NSError {
deviceInput = nil
print("Device Input Error: \(error.localizedDescription)")
}
}
The session is configured on a dedicated serial queue (sessionQueue) to avoid blocking the main thread during camera setup.
Preview Layer
The preview layer displays the camera feed:
CameraManager.swift:60-76
func addPreviewLayerToView(view: UIView){
if cameraAndAudioAccessPermitted {
if let previewLayer = previewLayer {
previewLayer.removeFromSuperlayer()
}
setupCamera {
self.embeddingView = view
DispatchQueue.main.async {
guard let previewLayer = self.previewLayer else { return }
previewLayer.frame = view.layer.bounds
view.clipsToBounds = true
view.layer.addSublayer(previewLayer)
}
}
}
}
Recording Flow
When the user presses the record button, recording starts and saves to a temporary file:
func startRecording(){
movieOutput?.startRecording(to: tempFilePath, recordingDelegate: self)
}
The temporary file path is dynamically generated:
fileprivate var tempFilePath: URL {
get{
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("tempMovie\(Date())").appendingPathExtension(VIDEO_FILE_EXTENSION)
return tempURL
}
}
When the button is released, recording stops:
func stopRecording(){
captureSession?.stopRunning()
if let output = self.movieOutput, output.isRecording {
output.stopRecording()
}
}
Step 3: Recording Complete
The delegate is called when recording finishes:
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
if let error = error {
print("Saving video failed: \(error.localizedDescription)")
} else {
delegate.finishRecording(outputFileURL, error)
}
}
The record button features a custom animation that transforms when pressed:
The button consists of two layers:
override func awakeFromNib() {
super.awakeFromNib()
originalCenter = self.center
outerLayer = CAShapeLayer()
outerLayer.frame = self.bounds
outerLayer.cornerRadius = originalRadius
outerLayer.backgroundColor = UIColor.clear.cgColor
outerLayer.borderColor = UIColor.Red.withAlphaComponent(0.5).cgColor
outerLayer.borderWidth = outerLineWidth
self.layer.addSublayer(outerLayer)
innerLayer = CAShapeLayer()
let offset = outerLineWidth + spacing
innerLayer.frame = CGRect(x: offset, y: offset, width: (originalRadius - offset) * 2, height: (originalRadius - offset) * 2)
innerLayer.cornerRadius = originalRadius - offset
innerLayer.backgroundColor = UIColor.Red.cgColor
self.layer.addSublayer(innerLayer)
}
Start Recording Animation
When recording starts, the button transforms from a circle to a rounded square:
func startRecordingAnimation(){
removeAnimations()
let outerBorderColorAnimation = RecordAnimation(keyPath: "borderColor")
outerBorderColorAnimation.fromValue = outerLayer.borderColor
outerBorderColorAnimation.toValue = UIColor.Red.cgColor
let outerScaleAnimation = RecordAnimation(keyPath: "transform.scale")
outerScaleAnimation.fromValue = 1
outerScaleAnimation.toValue = 1.5
outerLayer.add(outerBorderColorAnimation, forKey: "outerBackgroundColorAnimation")
outerLayer.add(outerScaleAnimation, forKey: "outerScaleAnimation")
let innerCornerRadiusAnimation = RecordAnimation(keyPath: "cornerRadius")
innerCornerRadiusAnimation.fromValue = innerLayer.cornerRadius
innerCornerRadiusAnimation.toValue = CGFloat(5)
let innerScaleAnimation = RecordAnimation(keyPath: "transform.scale")
innerScaleAnimation.fromValue = 1
innerScaleAnimation.toValue = 0.5
innerLayer.add(innerCornerRadiusAnimation, forKey: "innerCornerRadiusAnimation")
innerLayer.add(innerScaleAnimation, forKey: "innerScaleAnimation")
}
Stop Recording Animation
When recording stops, the button animates back to its original state:
RecordButton.swift:76-101
func stopRecodingAnimation(){
let outerBorderColorAnimation2 = RecordAnimation(keyPath: "borderColor")
outerBorderColorAnimation2.fromValue = UIColor.Red.cgColor
outerBorderColorAnimation2.toValue = UIColor.Red.withAlphaComponent(0.5).cgColor
let outerScaleAnimation2 = RecordAnimation(keyPath: "transform.scale")
outerScaleAnimation2.fromValue = 1.5
outerScaleAnimation2.toValue = 1
outerLayer.add(outerBorderColorAnimation2, forKey: "outerBackgroundColorAnimation2")
outerLayer.add(outerScaleAnimation2, forKey: "outerScaleAnimation2")
let innerCornerRadiusAnimation2 = RecordAnimation(keyPath: "cornerRadius")
innerCornerRadiusAnimation2.fromValue = CGFloat(5)
innerCornerRadiusAnimation2.toValue = CGFloat( originalRadius - outerLineWidth - spacing)
let innerScaleAnimation2 = RecordAnimation(keyPath: "transform.scale")
innerScaleAnimation2.fromValue = 0.5
innerScaleAnimation2.toValue = 1
innerLayer.add(innerCornerRadiusAnimation2, forKey: "innerCornerRadiusAnimation2")
innerLayer.add(innerScaleAnimation2, forKey: "innerScaleAnimation2")
UIView.animate(withDuration: TimeInterval(animationDuration), animations: { [weak self] in
guard let self = self else { return }
self.center = self.originalCenter!
})
}
The RecordAnimation class is a custom CABasicAnimation subclass with pre-configured properties:class RecordAnimation: CABasicAnimation {
override init() {
super.init()
duration = 0.7
fillMode = .forwards
isRemovedOnCompletion = false
}
}
Gesture Recognition
The recording is controlled by a long-press gesture:
MediaViewController.swift:252-264
@objc fileprivate func recordBtnPressed(sender: UILongPressGestureRecognizer){
let location = sender.location(in: self.view)
switch sender.state {
case .began:
startRecording()
case .changed:
recordView.locationChanged(location: location)
case .cancelled, .ended:
stopRecording()
default:
break
}
}
Saving to Temp Directory
Videos are temporarily stored in the app’s temp directory:
CameraManager.swift:178-183
func removeAllTempFiles(){
var directory = NSTemporaryDirectory()
sessionQueue.async {
directory.removeAll()
}
}
Always clear temporary files when the view loads to avoid storage buildup:MediaViewController.swift:54-57
override func viewDidLoad() {
super.viewDidLoad()
// Clear files from previous sessions
cameraManager.removeAllTempFiles()
setupView()
}
Saving to Photo Library
After recording, videos can be saved to the photo library:
CameraManager.swift:226-251
func saveToLibrary(videoURL: URL){
func save(){
sessionQueue.async { [weak self] in
self?.photoLibrary?.performChanges({
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: videoURL)
}, completionHandler: { (saved, error) in
if let error = error {
print("Saving to Photo Library Failed: \(error.localizedDescription)")
} else {
print("Saved at: \(videoURL)")
}
})
}
}
if PHPhotoLibrary.authorizationStatus() != .authorized {
PHPhotoLibrary.requestAuthorization({ status in
if status == .authorized {
save()
}
})
} else {
save()
}
}
Permission Handling
@objc func askForCameraAccess(_ completion: @escaping AccessPermissionCompletionBlock){
AVCaptureDevice.requestAccess(for: .video, completionHandler: { [weak self] access in
self?.checkIfBothPermissionGranted()
DispatchQueue.main.async {
completion(access)
}
})
}
@objc func askForMicrophoneAccess(_ completion: @escaping AccessPermissionCompletionBlock){
AVCaptureDevice.requestAccess(for: .audio, completionHandler: { [weak self] access in
self?.checkIfBothPermissionGranted()
DispatchQueue.main.async {
completion(access)
}
})
}