Select Multiple Items in SwiftUI List
Asked Answered
S

6

26

In UIKit you can select multiple rows of a UITableView by using allowsMultipleSelection - can this be done with the List in SwiftUI?

Shapely answered 13/7, 2019 at 20:30 Comment(5)
Yes. Check out how to use EditButton. hackingwithswift.com/quick-start/swiftui/…Abnormity
@dfd - Multiple selection and multi-row editing seem like different things.Rosati
@Rob, not sure what you mean. Technically, allowsMultipleSelection doesn't exist (yet) for a List. But functionally, if you use an EditButton with a List, you can select multiple rows, albeit very differently than a UITableView.Abnormity
That’s exactly what I mean. They’re analogous, but completely different things.Rosati
Could I programmatically tap the EditButton so that when the view is presented multiple items can be selected? Use case is applying multiple tags to a FileShapely
P
67

The only way to get multiple selection in SwiftUI right now is by using EditButton. However, that's not the only instance you might want to use multiple selection, and it would probably confuse users if you used EditButton multiple selection when you're not actually trying to edit anything.

I assume what you're really looking for is something like this:

enter image description here

Below is the code I wrote to create this:

struct MultipleSelectionList: View {
    @State var items: [String] = ["Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit"]
    @State var selections: [String] = []

    var body: some View {
        List {
            ForEach(self.items, id: \.self) { item in
                MultipleSelectionRow(title: item, isSelected: self.selections.contains(item)) {
                    if self.selections.contains(item) {
                        self.selections.removeAll(where: { $0 == item })
                    }
                    else {
                        self.selections.append(item)
                    }
                }
            }
        }
    }
}
struct MultipleSelectionRow: View {
    var title: String
    var isSelected: Bool
    var action: () -> Void

    var body: some View {
        Button(action: self.action) {
            HStack {
                Text(self.title)
                if self.isSelected {
                    Spacer()
                    Image(systemName: "checkmark")
                }
            }
        }
    }
}
Publus answered 14/7, 2019 at 0:29 Comment(5)
Thank you @graycampbell. Your solution is great.Borges
My full solution based on your example: pawelmadej.com/post/multi-select-picker-for-swiftuiBorges
@Publus Instead of using state I'm using "@Binding" and I get weird behaviour doing that sometimes. For instance clicking on a row will push (it's inside a NavigationView) a new view of the MultipleSelectionList...Acheron
@PeterWarbo It’s hard to say why that would be happening without seeing your code. If you want to post a new question and then link to it here, I’d be happy to take a look at itPublus
Bless you. Best solution. With a Stateable List you can send back to another view if needed. Thanks!Buchalter
R
13

First add this to your view

@State var selectedItems = Set<UUID>()

The type of the Set depends on the type you use to id: the items in the ForEach

Next declare the List

List(selection: $selectedItems) {
    ForEach(items, id: \.id) { item in
        Text("\(item.name)")
    }
}

Now whatever you select gets added to the selectedItems Set remember to clear it out after you use it.

Rowlock answered 17/6, 2020 at 19:14 Comment(0)
A
8

I created a custom ToggleStyle as follows:

import SwiftUI


enum Fruit: String, CaseIterable, Hashable {
    case apple = "Apple"
    case orange = "Orange"
    case banana = "Banana"
}

struct ContentView: View {

    @State var fruits = [Bool](repeating: false, count: Fruit.allCases.count)

    var body: some View {
        Form{
            ForEach(0..<fruits.count, id:\.self){i in
                Toggle(isOn: self.$fruits[i]){
                    Text(Fruit.allCases[i].rawValue)
                }.toggleStyle(CheckmarkToggleStyle())
            }
        }
    }
}

struct CheckmarkToggleStyle: ToggleStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        HStack {
            Button(action: { withAnimation { configuration.$isOn.wrappedValue.toggle() }}){
                HStack{
                    configuration.label.foregroundColor(.primary)
                    Spacer()
                    if configuration.isOn {
                        Image(systemName: "checkmark").foregroundColor(.primary)
                    }
                }
            }
        }
    }
}

Anchylose answered 17/12, 2019 at 14:14 Comment(0)
A
4

Here is an alternate way that uses a helper I created called Multiselect:

enter image description here

struct Fruit: Selectable {
    let name: String
    var isSelected: Bool
    var id: String { name }
}

struct FruitList: View {

    @State var fruits = [
        Fruit(name: "Apple", isSelected: true),
        Fruit(name: "Banana", isSelected: false),
        Fruit(name: "Kumquat", isSelected: true),
    ]

    var body: some View {
        VStack {
            Text("Number selected: \(fruits.filter { $0.isSelected }.count)")
            Multiselect(items: $fruits) { fruit in
                HStack {
                    Text(fruit.name)
                    Spacer()
                    if fruit.isSelected {
                        Image(systemName: "checkmark")
                    }
                }
            }
        }
    }
}

