Skip to main content
Luca Archidiacono

Volume Button Manager

Volume Button Manager: Override the Audio Control in iOS #

Developing a camera framework for iOS presented a unique challenge: capturing photos, videos, or audio using the volume button. Without a straightforward API from Apple for this, I had to come up with a solution that's easy to use.

Public API #

The Volume Manager introduces a straightforward public API, giving you control over volume button actions:

public var onAction: (() -> Void)?
public func start()
public func stop()

You can either manually start and stop or let the manager handle it automatically during initialization and deinitialization.

Observing the Volume #

Monitoring volume changes starts with observing AVAudioSession's outputVolume using MediaPlayer:

audioSession.observe(\.outputVolume, options: [.old, .new])

This observer notifies us of volume changes triggered by both the physical volume button and the slider in the Control Center.

Distinguishing between genuine button presses and changes from the Control Center involves monitoring the application's active state. By using the NotificationCenter and observing UIApplication.willResignActiveNotification (when the Application will not be active anymore) and UIApplication.didBecomeActiveNotificationUIApplication.didBecomeActiveNotification (when the Application did become active):

NotificationCenter.default.addObserver(self,
                                       selector: #selector(applicationDidChangeActive),
                                       name: UIApplication.willResignActiveNotification,
                                       object: nil)
NotificationCenter.default.addObserver(self,
                                       selector: #selector(applicationDidChangeActive),
                                       name: UIApplication.didBecomeActiveNotification,
                                       object: nil)

@objc
private func applicationDidChangeActive(notification: NSNotification) {
  isActive = notification.name == UIApplication.didBecomeActiveNotification
  if isActive {
    setupAudioSession()
    setupInitialVolume()
  }
}

The state of the Application will be captured and held using our global isActive property. Now we know if the Application is either Active or in the Background, hence we know if the Volume has been changed by using some sort of Button while the App is active, or the App has been put into the Background and something is on top of it (like the Control Center).

Stealthy MPVolumeView 👻 #

To trigger a Volume change, the requirement is also to have a visible and active MPVolumeView. While we are not interested in showing the MPVolumeView to the User (since we want to replace the action of changing the Volume with something else), we have to come up with a solution where the MPVolumeView is there but also not there.

Simply put, we visibly hide its presence but not fully hide it.

To have a generic solution, I came up with the following approach where we collect all the scenes of the current Application. Then we filter for the current active ones and iterate over all the scenes till we have the current active Window. After filtering through the scenes, we put the MPVolumeView on top of it and hide its presence by setting its alpha to 0.01, equivalent to not visible.

Also, notice that we are not hiding it by using isHidden = true. Because this would mean the MPVolumeView is not present and therefore will not catch Volume changes.

private func setupVolumeView() {
    let scene = UIApplication.shared.connectedScenes
        .filter { $0.activationState == .foregroundActive }
        .first { $0 is UIWindowScene }
        .flatMap { $0 as? UIWindowScene }?.windows
        .first(where: \.isKeyWindow)
    scene?.addSubview(volumeView)

    volumeView.isHidden = false
    volumeView.alpha = 0.01
}

Tackling Extremes: Max and Min Volume #

While putting the code together and testing the observation of Volume changes, I noticed some edge cases that need to be tackled. By nature, the volume change has a maximum of 1.0 and a minimum of 0.0. If we set the current volume to the maximum and want to volume up again, the observation for the volume changes does not capture a new value.

Its maximum and minimum are hard caps. Meaning that no further changes upwards, after having a current volume state of maximum 1.0, will trigger a volume change. This works vice versa with the minimum value.

In the real world, this can be a real problem. For example, given a user who just recently listened to very loud music (at maximum volume). He then opens the camera app and wants to take a picture by using the volume button (using the up button). Since the current state of the Volume is the maximum and initiating another volume up change will not trigger another volume change on the observer level, we have to come up with a clever workaround.

For the user and for the system itself, it does not matter if the current maximum is at 1.0 or at 0.99999 (the same goes for the minimum). The only place where this matters is for the observer. The fact that the value of the current volume has been changed (even by very little margins) triggers the observer to call our function, which we will set up later.

Therefore, we initiate at the start of the Volume Manager the current initialVolume. If the current initialVolume is set at the maximum (so at 1.0), we will adjust the current volume to be at 0.99999. The same goes for the minimum.

The setupInitialVolume function sets up the initial volume balance:

private func setupInitialVolume() {
    initialVolume = audioSession.outputVolume
    if initialVolume > maxVolume {
        initialVolume = maxVolume
        isAdjustingInitialVolume = true
        setVolume(initialVolume)
    } else if initialVolume < minVolume {
        initialVolume = minVolume
        isAdjustingInitialVolume = true
        setVolume(initialVolume)
    }
}

Dance of Observers and Actions #

By passing a callback to the previously mentioned audioSession.observe(\.outputVolume, options: [.old, .new]), the system will call our function when the volume has been changed.

Since the user has triggered a volume change (but we want to replace the actual volume change with something else), we have to be careful and try to reset the volume again to the initial volume.

The problem that we are facing is the following. Let's get back to the scenario where the user was listening to music at a Volume of 0.5. Now he opens our camera app and wants to capture multiple photos. Let's say he always presses the volume down button. Since the person captures around 5 pictures, we don't want to just capture the volume change and trigger some different action while also allowing changing the volume. This would result in the following problem: when the user leaves our app and wants to listen to his music again, expecting to have the same volume as previously set. But since we didn't reset the volume down changes that the user initiated, the user would go back to listening to his music on a volume set at 0.0 (because each volume down sets the volume to -0.1).

To solve this issue, we do the following:

volumeObserver = audioSession.observe(\.outputVolume, options: [
    .old,
    .new,
]) { [weak self] _, observedValue in
    guard let self,
          isActive,
          let newValue = observedValue.newValue,
          let oldValue = observedValue.oldValue
    else { 
        return
    }

    if isAdjustingInitialVolume {
        isAdjustingInitialVolume = false
        return
    }

    if (newValue == 1.0 && oldValue == 1.0) || (newValue == 0.0 && oldValue == 0.0) {
        onAction?()
        setupInitialVolume()
        return
    } else {
        onAction?()
        isAdjustingInitialVolume = true
        setVolume(initialVolume)
        return
    }
}

The interesting part is where we call setVolume(initialVolume). When initializing the VolumeManager we call setupInitialVolume at the init level and store the current initialVolume (check Tackling Extremes: Max and Min Volume). We store this property globally and use it to recover to the initial volume state - as soon as the user triggered a volume change.

Wrapping It Up #

As you can see, capturing the press of a volume button and replacing its Volume-Change action with a different action is not as straightforward as initially thought. Apple does not provide a seamless and easy-to-use API but a rich and dynamic one. You are able to do various things (which initially I thought would not have been possible) like hiding the Volume View, but you have to consider various edge cases.

If you want to see the whole code check out the following GitHub Repo.

I hope you enjoyed this little blog about the Volume Button Manager and hope to see you on the next post :D

Happy coding and stay swifty (not affiliated with Taylor Swift but with the Swift language).