Skip to content

Commit 70fc5da

Browse files
authored
Implement Menu in UIKitBackend (#118)
* Scaffold out different menu implementations * Implement Menu in UIKitBackend * Address initial PR comments * Rename transformMenu -> buildMenu
1 parent 1da7e29 commit 70fc5da

File tree

10 files changed

+227
-34
lines changed

10 files changed

+227
-34
lines changed

Sources/AppKitBackend/AppKitBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public final class AppKitBackend: AppBackend {
2121
public let requiresToggleSwitchSpacer = false
2222
public let defaultToggleStyle = ToggleStyle.button
2323
public let requiresImageUpdateOnScaleFactorChange = false
24+
public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover
2425

2526
public var scrollBarWidth: Int {
2627
// We assume that all scrollers have their controlSize set to `.regular` by default.

Sources/Gtk3Backend/Gtk3Backend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public final class Gtk3Backend: AppBackend {
3030
public let scrollBarWidth = 0
3131
public let defaultToggleStyle = ToggleStyle.button
3232
public let requiresImageUpdateOnScaleFactorChange = true
33+
public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover
3334

3435
var gtkApp: Application
3536

Sources/GtkBackend/GtkBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public final class GtkBackend: AppBackend {
3030
public let requiresToggleSwitchSpacer = false
3131
public let defaultToggleStyle = ToggleStyle.button
3232
public let requiresImageUpdateOnScaleFactorChange = false
33+
public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover
3334

3435
var gtkApp: Application
3536

Sources/SwiftCrossUI/Backend/AppBackend.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ public protocol AppBackend {
8484
/// manually rescale the image meaning that it must get rescaled when the
8585
/// scale factor changes.
8686
var requiresImageUpdateOnScaleFactorChange: Bool { get }
87+
/// How the backend handles rendering of menu buttons. Affects which menu-related methods
88+
/// are called.
89+
var menuImplementationStyle: MenuImplementationStyle { get }
8790

8891
/// Often in UI frameworks (such as Gtk), code is run in a callback
8992
/// after starting the app, and hence this generic root window creation
@@ -343,6 +346,14 @@ public protocol AppBackend {
343346
action: @escaping () -> Void,
344347
environment: EnvironmentValues
345348
)
349+
/// Sets a button's label and menu. Only used when ``menuImplementationStyle`` is
350+
/// ``MenuImplementationStyle/menuButton``.
351+
func updateButton(
352+
_ button: Widget,
353+
label: String,
354+
menu: Menu,
355+
environment: EnvironmentValues
356+
)
346357

347358
/// Creates a labelled toggle that is either on or off. Predominantly used by
348359
/// ``Toggle``.
@@ -432,16 +443,16 @@ public protocol AppBackend {
432443
)
433444

434445
/// Creates a popover menu (the sort you often see when right clicking on
435-
/// apps). The menu won't be visible until you call
436-
/// ``AppBackend/showPopoverMenu(_:at:relativeTo:closeHandler:)``.
446+
/// apps). The menu won't be visible when first created.
437447
func createPopoverMenu() -> Menu
438448
/// Updates a popover menu's content and appearance.
439449
func updatePopoverMenu(
440450
_ menu: Menu,
441451
content: ResolvedMenu,
442452
environment: EnvironmentValues
443453
)
444-
/// Shows the popover menu at a position relative to the given widget.
454+
/// Shows the popover menu at a position relative to the given widget. Only used when
455+
/// ``menuImplementationStyle`` is ``MenuImplementationStyle/dynamicPopover``.
445456
func showPopoverMenu(
446457
_ menu: Menu,
447458
at position: SIMD2<Int>,
@@ -668,6 +679,14 @@ extension AppBackend {
668679
) {
669680
todo()
670681
}
682+
public func updateButton(
683+
_ button: Widget,
684+
label: String,
685+
menu: Menu,
686+
environment: EnvironmentValues
687+
) {
688+
todo()
689+
}
671690

672691
public func createToggle() -> Widget {
673692
todo()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/// How a backend implements popover menus.
2+
///
3+
/// Regardless of implementation style, backends are expected to implement
4+
/// ``AppBackend/createPopoverMenu()``, ``AppBackend/updatePopoverMenu(_:content:environment:)``,
5+
/// and ``AppBackend/updateButton(_:label:action:environment:)``.
6+
public enum MenuImplementationStyle {
7+
/// The backend can show popover menus arbitrarily.
8+
///
9+
/// Backends that use this style must implement
10+
/// ``AppBackend/showPopoverMenu(_:at:relativeTo:closeHandler:)``. For these backends,
11+
/// ``AppBackend/createPopoverMenu()`` is not called until after the button is tapped.
12+
case dynamicPopover
13+
/// The backend requires menus to be constructed and attached to buttons ahead-of-time.
14+
///
15+
/// Backends that use this style must implement
16+
/// ``AppBackend/updateButton(_:label:menu:environment:)``.
17+
case menuButton
18+
}

Sources/SwiftCrossUI/Views/Menu.swift

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
public struct Menu: TypeSafeView {
1+
/// A button that shows a popover menu when clicked.
2+
///
3+
/// Due to technical limitations, the minimum supported OS's for menu buttons in UIKitBackend
4+
/// are iOS 14 and tvOS 17.
5+
public struct Menu {
26
public var label: String
37
public var items: [MenuItem]
48

59
var buttonWidth: Int?
610

7-
public var body = EmptyView()
8-
911
public init(_ label: String, @MenuItemsBuilder items: () -> [MenuItem]) {
1012
self.label = label
1113
self.items = items()
@@ -34,6 +36,11 @@ public struct Menu: TypeSafeView {
3436
}
3537
)
3638
}
39+
}
40+
41+
@available(iOS 14, macCatalyst 14, tvOS 17, *)
42+
extension Menu: TypeSafeView {
43+
public var body: EmptyView { return EmptyView() }
3744

3845
func children<Backend: AppBackend>(
3946
backend: Backend,
@@ -71,27 +78,51 @@ public struct Menu: TypeSafeView {
7178
size.x = buttonWidth ?? size.x
7279

7380
let content = resolve().content
74-
backend.updateButton(
75-
widget,
76-
label: label,
77-
action: {
78-
let menu = backend.createPopoverMenu()
81+
switch backend.menuImplementationStyle {
82+
case .dynamicPopover:
83+
backend.updateButton(
84+
widget,
85+
label: label,
86+
action: {
87+
let menu = backend.createPopoverMenu()
88+
children.menu = menu
89+
backend.updatePopoverMenu(
90+
menu,
91+
content: content,
92+
environment: environment
93+
)
94+
backend.showPopoverMenu(
95+
menu,
96+
at: SIMD2(0, size.y + 2),
97+
relativeTo: widget
98+
) {
99+
children.menu = nil
100+
}
101+
},
102+
environment: environment
103+
)
104+
105+
if !dryRun {
106+
backend.setSize(of: widget, to: size)
107+
children.updateMenuIfShown(
108+
content: content,
109+
environment: environment,
110+
backend: backend
111+
)
112+
}
113+
case .menuButton:
114+
let menu = children.menu as? Backend.Menu ?? backend.createPopoverMenu()
79115
children.menu = menu
80116
backend.updatePopoverMenu(
81117
menu,
82118
content: content,
83119
environment: environment
84120
)
85-
backend.showPopoverMenu(menu, at: SIMD2(0, size.y + 2), relativeTo: widget) {
86-
children.menu = nil
87-
}
88-
},
89-
environment: environment
90-
)
121+
backend.updateButton(widget, label: label, menu: menu, environment: environment)
91122

92-
if !dryRun {
93-
backend.setSize(of: widget, to: size)
94-
children.updateMenuIfShown(content: content, environment: environment, backend: backend)
123+
if !dryRun {
124+
backend.setSize(of: widget, to: size)
125+
}
95126
}
96127

97128
return ViewUpdateResult.leafView(size: ViewSize(fixedSize: size))

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 buildMenu(
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+
buildMenu(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.buildMenu(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+
}

0 commit comments

Comments
 (0)