Skip to content

Commit d9464b1

Browse files
authored
Merge pull request #3 from margelo/feat/swiftui-camera
Add SwiftUI Camera views and example app
2 parents 052c052 + 68a5dc8 commit d9464b1

56 files changed

Lines changed: 2935 additions & 75 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
ruby-version: '3.4.9'
2222
bundler-cache: true
2323
- name: Check Swift formatting
24-
run: xcrun swift-format lint --strict --recursive Sources Tests Example/Sources
24+
run: xcrun swift-format lint --strict --recursive Sources Tests Example/Sources ExampleSwiftUI/Sources
2525
- name: Lint pod and run tests
2626
run: bundle exec pod lib lint VisionCamera.podspec --platforms=ios --verbose
2727
- name: Install example dependencies

‎.github/workflows/swift-package.yml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,31 @@ jobs:
6767
name: swift-package-tests
6868
path: build/swift-package-tests.xcresult
6969
if-no-files-found: ignore
70+
71+
swiftui-example:
72+
runs-on: macos-26
73+
timeout-minutes: 30
74+
steps:
75+
- uses: actions/checkout@v6
76+
- name: Select Xcode
77+
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
78+
- name: Build SwiftUI simulator example
79+
run: >-
80+
xcodebuild
81+
-project ExampleSwiftUI/VisionCameraSwiftUIExample.xcodeproj
82+
-scheme VisionCameraSwiftUIExample
83+
-configuration Release
84+
-destination 'generic/platform=iOS Simulator'
85+
-derivedDataPath build/swiftui-simulator
86+
CODE_SIGNING_ALLOWED=NO
87+
build
88+
- name: Build SwiftUI device example
89+
run: >-
90+
xcodebuild
91+
-project ExampleSwiftUI/VisionCameraSwiftUIExample.xcodeproj
92+
-scheme VisionCameraSwiftUIExample
93+
-configuration Release
94+
-destination 'generic/platform=iOS'
95+
-derivedDataPath build/swiftui-device
96+
CODE_SIGNING_ALLOWED=NO
97+
build

‎Example/Podfile.lock‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ EXTERNAL SOURCES:
99
:path: ".."
1010

1111
SPEC CHECKSUMS:
12-
VisionCamera: 4ff46315dfa61b4addfe798883d725d140de15c1
12+
VisionCamera: 9347e4116668fa744f04c349083ecc3545ef14dc
1313

1414
PODFILE CHECKSUM: 82089221c7853fc03d4f401acf45dca76a256458
1515

