Search code examples
iosswiftuiswiftui-pickerswiftui-viewswiftui-layout

SwiftUI How to implement a custom dropdown menu like a Picker


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


Solution

  • 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

    Menu not open Menu opened

    The colors are there to show this popover does present above any other view