b5i/BetterMenus
A Swift DSL that simplifies the creation of UIMenus in UIKit.
Highlights
- 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 @BUIMenuBuilder functions. Conditional Logic: Use standard Swift control flow (if-else, switch) to conditionally include elements. Direct UIKit Integration: Seamlessly mix with standard UIMenu and UIAction elements in your builder closures. Async (deferred) menu elements with an optional, configurable cache. BetterContextMenuInteraction - a UIContextMenuInteraction wrapper that constructs menus via the builder and can be reloaded dynamically. Minimal dependencies (uses OrderedCollections internally for Async cache). Target: iOS 16.0+, 15.0 will be supported in a future release.
Installation
Add the package to your project with Swift Package Manager:
Xcode: File → Swift Packages → Add Package Dependency
Package URL: https://github.com/b5i/BetterMenus.gitor 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.
Quick Start
1 - Build a simple menu
@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.
2 - Inline items and dividers
@BUIMenuBuilder
func inlineMenu() -> UIMenu {
Text("Read-only text row")
Divider() // creates an inline separator group
Button("Action") { _ in /* ... */ }
}3 - Mix with native UIKit elements
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
}4 - Compose functions and use conditional logic
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 /* ... */ }
}5 - Toggle actions (stateful appearance handled by you)
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])
}6 - ForEach
Map arrays into menu elements:
ForEach(["Alice", "Bob", "Eve"]) { name in
Text("User: \(name)")
}7 - Stepper (inline ± controls)
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)")
}8 - Async / Deferred menu elements
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)
}
}
}Caching Behavior
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:)). |
Managing the Async Cache (AsyncStorage)
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). ``swift AsyncStorage.AsyncCacheMaxSize = 50 // Default is no limit ` Clear by Identifier: Manually remove a specific cached element. `swift // 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. `swift AsyncStorage.cleanCache { identifier in // e.g., clean all caches representing elements with users return (identifier as? String)?.hasPrefix("user-") ?? false } ``
Practical example: state updates
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 /* ... */ }
}
}Notes & Gotchas
- The builder produces standard
UIMenu/UIMenuElementinstances - all UIKit rendering rules and behaviors still apply.
Stateful elements like Toggle and Stepper do not persist state automatically. You must manage the state in your model and call reloadMenu() 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., preferredElementSize uses .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.
Contributing
Contributions, bug reports and feature requests are welcome. Open an issue or submit a PR.
Package Metadata
Repository: b5i/BetterMenus
Homepage: https://swiftpackageindex.com/b5i/BetterMenus
Stars: 3
Forks: 0
Open issues: 0
Default branch: main
Primary language: swift
License: MIT
Topics: dsl, swift, uikit, uimenu
README: README.md