Menu.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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
  4. /// UIKitBackend are iOS 14 and tvOS 17.
  5. public struct Menu {
  6. /// The menu's label.
  7. public var label: String
  8. /// The menu's items.
  9. public var items: [MenuItem]
  10. var buttonWidth: Int?
  11. /// Creates a menu.
  12. ///
  13. /// - Parameters:
  14. /// - label: The menu's label.
  15. /// - items: The menu's items.
  16. @MainActor
  17. public init(_ label: String, @ViewBuilder items: () -> some View) {
  18. self.label = label
  19. self.items = items()._asMenuItems
  20. }
  21. /// Resolves the menu to a representation used by backends.
  22. @MainActor
  23. func resolve() -> ResolvedMenu.Submenu {
  24. ResolvedMenu.Submenu(
  25. label: label,
  26. content: Self.resolve(items: items)
  27. )
  28. }
  29. @MainActor
  30. static func resolve(item: MenuItem) -> ResolvedMenu.Item {
  31. switch item {
  32. case .button(let button):
  33. .button(button.body.view0.view0.string, button.action)
  34. case .text(let text):
  35. .button(text.string, nil)
  36. case .toggle(let toggle):
  37. .toggle(
  38. toggle.label,
  39. toggle.active.wrappedValue,
  40. onChange: { toggle.active.wrappedValue = $0 }
  41. )
  42. case .separator:
  43. .separator
  44. case .submenu(let submenu):
  45. .submenu(submenu.resolve())
  46. case .modifiedEnvironment(let item, let modification):
  47. .modifiedEnvironment(resolve(item: item()), modification())
  48. }
  49. }
  50. /// Resolves the menu's items to a representation used by backends.
  51. @MainActor
  52. static func resolve(items: [MenuItem]) -> ResolvedMenu {
  53. ResolvedMenu(items: items.map(resolve(item:)))
  54. }
  55. }
  56. @available(iOS 14, macCatalyst 14, tvOS 17, *)
  57. extension Menu: TypeSafeView {
  58. public var body: EmptyView { return EmptyView() }
  59. public var _asMenuItems: [MenuItem] { [.submenu(self)] }
  60. func children<Backend: BaseAppBackend>(
  61. backend: Backend,
  62. snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
  63. environment: EnvironmentValues
  64. ) -> Children {
  65. MenuStorage()
  66. }
  67. func asWidget<Backend: BaseAppBackend>(
  68. _ children: MenuStorage,
  69. backend: Backend
  70. ) -> Backend.Widget {
  71. return backend.createSimpleButton()
  72. }
  73. func layoutableChildren<Backend: BaseAppBackend>(
  74. backend: Backend,
  75. children: MenuStorage
  76. ) -> [LayoutSystem.LayoutableChild] {
  77. []
  78. }
  79. func computeLayout<Backend: BaseAppBackend>(
  80. _ widget: Backend.Widget,
  81. children: MenuStorage,
  82. proposedSize: ProposedViewSize,
  83. environment: EnvironmentValues,
  84. backend: Backend
  85. ) -> ViewLayoutResult {
  86. func castBackend_inner<NewBackend: BaseAppBackend & BackendFeatures.MenuButtons>(_ backend: NewBackend) -> ViewLayoutResult {
  87. let widget = widget as! NewBackend.Widget
  88. // TODO: Look into ways to predict a button's natural size without
  89. // updating its content so that computeLayout can be a bit more of
  90. // a pure function.
  91. // Update the button before measuring its natural size
  92. switch backend.menuImplementationStyle {
  93. case .dynamicPopover(let backend):
  94. // Our menu button action implementation needs to know the size
  95. // of the button, but we don't have that yet, so just update it
  96. // with an empty action and fix it in commit.
  97. backend.updateSimpleButton(
  98. widget,
  99. label: label,
  100. environment: environment,
  101. action: {}
  102. )
  103. case .menuButton(let backend):
  104. let menu =
  105. children.menu.flatMap { $0 as? NewBackend.Menu }
  106. ?? backend.createPopoverMenu()
  107. children.menu = menu
  108. backend.updateButton(
  109. widget,
  110. label: label,
  111. menu: menu,
  112. environment: environment
  113. )
  114. }
  115. var size = backend.naturalSize(of: widget)
  116. size.x = buttonWidth ?? size.x
  117. return ViewLayoutResult.leafView(size: ViewSize(size))
  118. }
  119. guard let castedBackend = backend as? any (BaseAppBackend & BackendFeatures.MenuButtons) else {
  120. fatalError("Backend does not implement BackendFeatures.MenuButtons")
  121. }
  122. return castBackend_inner(castedBackend)
  123. }
  124. func commit<Backend: BaseAppBackend>(
  125. _ widget: Backend.Widget,
  126. children: MenuStorage,
  127. layout: ViewLayoutResult,
  128. environment: EnvironmentValues,
  129. backend: Backend
  130. ) {
  131. func castBackend_inner<NewBackend: BaseAppBackend & BackendFeatures.MenuButtons>(_ backend: NewBackend) {
  132. let widget = widget as! NewBackend.Widget
  133. let size = layout.size
  134. backend.setSize(of: widget, to: size.vector)
  135. switch backend.menuImplementationStyle {
  136. case .dynamicPopover(let backend):
  137. backend.updateSimpleButton(
  138. widget,
  139. label: label,
  140. environment: environment,
  141. action: {
  142. let content = resolve().content
  143. let menu = backend.createPopoverMenu()
  144. children.menu = menu
  145. backend.updatePopoverMenu(
  146. menu,
  147. content: content,
  148. environment: environment
  149. )
  150. backend.showPopoverMenu(
  151. menu,
  152. at: SIMD2(0, LayoutSystem.roundSize(size.height) + 2),
  153. relativeTo: widget
  154. ) {
  155. children.menu = nil
  156. }
  157. }
  158. )
  159. if let menu = children.menu {
  160. let content = resolve().content
  161. backend.updatePopoverMenu(
  162. menu as! NewBackend.Menu,
  163. content: content,
  164. environment: environment
  165. )
  166. }
  167. case .menuButton(let backend):
  168. // We can assume that computeLayout has already run, so children.menu
  169. // will already be correctly initialized.
  170. let content = resolve().content
  171. let menu = children.menu! as! NewBackend.Menu
  172. backend.updatePopoverMenu(
  173. menu,
  174. content: content,
  175. environment: environment
  176. )
  177. // Even though we update the button in computeLayout (in order
  178. // for naturalSize to work), we appear to have to update it again
  179. // in commit; otherwise UIKitBackend users get menu buttons that
  180. // aren't poppable until the second time that the view gets updated.
  181. // They also get menu button menus with toggles that only toggle every
  182. // second time. I'm not sure why any of that happens.
  183. // TODO: Investigate why the following is needed. It may point us to
  184. // some layout system/state management issues.
  185. backend.updateButton(
  186. widget,
  187. label: label,
  188. menu: menu,
  189. environment: environment
  190. )
  191. }
  192. }
  193. guard let castedBackend = backend as? any (BaseAppBackend & BackendFeatures.MenuButtons) else {
  194. fatalError("Backend does not implement BackendFeatures.MenuButtons")
  195. }
  196. castBackend_inner(castedBackend)
  197. }
  198. /// A temporary button width solution until arbitrary labels are supported.
  199. public func _buttonWidth(_ width: Int?) -> Menu {
  200. var menu = self
  201. menu.buttonWidth = width
  202. return menu
  203. }
  204. }
  205. class MenuStorage: ViewGraphNodeChildren {
  206. var menu: Any?
  207. var widgets: [AnyWidget] = []
  208. var erasedNodes: [ErasedViewGraphNode] = []
  209. init() {}
  210. }