Creating a simple and reusable Dropdown selector using SwiftUI

This article is aimed at creating a simple dropdown selector that is reusable using SwiftUI. Below is the result I’m looking forward to:

DropdownSelector multiple option

As you may know, SwiftUI is pretty cool when it comes to creating custom views easily. I would harness this capability to actually create the dropdown as shown above. So let’s get to it.

I have a SwiftUI view in a single file, DropdownSelector.swift where all the code would go in. You can decide to split some of the components within the file into multiple files, as you wish.

import SwiftUI

struct DropdownSelector: View {
    var body: some View {
        Text("Hello, World!")
    }
}

struct DropdownSelector_Previews: PreviewProvider {
    static var previews: some View {
        DropdownSelector()
    }
}

All I have right now is an empty view with just a Text. I would create a simple struct DropdownOption, representing a single option that a user can select. You can decide to keep the option as a String but to be safe when it comes to comparing selections and avoiding having similar values. The DropdownOption struct contains a key and a value.

struct DropdownOption: Hashable {
    let key: String
    let value: String

    public static func == (lhs: DropdownOption, rhs: DropdownOption) -> Bool {
        return lhs.key == rhs.key
    }
}

I have the struct conform to Hashable, knowing I would at some point later on make use of a ForEach to loop through the available options. I could use Identifiable protocol as well, but that would require that the struct has an id, which this doesn’t have right now.

Next is to add contents to the DropdownSelector view itself.

struct DropdownSelector: View {
    @State private var shouldShowDropdown = false
    @State private var selectedOption: DropdownOption? = nil
    var placeholder: String
    var options: [DropdownOption]
    var onOptionSelected: ((_ option: DropdownOption) -> Void)?
    private let buttonHeight: CGFloat = 45

    var body: some View {
        Button(action: {
            self.shouldShowDropdown.toggle()
        }) {
            HStack {
                Text(selectedOption == nil ? placeholder : selectedOption!.value)
                    .font(.system(size: 14))
                    .foregroundColor(selectedOption == nil ? Color.gray: Color.black)

                Spacer()

                Image(systemName: self.shouldShowDropdown ? "arrowtriangle.up.fill" : "arrowtriangle.down.fill")
                    .resizable()
                    .frame(width: 9, height: 5)
                    .font(Font.system(size: 9, weight: .medium))
                    .foregroundColor(Color.black)
            }
        }
        .padding(.horizontal)
        .cornerRadius(5)
        .frame(width: .infinity, height: self.buttonHeight)
        .overlay(
            RoundedRectangle(cornerRadius: 5)
                .stroke(Color.gray, lineWidth: 1)
        )
        .overlay(
            VStack {
                if self.shouldShowDropdown {
                    Spacer(minLength: buttonHeight + 10)
                    Dropdown(options: self.options, onSelect: { option in
                        shouldShowDropdown = false
                        selectedOption = option
                        self.onOptionSelected?(option)
                    })
                }
            }, alignment: .topLeading
        )
        .background(
            RoundedRectangle(cornerRadius: 5).fill(Color.white)
        )
    }
}

Let me explain all that is being done above:

  • I have a @State variable shouldShowDropdown, which is used to show or hide the dropdown when the DropdownSelector is tapped.
  • selectedOption @State variable is to be used to keep track of user selection and replace the placeholder.
  • There are three required params to be passed in when using DropdownSelector, placeholder which is the greyed out text to display before the user selects an option, options which is a list of the DropdownOption we created above, onOptionSelected which is a closure called when the user makes a selection.
  • A couple of UI implementations to create the look that is intended, including an overlay where the Dropdown (actual selection list) is displayed or hid. This Dropdown would be created in the next step.

The next step is to create the Dropdown view that displays the list of options.

struct Dropdown: View {
    var options: [DropdownOption]
    var onOptionSelected: ((_ option: DropdownOption) -> Void)?

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                ForEach(self.options, id: \.self) { option in
                    DropdownRow(option: option, onOptionSelected: self.onOptionSelected)
                }
            }
        }
        .frame(height: 100)
        .padding(.vertical, 5)
        .background(Color.white)
        .cornerRadius(5)
        .overlay(
            RoundedRectangle(cornerRadius: 5)
                .stroke(Color.gray, lineWidth: 1)
        )
    }
}

Above, is a simple view that contains a stack of DropdownRow (single option row item) by iterating the dropdown options, all wrapped in a ScrollView. This view takes two parameters, a list of dropdown options and a closure, onOptionSelected to be triggered when an option is selected.

The DropdownRow would be created below:

struct DropdownRow: View {
    var option: DropdownOption
    var onOptionSelected: ((_ option: DropdownOption) -> Void)?

    var body: some View {
        Button(action: {
            if let onOptionSelected = self.onOptionSelected {
                onOptionSelected(self.option)
            }
        }) {
            HStack {
                Text(self.option.value)
                    .font(.system(size: 14))
                    .foregroundColor(Color.black)
                Spacer()
            }
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 5)
    }
}

This is basically a text button that calls the onOptionSelected on tap. It takes a single dropdown option as parameter including the closure.

Looks like right now, there is a working copy of the DropdownSelector, all that is left is to display it in the Canvas by making use of the PreviewProvider.

struct DropdownSelector_Previews: PreviewProvider {
    static var uniqueKey: String {
        UUID().uuidString
    }

    static let options: [DropdownOption] = [
        DropdownOption(key: uniqueKey, value: "Sunday"),
        DropdownOption(key: uniqueKey, value: "Monday"),
        DropdownOption(key: uniqueKey, value: "Tuesday"),
        DropdownOption(key: uniqueKey, value: "Wednesday"),
        DropdownOption(key: uniqueKey, value: "Thursday"),
        DropdownOption(key: uniqueKey, value: "Friday"),
        DropdownOption(key: uniqueKey, value: "Saturday")
    ]


    static var previews: some View {
        Group {
            DropdownSelector(
                placeholder: "Day of the week",
                options: options,
                onOptionSelected: { option in
                    print(option)
            })
            .padding(.horizontal)
        }
    }
}

Above, I have created a list of options that are passed to the DropdownSelector. I utilised the UUID to generate a unique key for each of the options. Take a look at the result below.

DropdownSelector initial result

This is good. However, if at some point, I had to provide just one option, the dropdown would end up looking like below:

DropdownSelector fixed height.

A fixed height (100pt) is set for the Dropdown, this is not a good experience we expect. So how can this be made better?

struct Dropdown: View {
    var options: [DropdownOption]
    var onOptionSelected: ((_ option: DropdownOption) -> Void)?

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                ForEach(self.options, id: \.self) { option in
                    DropdownRow(option: option, onOptionSelected: self.onOptionSelected)
                }
            }
        }
        .frame(minHeight: CGFloat(options.count) * 30, maxHeight: 250)
        .padding(.vertical, 5)
        .background(Color.white)
        .cornerRadius(5)
        .overlay(
            RoundedRectangle(cornerRadius: 5)
                .stroke(Color.gray, lineWidth: 1)
        )
    }
}

Notice the change in the frame? Here, I set a minimum height that is equal to the number of options multiplied by a fixed height (30) and also a maximum height (250), which ensures the dropdown doesn’t grow too much but instead allows you to scroll. Take a look at the results below, for both single and multiple options:

DropdownSelector single option
DropdownSelector multiple option

There you go! A simple and reusable dropdown selector that can further be customised. You can find the full source code here.

Cheers!