‎ExampleSwiftUI/Info.plist‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleDevelopmentRegion</key>
6+
<string>en</string>
7+
<key>CFBundleExecutable</key>
8+
<string>$(EXECUTABLE_NAME)</string>
9+
<key>CFBundleIdentifier</key>
10+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11+
<key>CFBundleInfoDictionaryVersion</key>
12+
<string>6.0</string>
13+
<key>CFBundleName</key>
14+
<string>$(PRODUCT_NAME)</string>
15+
<key>CFBundlePackageType</key>
16+
<string>APPL</string>
17+
<key>CFBundleShortVersionString</key>
18+
<string>0.1.0</string>
19+
<key>CFBundleVersion</key>
20+
<string>1</string>
21+
<key>LSRequiresIPhoneOS</key>
22+
<true/>
23+
<key>NSCameraUsageDescription</key>
24+
<string>Use the camera to preview, take photos, and record videos.</string>
25+
<key>NSMicrophoneUsageDescription</key>
26+
<string>Record audio with camera videos.</string>
27+
<key>UIApplicationSceneManifest</key>
28+
<dict>
29+
<key>UIApplicationSupportsMultipleScenes</key>
30+
<false/>
31+
</dict>
32+
<key>UILaunchScreen</key>
33+
<dict/>
34+
<key>UISupportedInterfaceOrientations</key>
35+
<array>
36+
<string>UIInterfaceOrientationPortrait</string>
37+
<string>UIInterfaceOrientationLandscapeLeft</string>
38+
<string>UIInterfaceOrientationLandscapeRight</string>
39+
</array>
40+
<key>UISupportedInterfaceOrientations~ipad</key>
41+
<array>
42+
<string>UIInterfaceOrientationPortrait</string>
43+
<string>UIInterfaceOrientationPortraitUpsideDown</string>
44+
<string>UIInterfaceOrientationLandscapeLeft</string>
45+
<string>UIInterfaceOrientationLandscapeRight</string>
46+
</array>
47+
</dict>
48+
</plist>
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import SwiftUI
2+
import VisionCamera
3+
4+
@MainActor
5+
final class CameraModel: ObservableObject {
6+
// Outputs belong to this StateObject, so rendering the screen doesn't recreate them.
7+
let photoOutput = PhotoOutput()
8+
9+
@Published private(set) var device: CameraDevice?
10+
@Published private(set) var videoOutput: (any VideoOutput)?
11+
@Published private(set) var recordingMode: RecordingMode = .standard
12+
@Published private(set) var status = "Preparing camera..."
13+
@Published private(set) var thumbnail: UIImage?
14+
@Published private(set) var isActive = false
15+
@Published private(set) var isConfigured = false
16+
@Published private(set) var isRunning = false
17+
@Published private(set) var isInterrupted = false
18+
@Published private(set) var isBusy = false
19+
@Published private(set) var isRecording = false
20+
@Published private(set) var isFinalizing = false
21+
@Published var zoom = 1.0
22+
23+
private var microphoneGranted = false
24+
private var isPrepared = false
25+
private var isPreparing = false
26+
private var position: TargetCameraPosition = .back
27+
private var recorder: (any Recorder)?
28+
private var recordingID: UUID?
29+
private var isStartingRecording = false
30+
private var requestedActive = false
31+
private var sceneIsActive = false
32+
33+
var controlsEnabled: Bool {
34+
return requestedActive && isActive && isConfigured && isRunning && !isInterrupted && !isBusy && !isFinalizing
35+
}
36+
37+
var canSwitchCamera: Bool {
38+
return controlsEnabled && (!isRecording || recordingMode == .persistent)
39+
}
40+
41+
func prepare() async {
42+
guard !isPrepared, !isPreparing else { return }
43+
isPreparing = true
44+
defer { isPreparing = false }
45+
do {
46+
guard try await VisionCamera.requestCameraPermission() else {
47+
status = "Camera access is denied. Enable it in Settings to use the example."
48+
return
49+
}
50+
microphoneGranted = try await VisionCamera.requestMicrophonePermission()
51+
guard let device = VisionCamera.defaultCamera(for: position) else {
52+
status = "No camera is available. Run this example on an iPhone or iPad."
53+
return
54+
}
55+
self.device = device
56+
setVideoOutput()
57+
isPrepared = true
58+
// Permission prompts can change the scene phase. Apply its latest value after preparation.
59+
await updateActive()
60+
} catch {
61+
report(error)
62+
}
63+
}
64+
65+
func setActive(_ active: Bool) async {
66+
sceneIsActive = active
67+
await updateActive()
68+
}
69+
70+
private func updateActive() async {
71+
// This example pauses capture outside the active scene and ends both recording backends.
72+
// Camera owns session start/stop; the app owns the recording policy.
73+
requestedActive = sceneIsActive && isPrepared
74+
if requestedActive {
75+
isActive = true
76+
return
77+
}
78+
// If start is still pending, toggleRecording submits the stop once it starts.
79+
guard !isStartingRecording else { return }
80+
await finishRecording()
81+
// A new scene activation may have arrived while the stop request was being submitted.
82+
if !requestedActive { isActive = false }
83+
}
84+
85+
func didConfigure() {
86+
isConfigured = true
87+
showReadyStatus()
88+
}
89+
90+
func didStart() {
91+
isRunning = true
92+
showReadyStatus()
93+
}
94+
95+
func didStop() {
96+
isRunning = false
97+
}
98+
99+
func didInterrupt(_ reason: InterruptionReason) {
100+
isInterrupted = true
101+
status = "Camera interrupted: \(reason)"
102+
}
103+
104+
func didEndInterruption() {
105+
isInterrupted = false
106+
showReadyStatus()
107+
}
108+
109+
func report(_ error: Error) {
110+
status = error.localizedDescription
111+
}
112+
113+
func setRecordingMode(_ mode: RecordingMode) {
114+
guard controlsEnabled, !isRecording, recordingMode != mode else { return }
115+
recordingMode = mode
116+
setVideoOutput()
117+
}
118+
119+
func switchCamera() {
120+
guard canSwitchCamera else { return }
121+
let nextPosition: TargetCameraPosition = position == .back ? .front : .back
122+
guard let nextDevice = VisionCamera.defaultCamera(for: nextPosition) else {
123+
status = "The other camera is unavailable."
124+
return
125+
}
126+
isConfigured = false
127+
position = nextPosition
128+
device = nextDevice
129+
zoom = 1
130+
}
131+
132+
func capturePhoto() async {
133+
guard controlsEnabled else { return }
134+
isBusy = true
135+
defer { isBusy = false }
136+
do {
137+
let photo = try await photoOutput.capturePhoto()
138+
thumbnail = try await photo.image()
139+
status = "Photo captured"
140+
} catch {
141+
report(error)
142+
}
143+
}
144+
145+
func toggleRecording() async {
146+
guard controlsEnabled else { return }
147+
if isRecording {
148+
await finishRecording()
149+
return
150+
}
151+
guard let output = videoOutput else { return }
152+
isBusy = true
153+
isStartingRecording = true
154+
defer {
155+
isBusy = false
156+
isStartingRecording = false
157+
if !requestedActive { isActive = false }
158+
}
159+
do {
160+
let recorder = try await output.makeRecorder()
161+
// The scene may have become inactive while the output prepared the recorder.
162+
guard requestedActive else { return }
163+
let id = UUID()
164+
self.recorder = recorder
165+
recordingID = id
166+
isRecording = true
167+
try await recorder.start(
168+
// Keep the model and recorder alive through finalization, even if the screen disappears.
169+
// recordingFinished releases the recorder and breaks this ownership relationship.
170+
onFinished: { result in
171+
DispatchQueue.main.async {
172+
self.recordingFinished(id: id, message: "Video saved: \(result.fileURL.lastPathComponent)")
173+
}
174+
},
175+
onError: { error in
176+
DispatchQueue.main.async {
177+
self.recordingFinished(id: id, message: error.localizedDescription)
178+
}
179+
},
180+
onPaused: nil,
181+
onResumed: nil
182+
)
183+
isStartingRecording = false
184+
guard recordingID == id else { return }
185+
if !requestedActive {
186+
await finishRecording()
187+
} else if !isFinalizing {
188+
status = "Recording"
189+
}
190+
} catch {
191+
if let id = recordingID {
192+
recordingFinished(id: id, message: error.localizedDescription)
193+
} else {
194+
report(error)
195+
}
196+
}
197+
}
198+
199+
private func setVideoOutput() {
200+
isConfigured = false
201+
let options = VideoOutputOptions(isAudioEnabled: microphoneGranted)
202+
switch recordingMode {
203+
case .standard:
204+
videoOutput = MovieFileVideoOutput(options: options)
205+
case .persistent:
206+
videoOutput = VideoFrameOutput(options: options)
207+
}
208+
}
209+
210+
private func finishRecording() async {
211+
guard let recorder, !isFinalizing, !isStartingRecording else { return }
212+
isFinalizing = true
213+
status = "Finishing video..."
214+
await recorder.stop()
215+
// Retain the recorder until its callback confirms that the file is finalized.
216+
}
217+
218+
private func recordingFinished(id: UUID, message: String) {
219+
guard recordingID == id else { return }
220+
recorder = nil
221+
recordingID = nil
222+
isRecording = false
223+
isFinalizing = false
224+
status = message
225+
}
226+
227+
private func showReadyStatus() {
228+
guard isConfigured, isRunning, !isRecording else { return }
229+
status = microphoneGranted ? "Ready" : "Ready. Microphone denied; video will be silent."
230+
}
231+
}

0 commit comments

Comments
 (0)