前语

最近,在我正在开发一个在 Dribbble 上找到的规划的 SwiftUI 完结时,我想到了一个点子,能够经过一些酷炫的挑选器扩展该项目以缩小成果列表。

我决议挑选视图将由两个独立的挑选选项组成,两者都有一些可选项可供挑选。但然后我遇到了一个问题。在运用 UIKit 时,我总是将这种类型的视图完结为具有特定 UICollectionViewFlowLayoutUICollectionView。但在 SwiftUI 中该怎么完结呢?

让咱们来看看运用 SwiftUI 创立灵敏挑选器的完结!

可挑选协议

挑选器的最重要部分是,咱们能够经过该视图组件挑选一些所需的选项。因而,首要创立了一个 Selectable 协议。

全部契合该协议的方针有必要完结两个特点:displayedName(在挑选器中显现的称号)和 isSelected(一个布尔值,指示特定选项是否已挑选)。

此外,为了能够经过映射字符串值数组创立 Selectable 方针,完结 Selectable 的方针有必要供给带 displayedName 作为参数的自定义初始化。

IdentifiableHashable 协议保证咱们能够轻松创立具有 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
    // 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 函数来总结与给定输入值相关联的全部宽度(文本宽度、边框宽度、文本填充和间距)。

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 函数,总结与项目相关的全部高度)
  • 将显现在 VStack 中的行数
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,高度则由从前创立的函数核算。

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

现在 FlexiblePicker 现已完结,能够运用了!

总结

这篇文章介绍了怎么运用 SwiftUI 构建一个灵敏的挑选器(FlexiblePicker),用于挑选多个选项。

首要创立了一个 Selectable 协议,使得挑选的选项方针需求完结 displayedNameisSelected 特点。

然后,详细介绍了完结该挑选器的逻辑,包括怎么处理选项的布局、宽度和高度,以及怎么处理用户与按钮的交互。

最终,供给了一个简略的视图完结,能够在 SwiftUI 中运用该挑选器。这个挑选器可用于创立各种交互式挑选界面。