Building a Custom MultiSelect Picker in SwiftUI

Jimoh YusuphJimoh Yusuph
6 min read

SwiftUI has revolutionized how we build user interfaces on Apple platforms, making it easier and faster to create modern, responsive apps. While SwiftUI provides excellent built-in controls like Picker for single selections, there isn’t a native way for multiple selections.

In this tutorial, we will walk through how to build a custom MultiSelectPicker in SwiftUI. the component allows users to select multiple options from a list, we'll also cover how to handle pre-selection of items.

Creating a MultiSelect Picker in SwiftUI

Let’s break down the process step by step.

Step 1: Define the FormOption Model

We’ll use a FormOption struct to represent each option’s data. Some implementations may prefer a class and introduce isSelected property to manage selection state, as classes are reference types and preserve state across views. You can either conform your own model to the FormOption structure or create a custom class or struct that best fits your project’s architecture and selection logic.

struct FormOption: Hashable, Identifiable {
    public var id: String
    public var label: String
    public var value: String

    init(id: String = UUID().uuidString, label: String, value: String) {
        self.id = id
        self.label = label
        self.value = value
    }
}

Why a struct? A struct is value-safe, easy to test, avoids unintended mutations, and aligns well with SwiftUI’s emphasis on immutability. It helps prevent side effects and ensures more predictable behavior in your views.

Note: Make sure each option has a unique id, as it is used internally to manage selection for each item.

Step 2: Manage Logic with a ViewModel

We use a MultiSelectPickerViewModel to manage all logic in one place, which helps keep the UI clean and makes the logic testable, reusable, and easy to maintain.

@MainActor
class MultiSelectPickerViewModel: ObservableObject {
    @Published var allOptions: [FormOption]
    @Published var selectedOptions: Set<FormOption>
    @Published var selectedIDs: Set<String>

    init(options: [FormOption], preSelected: [FormOption] = []) {
        self.allOptions = options
        //Handling preselection
        let preSelectedIDs = Set(preSelected.map { $0.id })
        let selected = options.filter { preSelectedIDs.contains($0.id) }
        self.selectedOptions = Set(selected)

        self.selectedIDs = preSelectedIDs
    }

    func toggleSelection(_ option: FormOption) {
        if selectedOptions.contains(option) {
            selectedOptions.remove(option)
            selectedIDs.remove(option.id)
        } else {
            selectedOptions.insert(option)
            selectedIDs.insert(option.id)
        }
    }
}

Properties

  • @Published var allOptions:
    This holds the complete list of selectable items passed into the picker.

  • @Published var selectedOptions:
    We use the selectedOptions set to keep track of what’s currently selected.
    This is also useful for returning selected values and displaying selected labels in the UI

The selection logic is handled by the toggleSelection function. It updates which options are selected.

Preselection Logic

if you want to support preselected options, it's handled during initialization.
The IDs of the preSelected items are compared with those in allOptions to set up the initial selection.

let preSelectedIDs = Set(preSelected.map { $0.id }) 
let selected = options.filter { preSelectedIDs.contains($0.id) }

Why Use selectedIDs?

Though selectedOptions holds the selected objects, we also maintain a separate selectedIDs: Set<String> for these reasons:

  • Fast lookups.contains(id) is much quicker than comparing entire objects.

  • Efficient rendering – Ideal for use in List and SwiftUI views where identity is based on id.

  • Better scalability – Keeps the UI logic simple and performs well, especially with large lists.

FYI: I use a set instead of an array because it automatically handles uniqueness and provides faster lookups and removals with O(1) time complexity.

Step 3: Create the Option Item Row

Each option in the picker are displayed in a row. We create a view that will show each option as a Text and a checkmark if it’s selected.

struct MultipleSelectionRow: View {
    var option: FormOption
    var isSelected: Bool
    var onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack {
                Text(option.label)
                Spacer()
                if isSelected {
                    Image(systemName: "checkmark")
                        .foregroundColor(.accentColor)
                }
            }
        }
    }
}

Step 3: Displaying and Handling Selection

We display all available options using a SwiftUI List. When the user taps to open the picker, the list appears, allowing each item to be selected or deselected. As mentioned earlier, the selection logic is handled inside the ViewModel, not directly in the view, which helps to keep the UI clean.

I've also added helpful features like search and clear selection to optimize performance for large datasets and improve the overall user experience. However, I won’t go into detail on those features in this article.

struct OptionsSelectionSheet: View {
    var allOptions: [FormOption]
    var title: String
    @Binding var selectedIDs: Set<String>
    var onToggle: (FormOption) -> Void
    var onDone: () -> Void

