DEV Community

Cover image for Simple Context Menu in iOS by Tap
Sergey-Sharyk
Sergey-Sharyk

Posted on

Simple Context Menu in iOS by Tap

Problem

Not too long ago, I started developing an iOS version of one of our company’s mobile apps. The new feature included a requirement to open a multi-option menu when a commonly used three-dot element was clicked. Such a menu has probably been seen by every smartphone user:

Examples of contextual menus in iOS (left) and Android (right)

For simplicity, such menus will be further referred to as “context menus”.

However, for iPhone owners it’s more usual to get such dialogs after holding the button for a long time, while I needed to show the menu at a simple click. After studying the documentation and surfing the web in search of suitable solutions, I found the following:

  • yes, in iOS it is possible to show a context menu using UIKit SDK BUT only when a visual element is long pressed, and this is expected behavior;
  • yes, it is possible to trigger a simple tap to show a context menu using the Private API, BUT this is forbidden by AppStore rules;
  • yes, it is possible to assign a menu to a button as the main action, BUT this is only applicable from iOS 14 and only for UIButtons. Thus, off-the-shelf solutions didn’t fit, so I decided to write my own mechanism for showing context menus.

 
 
 

Filling menu items

 
First, let’s implement methods for filling the menu. For this purpose, it is convenient to use a combination of “Builder” and “Prototype” design patterns to give an opportunity to set only parameters interesting for the client, and after the process is completed, to get a fully ready to work menu. To do this we will need:

  1. a description of each menu option (each option contains an optional icon, text and a click handler);
  2. a set of menu options;
  3. a method for adding items to the menu; it takes the menu item contents as parameters, and for encapsulation purposes converts them to the internal data structure defined in (1);
class ContextMenu: UIView {

    /// (1) for convenience and clarification of the intention 
    typealias MenuOptionAction = () -> ()

    /// (1): each item of the menu contains text (with optional icon) and can react onn user's tap on it
    private struct MenuOptionItem {
        let text: String
        let icon: UIImage?
        let action: MenuOptionAction
    }

    class Builder {

        /// (2) the builder operates the list of options
        private var items: [MenuOptionItem] = []

        /// (3) seed the items
        func addOption(withText s: String, icon image: UIImage?, _ callback: @escaping MenuOptionAction) -> Builder {
            self.items.append(MenuOptionItem(text: s, icon: image, action: callback))
            return self
        }

        /// ... see more later
    }
}
Enter fullscreen mode Exit fullscreen mode

It should be noted — method (3) returns an instance of the builder itself, which makes it possible to use call chains of this method, as will be shown in the example. This builder contains minimal functionality, but can be improved if necessary (e.g. you can add nested menus, customization ability for each option, etc.).

 
 
 

Preparing for display

Now let’s implement a method to complete the construction and get a finished instance of the context menu. A context menu is a custom UIView that contains all of its options as children. For convenience, the elements are placed in a stack and separated by thin lines. At the same time, the menu itself is placed in a full-screen container, which allows you to customize the blur effect for all elements on the screen, except for the menu itself; as well as to implement closing the menu by clicking outside of its elements.

import UIKit

class ContextMenu: UIView {

    class Builder {
        func build() -> ContextMenu {
            /// create custom view for the menu
            /// this view itself takes all space, but only part of it used for menu items
            /// we still need to occupy all screen to handle taps outside the menu items and dismiss the menu in the case
            let menu = ContextMenu(frame: CGRect(x: 0, y: 0, width: AppConstants.screenWidth, height: AppConstants.screenHeight))

            /// first, configure container for menu items
            let w = AppConstants.screenWidth * 0.8
            let stack = createOptionsStack()

            /// inflate the with with the known items (create new views and set their parameters)
            populateOptions(into: stack)

            /// make stylization
            applyStyles(for: stack)
            let blurredEffectView = wrapWithBlur(butKeep: stack)

            /// prepare interaction
            menu.tap {
                /// just hide the menu when click outside the menu items
                self.dismiss()
            }

            /// put it all together
            menu.addSubview(stack)
            menu.putTo(parent: blurredEffectView.contentView)

            /// when created, the menu is invisible before attached explicitly (see later)
            menu.alpha = 0.0

            // register some direct links
            menu.stack = stack
            menu.blurContainer = blurredEffectView

            return menu
        }

        // MARK: - internal methods for building

