Documentation Index Fetch the complete documentation index at: https://mintlify.com/pointfreeco/swift-composable-architecture/llms.txt
Use this file to discover all available pages before exploring further.
The Voice Memos example demonstrates how to build a feature that interacts with device capabilities like the microphone and audio system. It showcases permission handling, audio recording and playback, and complex async effects.
Overview
This example shows how to:
Request and handle permissions
Record audio with real-time feedback
Play audio with progress tracking
Model complex state with enums
Create custom dependency clients
Coordinate multiple async effects
Implementation
VoiceMemos Reducer
VoiceMemo Reducer
View
import AVFoundation
import ComposableArchitecture
import SwiftUI
@Reducer
struct VoiceMemos {
@ObservableState
struct State : Equatable {
@Presents var alert: AlertState<Action.Alert> ?
var audioRecorderPermission = RecorderPermission. undetermined
@Presents var recordingMemo: RecordingMemo.State ?
var voiceMemos: IdentifiedArrayOf<VoiceMemo.State> = []
enum RecorderPermission {
case allowed
case denied
case undetermined
}
}
enum Action : Sendable {
case alert (PresentationAction<Alert>)
case onDelete (IndexSet)
case openSettingsButtonTapped
case recordButtonTapped
case recordPermissionResponse ( Bool )
case recordingMemo (PresentationAction<RecordingMemo.Action>)
case voiceMemos (IdentifiedActionOf<VoiceMemo>)
enum Alert : Equatable {}
}
@Dependency (\. audioRecorder . requestRecordPermission ) var requestRecordPermission
@Dependency (\. date ) var date
@Dependency (\. openSettings ) var openSettings
@Dependency (\. temporaryDirectory ) var temporaryDirectory
@Dependency (\. uuid ) var uuid
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case . alert :
return . none
case . onDelete ( let indexSet) :
state. voiceMemos . remove ( atOffsets : indexSet)
return . none
case . openSettingsButtonTapped :
return . run { _ in
await self . openSettings ()
}
case . recordButtonTapped :
switch state.audioRecorderPermission {
case . undetermined :
return . run { send in
await send (
. recordPermissionResponse (
self . requestRecordPermission ()
)
)
}
case . denied :
state. alert = AlertState {
TextState ( "Permission is required to record voice memos." )
}
return . none
case . allowed :
state. recordingMemo = newRecordingMemo
return . none
}
case . recordingMemo (. presented (. delegate (. didFinish (. success ( let recordingMemo))))) :
state. recordingMemo = nil
state. voiceMemos . insert (
VoiceMemo. State (
date : recordingMemo. date ,
duration : recordingMemo. duration ,
url : recordingMemo. url
),
at : 0
)
return . none
case . recordingMemo (. presented (. delegate (. didFinish (. failure )))) :
state. alert = AlertState {
TextState ( "Voice memo recording failed." )
}
state. recordingMemo = nil
return . none
case . recordingMemo :
return . none
case . recordPermissionResponse ( let permission) :
state. audioRecorderPermission = permission ? . allowed : . denied
if permission {
state. recordingMemo = newRecordingMemo
return . none
} else {
state. alert = AlertState {
TextState ( "Permission is required to record voice memos." )
}
return . none
}
case . voiceMemos (. element ( id : let id, action : . delegate ( let delegateAction))) :
switch delegateAction {
case . playbackFailed :
state. alert = AlertState {
TextState ( "Voice memo playback failed." )
}
return . none
case . playbackStarted :
for memoID in state.voiceMemos.ids where memoID != id {
state. voiceMemos [ id : memoID] ? . mode = . notPlaying
}
return . none
}
case . voiceMemos :
return . none
}
}
. ifLet (\.$alert, action : \. alert )
. ifLet (\.$recordingMemo, action : \. recordingMemo ) {
RecordingMemo ()
}
. forEach (\. voiceMemos , action : \. voiceMemos ) {
VoiceMemo ()
}
}
private var newRecordingMemo: RecordingMemo.State {
RecordingMemo. State (
date : self . date . now ,
url : self . temporaryDirectory ()
. appendingPathComponent ( self . uuid (). uuidString )
. appendingPathExtension ( "m4a" )
)
}
}
Key Concepts
Permission Handling
Model permission state explicitly:
var audioRecorderPermission = RecorderPermission. undetermined
enum RecorderPermission {
case allowed
case denied
case undetermined
}
Request permission when needed:
case . recordButtonTapped :
switch state.audioRecorderPermission {
case . undetermined :
return . run { send in
await send (
. recordPermissionResponse (
self . requestRecordPermission ()
)
)
}
case . denied :
state. alert = AlertState {
TextState ( "Permission is required to record voice memos." )
}
return . none
case . allowed :
state. recordingMemo = newRecordingMemo
return . none
}
State Machines with Enums
Model playback state as an enum:
enum Mode : Equatable {
case notPlaying
case playing ( progress : Double )
}
This ensures:
Progress only exists when playing
Invalid states are impossible
Transitions are explicit
Audio Playback
Manage playback with coordinated effects:
case . playButtonTapped :
switch state.mode {
case . notPlaying :
state. mode = . playing ( progress : 0 )
return . run { [url = state. url ] send in
await send (. delegate (. playbackStarted ))
// Run audio playback and timer concurrently
async let playAudio: Void = send (
. audioPlayerClient (
Result { try await self . audioPlayer . play ( url : url) }
)
)
var start: TimeInterval = 0
for await _ in self .clock. timer ( interval : . milliseconds ( 500 )) {
start += 0.5
await send (. timerUpdated (start))
}
await playAudio
}
. cancellable ( id : CancelID. play , cancelInFlight : true )
case . playing :
state. mode = . notPlaying
return . cancel ( id : CancelID. play )
}
Custom Dependencies
Define audio client interfaces:
@DependencyClient
struct AudioPlayerClient {
var play: @Sendable (URL) async throws -> Bool
}
@DependencyClient
struct AudioRecorderClient {
var requestRecordPermission: @Sendable () async -> Bool
var startRecording: @Sendable (URL) async throws -> Bool
var stopRecording: @Sendable () async -> Void
}
extension DependencyValues {
var audioPlayer: AudioPlayerClient {
get { self [AudioPlayerClient. self ] }
set { self [AudioPlayerClient. self ] = newValue }
}
var audioRecorder: AudioRecorderClient {
get { self [AudioRecorderClient. self ] }
set { self [AudioRecorderClient. self ] = newValue }
}
}
Coordinating Multiple Memos
Stop other memos when one starts playing:
case . voiceMemos (. element ( id : let id, action : . delegate (. playbackStarted ))) :
// Stop all other memos
for memoID in state.voiceMemos.ids where memoID != id {
state. voiceMemos [ id : memoID] ? . mode = . notPlaying
}
return . none
Presentation Logic
Use @Presents for modals and alerts:
@Presents var alert: AlertState<Action.Alert> ?
@Presents var recordingMemo: RecordingMemo.State ?
Integrate with reducers:
. ifLet (\.$alert, action : \. alert )
. ifLet (\.$recordingMemo, action : \. recordingMemo ) {
RecordingMemo ()
}
Testing
@Test
func testRecordPermissionDenied () async {
let store = TestStore ( initialState : VoiceMemos. State ()) {
VoiceMemos ()
} withDependencies : {
$0 . audioRecorder . requestRecordPermission = { false }
}
await store. send (. recordButtonTapped )
await store. receive (\. recordPermissionResponse ) {
$0 . audioRecorderPermission = . denied
$0 . alert = AlertState {
TextState ( "Permission is required to record voice memos." )
}
}
}
@Test
func testRecordAndPlayback () async {
let clock = TestClock ()
let store = TestStore ( initialState : VoiceMemos. State ()) {
VoiceMemos ()
} withDependencies : {
$0 . audioRecorder . requestRecordPermission = { true }
$0 . continuousClock = clock
$0 . date . now = Date ( timeIntervalSince1970 : 1234567890 )
$0 . uuid = . incrementing
}
await store. send (. recordButtonTapped )
await store. receive (\. recordPermissionResponse ) {
$0 . audioRecorderPermission = . allowed
$0 . recordingMemo = RecordingMemo. State (
date : Date ( timeIntervalSince1970 : 1234567890 ),
url : URL ( fileURLWithPath : "/tmp/00000000-0000-0000-0000-000000000000.m4a" )
)
}
// Test recording flow...
}
Source Code
View the complete example in the TCA repository:
Next Steps