Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Menu in UIKitBackend #118

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public final class AppKitBackend: AppBackend {
public let requiresToggleSwitchSpacer = false
public let defaultToggleStyle = ToggleStyle.button
public let requiresImageUpdateOnScaleFactorChange = false
public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover

public var scrollBarWidth: Int {
// We assume that all scrollers have their controlSize set to `.regular` by default.
Expand Down
1 change: 1 addition & 0 deletions Sources/Gtk3Backend/Gtk3Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public final class Gtk3Backend: AppBackend {
public let scrollBarWidth = 0
public let defaultToggleStyle = ToggleStyle.button
public let requiresImageUpdateOnScaleFactorChange = true
public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover

var gtkApp: Application

Expand Down
1 change: 1 addition & 0 deletions Sources/GtkBackend/GtkBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public final class GtkBackend: AppBackend {
public let requiresToggleSwitchSpacer = false
public let defaultToggleStyle = ToggleStyle.button
public let requiresImageUpdateOnScaleFactorChange = false
public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover

var gtkApp: Application

Expand Down
21 changes: 20 additions & 1 deletion Sources/SwiftCrossUI/Backend/AppBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ public protocol AppBackend {
/// manually rescale the image meaning that it must get rescaled when the
/// scale factor changes.
var requiresImageUpdateOnScaleFactorChange: Bool { get }
/// How the backend handles menu rendering. Affects which menu-related methods are called.
var menuImplementationStyle: MenuImplementationStyle { get }

/// Often in UI frameworks (such as Gtk), code is run in a callback
/// after starting the app, and hence this generic root window creation
Expand Down Expand Up @@ -343,6 +345,14 @@ public protocol AppBackend {
action: @escaping () -> Void,
environment: EnvironmentValues
)
/// Sets a button's label and menu. May not be called depending on the value of
/// ``menuImplementationStyle``.
func updateButton(
_ button: Widget,
label: String,
menu: Menu,
environment: EnvironmentValues
)

/// Creates a labelled toggle that is either on or off. Predominantly used by
/// ``Toggle``.
Expand Down Expand Up @@ -441,7 +451,8 @@ public protocol AppBackend {
content: ResolvedMenu,
environment: EnvironmentValues
)
/// Shows the popover menu at a position relative to the given widget.
/// Shows the popover menu at a position relative to the given widget. May not be called
/// depending on the value of ``menuImplementationStyle``.
func showPopoverMenu(
_ menu: Menu,
at position: SIMD2<Int>,
Expand Down Expand Up @@ -668,6 +679,14 @@ extension AppBackend {
) {
todo()
}
public func updateButton(
_ button: Widget,
label: String,
menu: Menu,
environment: EnvironmentValues
) {
todo()
}

public func createToggle() -> Widget {
todo()
Expand Down
18 changes: 18 additions & 0 deletions Sources/SwiftCrossUI/Backend/MenuImplementationStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// How a backend implements popover menus.
///
/// Regardless of implementation style, backends are expected to implement
/// ``AppBackend/createPopoverMenu()``, ``AppBackend/updatePopoverMenu(_:content:environment:)``,
/// and ``AppBackend/updateButton(_:label:action:environment:)``.
public enum MenuImplementationStyle {
/// The backend can show popover menus arbitrarily.
///
/// Backends that use this style must implement
/// ``AppBackend/showPopoverMenu(_:at:relativeTo:closeHandler:)``. For these backends,
/// ``AppBackend/createPopoverMenu()`` is not called until after the button is tapped.
case dynamicPopover
/// The backend requires menus to be constructed and passed to buttons ahead-of-time.
///
/// Backends that use this style must implement
/// ``AppBackend/updateButton(_:label:menu:environment:)``.
case menuButton
}
55 changes: 38 additions & 17 deletions Sources/SwiftCrossUI/Views/Menu.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
public struct Menu: TypeSafeView {
public struct Menu {
public var label: String
public var items: [MenuItem]

var buttonWidth: Int?

public var body = EmptyView()

public init(_ label: String, @MenuItemsBuilder items: () -> [MenuItem]) {
self.label = label
self.items = items()
Expand Down Expand Up @@ -34,6 +32,11 @@ public struct Menu: TypeSafeView {
}
)
}
}

@available(iOS 14, macCatalyst 14, tvOS 17, *)
extension Menu: TypeSafeView {
public var body: EmptyView { return EmptyView() }

func children<Backend: AppBackend>(
backend: Backend,
Expand Down Expand Up @@ -71,27 +74,45 @@ public struct Menu: TypeSafeView {
size.x = buttonWidth ?? size.x

let content = resolve().content
backend.updateButton(
widget,
label: label,
action: {
let menu = backend.createPopoverMenu()
switch backend.menuImplementationStyle {
case .dynamicPopover:
backend.updateButton(
widget,
label: label,
action: {
let menu = backend.createPopoverMenu()
children.menu = menu
backend.updatePopoverMenu(
menu,
content: content,
environment: environment
)
backend.showPopoverMenu(menu, at: SIMD2(0, size.y + 2), relativeTo: widget)
{
children.menu = nil
}
},
environment: environment
)

if !dryRun {
backend.setSize(of: widget, to: size)
children.updateMenuIfShown(
content: content, environment: environment, backend: backend)
}
case .menuButton:
let menu = children.menu as? Backend.Menu ?? backend.createPopoverMenu()
children.menu = menu
backend.updatePopoverMenu(
menu,
content: content,
environment: environment
)
backend.showPopoverMenu(menu, at: SIMD2(0, size.y + 2), relativeTo: widget) {
children.menu = nil
}
},
environment: environment
)
backend.updateButton(widget, label: label, menu: menu, environment: environment)

if !dryRun {
backend.setSize(of: widget, to: size)
children.updateMenuIfShown(content: content, environment: environment, backend: backend)
if !dryRun {
backend.setSize(of: widget, to: size)
}
}

return ViewUpdateResult.leafView(size: ViewSize(fixedSize: size))
Expand Down
32 changes: 23 additions & 9 deletions Sources/UIKitBackend/UIKitBackend+Control.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import SwiftCrossUI
import UIKit

final class ButtonWidget: WrapperWidget<UIButton> {
var onTap: (() -> Void)?
private let event: UIControl.Event

var onTap: (() -> Void)? {
didSet {
if oldValue == nil {
child.addTarget(self, action: #selector(buttonTapped), for: event)
}
}
}

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

init() {
let type: UIButton.ButtonType
let event: UIControl.Event
#if os(tvOS)
type = .system
event = .primaryActionTriggered
Expand All @@ -20,7 +27,6 @@ final class ButtonWidget: WrapperWidget<UIButton> {
event = .touchUpInside
#endif
super.init(child: UIButton(type: type))
child.addTarget(self, action: #selector(buttonTapped), for: event)
}
}

Expand Down Expand Up @@ -177,14 +183,11 @@ extension UIKitBackend {
ButtonWidget()
}

public func updateButton(
_ button: Widget,
label: String,
action: @escaping () -> Void,
func setButtonTitle(
_ buttonWidget: ButtonWidget,
_ label: String,
environment: EnvironmentValues
) {
let buttonWidget = button as! ButtonWidget

// tvOS's buttons change foreground color when focused. If we set an
// attributed string for `.normal` we also have to set another for
// `.focused` with a colour that's readable on a white background.
Expand All @@ -204,6 +207,17 @@ extension UIKitBackend {
for: .normal
)
#endif
}

public func updateButton(
_ button: Widget,
label: String,
action: @escaping () -> Void,
environment: EnvironmentValues
) {
let buttonWidget = button as! ButtonWidget

setButtonTitle(buttonWidget, label, environment: environment)

buttonWidget.onTap = action
}
Expand Down
66 changes: 66 additions & 0 deletions Sources/UIKitBackend/UIKitBackend+Menu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import SwiftCrossUI
import UIKit

extension UIKitBackend {
public final class Menu {
var uiMenu: UIMenu?
}

public func createPopoverMenu() -> Menu {
return Menu()
}

static func transformMenu(
content: ResolvedMenu,
label: String,
identifier: UIMenu.Identifier? = nil
) -> UIMenu {
let children = content.items.map { (item) -> UIMenuElement in
switch item {
case let .button(label, action):
if let action {
UIAction(title: label) { _ in action() }
} else {
UIAction(title: label, attributes: .disabled) { _ in }
}
case let .submenu(submenu):
transformMenu(content: submenu.content, label: submenu.label)
}
}

return UIMenu(title: label, identifier: identifier, children: children)
}

public func updatePopoverMenu(
_ menu: Menu, content: ResolvedMenu, environment _: EnvironmentValues
) {
menu.uiMenu = UIKitBackend.transformMenu(content: content, label: "")
}

public func updateButton(
_ button: Widget,
label: String,
menu: Menu,
environment: EnvironmentValues
) {
if #available(iOS 14, macCatalyst 14, tvOS 17, *) {
let buttonWidget = button as! ButtonWidget
setButtonTitle(buttonWidget, label, environment: environment)
buttonWidget.child.menu = menu.uiMenu
buttonWidget.child.showsMenuAsPrimaryAction = true
} else {
preconditionFailure("Current OS is too old to support menu buttons.")
}
}

public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) {
#if targetEnvironment(macCatalyst)
let appDelegate = UIApplication.shared.delegate as! ApplicationDelegate
appDelegate.menu = submenus
#else
// Once keyboard shortcuts are implemented, it might be possible to do them on more
// platforms than just Mac Catalyst. For now, this is a no-op.
print("UIKitBackend: ignoring \(#function) call")
#endif
}
}
51 changes: 46 additions & 5 deletions Sources/UIKitBackend/UIKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public final class UIKitBackend: AppBackend {
public let defaultPaddingAmount = 15
public let requiresToggleSwitchSpacer = true
public let defaultToggleStyle = ToggleStyle.switch
public let menuImplementationStyle = MenuImplementationStyle.menuButton

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

public func show(widget: Widget) {
}

// TODO: Menus
public typealias Menu = Never
public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) {
}
}

extension App {
Expand Down Expand Up @@ -160,6 +156,8 @@ open class ApplicationDelegate: UIResponder, UIApplicationDelegate {
}
}

var menu: [ResolvedMenu.Submenu] = []

public required override init() {
super.init()
}
Expand Down Expand Up @@ -215,6 +213,49 @@ open class ApplicationDelegate: UIResponder, UIApplicationDelegate {

return true
}

/// Map a menu's label to its identifier.
///
/// The commands API only gives control over the label of each menu. Override this method if
/// you also need to control the menus' identifiers.
///
/// This method is only used on Mac Catalyst.
open func mapMenuIdentifier(_ label: String) -> UIMenu.Identifier {
switch label {
case "File": .file
case "Edit": .edit
case "View": .view
case "Window": .window
case "Help": .help
default:
if let bundleId = Bundle.main.bundleIdentifier {
.init(rawValue: "\(bundleId).\(label)")
} else {
.init(rawValue: label)
}
}
}

/// Asks the receiving responder to add and remove items from a menu system.
///
/// When targeting Mac Catalyst, you should call `super.buildMenu(with: builder)` at some
/// point in your implementation. If you do not, then calls to
/// ``SwiftCrossUI/Scene/commands(_:)`` will have no effect.
open override func buildMenu(with builder: any UIMenuBuilder) {
guard builder.system == .main else { return }

for submenu in menu {
let menuIdentifier = mapMenuIdentifier(submenu.label)
let menu = UIKitBackend.transformMenu(
content: submenu.content, label: submenu.label, identifier: menuIdentifier)

if builder.menu(for: menuIdentifier) == nil {
builder.insertChild(menu, atEndOfMenu: .root)
} else {
builder.replace(menu: menuIdentifier, with: menu)
}
}
}
}

/// The root class for scene delegates of SwiftCrossUI apps.
Expand Down
1 change: 1 addition & 0 deletions Sources/WinUIBackend/WinUIBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public final class WinUIBackend: AppBackend {
public let requiresToggleSwitchSpacer = false
public let defaultToggleStyle = ToggleStyle.button
public let requiresImageUpdateOnScaleFactorChange = false
public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover

public var scrollBarWidth: Int {
12
Expand Down
Loading