Skip to content
Merged
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
25 changes: 22 additions & 3 deletions Sources/SwiftCrossUI/Backend/AppBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ 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 rendering of menu buttons. 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 +346,14 @@ public protocol AppBackend {
action: @escaping () -> Void,
environment: EnvironmentValues
)
/// Sets a button's label and menu. Only used when ``menuImplementationStyle`` is
/// ``MenuImplementationStyle/menuButton``.
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 @@ -432,16 +443,16 @@ public protocol AppBackend {
)

/// Creates a popover menu (the sort you often see when right clicking on
/// apps). The menu won't be visible until you call
/// ``AppBackend/showPopoverMenu(_:at:relativeTo:closeHandler:)``.
/// apps). The menu won't be visible when first created.
func createPopoverMenu() -> Menu
/// Updates a popover menu's content and appearance.
func updatePopoverMenu(
_ menu: Menu,
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. Only used when
/// ``menuImplementationStyle`` is ``MenuImplementationStyle/dynamicPopover``.
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 attached to buttons ahead-of-time.
///
/// Backends that use this style must implement
/// ``AppBackend/updateButton(_:label:menu:environment:)``.
case menuButton
}
65 changes: 48 additions & 17 deletions Sources/SwiftCrossUI/Views/Menu.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
public struct Menu: TypeSafeView {
/// A button that shows a popover menu when clicked.
///
/// Due to technical limitations, the minimum supported OS's for menu buttons in UIKitBackend
/// are iOS 14 and tvOS 17.
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 +36,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 +78,51 @@ 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 buildMenu(
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):
buildMenu(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.buildMenu(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
}
}
Loading
Loading