一、前言
- 在 Dribbble 上找到的设计的 SwiftUI 实现时,可以尝试通过一些酷炫的筛选器扩展该项目以缩小结果列表。筛选视图将由两个独立的筛选选项组成,两者都有一些可选项可供选择。但是,在使用 UIKit 时,总是将这种类型的视图实现为具有特定 UICollectionViewFlowLayout 的 UICollectionView。
- 那么,在 SwiftUI 中该如何实现呢?现在来看看使用 SwiftUI 创建灵活选择器的实现。
二、可选择协议
- 选择器的最重要部分是,可以通过该视图组件选择一些所需的选项。因此,首先创建一个 Selectable 协议。所有符合该协议的对象必须实现两个属性:displayedName(在选择器中显示的名称)和 isSelected(一个布尔值,指示特定选项是否已选择)。
- 此外,为了能够通过映射字符串值数组创建 Selectable 对象,实现 Selectable 的对象必须提供带 displayedName 作为参数的自定义初始化。Identifiable 和 Hashable 协议确保我们可以轻松创建具有 ForEach 循环的 SwiftUI 视图。此外,符合 Selectable 协议的所有对象都将实现存储 UUID 值的常量 id。
- 故意省略符合 Selectable 协议的对象的实现,因为这是显而易见的。核心代码如下:
protocol Selectable: Identifiable, Hashable {
var displayedName: String { get }
var isSelected: Bool { get set }
init(displayedName: String)
}
三、自定义化
- 不仅是创建灵活的选择器的实现,还要尽量使其可自定义。因此,将使用符合 Selectable 协议的泛型类型 T 创建 FlexiblePicker,这样,以后更容易重用该组件,因为它将是独立于类型的。
- 在实现选择器本身之前,可以列出所有可自定义属性。接下来,创建用于计算特定字符串值的宽度和高度的字符串扩展。由于允许更改字体大小和权重,因此先前提到的两个扩展都以由灵活选择器使用的 UIFont 作为参数。
extension String {
func getWidth(with font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let size = self.size(withAttributes: fontAttributes)
return size.width
}
func getHeight(with font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let size = self.size(withAttributes: fontAttributes)
return size.height
}
}
- 由于字符串扩展用于计算给定字符串的大小,因此需要将所有 UIFont 权重转换为 SwiftUI 等效项。这就是为什么需要引入一个 FontWeight 枚举,其中包含以 UIFont 权重命名的所有可能情况。
- 此外,该枚举有两个属性,一个返回 UIFont 权重,另一个返回 SwiftUI Font 权重。通过这种方式,只需向 FlexiblePicker 提供 FontWeight 枚举的特定情况。
enum FontWeight {
case light
var swiftUIFontWeight: Font.Weight {
switch self {
case .light: return .light
}
}
var uiFontWeight: UIFont.Weight {
switch self {
case .light: return .light
}
}
}
四、FlexiblePicker 逻辑
- 之后,终于准备好开始编写 FlexiblePicker 的实现了。首先,需要一个函数来计算并返回输入数据的所有宽度,通过将所有输入值映射到元组中,其中包含输入值和自身的宽度来完成。
- 在映射中,使用 reduce 函数来总结与给定输入值相关联的所有宽度(文本宽度、边框宽度、文本填充和间距)。
private func calculateWidths(for data: [T]) -> [(value: T, width: CGFloat)] {
return data.map { selectableType -> (T, CGFloat) in
let font = UIFont.systemFont(ofSize: fontSize, weight: fontWeight.uiFontWeight)
let textWidth = selectableType.displayedName.getWidth(with: font)
let width = [textPadding, textPadding, borderWidth, borderWidth, spacing]
.reduce(textWidth, +)
return (selectableType, width)
}
}
- 现在,计算宽度的函数已经准备好,可以遍历所有输入数据并将它们分成单独的数组,每个数组包含能够适应同一 HStack 中的项目的项目。
- 逻辑很简单,需要有两个数组:
-
- singleLineResult 数组——负责存储适合特定行的项目;
-
- allLinesResult 数组——负责存储所有项目数组(每个数组都等同于一行项目)。
- 首先,检查从 HStack 行宽中减去项宽的结果是否大于 0:
-
- 如果满足条件,将当前项附加到 singleLineResult 中,更新可用的 HStack 行宽,并继续到下一个元素。
-
- 如果结果小于 0,这意味着无法将下一个元素放入给定行中,因此将 singleLineResult 附加到 allLinesResult 中,将 singleLineResult 设置为仅由当前元素组成的数组(不能适应上一行的元素),并通过减去当前项的宽度来更新 HStack 的行宽。
- 在遍历所有元素之后,必须处理特定的边缘情况。singleLineResult 可能不会为空,也不会附加到 allLinesResult 中,因为只在减去项目宽度的结果小于 0 时附加 singleLineResult。在这种情况下,我们必须检查 singleLineResult 是否为空。如果为真,返回 allLinesResult,如果不为真,必须首先附加 singleLineResult,然后返回 allLinesResult。
private func divideDataIntoLines(lineWidth: CGFloat) -> [[T]] {
let data = calculateWidths(for: inputData)
var singleLineWidth = lineWidth
var allLinesResult = [[T]]()
var singleLineResult = [T]()
var partialWidthResult: CGFloat = 0
data.forEach { (selectableType, width) in
partialWidthResult = singleLineWidth - width
if partialWidthResult > 0 {
singleLineResult.append(selectableType)
singleLineWidth -= width
} else {
allLinesResult.append(singleLineResult)
singleLineResult = [selectableType]
singleLineWidth = lineWidth - width
}
}
guard !singleLineResult.isEmpty else { return allLinesResult }
allLinesResult.append(singleLineResult)
return allLinesResult
}
- 最后但并非最不重要的是,必须计算 VStack 的高度,以使 SwiftUI 更容易解释我们的视图组件,VStack 的高度是根据两个值计算的:
-
- 输入数据中任何项目的高度(类似于宽度的计算,通过使用 reduce 函数,总结与项目相关的所有高度);
-
private func calculateVStackHeight(width: CGFloat) -> CGFloat {
let data = divideDataIntoLines(lineWidth: width)
let font = UIFont.systemFont(ofSize: fontSize, weight: fontWeight.uiFontWeight)
guard let textHeight = data.first?.first?.displayedName
.getHeight(with: font) else { return 16 }
let result = [textPadding, textPadding, borderWidth, borderWidth, spacing]
.reduce(textHeight, +)
return result * CGFloat(data.count)
}
- 将这两个数字相乘的结果将是我们的 VStack 的高度。
五、FlexiblePicker 视图
- 最后,当所有逻辑准备好后,需要实现一个视图主体。如之前所提到的,视图将使用嵌套的 ForEach 循环创建。需要记住的是,ForEach 循环要求迭代的集合中的每个元素必须符合 Identifiable 协议,或者应该具有唯一的标识符。这就是为什么将分隔行的结果映射到元组中,其中包含每行和 UUID 值。
- 由于如此,可以向 ForEach 循环提供 id 参数。另一点需要记住的是,ForEach 循环期望获得一些 View 作为返回值。如果只插入另一个 ForEach 循环,将在视图的适当功能性方面遇到问题,因为 ForEach 不是一种 View。这就是为什么首先将整个 ForEach 循环包装在 HStack 中,然后再包装在 Group 中,以确保编译器可以正确解释一切。
var body: some View {
GeometryReader { geo in
VStack(alignment: alignment, spacing: spacing) {
ForEach(
divideDataIntoLines(lineWidth: geo.size.width)
.map { (data: $0, id: UUID()) },
id: \.id
) { dataArray in
Group {
HStack(spacing: spacing) {
ForEach(dataArray.data, id: \.id) { data in
Button(action: { updateSelectedData(with: data)
}) {
Text(data.displayedName)
.lineLimit(1)
.foregroundColor(textColor)
.font(.system(
size: fontSize,
weight: fontWeight.swiftUIFontWeight
))
.padding(textPadding)
}
.background(
data.isSelected
? selectedColor.opacity(0.5)
: notSelectedColor.opacity(0.5)
)
.cornerRadius(10)
.disabled(!isSelectable)
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: borderWidth))
}
}
}
}
}
.frame(width: geo.size.width, height: calculateVStackHeight(width: geo.size.width))
}
}
}
- 几乎所有都已经完成,只需添加一个函数来处理与按钮的用户交互,该函数只需切换特定数据的 isSelected 属性:
private func updateSelectedData(with data: T) {
guard let index = inputData.indices
.first(where: { inputData[$0] == data }) else { return }
inputData[index].isSelected.toggle()
}
- 其余的代码很简单,主要是配置所有属性,如字体、颜色或边框。此外,在 VStack 的底部,我们设置一个 frame,其中宽度取自 GeometryReader,高度则由先前创建的函数计算。
- 现在 FlexiblePicker 已经完成,便可以使用了。
六、总结
- 本文完整使用 SwiftUI 构建一个灵活的选择器(FlexiblePicker),用于选择多个选项。
- 首先创建了一个 Selectable 协议,使得选择的选项对象需要实现 displayedName 和 isSelected 属性。
- 然后,详细介绍了实现该选择器的逻辑,包括如何处理选项的布局、宽度和高度,以及如何处理用户与按钮的交互。
- 最后,提供了一个简单的视图实现,可以在 SwiftUI 中使用该选择器,这个选择器可用于创建各种交互式选择界面。