使用 SwiftUI 创建一个灵活的选择器

2023-11-03 16:18:26 浏览数 (4)

前言

最近,在我正在开发一个在 Dribbble 上找到的设计的 SwiftUI 实现时,我想到了一个点子,可以通过一些酷炫的筛选器扩展该项目以缩小结果列表。

我决定筛选视图将由两个独立的筛选选项组成,两者都有一些可选项可供选择。但然后我遇到了一个问题。在使用 UIKit 时,我总是将这种类型的视图实现为具有特定 UICollectionViewFlowLayoutUICollectionView。但在 SwiftUI 中该如何实现呢?

让我们来看看使用 SwiftUI 创建灵活选择器的实现!

可选择协议

选择器的最重要部分是,我们可以通过该视图组件选择一些所需的选项。因此,首先创建了一个 Selectable 协议。

所有符合该协议的对象必须实现两个属性:displayedName(在选择器中显示的名称)和 isSelected(一个布尔值,指示特定选项是否已选择)。

此外,为了能够通过映射字符串值数组创建 Selectable 对象,实现 Selectable 的对象必须提供带 displayedName 作为参数的自定义初始化。

IdentifiableHashable 协议确保我们可以轻松创建具有 ForEach 循环的 SwiftUI 视图。此外,符合 Selectable 协议的所有对象都将实现存储 UUID 值的常量 id。

我会故意省略符合 Selectable 协议的对象的实现,因为我认为这是显而易见的。核心代码如下:

代码语言:javascript复制
protocol Selectable: Identifiable, Hashable {
    var displayedName: String { get }
    var isSelected: Bool { get set }
    
    init(displayedName: String)
}

自定义化

我的目标不仅是创建灵活的选择器的实现,还要尽量使其可自定义。

因此,将使用符合 Selectable 协议的泛型类型 T 创建 FlexiblePicker。这样,以后更容易重用该组件,因为它将是独立于类型的。

在实现选择器本身之前,我列出了所有可自定义属性。接下来,创建了用于计算特定字符串值的宽度和高度的字符串扩展。由于我的实现允许更改字体大小和权重,因此先前提到的两个扩展都以由灵活选择器使用的 UIFont 作为参数。

代码语言:javascript复制
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 枚举的特定情况。

代码语言:javascript复制
enum FontWeight {
    case light
    // the rest of possible cases
    
    var swiftUIFontWeight: Font.Weight {
        switch self {
        case .light:            return .light
        // switching through the rest of possible cases 
        }
    }
    
    var uiFontWeight: UIFont.Weight {
        switch self {
        case .light:            return .light
        // switching through the rest of possible cases 
        }
    }
}

FlexiblePicker 逻辑

之后,我终于准备好开始编写 FlexiblePicker 的实现了。

首先,我需要一个函数来计算并返回输入数据的所有宽度。我通过将所有输入值映射到元组中,其中包含输入值和自身的宽度来完成。

在映射中,我使用 reduce 函数来总结与给定输入值相关联的所有宽度(文本宽度、边框宽度、文本填充和间距)。

代码语言:javascript复制
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

代码语言:javascript复制
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 函数,总结与项目相关的所有高度)
  • 将显示在 VStack 中的行数
代码语言:javascript复制
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 中,以确保编译器可以正确解释一切。

代码语言:javascript复制
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 属性。

代码语言:javascript复制
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 协议,使得选择的选项对象需要实现 displayedNameisSelected 属性。

然后,详细介绍了实现该选择器的逻辑,包括如何处理选项的布局、宽度和高度,以及如何处理用户与按钮的交互。

最后,提供了一个简单的视图实现,可以在 SwiftUI 中使用该选择器。这个选择器可用于创建各种交互式选择界面。

- EOF -

1 人点赞