How to make List with single selection with SwiftUI 5
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
}
}
}
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) })
}
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