I have been brainstorming ideas for implementing a button dropdown view similar to what you see in a default Picker. I have been experimenting with this in general to find a way to overlay a view on top of a parent view like the Picker does. I implemented a DropDownMenu View but it doesn't hit the notes that I want out of it
struct DropdownMenu<T, Views>: View {
var title: String
var selection: Binding<T>
var options: [T]
init(title: String, selection: Binding<T>, options: [T], @ViewBuilder views: @escaping () -> TupleView<Views>) {
self.title = title
self.selection = selection
self.options = options
self.content = views().getViews
}
@State private var showMenu: Bool = false
var content: [AnyView] = []
var body: some View {
Button {
showMenu = true
} label: {
Text(title)
.frame(minWidth: 100)
}
.buttonStyle(BorderedButtonStyle())
.overlay {
ScrollView {
VStack {
ForEach(content.indices, id: \.self) { index in
Button {
selection.wrappedValue = options[index]
showMenu = false
} label: {
content[index]
.frame(width: 350)
.frame(minHeight: 65)
}
}
}
}
.frame(width: 350, height: 225)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.zIndex(99999)
.opacity(showMenu ? 1 : 0)
}
}
}
The problem with this view is that its content is not able to overlay over the content inside the parent, even if I set the zIndex very high. Also, the view itself requires me to pass a list of my available options and requires me to wrap provided views in buttons using this janky way of converting passed views to AnyViews. Finally, this overlay will not know when it's near the edge of the screen so it can't move itself to make sure it fits.
When I consider an ideal solution, it seems like I would need to implement a Layout instead. This solves my view being able to present anywhere within the screen's bounds and it allows me to use a layoutValue to store a tag value like .tag does for the Picker. The problem is that with a Layout there seems to be no way to respond to on-tap events on a subview to tell the DropDownView to stop showing the overlay Layout and update a binding variable. Unless there is some way to access a layoutValue from a View there really is no way to create simplified view definitions like SwiftUI gives us with Picker and whatnot right?
Is there any way in the current version of SwiftUI to implement a view that can overlay over any view, take a modifier-like tag to set a value associated with its option views, and have the ability to update a binding selection variable as the Picker does? Basically, is there a way to implement a completely custom Picker in the current version of SwiftUI
With the help of the comments, I was able to come up with a solution that I liked.
This solution requires iOS 16.4.
@Sweeper mentioned using a popover which before iOS 16.4 only showed a sheet but on iPad will show a small popup window with your content on top of the view that is presenting it. After 16.4, you can use the .presentationCompactAdaptation(.popover) to have the same presentation style as iPad would. With that and the help of some underscore APIs mentioned here I was able to implement this.
@available(iOS 16.4, *)
struct PopoverLayout<T>: _VariadicView_MultiViewRoot {
@Binding var selection: T
@Binding var isShowing: Bool
func body(children: _VariadicView.Children) -> some View {
ForEach(children) { child in
Button {
if let tag = child[MenuTagTrait<T>.self] {
selection = tag
}
isShowing = false
} label: {
child
}
}
}
}
@available(iOS 16.4, *)
public struct PopupMenu<T, Content>: View where Content: View {
public var title: String
@Binding public var selection: T
@ViewBuilder public var content: Content
@State private var showMenu: Bool = false
public var body: some View {
Button {
showMenu = true
} label: {
Text(title)
.frame(minWidth: 100)
}
.buttonStyle(BorderedButtonStyle())
.popover(isPresented: $showMenu, attachmentAnchor: .point(.center)) {
ScrollView {
VStack {
_VariadicView.Tree(PopoverLayout(selection: $selection, isShowing: $showMenu)) {
content
.frame(minWidth: 125, maxWidth: 300, idealHeight: 65)
}
}
}
.frame(maxWidth: 350, maxHeight: 225)
.clipShape(RoundedRectangle(cornerRadius: 10))
.presentationCompactAdaptation(.popover)
}
}
}
struct MenuTagTrait<T>: _ViewTraitKey {
static var defaultValue: T? { nil }
}
@available(iOS 13.0, *)
extension View {
public func menuTag<T>(_ tag: T?) -> some View {
_trait(MenuTagTrait<T>.self, tag)
}
}
This can be used like so
VStack {
Color.blue
PopupMenu(title: "Menu", selection: $selection) {
Text("Option 1").menuTag(1)
Text("Option 2").menuTag(2)
Text("Option 3").menuTag(3)
Text("Another Option With A Long Name").menuTag(4)
}
Color.black
}
Which will result in a view like this
The colors are there to show this popover does present above any other view