With the supporting code here:

protocol Selectable: Identifiable {
    var name: String { get }
    var isSelected: Bool { get set }
}

struct Multiselect<T: Selectable, V: View>: View {
    @Binding var items: [T]
    var rowBuilder: (T) -> V

    var body: some View {
        List(items) { item in
            Button(action: { self.items.toggleSelected(item) }) {
                self.rowBuilder(item)
            }
        }
    }
}

extension Array where Element: Selectable {
    mutating func toggleSelected(_ item: Element) {
        if let index = firstIndex(where: { $0.id == item.id }) {
            var mutable = item
            mutable.isSelected.toggle()
            self[index] = mutable
        }
    }
}
Anthropophagi answered 7/4, 2020 at 14:32 Comment(0)
A
3

I found an approach using a custom property wrapper that enables the selection to be modified from a child view using a Binding:

struct Fruit: Selectable {
    let name: String
    var isSelected: Bool
    var id: String { name }
}

struct FruitList: View {
    @State var fruits = [
        Fruit(name: "Apple", isSelected: true),
        Fruit(name: "Banana", isSelected: false),
        Fruit(name: "Kumquat", isSelected: true),
    ]

    var body: some View {
        VStack {
            Text("Number selected: \(fruits.filter { $0.isSelected }.count)")
            BindingList(items: $fruits) {
                FruitRow(fruit: $0)
            }
        }
    }

    struct FruitRow: View {
        @Binding var fruit: Fruit

        var body: some View {
            Button(action: { self.fruit.isSelected.toggle() }) {
                HStack {
                    Text(fruit.isSelected ? "☑" : "☐")
                    Text(fruit.name)
                }
            }
        }
    }
}

Here is the source for BindingList

Anthropophagi answered 6/4, 2020 at 12:50 Comment(0)
B
2

my 2 cents with a super simple solution:

for Georg, multiple example with NO bool in model. (note: I managed to use colors similar to Cells for iOS, can be more tuned... for dark mode / macOS..)

import SwiftUI


struct Item: Identifiable{
    let id: Int
    let name: String
    let emoji: String
}

struct ContentView: View {
    @State private var selection: Int? = nil
    @State private var multiSelection = Set<Int>()


    let items = [
        Item(id: 10, name:"Pizza", emoji: "🍕"),
        Item(id: 20, name:"Spaghetti", emoji: "🍝"),
        Item(id: 30, name:"Caviar", emoji: "🫧"),
    ]


    var body: some View {
        
        List(selection: $selection) {
            ForEach(items) { item in
                let sel = multiSelection.contains(item.id)
                CellView(txt: item.name, emoji: item.emoji, isSelected: sel)
            }
        }.onChange(of: selection, selectionHasChanged)

        if self.multiSelection.isEmpty == false{
            Button("Buy!") {
                print("bought!")
            }
        }
    }
    
    func selectionHasChanged(){
        guard let selection = selection else{
            return
        }
        //print(selection)
        if multiSelection.contains(selection){
            multiSelection.remove(selection)
        }else{
            multiSelection.insert(selection)
        }
        print(multiSelection.description)

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            self.selection = nil
        }
    }
}




struct CellView: View {
    
    internal init(txt: String, emoji: String, isSelected: Bool) {
        self.txt = txt
        self.emoji = emoji
        self.isSelected = isSelected
        self.backgroundColor = isSelected ? Color(uiColor: .lightGray) : Color(uiColor: .systemFill) // to be tuned..
    }
    
    let txt: String
    let emoji: String
    let isSelected: Bool
    let backgroundColor: Color

    var body: some View {
        HStack {
            if isSelected{
                Text(txt)
                    .bold()
            }else{
                Text(txt)
            }
            Spacer()
            Text(emoji)
             .imageScale(.large)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(backgroundColor)

    }
}

// OLD version (2021)

import SwiftUI

struct ListDemo: View {
    @State var items = ["Pizza", "Spaghetti", "Caviar"]
    @State var selection = Set<String>()
    
    var body: some View {
        List(items, id: \.self, selection: $selection) { (item : String) in
            
            let s = selection.contains(item) ? "√" : " "
            
            HStack {
                Text(s+item)
                Spacer()
            }
            .contentShape(Rectangle())
            .onTapGesture {
                if  selection.contains(item) {
                    selection.remove(item)
                }
                else{
                    selection.insert(item)
                }
                print(selection)
            }
        }
        .listStyle(GroupedListStyle())
    }
}

using string in set is sub-optimal, better to use id OR using strings with data and selection state.

Bergren answered 21/1, 2021 at 20:33 Comment(2)
Would this also be possible if you had multiple Sections? So your items would be something like [["Pizza", "Spaghetti"], ["Caviar"]]?Distillate
yes, but I prefer to use a viewModel or a more complex solution, a bit beyond this example.Bergren

© 2022 - 2024 — McMap. All rights reserved.