Creating an accessible audio player in SwiftUI (part 1)
This reusable component will adapt to dynamic font sizes and extra large accessibility sizes and provide voiceover support.
(updated for iOS 15 and Xcode 13, with a second part here)
This has been recently a small project of mine. I want to present you with a simple and reusable audio player which will integrate the AVAudioPlayer framework in a SwiftUI view with the controls to play and stop an audio track and also a slick progress bar.
We all love SwiftUI for its ability to create beautiful UI layouts. Today we will take advantage of SwiftUI layout capabilities to create a view that will resize itself depending on the dynamic fonts used.
I will start with simple code and as we go along and the whole project will be getting some extra accessibility with VoiceOver and dynamic fonts.
I will post the final code on GitHub as a Gist!
This is what we will be building. Minimal yet functional UI.
If this is something that might interest you then continue reading! Let's get started!
The first pass. Create the project in Xcode.
Create a new project in Xcode. I will name it "AccessibleAudioPlayer". Add a new SwiftUI file and call it "AudioPlayerView". First, add these imports to your new file and add a title like "Audio Player"
import AVKit
import SwiftUI
struct AudioPlayerView: View {
var body: some View {
Text("Audio Player")
.bold()
.multilineTextAlignment(.center)
.font(.title)
.minimumScaleFactor(0.75)
.padding()
}
}
Better previews.
Add these lines of code to your previews. This will help as we code along we can see our view in different sizes. The first modifier gives us a size for the view. And the second simulates the dynamic font selected. You can add as many as you want.
struct AudioPlayerView_Previews: PreviewProvider {
static var previews: some View {
AudioPlayerView()
.previewLayout(
PreviewLayout.fixed(width: 400, height: 300))
.previewDisplayName("Default preview")
AudioPlayerView()
.previewLayout(
PreviewLayout.fixed(width: 400, height: 300))
.environment(\.sizeCategory, .accessibilityExtraLarge)
}
}
You should have the following, two previews with different font sizes:
Add the properties.
Now we will continue adding some property to store our data. In SwiftUI the data that needs to change is stored in a @State
property wrapper.
Let's add them now.
1 - We will initialise an instance of AVAudioPlayer
in the initialiser of the view. So this is an implicitly unwrapped optional. This means that it is not there at the very beginning but it will be surely there when the view has been created.
2 - The progress will update our slider.
3 - I need to know if the player is currently playing. I will create a boolean flag.
4 - The duration is read at the beginning and it is a property of the audio track.
5 - The last two, formattedDuration and formattedProgress, are initialised with defaults like nil and zero and will be updated when the track is loaded; they will be shown at both sides of the progress bar, as a duration and progress in minutes and seconds.
@State var audioPlayer: AVAudioPlayer!
@State var progress: CGFloat = 0.0
@State private var playing: Bool = false
@State var duration: Double = 0.0
@State var formattedDuration: String = ""
@State var formattedProgress: String = "00:00"
Continue with the UI - the progress bar
Now back to the UI. We will add an HStack for showing our progress bar and embed it in a Vstack together with the title . Add this HStack to the code now:
HStack {
Text(formattedProgress)
.font(.caption.monospacedDigit())
// this is a dynamic length progress bar
GeometryReader { gr in
Capsule()
.stroke(Color.blue, lineWidth: 2)
.background(
Capsule()
.foregroundColor(Color.blue)
.frame(width: gr.size.width * progress,
height: 8), alignment: .leading)
}
.frame( height: 8)
Text(formattedDuration)
.font(.caption.monospacedDigit())
}
.padding()
.frame(height: 50, alignment: .center)
Did you notice the GeometryReader?
I use an elongated capsule 8 points high to show the progress.
Also, I need to know how long is this capsule and therefore I need to use the GeometryReader.
The advantage of doing this is that I do not tell the capsule/progress bar which size and width it will have, it will get what's left of the HStack.
Note also that the duration uses the mono digit font:.font(.caption.monospacedDigit())
This is to prevent the numbers from changing position when the kerning changes!
The capsule will be filled with a background of the same colour as the stroke as our track progresses.
Continue with the UI - the control buttons.
We want our buttons to be centred, therefore we have two Spacers in the HStack, and we will use the SFSymbols for the UI which will have a font modifier!
I put the link to the WWDC video below in sources, but in this way I can use the image with the dynamic font.
// the control buttons
HStack(alignment: .center, spacing: 20) {
Spacer()
Button(action: {
let decrease = self.audioPlayer.currentTime - 15
if decrease < 0.0 {
self.audioPlayer.currentTime = 0.0
} else {
self.audioPlayer.currentTime -= 15
}
}) {
Image(systemName: "gobackward.15")
.font(.title)
.imageScale(.medium)
}
Button(action: {
if audioPlayer.isPlaying {
playing = false
self.audioPlayer.pause()
} else if !audioPlayer.isPlaying {
playing = true
self.audioPlayer.play()
}
}) {
Image(systemName: playing ?
"pause.circle.fill" : "play.circle.fill")
.font(.title)
.imageScale(.large)
}
Button(action: {
let increase = self.audioPlayer.currentTime + 15
if increase < self.audioPlayer.duration {
self.audioPlayer.currentTime = increase
} else {
self.audioPlayer.currentTime = duration
}
}) {
Image(systemName: "goforward.15")
.font(.title)
.imageScale(.medium)
}
Spacer()
}
Add .foregroundColor(.blue)
to the end of the Vstack.
Our layout is almost done.
Add the audio file to the assets.
Now please add to the project an m4a audio file to test and name it "audioTest.m4a" or a different compatible format. You know what I mean ;)
Please add the file to the project and make sure that it is copied and part of the target like this:
Initialise the AudioPlayer
We add at the very end our logic in .onAppear
where we initialise our string formatter for the times and prepare the audio player.
Let's create the function initialiseAudioPlayer
and put it just outside the body of the project but not outside the view!
The formatter will take care that the minutes and seconds are nicely padded.
func initialiseAudioPlayer() {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.unitsStyle = .positional
formatter.zeroFormattingBehavior = [ .pad ]
// more to come!
}
Next the audio player!
Add this to the function, we are going to look for the audio file in our bundle and prepare to play:
// init audioPlayer - I use force unwrapping here for brevity and because I know that it cannot fail since I just added the file to the app.
let path = Bundle.main.path(forResource: "audioTest",
ofType: "m4a")!
self.audioPlayer = try! AVAudioPlayer(contentsOf:
URL(fileURLWithPath: path))
self.audioPlayer.prepareToPlay()
// more to come!
Then once we have the player ready to play the track we can get its duration and update our UI with this information.
Also the timer will start running and the progress will be calculated. This will update the UI automatically because the progress is declared as a@State
variable.
It is SwiftUI after all we are dealing with here ๐
//The formattedDuration is the string to display
formattedDuration = formatter.string(from:
TimeInterval(self.audioPlayer.duration))!
duration = self.audioPlayer.duration
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true)
{ _ in
if !audioPlayer.isPlaying {
playing = false
}
progress = CGFloat(audioPlayer.currentTime /
audioPlayer.duration)
formattedProgress = formatter
.string(from:
TimeInterval(self.audioPlayer.currentTime))!
}
}
Below the VStack last closing brace add this modifier and we are done!
.onAppear {
initialiseAudioPlayer()
}
The final touches.
The previews should look like this:
In content view add your new view if you want to display it on the iPhone simulator.
struct ContentView: View {
var body: some View {
AudioPlayerView()
}
}
Play a bit with the sizes if you like. The length of the progress bar will adjust according to the dynamic sizes and will give enough room to the text:
Voiceover
We can add some accessibility modifiers for a better experience:
Add this to the end of the first HStack.
The buttons will be read correctly by VoicOver, another bit of SwiftUI magic!
The progress bar needs still a bit of help.
If the track is currently playing I just let VoiceOver say at what point in time it is playing.
If it is paused I let VoiceOver give me the duration of the track instead.
Try it out and let me know what do you think!
.accessibilityElement(children: .ignore)
.accessibility(identifier: "player")
.accessibilityLabel(playing ? Text("Playing at ") : Text("Duration"))
.accessibilityValue(Text("\(formattedProgress)"))
Here is the code again as a gist: gist.github.com/multitudes/e1af067a86fa1bce..
We can do better
The view right now does too much logic work. A view should just be a view. Using a ViewModel to do the logic would be a better solution. I did not do it here because the tutorial was already long enough :).
Please check the next post for part 2!
Where to go from here?
- Extra Challenge. The code can be refactored with MVVM, adding a view model containing the logic. In this case, the
@State
properties and theinitialiseAudioPlayer()
function would be in the ViewModel. - This audio player is thought as a component of a bigger view. Images and text can be added to the layout. The track can be passed as a binding.
- UI and Unit Tests can be added to the app.
- Maybe I missed something? Please let me know in the comments!
Sources
About scaling SFSymbols:
developer.apple.com/videos/play/wwdc2020/10..
Date formatting:
hackingwithswift.com/books/ios-swiftui/work..
General inspiration and good practices, this was a really good tutorial:
developer.apple.com/tutorials/app-dev-train..
Again for inspiration, Paul Hudson of Hacking with Swift has a really nice Swift Package for a basic audio button called Subsonic.
It is a great solution if you need a simple press and play audio player. ๐๐ป