A reimplementation of iOS context menus with more features: headers, custom views, show the menu whenever you want. Behavior and design of the original iOS context menus is 1:1 mimicked.
AveMenuKit is a reimplementation of iOS context menus. Since iOS 14, it's possible to show UIMenu from controls and bar button items. However, this is pretty locked down:
- you can't use custom header views (Apple itself can)
- you can't present the menu yourself, you have to use
UIContextMenuInteraction(Apple itself can) - you can't dynamically update the menu when it's being displayed (Apple itself can)
- you can't use custom rows (Apple itself can)
- newer options, such as palettes and smaller elements are not backwards compatible.
This reimplementation unlocks those limitations by providing a similar API without any of these restrictions, and it's fully backwards compatible up to iOS 13.
let myHeaderView = createProfileHeaderView()
let menu = Menu(
children: [
Action(title: "Preferences", image: UIImage(systemName: "gear") handler: { _ in handlePreferences() }),
// we want a separator, no need to build a whole menu hierarchy
.separator,
Action(title: "Include Attachments", isSelected: true),
// show a submenu with 2 elements
.submenu(title: "Display As",
Action(title: "Icons", isSelected: true),
Action(title: "Titles", isSelected: false)
)
],
headers: [
// here we add a header element to the menu that is our profile view
CustomView(view: myHeaderView)
]
)
// we can just present the menu whenever we want
MenuPresentation.presentMenu(menu, source: .view(myButton), animated: true)
In general, we mimick the UIMenu API, with some slight differences (e.g. we use isSelected instead of state) and extra options and elements.
Here's a list of the elements that make up a menu:
Menu
A menu shows a list of items and submenus. You can embed a menu into another menu: it's either a submenu that opens on top of the menu or an inline menu by setting the displaysInline property to true.
A regular (main) menu with different elements:
An inline submenu (in the red square):
An opened non-inline submenu:
titlethe title of the menusubTitlethe subtitle of the menuimagethe image of the menu
isEnabledif false, the element cannot be selectedisDestructiveset this if the element is for a destructive operationisHiddenif set to true, this element won't be shown at all
childrenthe elements that make up this menuheaderselements that will be presented sticky at the top of the menu
preferredElementSizeyou can set this tosmallormediumto show the elements in this menu in a side-by-side configurationdisplaysInlineif this is true, a menu that's part of another menu will have it's elements be shown inside of its parent, instead of opening a new submenudisplaysAsPalettedisplays this menu as a palette. See thePalettesectionbetweenMenusSeparatorStylethis determines if separators are shown between different inline menusonlyDismissesSubMenuif set to true, tapping an element in this submenu will not dismiss the whole menu, but just close the submenu so we go back to the parent menu.
Action
An Action is the most common element you see in a menu: it has a title, image and will call a handler when it's tapped. An action can also be in a selected state showing a checkmark by setting the isSelected flag to true.
Menu(children: [
Action(title: "Preferences", image: UIImage(systemName: "gear")),
Action(title: "Synchronize", image: UIImage(systemName: "cloud"), isEnabled: false),
Action(title: "Show Categories", image: UIImage(systemName: "bookmark"), isSelected: true),
Action(title: "Sort By", subtitle: "Newest First", image: UIImage(systemName: "arrow.up.arrow.down")),
Action(title: "Delete", image: UIImage(systemName: "trash"), isDestructive: true),
])
Will result in the following menu:
titlethe title of the actionsubTitlethe subtitle of the actionimagethe image of the actionselectedImagethe image that will be used ifisSelected = true
isSelectedif true, the item will be shown with a checkmark indicating selectionisEnabledif false, the element cannot be selectedisDestructiveset this if the element is for a destructive operationisHiddenif set to true, this element won't be shown at all
handlerthe handler that will be invoked when the user taps on the itemkeepsMenuPresentedif true, the menu will not be dismissed when the user taps on the item
LazyMenuElement
A placeholder menu element that will replace itself with the result of a provider callback. You use this to load menu contents on demand. Set the shouldCache flag to determine if the provided contents will be cached or not. If not cached, every time the (sub)menu reappears the provider is queried for contents again.
LazyMenuElement(shouldCache: false, provider: { completion in
// pretend we are loading data from somewhere that takes 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: {
completion([
Action(title: "John"),
Action(title: "Diane"),
Action(title: "Peter"),
Action(title: "Christina"),
])
})
})
Will result in the following:
providerthe provider closure that will be called to provide contents. Acompletionhandler will be called that should be called with the new contents.shouldCacheif shouldCache is true, once the content is provided it will never be queried again, even if the (sub)menu is hidden and presented later again. If true, the provider will be queried whenever the (sub)menu appears again.isHiddenif set to true, this element won't be shown at all
Separator
Sometimes you just want to show a separator between elements, without introducing a whole submenu that complicate things. This is where Separator comes in. It's a separator.
Separator()
The separator is marked in red here:
isHiddenif set to true, this element won't be shown at all
TitleHeader
Sometimes you want to have a title header, without introducing a whole submenu. This is where TitleHeader comes in.
TitleHeader("My Title")
The title header is marked in red here:
titlethe title to showisHiddenif set to true, this element won't be shown at all
SearchField
Embeds a search field in the menu. Best used as a headers element in a (sub)menu.
// we define a set of languages as Actions
let languages = [
Action(title: "Dutch"),
Action(title: "English"),
Action(title: "French"),
Action(title: "German"),
Action(title: "Italian"),
Action(title: "Spanish"),
Action(title: "Swedish"),
]
// next we have a search field that on search filters the languages by hiding the elements that don't match'
let searchField = SearchField(placeholder: "Search For a Language", updater: { searchText in
for language in languages {
language.isHidden = (searchText.isEmpty == false && language.title?.localizedCaseInsensitiveContains(searchText) == false)
}
})
// and finally we build a menu with the languages as children and the searchField as a header
return Menu(children: languages, headers: [searchField])
This shows as:
placeholderthe placeholder that is shown in the search field when the user didn't any text yetsearchTextthe search text to show in the search field by default. Will be updated when the user types in the search fieldupdaterthe callback that will be called when the user types in the search field.shouldAutomaticallyFocusOnAppearanceif true, the search field will become first responder when it appears to the userisEnabledif false, the search field cannot be focusedisHiddenif set to true, this element won't be shown at all
CustomView
This allows you to embed a custom view in a menu. The element cannot be highlighted and the custom view can be interacted with by the user (e.g. you can place controls in it). This element is best used a headers element.
The view you supply can take up the full width of the menu and is free to determine its own height.
// a function that creates a header view for us with a profile photo and a name and subtitle
let headerView = createHeaderView()
// next we create an element for it
let customViewElement = CustomView(view: headerView)
let menu = Menu(children: [...], headers: [customViewElement])
This results in:
viewthe custom view. This is aReusableViewConfigurationfor more flexibility. See the discussion below.isHiddenif set to true, this element won't be shown at all
init(view: UIView)takes an existing view and shows itinit(viewProvider: @escaping () -> UIView)creates the view on demand by calling theviewProviderblock when needed
If you use CustomViewAction as a one-off element, you usually don't need reusablity and can use one of the convenience initializers. However, if you build a subclass or reusable Element, your element can be shown a lot of times and you need to account for reusability by using a full ReusableViewConfiguration.
See the discussion on ReusableViewConfiguration below.
CustomViewAction
This allows you to embed a custom view in a menu and have the highlight and tap the element. When the element is tapped, a handler is called and the menu is dismissed, like a regular Action. The custom view cannot be interacted with by the user.
The view you supply can take up the full width of the menu and is free to determine its own height.
// a function that creates a header view for us with a profile photo and a name and subtitle
let headerView = createHeaderView()
// next we create an element for it and register a handler
let customViewActionElement = CustomView(view: headerView, handler: { _ in
print("Selected!")
})
let menu = Menu(children: [customViewActionElement])
This results in:
viewthe custom view. This is aReusableViewConfigurationfor more flexibility. See the discussion below.
isEnabledif false, the element cannot be selected. The custom view can change the appearance of the menu.isDestructiveset this if the element is for a destructive operation. The custom view can change the appearance of the menu.isHiddenif set to true, this element won't be shown at all
handlerthe handler that will be invoked when the user taps on the itemkeepsMenuPresentedif true, the menu will not be dismissed when the user taps on the item
init(view: UIView)takes an existing view and shows itinit(viewProvider: @escaping () -> UIView)creates the view on demand by calling theviewProviderblock when needed
If you use CustomViewAction as a one-off element, you usually don't need reusablity and can use one of the convenience initializers. However, if you build a subclass or reusable MenuElement, your element can be shown a lot of times and you need to account for reusability by using a full ReusableViewConfiguration.
See the discussion on ReusableViewConfiguration below.
CustomContentViewAction
This allows you to make a custom Action element with a custom view as content and a custom trailing accessory. The custom view and trailing accessory are positioned and managed for you, so that it follows the layout of other Action elements. E.g, your content might be insetted from the edges of the menu, depending on the configuration of the action and other.
A CustomContentViewAction also can show a checkmark and has a handler when it is tapped on. The views you provide are not interactable.
// Our custom content view is a label that shows an attributed string to
// show a (beta) label in a custom font and color.
let contentView = ReusableViewConfiguration.reusableView(
reuseIdentifier: "MyLabel",
provider: {
// simple label - we could do more configuration here if needed
return UILabel()
}, updater: { label, metrics, animated in
// configure our label with the metrics
label.numberOfLines = metrics.maximumNumberOfLines
label.textColor = metrics.contentColor
label.font = metrics.contentFont
// and set an attributed string as the label text
let attributedText = NSMutableAttributedString(string: "AutoSummary")
attributedText.append(NSAttributedString(string: " (beta)", attributes: [
.font: UIFont.preferredFont(forTextStyle: .caption1),
.foregroundColor: metrics.contentColor.withAlphaComponent(0.5),
.baselineOffset: 5,
]))
label.attributedText = attributedText
}
)
// `Action` can only show images, but we want to show an emoji, so
// our trailing accessory is a `UILabel` that shows an emoji.
//
// We use the `viewClass` variant here, since we don't configure the label
let trailingAccessoryView = ReusableViewConfiguration.reusableView(
reuseIdentifier: "MyAccessoryLabel",
viewClass: UILabel.self,
updater: { label, metrics, animated in
label.font = metrics.contentFont
label.numberOfLines = 1
label.text = "😍"
}
)
// configure our `CustomContentViewAction`. Notice how we can use the `isSelected` property, just like with regular Actions
let customContentViewAction = CustomContentViewAction(contentView: contentView, trailingAccessoryView: trailingAccessoryView, isSelected: true)
// and create our menu with our custom action
return Menu(children: [
Action(title: "Preferences", image: UIImage(systemName: "gear")),
.separator,
Action(title: "Use Language", image: UIImage(systemName: "globe"), isSelected: true),
Action(title: "Use Location", image: UIImage(systemName: "location")),
customContentViewAction
])
This results in the following menu, where the bottom element is using a custom content view: notice the (beta) label in a different font and color on the title and the use of an emoji as image.
contentViewthe custom content view that is placed where thetitleof a normalActionis shown. This is aReusableViewConfigurationfor more flexibility. See the discussion below.trailingAccessoryViewthe trailing accessory view that is placed where theimageof a normalActionis shown. This is aReusableViewConfigurationfor more flexibility. See the discussion below.
isSelectedif true, the item will be shown with a checkmark indicating selectionisEnabledif false, the element cannot be selectedisDestructiveset this if the element is for a destructive operationisHiddenif set to true, this element won't be shown at all
handlerthe handler that will be invoked when the user taps on the itemkeepsMenuPresentedif true, the menu will not be dismissed when the user taps on the item
You usually use this element when you want to provide a subclass or a reusable MenuElement. Because your custom element can be shown a lot of times in a menu, you need to account for reusability for performance reasons.
See the discussion on ReusableViewConfiguration below.
Group
This element can be used to logically group a list of other elements - it doesn't do anything.
You can also use this for subclassing to hide your internal element representation. For example, if you want to provide a Language element you could either:
- subclass
Actionand add alanguageCodeproperty which then sets thetitle. All the original properties ofActioncan still be set, includingtitle. - subclass
Group, add alanguageCodeproperty and have an internalactionelement that you override. This way, you don't leak out that you use anActionfor displaying your contents. See the example below.
class Language: Group {
var languageCode: String {
didSet {
guard languageCode != oldValue else { return }
action.title = languageNameFromCode(languageCode)
setNeedsUpdate()
}
}
private let action = UIAction()
public var displayedElements: [MenuElement] {
return [action]
}
init(code: String) {
self.languageCode = code
action.title = languageNameFromCode(code)
super.init()
}
}
let englishLanguageElement = Language(code: "en")
let spanishLanguageElement = Language(code: "es")
let menu = Menu(children: [englishLanguageElement, spanishLanguageElement])
As you can see, people instantiating a Language subclass Element cannot see how it's internal implementation uses an Action row to actual display content.
displayedElementsoverride this in subclasses to dynamically provide the content of the groupisHiddenif set to true, this element won't be shown at all
AveMenuKit gives you freedom in where and how you present your menus - as opposed to UIKit, where you cannot freely present your menu - you always have to use UIKit's interaction and gestures.
You can use two ways to present menus:
MenuInteractionpresents the menu for you on tap & long pressMenuPresentationyou are in charge of presenting the menu when you need
MenuInteraction
A MenuInteraction can be added to any UIView or UIControl and will add appropriate gesture recognizers to show the menu when tapped or long pressed.
The simplest form is to add an interaction with a passed in menu:
let myButton = UIButton()
let myMenu = createMyMenu()
myButton.addInteraction(MenuInteraction(menu: myMenu))
You can also dynamically provide a menu every time it is needed:
let myButton = UIButton()
myButton.addInteraction(MenuInteraction(menuProvider: { [weak self] in
// this will be called every time the menu is presented
return self?.createMyMenu()
}))
menuProvidera closure that will be called every time a menu is neededmenuconvenience - set a menu that will be used for every presentationattachmentPointProviderif you want to attach the menu at a different point, use this closure and return the point where the menu should attach to the control.preferredElementOrderthe preferred order of the elements in the menu
presentMenu(animated:)presents the menu if possibledismissMenu(animated:)dismisses any presented menu
MenuPresentation
If you want more control over how a menu is presented and when, you use MenuPresentation. Now you are in charge of presenting the menu on user interaction and you can configure it any way you want.
The simplest way of presenting a menu is using the static presentMenu() helper:
func onButtonTap() {
let myMenu = createMyMenu()
MenuPresentation.presentMenu(myMenu, source: .view(myButton), animated: true)
}
If you want more control, you can also instantiate a MenuPresentation and even keep it around for a long time:
// configure a presentation
let presentation = MenuPresentation()
presentation.source = .view(myButton, attachmentPoint: CGPoint(x: 100, y: 10))
presentation.preferredElementOrder = .fixed
presentation.dismissalCallback = { [weak myButton] in
myButton.isHighlighted = false
}
// and present the menu
myButton.isHighlighted = true
presentation.present(animated: true)
menuthe menu to present - must be set before callingpresent()sourcethe source view or bar button item that presents the animation - must be set before callingpresent()preferredElementOrderthe preferred order of the elements in the menutransferringLongPressGestureRecognizerused to transfer long presses to the menu, so the user can smoothly select items after a long press by just moving their finger.dismissalCallbackwill be called when the menu is no longer presented
present(animated:)presents the menu.menuandsourcemust be setdismiss(animated:)dismisses the currently presented menu.presentMenu(...)static convenience method to present a menu in one go. Returns the createdMenuPresentationthat is already presenting a menu.
If you present a menu on long press, it would be nice if the user can just move their finger towards the menu and select a menu element in one go, without ever lifting their finger. You can achieve this by setting transferringLongPressGestureRecognizer: the menu will transfer the long press gesture to the menu and allow smooth selection.
You can even set this after using the static presentMenu() helper:
func onLongPressStarted(sender: UILongPressGestureRecognizer) {
let presentation = MenuPresentation.presentMenu(myMenu, source: .view(myButton), animated: true)
presentation.transferringLongPressGestureRecognizer = sender
}
Small & Medium Elements
Menus with preferredElementSize set to .small or .medium will try to render their Action, non-inline Menu and LazyMenuElements as side-by-side buttons. .small can hold up to 5 elements, .medium up to 3. Any extra elements or custom elements will be shown as regular elements.
Palette Menus
A menu with displaysAsPalette set to true will render any Action, non-inline Menu or LazyMenuElement as inline scrollable palette strip.
You can control the way isSelected elements are shown using the paletteSelectionStyle property on the Menu. By default it tints the image, but you can also use a circle or an open circle.
When to Use Which CustomView Element?
There are three custom view elements in AveMenuKit, for different use cases.
- Use
CustomViewif you want to display a view in the menu, most often as a header. - Use
CustomViewActionif you want an element that the user can tap on, but with a completely custom layout - Use
CustomContentViewActionif you want anActionbut with different content and image views.
ReusableViewConfiguration
For simple, one-off views such as header you don't need to take reusability into account. However, if you make a subclass element that provides a different configuration you need to think of reusability. Your element subclass could be added hundreds of time to a menu. To keep performance okay, you need to reuse views. ReusableViewConfiguration allows you to do that.
Creating a custom view is separated from updating it. ReusableViewConfiguration has split this into two methods:
providergets called when we need a new custom viewupdateis called when a custom view needs to be updated, which can happen many times when a menu is displayed.
To make things performant, the menu will try to avoid calling the provider and re-use previously used custom views that are no longer displayed. It will still call update() on these views, so you can configure the view correctly.
You make use of this system by setting the ReusableViewConfiguration reuseIdentifier to something unique: the menu takes care of caching views for you.
Use the .reusableView(reuseIdentifier:...) methods to register a reusable view.
If you are not subclassing, you can also use per-element views. A per-element view is strictly re-used only for the same exact element instance. If the element scrolls offscreen its view is cached, if it displays again the view might be reused saving a costly creation of the view.
Use the .singleElementView() methods to create such a view.
If you don't really need any of the caching, use one-off views. Those views are never cached
Use the .view() methods to use a single pre-created view or use the .viewProvider() method to create it on demand.
- Accessility Improvements (there's general support)
- support reduced animations
- UIBarButtonItem

