如何使用 SwiftUI 制作单选列表

How to make List with single selection with SwiftUI

我正在创建单个 selection 列表以在我的应用程序的不同位置使用。


问题:

有没有我不知道的简单解决方案?

如果没有,我该如何完成当前的解决方案?


我的目标:

  1. 列表始终只有一项 selected 或一项或 none 项 selected(取决于配置)
  2. 透明背景
  3. 关于项目 select - 执行通过 init() 方法设置为参数的操作。 (该操作需要 selected 项目信息。)
  4. 以编程方式更改列表数据并将 selection 重置为第一项

我目前的解决方案如下: List view with second item selected

我不能使用 Picker,因为外部操作(目标 3)很耗时。所以我认为它不会顺利进行。 很可能在 SwiftUI 中有解决我的问题的方法,但要么我错过了它,因为我是 swift 的新手,要么据我所知,在 SwiftUI 中并不是所有的东西都能完美运行,例如:List 的透明背景(这就是为什么我需要清除 init() 中的背景。

所以我开始自己实现 selection,到此为止: 单击项目(按钮)时,我当前的解决方案不会更新视图。 (只能出去和回到页面更新视图)。而且仍然可以 select 编辑多个项目。

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)
]

测试一些想法:

尝试在 ModuleList 中添加 (isSelected: Bool) 的 @State 数组并将其绑定到可能会更新视图的 Module isSelected 参数...但是在 init() 中填充此数组失败,因为 @State 数组参数将保留.append() 之后为空...也许添加函数 setList 可以解决这个问题,而我的目标是 Nr。 4. 但我不确定这是否真的会首先更新我的观点。

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"
 ]

选择

SwiftUI 目前没有内置的方法来 select 列表的一行并相应地改变它的外观。但你实际上非常接近你的答案。事实上,您的 selection 实际上已经在工作,只是没有以任何方式使用。

为了说明,请在 ForEach 中的 ModuleCell(...) 之后添加以下行:

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

换句话说,"If my current row (i) matches the value stored in selectionKeeper, color the cell red, otherwise, use the default color."您会看到当您点击不同的行时,红色会随着您的点击而变化,表明底层 select离子实际上正在发生变化。

Deselection

如果你想启用deselection,你可以传入一个Binding<Int?>作为你的selection,并在当前selected 行被点击:

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()
    }
}

去重状态和关注点分离

在更结构化的层面上,当使用像 SwiftUI 这样的声明性 UI 框架时,您确实需要一个单一的事实来源,并清楚地将您的视图与模型分开。目前,您有重复的状态 — ModuleList 中的 selectionKeeperModule 中的 isSelected 都跟踪给定模块是否被 selected。

此外,isSelected 确实应该是您的视图 (ModuleCell) 的 属性,而不是模型 (Module) 的,因为它与您的视图有关出现,不是每个模块的固有数据。

因此,您的 ModuleCell 应该看起来像这样:

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 ------------------------------^
    }
}

你的ForEach看起来像

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

实现此目的的最简单方法是在包含带有选择的列表的视图中使用@State,并将其作为@Binding 传递给单元格:

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
            }
    }
}

使用绑定 Optional 类型选择变量。 List 将只允许选择一个元素。

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")
            }
        }
    }
}
  • 在 macOS 10.15.2 (19C57) 上针对 Mac 应用程序使用 Xcode 版本 11.3 (11C29) 进行了测试。

这是一个更通用的方法,您仍然可以根据需要扩展答案;

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


详情

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
            }
        }
    }
}

并进行演示;

struct PlaygroundView: View {
    
    struct Koko: Identifiable {
        let id = UUID().uuidString
        var name: String
    }
    
    var mock = Array(0...10).map { Koko(name: "Item - \([=11=])") }
    @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()
                }
            }
        }
    }
    
}

最终结果