How to make List with single selection with SwiftUI

21,857

Solution 1

Selection

SwiftUI does not currently have a built in way to select one row of a list and change its appearance accordingly. But you're actually very close to your answer. In fact, your selection is in fact already working, but just isn't being used in any way.

To illustrate, add the following line right after ModuleCell(...) in your ForEach:

.background(i == self.selectionKeeper ? Color.red : nil)

In other words, "If my current row (i) matches the value stored in selectionKeeper, color the cell red, otherwise, use the default color." You'll see that as you tap on different rows, the red coloring follows your taps, showing the underlying selection is in fact changing.

Deselection

If you wanted to enable deselection, you could pass in a Binding<Int?> as your selection, and set it to nil when the currently selected row is tapped:

struct ModuleList: View {
    var modules: [Module] = []
    // this is new ------------------v
    @Binding var selectionKeeper: Int?
    var Action: () -> Void

    // this is new ----------------------------v
    init(list: [Module], selection: Binding<Int?>, action: @escaping () -> Void) {

    ...

    func changeSelection(index: Int){
        if selectionKeeper != index {
            selectionKeeper = index
        } else {
            selectionKeeper = nil
        }
        self.Action()
    }
}

Deduplicating State and Separation of Concerns

On a more structural level, you really want a single source of truth when using a declarative UI framework like SwiftUI, and to cleanly separate your view from your model. At present, you have duplicated state — selectionKeeper in ModuleList and isSelected in Module both keep track of whether a given module is selected.

In addition, isSelected should really be a property of your view (ModuleCell), not of your model (Module), because it has to do with how your view appears, not the intrinsic data of each module.

Thus, your ModuleCell should look something like this:

struct ModuleCell: View {
    var module: Module
    var isSelected: Bool // Added this
    var Action: () -> Void

    // Added this -------v
    init(module: Module, isSelected: Bool, action: @escaping () -> Void) {
        UITableViewCell.appearance().backgroundColor = .clear
        self.module = module
        self.isSelected = isSelected  // Added this
        self.Action = action
    }

    var body: some View {
        Button(module.name, action: {
            self.Action()
        })
            .frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
            .modifier(Constants.CellSelection(isSelected: isSelected))
            // Changed this ------------------------------^
    }
}

And your ForEach would look like

ForEach(0..<modules.count) { i in
    ModuleCell(module: self.modules[i],
               isSelected: i == self.selectionKeeper,
               action: { self.changeSelection(index: i) })
}

Solution 2

The easiest way to achieve this would be to have @State in the View containing the list with the selection and pass it as @Binding to the cells:

struct SelectionView: View {

    let fruit = ["apples", "pears", "bananas", "pineapples"]
    @State var selectedFruit: String? = nil

    var body: some View {
        List {
            ForEach(fruit, id: \.self) { item in
                SelectionCell(fruit: item, selectedFruit: self.$selectedFruit)
            }
        }
    }
}

struct SelectionCell: View {

    let fruit: String
    @Binding var selectedFruit: String?

    var body: some View {
        HStack {
            Text(fruit)
            Spacer()
            if fruit == selectedFruit {
                Image(systemName: "checkmark")
                    .foregroundColor(.accentColor)
            }
        }   .onTapGesture {
                self.selectedFruit = self.fruit
            }
    }
}

Solution 3

Here is a more generic approach, you can still extend answer according to your needs;

TLDR
https://gist.github.com/EnesKaraosman/d778cdabc98ca269b3d162896bea8aac


Detail

struct SingleSelectionList<Item: Identifiable, Content: View>: View {
    
    var items: [Item]
    @Binding var selectedItem: Item?
    var rowContent: (Item) -> Content
    