        /// - Returns: container for menu items
        private func createOptionsStack() -> UIStackView {
            let stack = UIStackView(frame: CGRect(x: 0, y: 0, width: w, height: ContextMenuItem.HEIGHT * CGFloat(items.count)))
            stack.backgroundColor = .black.withAlphaComponent(0.3)
            stack.axis = .vertical
            stack.distribution = .fillProportionally
            stack.spacing = 0
        }

        /// Inserts and configures each option item as custom view
        private func populateOptions(into stack: UIStackView, of menu: ContextMenu) {
            let initOffset = ContextMenuItem.HEIGHT / 2
            var h: CGFloat = -initOffset
            items.enumerated().forEach { (index, option) in
                /// Make view inflation
                let item = nib(ContextMenuItem.self)
                item.translatesAutoresizingMaskIntoConstraints = false
                item.frame = CGRect(x: 0, y: h, width: ContextMenu.Builder.menuWidth, height: ContextMenuItem.HEIGHT)
                h += ContextMenuItem.HEIGHT
                item.set(icon: option.icon)
                item.set(text: option.text)
                item.tap {
                    /// when user taps on the menu item, execute the provided action and dismiss the menu
                    menu.dismiss()
                    option.action()
                }
                stack.addArrangedSubview(item)

                if index != items.count - 1 {
                    let separatorView = UIView(frame: CGRect(x: 0, y: h + initOffset, width: ContextMenu.Builder.menuWidth, height: 1))
                    separatorView.backgroundColor = .divider()
                    stack.addArrangedSubview(separatorView)
                    h += 1
                }
            }
        }

        /// Configures UI 
        private func applyStyles(for stack: UIStackView) {            
            /// some styling for items
            stack.layer.cornerRadius = 12
            stack.layer.borderWidth = 1
            stack.layer.borderColor = UIColor.purple.cgColor
        }

        /// Add blur for the entire screen (except items container)
        private func wrapWithBlur(butKeep stack: UIView) -> BlurVisualEffectView {
            let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark)
            let blurredEffectView = BlurVisualEffectView(effect: blurEffect, intensity: 0.3)
            blurredEffectView.alpha = 0.0
            blurredEffectView.frame = stack.bounds
            return blurredEffectView
        }
    }

    /// keep references to some of important subviews to simplify access
    private var stack: UIStackView? = nil
    private var blurContainer: UIVisualEffectView? = nil
}
Enter fullscreen mode Exit fullscreen mode

 
Overall, nothing extraordinary here, and I hope the added comments fully explain the principle of the builder. I would like to point out only a few points:

  • in populateOptions, for each element, an option View is created using the extension method nib(_:):
func nib<T: UIView>(_ viewType: T.Type) -> T {
   let nibName = String(describing: type(of: viewType)).remove(suffix: ".Type")
   return Bundle.main.loadNibNamed(nibName, owner: nil, options: nil)![0] as! T
}
Enter fullscreen mode Exit fullscreen mode
  • other extension methods are also used, the implementation of which is omitted to simplify the article and focus on its main content;
  • the layout of the element itself is quite trivial, depends on the design of the project and is omitted here.

The standard UIVisualEffectView that handles blur was blurring the screen too much, so we had to make a custom implementation with reduced blur intensity (thanks to darrarski who published his version — available on Github Gists).

 
 
 

Showing and hiding the context menu

The menu is ready to be displayed, now we need to show it when clicking on some (let’s call it “target”) UI element. To do this, we perform a few simple steps in sequence:

  1. define the parent ViewController on top of which the menu should be added;
  2. decide where to place the menu options. To do this, we qualitatively evaluate the area of the screen where the target element is located and place the menu options on a free space (above or below the target element);
  3. smoothly show the menu — for simplicity, in the example we just bring it from transparency to opacity with simultaneous resizing;
  4. also, we need a way to remove the menu (by clicking either on the menu item or completely outside of it) — here we perform the animation described in step 3, and after its completion — remove the container with the menu from the hierarchy of visual components.

 
 
 

Launching

As an example, let’s consider an application in which a user can communicate with specialists in a certain area, and after the conversation leave a review. Such feedback affects the rating of the expert, is visible to other users of the service, and therefore falls under the definition of user-generated content. Accordingly, the application should provide an opportunity to complain about a review that violates the requirements. It was decided to realize this by showing a context menu with options when clicking on the button of additional actions for the review:

And this is what came out:

Open the context menu

Top comments (0)