While implementing audio playback with AVAudioPlayer, you may need to display the playback progress with UIProgressView, or even to synchronize the state of the player with UISlider. AVAudioPlayer doesn’t have any functionality to get periodically notified about the current playback position. However, it has 2 aptly named properties that represent the time of the current playback position and the duration of the audio file: currentTime and duration respectively.

Bad approach - using Timer

The first thing that comes to mind is to set up a Timer to periodically calculate the playback progress and to update the UI. Picking the right update interval also seems as easy as getting the refresh rate of the device’s screen and figuring out the duration of a single frame: 1 / Double(UIScreen.main.maximumFramesPerSecond). But here I have to stop you since this approach has a flaw - the timer isn’t synchronized with screen refreshing, so there’s no guarantee that each invocation of the timer will correspond with the new frame being drawn on the screen. This may cause an unpleasant jitter in the UI, and this phenomenon is covered in great detail in this article. Fortunately, there’s a more suitable alternative, and as you’ve guessed, I’m talking about CADisplayLink.

CADisplayLink is a timer object that is synchronized with the refresh rate of the display. To learn more about it, check the documenation and this detailed article. Instead, let’s see how it can be applied to the task.

Implementing UI updating

At first, let’s implement updating of the UI components using the display link:

class AudioPlayerViewController: UIViewController {
    // 1
    @IBOutlet weak var slider: UISlider!
    // or
    @IBOutlet weak var progressView: UIProgressView!

    // 2
    var player: AVAudioPlayer

    // 3
    lazy var displayLink: CADisplayLink = CADisplayLink(target: self, selector: #selector(updatePlaybackStatus))

    // 4
    func startUpdatingPlaybackStatus() {
        displayLink.add(to: .main, forMode: .common)
    }

    func stopUpdatingPlaybackStatus() {
        displayLink.invalidate()
    }

    // 5
    @objc
    func updatePlaybackStatus() {
        let playbackProgress = Float(player.currentTime / player.duration)

        slider.setValue(playbackProgress, animated: true)
        // or
        progressView.setProgress(playbackProgress, animated: true)
    }
}

Additional comments to the code:

  1. An outlet to the UI component you want to update. It can be UISlider, UIProgressView, or some custom view that shows the progress. If you stick with UISlider, make sure that its min and max values are set to 0 and 1 respectively. The same applies to custom views as well;
  2. A reference to the AVAudioPlayer, just for clarity;
  3. A property storing an instance of CADisplayLink. Since we use self as the target, the initialization of CADisplayLink should be done after initializing the parent class to be able to correctly reference it. For that purpose I made it lazy;
  4. Methods that start and stop updating the UI. You should call them when the playback status changes.
  5. The method that gets called during screen refresh, the logic for updating the UI is stored here.

That’s all that has to be done to update the UI component. However, if you want to use a slider for seeking through audio, some more things have to be done.

Adding playback control with slider

I’m going to replicate the behavior of the Music.app, where the audio position changes only at the moment of releasing the slider. Here’s what has to be added:

// 1
@IBOutlet weak var slider: UISlider! {
    didSet {
        slider.addTarget(self, action: #selector(didBeginDraggingSlider), for: .touchDown)
        slider.addTarget(self, action: #selector(didEndDraggingSlider), for: .valueChanged)
        slider.isContinuous = false
    }
}

// 2
@objc
func didBeginDraggingSlider() {
    displayLink.isPaused = true
}

@objc
func didEndDraggingSlider() {
    let newPosition = player.duration * Double(slider.value)
    player.currentTime = newPosition

    displayLink.isPaused = false
}
  1. Add actions for slider’s touchDown and valueChanged events to get notified when the user begins and ends dragging the slider respectively. Also, set one’s isContinuous property to false, so the valueChanged event is reported only when the user releases the slider. This setup can also be done with Interface Builder and IBActions;
  2. Add the methods for handling slider’s events and implement fairly straightforward logic - pause display link when the user begins dragging the slider, so its value isn’t updated during user interaction; seek the audio to the selected position and resume display link when the user released the slider.

That’s all that has to be done to make the user experience of your app a bit sleeker!