    var body: some View {
        List(items) { item in
            rowContent(item)
                .modifier(CheckmarkModifier(checked: item.id == self.selectedItem?.id))
                .contentShape(Rectangle())
                .onTapGesture {
                    self.selectedItem = item
                }
        }
    }
}

struct CheckmarkModifier: ViewModifier {
    var checked: Bool = false
    func body(content: Content) -> some View {
        Group {
            if checked {
                ZStack(alignment: .trailing) {
                    content
                    Image(systemName: "checkmark")
                        .resizable()
                        .frame(width: 20, height: 20)
                        .foregroundColor(.green)
                        .shadow(radius: 1)
                }
            } else {
                content
            }
        }
    }
}

And to demonstrate;

struct PlaygroundView: View {
    
    struct Koko: Identifiable {
        let id = UUID().uuidString
        var name: String
    }
    
    var mock = Array(0...10).map { Koko(name: "Item - \($0)") }
    @State var selectedItem: Koko?
    
    
    var body: some View {
        VStack {
            Text("Selected Item: \(selectedItem?.name ?? "Select one")")
            Divider()
            SingleSelectionList(items: mock, selectedItem: $selectedItem) { (item) in
                HStack {
                    Text(item.name)
                    Spacer()
                }
            }
        }
    }
    
}

Final Result
enter image description here

Solution 4

Use binding with Optional type selection variable. List will allow only one element selected.

struct ContentView: View {
    // Use Optional for selection.
    // Instead od Set or Array like this...
    // @State var selection = Set<Int>()
    @State var selection = Int?.none
    var body: some View {
        List(selection: $selection) {
            ForEach(0..<128) { _ in
                Text("Sample")
            }
        }
    }
}
  • Tested with Xcode Version 11.3 (11C29) on macOS 10.15.2 (19C57) for a Mac app.
Share:
21,857
White_Tiger
Author by

White_Tiger

Updated on May 12, 2021

Comments

  • White_Tiger
    White_Tiger about 3 years

    I am creating single selection list to use in different places in my app.


    Questions:

    Is there an easy solution I don't know?

    If there isn't, how can I finish my current solution?


    My goal:

    1. List with always only one item selected or one or none item selected (depending on configuration)
    2. Transparent background
    3. On item select - perform action which is set as parameter via init() method. (That action requires selected item info.)
    4. Change list data programmatically and reset selection to first item

    My current solution looks like: List view with second item selected

    I can't use Picker, because outer action (goal Nr. 3) is time consuming. So I think it wouldn't work smoothly. Most probably there is solution to my problem in SwiftUI, but either I missed it, because I am new to swift or as I understand not everything works perfectly in SwiftUI yet, for example: transparent background for List (which is why i needed to clear background in init()).

    So I started implementing selection myself, and stoped here: My current solution does not update view when item(Button) is clicked. (Only going out and back to the page updates view). And still multiple items can be selected.

    import SwiftUI
    
    struct ModuleList: View {
        var modules: [Module] = []
        @Binding var selectionKeeper: Int
        var Action: () -> Void
    
    
        init(list: [Module], selection: Binding<Int>, action: @escaping () -> Void) {
            UITableView.appearance().backgroundColor = .clear
            self.modules = list
            self._selectionKeeper = selection
            self.Action = action
        }
    
        var body: some View {
            List(){
                ForEach(0..<modules.count) { i in
                    ModuleCell(module: self.modules[i], action: { self.changeSelection(index: i) })
                    }
            }.background(Constants.colorTransparent)
        }
    
        func changeSelection(index: Int){
            modules[selectionKeeper].isSelected =  false
            modules[index].isSelected = true
            selectionKeeper = index
            self.Action()
        }
    }
    
    struct ModuleCell: View {
        var module: Module
        var Action: () -> Void
    
        init(module: Module, action: @escaping () -> Void) {
            UITableViewCell.appearance().backgroundColor = .clear
            self.module = module
            self.Action = action
        }
    
        var body: some View {
            Button(module.name, action: {
                self.Action()
            })
                .frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
                .modifier(Constants.CellSelection(isSelected: module.isSelected))
        }
    }
    
