| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- /// 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 {
- /// The menu's label.
- public var label: String
- /// The menu's items.
- public var items: [MenuItem]
- var buttonWidth: Int?
- /// Creates a menu.
- ///
- /// - Parameters:
- /// - label: The menu's label.
- /// - items: The menu's items.
- @MainActor
- public init(_ label: String, @ViewBuilder items: () -> some View) {
- self.label = label
- self.items = items()._asMenuItems
- }
- /// Resolves the menu to a representation used by backends.
- @MainActor
- func resolve() -> ResolvedMenu.Submenu {
- ResolvedMenu.Submenu(
- label: label,
- content: Self.resolve(items: items)
- )
- }
- @MainActor
- static func resolve(item: MenuItem) -> ResolvedMenu.Item {
- switch item {
- case .button(let button):
- .button(button.body.view0.view0.string, button.action)
- case .text(let text):
- .button(text.string, nil)
- case .toggle(let toggle):
- .toggle(
- toggle.label,
- toggle.active.wrappedValue,
- onChange: { toggle.active.wrappedValue = $0 }
- )
- case .separator:
- .separator
- case .submenu(let submenu):
- .submenu(submenu.resolve())
- case .modifiedEnvironment(let item, let modification):
- .modifiedEnvironment(resolve(item: item()), modification())
- }
- }
- /// Resolves the menu's items to a representation used by backends.
- @MainActor
- static func resolve(items: [MenuItem]) -> ResolvedMenu {
- ResolvedMenu(items: items.map(resolve(item:)))
- }
- }
- @available(iOS 14, macCatalyst 14, tvOS 17, *)
- extension Menu: TypeSafeView {
- public var body: EmptyView { return EmptyView() }
- public var _asMenuItems: [MenuItem] { [.submenu(self)] }
- func children<Backend: BaseAppBackend>(
- backend: Backend,
- snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
- environment: EnvironmentValues
- ) -> Children {
- MenuStorage()
- }
- func asWidget<Backend: BaseAppBackend>(
- _ children: MenuStorage,
- backend: Backend
- ) -> Backend.Widget {
- return backend.createSimpleButton()
- }
- func layoutableChildren<Backend: BaseAppBackend>(
- backend: Backend,
- children: MenuStorage
- ) -> [LayoutSystem.LayoutableChild] {
- []
- }
- func computeLayout<Backend: BaseAppBackend>(
- _ widget: Backend.Widget,
- children: MenuStorage,
- proposedSize: ProposedViewSize,
- environment: EnvironmentValues,
- backend: Backend
- ) -> ViewLayoutResult {
- func castBackend_inner<NewBackend: BaseAppBackend & BackendFeatures.MenuButtons>(_ backend: NewBackend) -> ViewLayoutResult {
- let widget = widget as! NewBackend.Widget
- // TODO: Look into ways to predict a button's natural size without
- // updating its content so that computeLayout can be a bit more of
- // a pure function.
- // Update the button before measuring its natural size
- switch backend.menuImplementationStyle {
- case .dynamicPopover(let backend):
- // Our menu button action implementation needs to know the size
- // of the button, but we don't have that yet, so just update it
- // with an empty action and fix it in commit.
- backend.updateSimpleButton(
- widget,
- label: label,
- environment: environment,
- action: {}
- )
- case .menuButton(let backend):
- let menu =
- children.menu.flatMap { $0 as? NewBackend.Menu }
- ?? backend.createPopoverMenu()
- children.menu = menu
- backend.updateButton(
- widget,
- label: label,
- menu: menu,
- environment: environment
- )
- }
- var size = backend.naturalSize(of: widget)
- size.x = buttonWidth ?? size.x
- return ViewLayoutResult.leafView(size: ViewSize(size))
- }
- guard let castedBackend = backend as? any (BaseAppBackend & BackendFeatures.MenuButtons) else {
- fatalError("Backend does not implement BackendFeatures.MenuButtons")
- }
- return castBackend_inner(castedBackend)
- }
- func commit<Backend: BaseAppBackend>(
- _ widget: Backend.Widget,
- children: MenuStorage,
- layout: ViewLayoutResult,
- environment: EnvironmentValues,
- backend: Backend
- ) {
- func castBackend_inner<NewBackend: BaseAppBackend & BackendFeatures.MenuButtons>(_ backend: NewBackend) {
- let widget = widget as! NewBackend.Widget
- let size = layout.size
- backend.setSize(of: widget, to: size.vector)
- switch backend.menuImplementationStyle {
- case .dynamicPopover(let backend):
- backend.updateSimpleButton(
- widget,
- label: label,
- environment: environment,
- action: {
- let content = resolve().content
- let menu = backend.createPopoverMenu()
- children.menu = menu
- backend.updatePopoverMenu(
- menu,
- content: content,
- environment: environment
- )
- backend.showPopoverMenu(
- menu,
- at: SIMD2(0, LayoutSystem.roundSize(size.height) + 2),
- relativeTo: widget
- ) {
- children.menu = nil
- }
- }
- )
- if let menu = children.menu {
- let content = resolve().content
- backend.updatePopoverMenu(
- menu as! NewBackend.Menu,
- content: content,
- environment: environment
- )
- }
- case .menuButton(let backend):
- // We can assume that computeLayout has already run, so children.menu
- // will already be correctly initialized.
- let content = resolve().content
- let menu = children.menu! as! NewBackend.Menu
- backend.updatePopoverMenu(
- menu,
- content: content,
- environment: environment
- )
- // Even though we update the button in computeLayout (in order
- // for naturalSize to work), we appear to have to update it again
- // in commit; otherwise UIKitBackend users get menu buttons that
- // aren't poppable until the second time that the view gets updated.
- // They also get menu button menus with toggles that only toggle every
- // second time. I'm not sure why any of that happens.
- // TODO: Investigate why the following is needed. It may point us to
- // some layout system/state management issues.
- backend.updateButton(
- widget,
- label: label,
- menu: menu,
- environment: environment
- )
- }
- }
- guard let castedBackend = backend as? any (BaseAppBackend & BackendFeatures.MenuButtons) else {
- fatalError("Backend does not implement BackendFeatures.MenuButtons")
- }
- castBackend_inner(castedBackend)
- }
- /// A temporary button width solution until arbitrary labels are supported.
- public func _buttonWidth(_ width: Int?) -> Menu {
- var menu = self
- menu.buttonWidth = width
- return menu
- }
- }
- class MenuStorage: ViewGraphNodeChildren {
- var menu: Any?
- var widgets: [AnyWidget] = []
- var erasedNodes: [ErasedViewGraphNode] = []
- init() {}
- }
|