Igor Kulman

Creating iOS context menu with highlight and dim

· Igor Kulman

The iOS messaging application I work on features a context menu in the chat. You long-press any message in the chat and the context menu appears. This menu was originally implemented using the standard UIMenuController.

The UIMenuController is an old-style iOS API that is hard to use and does not work very well. In some situations tapping its items just did not call the assigned selectors and the menu did not work.

As part of the ongoing redesign of the application I decided to implement a new custom context menu that would look as the designer imagined and more importantly work reliably. I did not want to use any 3rd party library to keep it as simple and possible.

Using just UIKit I came up with a context menu with a dim effect and a highlight on the selected item

Context menu with highlight and dim

Here is how I approached building it.

1. New UIWindow

The first step was to create a new UIWindow and put it on top of the main application window. This was needed mostly to show the context menu as a standard UIViewController presented as .popover without dismissing the keyboard.

If you just create a new UIWindow and make it visible, it is shown under the keyboard window. To make it show above the keyboard window you need to set its windowLevel to a value higher that the windowLevel of the keyboard window.

This does not work as expected on iOS 11 and newer. The setter would not allow you to assign a value higher than the windowLevel of the keyboard window, it will be always 1 less.

The solution to this was to create a custom class inheriting from UIWindow and overriding the windowLevel getter with some hard-coded high value

final class MessageContextMenuWindow: UIWindow {
    // needed because just setting the level on iOS 11+ to be more than the keyboard does not work for some reason
    override var windowLevel: UIWindow.Level {
        get {
            return UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude - 1)
        }
        set { }
    }
}

If you now create this window and make it visible, it will be shown on top of your main window without dismissing the keyboard, but it will be transparent for now

Transparent window overlay

2. UIViewController with the original view snapshot

The next step was to create a UIViewController that will be shown in the new window.

Dim effect

First I just set the background to some level of semi-transparent black

let backgroundDuration: TimeInterval = 0.1
UIView.animate(withDuration: backgroundDuration) {
    self.view.backgroundColor = #colorLiteral(red: 0.01568627451, green: 0.01568627451, blue: 0.05882352941, alpha: 0.5)
}

to get the dim effect

Dim effect

Highlighted original view

The next step was taking the original view, for example the contentView of the UITableViewCell and highlight it in this new UIViewController.

With focusedView as the original view I first created a snapshot from this view

guard let snapshotView = focusedView.snapshotView(afterScreenUpdates: false) else {
    return
}

This created a new UIView that is “flat” (no subviews) and looked exactly like the original view.

This snapshot view then needed to be added to the UIViewController

view.addSubview(snapshotView)

The trick here was to position it exactly over the original view in the main application window. To do this I needed to convert the position of the original view to the position in the UIViewController

guard let focusedViewSuperview = focusedView.superview else {
    return
}

let convertedFrame = view.convert(focusedView.frame, from: focusedViewSuperview)
snapshotView.frame = convertedFrame

With that you can now see the view highlighted

Highlight effect

3. UIViewController shown as .popover

The final step was to show the actual context menu. I wanted to keep it simple so I just used an UITableViewController with fixed size

override func viewDidLoad() {
    super.viewDidLoad()

    tableView.reloadData()
    tableView.layoutIfNeeded()
    preferredContentSize = CGSize(width: 200, height: tableView.contentSize.height)
}

and presented it like a .popover

let vc = MessageContextMenuViewController(message: message)
vc.modalPresentationStyle = .popover
vc.popoverPresentationController?.delegate = self
vc.popoverPresentationController?.sourceView = snapshotView
vc.popoverPresentationController?.sourceRect = snapshotView.bounds
vc.popoverPresentationController?.backgroundColor = UIColor.white.withAlphaComponent(0.9)
present(vc, animated: true, completion: nil)

There are two important things here; properly positioning the popover and setting its delegate

extension MessageContextMenuWindowViewController: UIPopoverPresentationControllerDelegate {
    func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
        return .none
    }
}

If you forget to set the delegate and override adaptivePresentationStyle the popover will be shown fullscreen.

4. The result

The context menu was now finished

Completed menu

See also