A lightweight Swift helper to build UIMenu and UIMenuElement hierarchies with a SwiftUI-like DSL using a @resultBuilder.
Designed for UIKit apps (iOS 16.0+) - compose menus declaratively, add async/deferred items, and wire context menus that can be reloaded at runtime. All of that in a single file for easy integration.
- Declarative
@BUIMenuBuilderDSL for buildingUIMenutrees. - Swift-friendly types:
Menu,Button,Toggle,Text,Section,ControlGroup,Stepper,ForEach,Divider, etc. - Full Composability: Build complex menus by calling other
@BUIMenuBuilderfunctions. - Conditional Logic: Use standard Swift control flow (
if-else,switch) to conditionally include elements. - Direct UIKit Integration: Seamlessly mix with standard
UIMenuandUIActionelements in your builder closures. Async(deferred) menu elements with an optional, configurable cache.BetterContextMenuInteraction- aUIContextMenuInteractionwrapper that constructs menus via the builder and can be reloaded dynamically.- Minimal dependencies (uses
OrderedCollectionsinternally forAsynccache). - Target: iOS 16.0+, 15.0 will be supported in a future release.
Add the package to your project with Swift Package Manager:
Xcode: File → Swift Packages → Add Package Dependency
Package URL: https://github.com/b5i/BetterMenus.git
or in Package.swift:
dependencies: [
.package(url: "https://github.com/b5i/BetterMenus.git", from: "1.1.0")
]or put the single file Sources/BetterMenus/BetterMenus.swift directly in your project.
Then import:
import BetterMenusNote: the package builds on top of UIKit's
UIMenu/UIActionAPIs and requires iOS 16.0 or newer at compile time.
@BUIMenuBuilder
func makeMenu() -> UIMenu {
Menu("Edit") {
Button("Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
print("Copy tapped")
}
Button("Paste", image: UIImage(systemName: "doc.on.clipboard")) { _ in
print("Paste tapped")
}
}
}The BUIMenuBuilder produces a UIMenu you can assign directly to UIButton.menu, return from a UIContextMenuInteraction provider, or present in other UIKit APIs that accept UIMenu.
@BUIMenuBuilder
func inlineMenu() -> UIMenu {
Text("Read-only text row")
Divider() // creates an inline separator group
Button("Action") { _ in /* ... */ }
}You can include UIMenu and UIAction instances directly in the builder.
func makeNativeSubmenu() -> UIMenu {
let subAction = UIAction(title: "Native Action", handler: { _ in print("Tapped!") })
return UIMenu(title: "Native Submenu", children: [subAction])
}
@BUIMenuBuilder
func mixedMenu() -> UIMenu {
Button("BetterMenus Button") { _ in /* ... */ }
makeNativeSubmenu() // Include a UIMenu directly
}Call other @BUIMenuBuilder functions and use if-else to build your menu dynamically.
var someCondition = true
@BUIMenuBuilder
func featureMenu() -> UIMenu {
if someCondition {
Text("Feature is ON")
} else {
Text("Feature is OFF")
}
}
@BUIMenuBuilder
func masterMenu() -> UIMenu {
// Call another builder function to compose menus
featureMenu()
Divider()
Button("Another Action") { _ in /* ... */ }
}Toggle converts to a UIAction with .on/.off states. You are responsible for managing the underlying app state and calling reloadMenu() if you want the visible menu to reflect changes.
var isOn: Toggle.ToggleState = .off
@BUIMenuBuilder
func toggleMenu() -> UIMenu {
Toggle("Enable feature", state: isOn) { _, _ in
// Update your model
isOn = isOn.opposite
}
.style([.keepsMenuPresented])
}Map arrays into menu elements:
ForEach(["Alice", "Bob", "Eve"]) { name in
Text("User: \(name)")
}var count: Int = 1
Stepper(value: count, closeMenuOnTap: false,
incrementButtonPressed: { _ in count += 1 /* then reload */ },
decrementButtonPressed: { _ in count -= 1 /* then reload */ }) { value in
Text("Amount: \(value)")
}Create UIDeferredMenuElement-backed items that fetch content asynchronously.
Async {
// This closure runs in an async context
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
return ["Alice", "Bob", "Eve"]
} body: { users in
// This closure builds the menu once the data is fetched
Menu("Users") {
ForEach(users) { user in
Text(user)
}
}
}BetterMenus provides caching for Async menu elements so you can avoid re-fetching data unnecessarily.
There are two important tools in this system:
By default, an Async element caches the final UIDeferredMenuElement (the rendered menu).
If you enable .calculateBodyWithCache(true), the cache will instead store the raw Result produced by your asyncFetch closure.
This allows the menu body to be recalculated later without re-running the async fetch. For example:
Async {
await fetchMenuData()
} body: { data in
UIMenu(title: "Items", children: data.map(makeMenuItem))
}
.cached(true)
.identifier("menu-cache")
.calculateBodyWithCache(true)- If
calculateBodyWithCacheis false (default): The menu is cached as-is, and reused directly on refresh. - If
calculateBodyWithCacheis true: ThefetchMenuData()result is cached, and thebodybuilder will be called again when the menu refreshes.
reloadMenu() explicitly on your BetterContextMenuInteraction (or a custom UIContextMenuInteraction) to trigger the rebuild.
Once a result is cached (via calculateBodyWithCache), you can modify it at runtime without refetching.
AsyncStorage.modifyCache(forIdentifier: "menu-cache") { (data: [Item]) in
var copy = data
copy.append(Item(name: "Injected item"))
return copy
}- The closure receives the cached value and return a value of type
T(the same type yourasyncFetchreturns otherwise the modification will be rejected). - Returns
trueif the cache was successfully updated, otherwisefalse.
This is useful for:
- Injecting items into the menu without hitting the network again.
- Adjusting cached data after a background update.
- Fixing up cached state when identifiers collide.
The interaction between cached and identifier determines when an Async menu element is reloaded:
cached |
identifier |
Behavior |
|---|---|---|
false |
nil or set |
The element reloads every time it is shown or refreshed. Nothing is stored in the cache. |
true |
nil |
The element is cached only for the current menu lifecycle. It won’t reload when the menu is reopened without modifications, but will reload on explicit refresh. |
true |
non-nil |
The element persists in the cache across menu lifecycles. It will not reload on refresh. To reload, you must explicitly remove it from the cache (e.g. via AsyncStorage.cleanCache(forIdentifier:)). |
When cached == true and an identifier is provided, the result is stored in a global cache to avoid re-fetching. You can manage this cache statically:
- Set Cache Size: Adjust the maximum number of items in the cache (LRU policy).
AsyncStorage.AsyncCacheMaxSize = 50 // Default is no limit
- Clear by Identifier: Manually remove a specific cached element.
// Returns true if an element was removed let didClean = AsyncStorage.cleanCache(forIdentifier: "user-list")
- Clear by Condition: Remove all cached elements that satisfy a condition.
AsyncStorage.cleanCache { identifier in // e.g., clean all caches representing elements with users return (identifier as? String)?.hasPrefix("user-") ?? false }
BetterContextMenuInteraction is a convenience wrapper around UIContextMenuInteraction that accepts a @BUIMenuBuilder body and supports dynamic menu reloading.
It uses a public, nested Delegate class (BetterContextMenuInteraction.Delegate) to manage the menu presentation. While you can provide a previewProvider directly in the initializer for most cases, you can also subclass the delegate to gain more advanced control over the UIContextMenuConfiguration and other delegate behaviors.
// In your UIViewController
var ctx: BetterContextMenuInteraction?
func setupView() {
let myView = UIView()
// Provide the body and an optional preview provider directly.
ctx = BetterContextMenuInteraction(
body: makeMenu,
previewProvider: {
let previewVC = UIViewController()
previewVC.view.backgroundColor = .systemBlue
previewVC.preferredContentSize = CGSize(width: 120, height: 120)
return previewVC
}
)
myView.addInteraction(ctx!)
// Store `ctx` to call `ctx.reloadMenu()` when underlying state changes.
}
@BUIMenuBuilder
func makeMenu() -> UIMenu {
// ... your menu definition
}public init(
@BUIMenuBuilder body: @escaping () -> UIMenu,
previewProvider: UIContextMenuContentPreviewProvider? = nil,
delegate: BetterUIContextMenuInteractionDelegate? = nil
)For advanced behaviors beyond providing a menu and a preview (e.g., custom animations), you can subclass BetterContextMenuInteraction.Delegate and override its methods. You can then pass an instance of your custom delegate during initialization.
class CustomDelegate: NSObject, BetterUIContextMenuInteractionDelegate {
var currentMenu: UIMenu
var previewProvider: UIContextMenuContentPreviewProvider?
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionCommitAnimating
) {
print("Preview action committed!")
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.currentMenu })
}
init(currentMenu: UIMenu, previewProvider: UIContextMenuContentPreviewProvider? = nil) {
self.currentMenu = currentMenu
self.previewProvider = previewProvider
}
}
let delegate = CustomDelegate(
currentMenu: UIMenu(title: "", children: []),
previewProvider: nil
)
let myInteraction = BetterContextMenuInteraction(body: makeMenu, delegate: delegate)Use reloadMenu(withIdentifier:) to target a specific submenu for refresh:
ctx?.reloadMenu(withIdentifier: "stepper-menu")If you call reloadMenu() without specifying an identifier, it will update the root menu. However, if a submenu is currently open, that submenu won't reflect the changes until it is closed and reopened again. This is because the update applies to the visible menus based on UIKit's menu presentation behavior.
All types require iOS 16.0+
| Type | Description |
|---|---|
@resultBuilder public struct BUIMenuBuilder |
Build a UIMenu from declarative elements. |
protocol MenuBuilderElement |
Conformance bridge to UIMenuElement. UIMenu and UIAction conform by default, so you can use them directly in the builder. |
struct Menu |
A grouped UIMenu node with a @BUIMenuBuilder body. |
struct Button |
Builds a UIAction. |
struct Toggle |
Builds a stateful UIAction (on/off). |
struct ForEach<T> |
Maps collections to menu children. |
struct Text |
A simple, inert text row. |
struct Stepper<T: Strideable> |
Inline menu with increment/decrement buttons. |
struct Section |
Inline submenu with a title. |
struct ControlGroup |
Groups controls with a .medium preferred element size. |
struct Async<Result> |
UIDeferredMenuElement builder with configurable caching: • .cached(true/false) – store menu or result.• .identifier(key) – persist cache across menu lifecycles.• .calculateBodyWithCache(true/false) – cache raw Result or rendered menu.• Use AsyncStorage.modifyCache(forIdentifier:_:) to safely mutate cached results. |
struct Divider |
A visual separator. |
class BetterContextMenuInteraction: UIContextMenuInteraction |
Context menu interaction using a builder body. • Supports reloadMenu() to refresh menus after data changes.• Customizable delegate via BetterUIContextMenuInteractionDelegate.• Optional previewProvider for previews. |
final class MyViewController: UIViewController {
private let button = UIButton(type: .system)
private var ctx: BetterContextMenuInteraction?
private var isEnabled: Toggle.ToggleState = .off {
didSet {
// When the state changes, reload the visible menu
ctx?.reloadMenu()
}
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(button)
// ... layout button ...
ctx = BetterContextMenuInteraction(body: makeMenu)
button.addInteraction(ctx!)
}
@BUIMenuBuilder
func makeMenu() -> UIMenu {
Toggle("Enable", state: isEnabled) { _, _ in
self.isEnabled = self.isEnabled.opposite
}
Button("Do something") { _ in /* ... */ }
}
}- The builder produces standard
UIMenu/UIMenuElementinstances - all UIKit rendering rules and behaviors still apply. - Stateful elements like
ToggleandStepperdo not persist state automatically. You must manage the state in your model and callreloadMenu()to reflect changes. - The package targets iOS 16+ because it relies on modern menu APIs. Some appearance defaults may change on iOS 17+ (e.g.,
preferredElementSizeuses.automatic). - When using a
Toggle, you might encounter a weird UI behavior where the menu gets translated to the right or left after tapping the toggle (this happens when a checkmark is shown or dismissed). This is a known UIKit behavior.
Contributions, bug reports and feature requests are welcome. Open an issue or submit a PR.
Author: Antoine Bollengier - github.com/b5i License: MIT
- Apple's Swift Collections to make the cache for
Asyncelements. Licensed with Apache License 2.0.