    var body: some View {
            List {
                ForEach(allOptions, id: \.id) { option in
                    MultipleSelectionRow(
                        option: option,
                        isSelected: selectedIDs.contains(option.id),
                        onTap: { onToggle(option) }
                    )
                }
            }
            .listStyle(.plain)
            .searchable(text: $searchText, prompt: "Search \(title.lowercased())")
            .navigationTitle(title)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    if !selectedIDs.isEmpty {
                        Button("Clear", action: onClear)
                            .foregroundColor(.red)
                    }
                }
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Done", action: onDone)
                }
            }
        }
    }
}

Step 4: Building the Multi Picker View (The Moment We've Been Waiting For)

Next, we create the picker view that displays the list of options and style it to resemble the native SwiftUI Picker. When users tap on the selection field, it present the options selection sheet we created earlier.

I'm an advocate for accessibility, so I’ve made sure the component includes appropriate accessibility labels and traits to support VoiceOver users.

struct MultiSelectPicker: View {
    var title: String
    @Binding public var options: [FormOption]
    var preSelected: [FormOption]
    var onDone: ((Set<FormOption>) -> Void)? = nil

    @StateObject private var viewModel: MultiSelectPickerViewModel
    @State private var showOptionsSheet = false

    init(
        title: String,
        options: Binding<[FormOption]>,
        preSelected: [FormOption] = [],
        onDone: ((Set<FormOption>) -> Void)? = nil
    ) {
        self.title = title
        self._options = options
        self.preSelected = preSelected
        self.onDone = onDone
        _viewModel = StateObject(wrappedValue: MultiSelectPickerViewModel(
            options: options.wrappedValue,
            preSelected: preSelected
        ))
    }

    var body: some View {
        VStack(alignment: .leading) {
            Button {
                showOptionsSheet.toggle()
            } label: {
                HStack {
                    Text(displayedLabelText)
                        .accessibilityLabel(accessibilityLabelText)
                    Spacer()
                    Image(systemName: "chevron.down")
                }
                .padding()
                .background(Color.gray.opacity(0.2))
                .cornerRadius(10)
            }
            .accessibilityElement(children: .combine)

            .sheet(isPresented: $showOptionsSheet) {
                OptionsSelectionSheet(
                    allOptions: viewModel.filteredOptions,
                    title: title,
                    selectedIDs: $viewModel.selectedIDs,
                    searchText: $viewModel.searchText,
                    onToggle: { viewModel.toggleSelection($0) },
                    onClear: viewModel.clearSelection,
                    onDone: {
                        showOptionsSheet = false
                        options = viewModel.allOptions
                        onDone?(viewModel.selectedOptions)
                    }
                )
            }
        }
    }

    private var displayedLabelText: String {
        viewModel.selectedOptions.isEmpty
            ? title
            : viewModel.selectedOptions.map { $0.label }.joined(separator: ", ")
    }

    private var accessibilityLabelText: String {
        viewModel.selectedOptions.isEmpty
            ? title
            : "Selected: \(viewModel.selectedOptions.map { $0.label }.joined(separator: ", "))"
    }
}

Finally, here's how we can use the picker in our app:

import SwiftUI
import MultiSelectPicker

struct ContentView: View {
    @State var options: [FormOption] = [
        FormOption(label: "Reading", value: "reading"),
        FormOption(label: "Traveling", value: "traveling"),
        FormOption(label: "Cooking", value: "cooking"),
        FormOption(label: "Gaming", value: "gaming")
    ]

    var body: some View {
        MultiSelectPicker(title: "Hobbies", options: $options,
            onDone: { selected in
                print("Selected:", selected)
            }
        )
        .padding()
    }
}

Wrapping Up

We've successfully built a custom multi-option picker in SwiftUI that allows users to select multiple options with full control of the design and functionality.

I’ve also created a Swift package that you can integrate directly into your project, or simply copy the code and use in your app:

.package(url: "https://github.com/yusuphjoluwasen/MultiSelectPicker.git", from: "1.0.3")

Feel free to tweak the FormOption model or any part of the code to suit your app's needs.

Happy Coding 💻

10
Subscribe to my newsletter

Read articles from Jimoh Yusuph directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jimoh Yusuph
Jimoh Yusuph

I'm an iOS with a passion for creating secured high-performance applications. I enjoy reading and exploring ways to enhance app functionalities and efficiency. I have strong practical experience developing applications in Swift, Kotlin, Javascript, Python, and Flutter. Having led teams in the past, I greatly value collaboration and teamwork, and I take pleasure in sharing my knowledge with others.