Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 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
}
}
Loading
Loading