Creating Debug Menus in Swift with UIContextMenuInteraction

Creating Debug Menus in Swift with UIContextMenuInteraction

Context Menu 2

Debug menus in iOS are a very effective way to make your day more productive as a developer. If you find yourself doing the same debugging tasks over and over, such as printing backend responses, skipping to specific view controllers or copying user identifiers, it's nice to have special developer menus in your app that automatically handles these tasks for you.

iOS 13 introduced UIContextMenuInteraction -- a new context menu API that replaces the older (and much harder to use) peek-and-pop menus. When a UIView with a registered interaction is pressed, a menu containing actions and an optional preview will show up. It works similarly to an UIAlertController, but nicer to look at and much easier to implement! New in iOS 14, we can even add asynchronously resolved actions to it.

Context Menu

Because these context menus are implemented directly into the view (as opposed to something like an alert, which is an independent view controller), we can create an abstraction that implements debug menus to specific views if we're running a developer build.

Example: Copy a logged user's data to the pasteboard

In this example, let's pretend that we have an app where we often have to retrieve the logged account's user identifier -- in past projects, I had to do this a lot for debugging and bug reporting purposes, either to let the backend know which user I am having a problem with or to be able to manually send backend requests in a service like Postman.

I normally did that by setting a breakpoint and printing the user's model fields, which was a very slow and annoying process. Let's add a special debug menu that does this for us.

UIContextMenuInteraction works through a delegate -- to have a debug menu show up for a view, you must inherit the delegate, define a list of UIActions that should show up and insert the interaction in the view. In this case, we'll add the interaction directly in the initializer.

Here's how our "debuggable user view" looks like:

struct User {
    let identifier: String
    let name: String
}

final class UserTextField: UITextField {

    private(set) var user: User?

    override init(frame: CGRect) {
        super.init(frame: frame)
        let interaction = UIContextMenuInteraction(delegate: self)
        addInteraction(interaction)
    }

    required init?(coder: NSCoder) {
        fatalError()
    }

    func render(user: User) {
        self.user = user
        text = "Logged as \(user.name)"
    }
}

extension UserTextField: UIContextMenuInteractionDelegate {
    func contextMenuInteraction(
        _ interaction: UIContextMenuInteraction,
        configurationForMenuAtLocation location: CGPoint
    ) -> UIContextMenuConfiguration? {
        return UIContextMenuConfiguration(
            identifier: nil,
            previewProvider: nil
        ) { _ in
            return UIMenu(title: "Debug Menu", children: [self.copyIdentifierAction()])
        }
    }

    func copyIdentifierAction() -> UIAction {
        return UIAction(title: "Copy Identifier") { _ in
            UIPasteboard.general.string = self.user?.identifier ?? ""
        }
    }
}
Context Menu 2

There are a few ways to remove this code from release builds, and my recommended way is to use preprocessor macros to completely eliminate the "debug code", as other approaches will still allow your code to be reverse-engineered by hackers.

#if DEBUG
extension UserTextField: UIContextMenuInteractionDelegate {
...
#endif

Expanding it for every UIView in the app

We have created a debug menu for a single view, but what about the others? As you can see, this code doesn't look very nice and will get difficult to maintain pretty quickly. To remedy this, we can subclass UIContextMenuInteraction into a cleaner abstraction that only requires the list of actions to display -- no configurations, previews or other annoying things.

I chose to create DebugMenuInteraction -- a special UIContextMenuInteraction that handles its own delegate, exposing a DebugMenuInteractionDelegate instead to retrieve the list of debugging actions.

public protocol DebugMenuInteractionDelegate: AnyObject {
    func debugActions() -> [UIMenuElement]
}

public final class DebugMenuInteraction: UIContextMenuInteraction {

    class DelegateProxy: NSObject, UIContextMenuInteractionDelegate {
        weak var delegate: DebugMenuInteractionDelegate?

        public func contextMenuInteraction(
            _ interaction: UIContextMenuInteraction,
            configurationForMenuAtLocation location: CGPoint
        ) -> UIContextMenuConfiguration? {
            return UIContextMenuConfiguration(
                identifier: nil,
                previewProvider: nil
            ) { [weak self] _ in
                let actions = self?.delegate?.debugActions() ?? []
                return UIMenu(title: "Debug Actions", children: actions)
            }
        }
    }

    private let contextMenuDelegateProxy: DelegateProxy

    public init(delegate: DebugMenuInteractionDelegate) {
        let contextMenuDelegateProxy = DelegateProxy()
        contextMenuDelegateProxy.delegate = delegate
        self.contextMenuDelegateProxy = contextMenuDelegateProxy
        super.init(delegate: contextMenuDelegateProxy)
    }
}

This allows us to refactor UserTextField into a much cleaner structure:

final class UserTextField: UITextField {

    private(set) var user: User?

    func render(user: User) {
        self.user = user
        text = "Logged as \(user.name)"
    }
}

#if DEBUG
extension UserTextField: DebugMenuInteractionDelegate {
    func debugActions() -> [UIAction] {
        let copyId = UIAction(title: "Copy Identifier") { _ in
            UIPasteboard.general.string = self.user?.identifier ?? ""
        }
        return [copyId]
    }
}
#endif

Previously, the interaction was being added directly in the class's initializer. This can still be done, but to allow us to better separate the "debug code" from the production code, we can create a global extension instead:

extension UIView {
    public func addDebugMenuInteraction() {
        #if DEBUG
        guard let delegate = self as? DebugMenuInteractionDelegate else {
            return
        }
        let debugInteraction = DebugMenuInteraction(delegate: delegate)
        addInteraction(debugInteraction)
        #endif
    }
}

Since the extension itself is able to determine if we're running a debug build, calls to addDebugMenuInteraction() can be kept in production code as the compiler will automatically optimize it out of the build.

With these abstractions, the final result is the same, but the code is now easier to maintain and evolve as supporting different views is just a matter of extending them to conform to the new delegate.

iOS 14 - Asynchronous actions with UIDeferredMenuElement

The arrival of iOS 14 introduced the possibility of creating menu elements that are resolved asynchronously. If your debug actions involves some data that requires an API call, you can use the new APIs to create async actions that are automatically displayed in the UIContextMenuInteraction with a special loading UI:

func serverInformation() -> UIMenuElement {
    return UIDeferredMenuElement { completion in
        // an Async task that fetches some information about a server:
        completion([printServerInformationAction(serverInfo)])
    }
}

The completion type of the deferred action is an array of UIMenuElements, meaning that you can a single action can be resolved to multiple entries in the debug menu. When displayed, you'll get a nice loading screen that is replaced with the real actions once the completion handler is called.

Context Menu Async

If you're interested in creating debug menus, the code we created here is available in SPM and CocoaPods as the DebugActions library.