Skip to content

Commit ebffe32

Browse files
committed
Implement Menu in UIKitBackend
1 parent c553568 commit ebffe32

File tree

4 files changed

+141
-17
lines changed

4 files changed

+141
-17
lines changed

Sources/SwiftCrossUI/Views/Menu.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
public struct Menu: TypeSafeView {
1+
public struct Menu {
22
public var label: String
33
public var items: [MenuItem]
44

55
var buttonWidth: Int?
66

7-
public var body = EmptyView()
8-
97
public init(_ label: String, @MenuItemsBuilder items: () -> [MenuItem]) {
108
self.label = label
119
self.items = items()
@@ -34,6 +32,11 @@ public struct Menu: TypeSafeView {
3432
}
3533
)
3634
}
35+
}
36+
37+
@available(iOS 14, macCatalyst 14, tvOS 17, *)
38+
extension Menu: TypeSafeView {
39+
public var body: EmptyView { return EmptyView() }
3740

3841
func children<Backend: AppBackend>(
3942
backend: Backend,

Sources/UIKitBackend/UIKitBackend+Control.swift

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import SwiftCrossUI
22
import UIKit
33

44
final class ButtonWidget: WrapperWidget<UIButton> {
5-
var onTap: (() -> Void)?
5+
private let event: UIControl.Event
6+
7+
var onTap: (() -> Void)? {
8+
didSet {
9+
if oldValue == nil {
10+
child.addTarget(self, action: #selector(buttonTapped), for: event)
11+
}
12+
}
13+
}
614

715
@objc
816
func buttonTapped() {
@@ -11,7 +19,6 @@ final class ButtonWidget: WrapperWidget<UIButton> {
1119

1220
init() {
1321
let type: UIButton.ButtonType
14-
let event: UIControl.Event
1522
#if os(tvOS)
1623
type = .system
1724
event = .primaryActionTriggered
@@ -20,7 +27,6 @@ final class ButtonWidget: WrapperWidget<UIButton> {
2027
event = .touchUpInside
2128
#endif
2229
super.init(child: UIButton(type: type))
23-
child.addTarget(self, action: #selector(buttonTapped), for: event)
2430
}
2531
}
2632

@@ -177,14 +183,11 @@ extension UIKitBackend {
177183
ButtonWidget()
178184
}
179185

180-
public func updateButton(
181-
_ button: Widget,
182-
label: String,
183-
action: @escaping () -> Void,
186+
func setButtonTitle(
187+
_ buttonWidget: ButtonWidget,
188+
_ label: String,
184189
environment: EnvironmentValues
185190
) {
186-
let buttonWidget = button as! ButtonWidget
187-
188191
// tvOS's buttons change foreground color when focused. If we set an
189192
// attributed string for `.normal` we also have to set another for
190193
// `.focused` with a colour that's readable on a white background.
@@ -204,6 +207,17 @@ extension UIKitBackend {
204207
for: .normal
205208
)
206209
#endif
210+
}
211+
212+
public func updateButton(
213+
_ button: Widget,
214+
label: String,
215+
action: @escaping () -> Void,
216+
environment: EnvironmentValues
217+
) {
218+
let buttonWidget = button as! ButtonWidget
219+
220+
setButtonTitle(buttonWidget, label, environment: environment)
207221

208222
buttonWidget.onTap = action
209223
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import SwiftCrossUI
2+
import UIKit
3+
4+
extension UIKitBackend {
5+
public final class Menu {
6+
var uiMenu: UIMenu?
7+
}
8+
9+
public func createPopoverMenu() -> Menu {
10+
return Menu()
11+
}
12+
13+
static func transformMenu(
14+
content: ResolvedMenu,
15+
label: String,
16+
identifier: UIMenu.Identifier? = nil
17+
) -> UIMenu {
18+
let children = content.items.map { (item) -> UIMenuElement in
19+
switch item {
20+
case let .button(label, action):
21+
if let action {
22+
UIAction(title: label) { _ in action() }
23+
} else {
24+
UIAction(title: label, attributes: .disabled) { _ in }
25+
}
26+
case let .submenu(submenu):
27+
transformMenu(content: submenu.content, label: submenu.label)
28+
}
29+
}
30+
31+
return UIMenu(title: label, identifier: identifier, children: children)
32+
}
33+
34+
public func updatePopoverMenu(
35+
_ menu: Menu, content: ResolvedMenu, environment _: EnvironmentValues
36+
) {
37+
menu.uiMenu = UIKitBackend.transformMenu(content: content, label: "")
38+
}
39+
40+
public func updateButton(
41+
_ button: Widget,
42+
label: String,
43+
menu: Menu,
44+
environment: EnvironmentValues
45+
) {
46+
if #available(iOS 14, macCatalyst 14, tvOS 17, *) {
47+
let buttonWidget = button as! ButtonWidget
48+
setButtonTitle(buttonWidget, label, environment: environment)
49+
buttonWidget.child.menu = menu.uiMenu
50+
buttonWidget.child.showsMenuAsPrimaryAction = true
51+
} else {
52+
preconditionFailure("Current OS is too old to support menu buttons.")
53+
}
54+
}
55+
56+
public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) {
57+
#if targetEnvironment(macCatalyst)
58+
let appDelegate = UIApplication.shared.delegate as! ApplicationDelegate
59+
appDelegate.menu = submenus
60+
#else
61+
// Once keyboard shortcuts are implemented, it might be possible to do them on more
62+
// platforms than just Mac Catalyst. For now, this is a no-op.
63+
print("UIKitBackend: ignoring \(#function) call")
64+
#endif
65+
}
66+
}

Sources/UIKitBackend/UIKitBackend.swift

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public final class UIKitBackend: AppBackend {
1414
public let defaultPaddingAmount = 15
1515
public let requiresToggleSwitchSpacer = true
1616
public let defaultToggleStyle = ToggleStyle.switch
17+
public let menuImplementationStyle = MenuImplementationStyle.menuButton
1718

1819
// TODO: When tables are supported, update these
1920
public let defaultTableRowContentHeight = -1
@@ -110,11 +111,6 @@ public final class UIKitBackend: AppBackend {
110111

111112
public func show(widget: Widget) {
112113
}
113-
114-
// TODO: Menus
115-
public typealias Menu = Never
116-
public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) {
117-
}
118114
}
119115

120116
extension App {
@@ -160,6 +156,8 @@ open class ApplicationDelegate: UIResponder, UIApplicationDelegate {
160156
}
161157
}
162158

159+
var menu: [ResolvedMenu.Submenu] = []
160+
163161
public required override init() {
164162
super.init()
165163
}
@@ -215,6 +213,49 @@ open class ApplicationDelegate: UIResponder, UIApplicationDelegate {
215213

216214
return true
217215
}
216+
217+
/// Map a menu's label to its identifier.
218+
///
219+
/// The commands API only gives control over the label of each menu. Override this method if
220+
/// you also need to control the menus' identifiers.
221+
///
222+
/// This method is only used on Mac Catalyst.
223+
open func mapMenuIdentifier(_ label: String) -> UIMenu.Identifier {
224+
switch label {
225+
case "File": .file
226+
case "Edit": .edit
227+
case "View": .view
228+
case "Window": .window
229+
case "Help": .help
230+
default:
231+
if let bundleId = Bundle.main.bundleIdentifier {
232+
.init(rawValue: "\(bundleId).\(label)")
233+
} else {
234+
.init(rawValue: label)
235+
}
236+
}
237+
}
238+
239+
/// Asks the receiving responder to add and remove items from a menu system.
240+
///
241+
/// When targeting Mac Catalyst, you should call `super.buildMenu(with: builder)` at some
242+
/// point in your implementation. If you do not, then calls to
243+
/// ``SwiftCrossUI/Scene/commands(_:)`` will have no effect.
244+
open override func buildMenu(with builder: any UIMenuBuilder) {
245+
guard builder.system == .main else { return }
246+
247+
for submenu in menu {
248+
let menuIdentifier = mapMenuIdentifier(submenu.label)
249+
let menu = UIKitBackend.transformMenu(
250+
content: submenu.content, label: submenu.label, identifier: menuIdentifier)
251+
252+
if builder.menu(for: menuIdentifier) == nil {
253+
builder.insertChild(menu, atEndOfMenu: .root)
254+
} else {
255+
builder.replace(menu: menuIdentifier, with: menu)
256+
}
257+
}
258+
}
218259
}
219260

220261
/// The root class for scene delegates of SwiftCrossUI apps.

0 commit comments

Comments
 (0)