    class Module: Identifiable {
        var id = UUID()
        var name: String = ""
        var isSelected: Bool = false
        var address: Int
    
        init(name: String, address: Int){
            self.name = name
            self.address = address
        }
    }
    
    let testLines = [
        Module(name: "Line1", address: 1),
        Module(name: "Line2", address: 3),
        Module(name: "Line3", address: 5),
        Module(name: "Line4", address: 6),
        Module(name: "Line5", address: 7),
        Module(name: "Line6", address: 8),
        Module(name: "Line7", address: 12),
        Module(name: "Line8", address: 14),
        Module(name: "Line9", address: 11),
        Module(name: "Line10", address: 9),
        Module(name: "Line11", address: 22)
    ]
    

    Testing some ideas:

    Tried adding @State array of (isSelected: Bool) in ModuleList and binding it to Module isSelected parameter that MIGHT update view... But failed then populating this array in init(), because @State array parameter would stay empty after .append()... Maybe adding function setList would have solved this, and my goal Nr. 4. But I was not sure if this would really update my view in the first place.

    struct ModuleList: View {
         var modules: [Module] = []
         @State var selections: [Bool] = []
    
    
         init(list: [String]) {
             UITableView.appearance().backgroundColor = .clear
             selections = [Bool] (repeating: false, count: list.count) // stays empty
             let test = [Bool] (repeating: false, count: list.count) // testing: works as it should
             selections = test
             for i in 0..<test.count { // for i in 0..<selections.count {
                 selections.append(false)
                 modules.append(Module(name: list[i], isSelected: $selections[i])) // Error selections is empty
             }
         }
    
         var body: some View {
             List{
                 ForEach(0..<modules.count) { i in
                     ModuleCell(module: self.modules[i], action: { self.changeSelection(index: i) })
                     }
             }.background(Constants.colorTransparent)
         }
         func changeSelection(index: Int){
             modules[index].isSelected = true
         }
     }
    
     struct ModuleCell: View {
         var module: Module
         var Method: () -> Void
    
         init(module: Module, action: @escaping () -> Void) {
             UITableViewCell.appearance().backgroundColor = .clear
             self.module = module
             self.Method = action
         }
    
         var body: some View {
             Button(module.name, action: {
                 self.Method()
             })
                 .frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
                 .modifier(Constants.CellSelection(isSelected: module.isSelected))
         }
     }
    
     struct Module: Identifiable {
         var id = UUID()
         var name: String = ""
         @Binding var isSelected: Bool
    
         init(name: String, isSelected: Binding<Bool>){
             self.name = name
             self._isSelected = isSelected
         }
     }
    
     let testLines = ["Line1","Line2","Line3","Line4"
     ]
    
  • White_Tiger
    White_Tiger over 4 years
    Thanks, SUPER helpful explanation and tips! Changing list and setting selection also works great. Testing this I just noticed, that i need to scroll the list back up to selected (first item in my case). As far as I googled this seems a tough task to do. Maybe you have some ideas for that too?
  • John M.
    John M. over 4 years
    Unfortunately, I don't think you can programmatically scroll to a particular item right now. SwiftUI 1.0 seems focused on tackling simple, common use cases, and they were not trying to implement all the functionality of UIKit elements this year. I'm sure next year we'll see expanded capabilities around things like List offset manipulation.
  • Frederic Adda
    Frederic Adda over 4 years
    That looks interesting but how do you implement it in practice? Do you have a working example? This does not work by itself.
  • Akash Chaudhary
    Akash Chaudhary over 4 years
    The only answer that worked for me. Also the easiest. Thanks a lot man , you saved my day.
  • Lipi
    Lipi almost 4 years
    More simple, more clean
  • William Hu
    William Hu over 3 years
    I have to add .background(Color.white) or the HStack is just same width as Text, then not the whole view can be clicked. Also could setup the HStack width for achieving this, anyway this is a great answer.