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

这篇文章介绍了如何使用 SwiftUI 构建一个灵活的选择器(FlexiblePicker),用于选择多个选项。首先创建了一个 ​​Selectable​​ 协议,使得选择的选项对象需要实现 ​​DisplayedName​​ 和IsSelected​​ 属性。

这篇文章介绍了如何使用 SwiftUI 构建一个灵活的选择器(FlexiblePicker),用于选择多个选项。首先创建了一个 ​​Selectable​​ 协议,使得选择的选项对象需要实现 ​​DisplayedName​​ 和IsSelected​​ 属性。

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

前言

最近,在我正在开发一个在 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
    // 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协议,使得选择的选项对象需要实现displayedName和isSelected属性。

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

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

©本文为清一色官方代发,观点仅代表作者本人,与清一色无关。清一色对文中陈述、观点判断保持中立,不对所包含内容的准确性、可靠性或完整性提供任何明示或暗示的保证。本文不作为投资理财建议,请读者仅作参考,并请自行承担全部责任。文中部分文字/图片/视频/音频等来源于网络,如侵犯到著作权人的权利,请与我们联系(微信/QQ:1074760229)。转载请注明出处:清一色财经

(0)
打赏 微信扫码打赏 微信扫码打赏 支付宝扫码打赏 支付宝扫码打赏
清一色的头像清一色管理团队
上一篇 2023年11月4日 17:28
下一篇 2023年11月4日 18:14

相关推荐

发表评论

登录后才能评论

联系我们

在线咨询:1643011589-QQbutton

手机:13798586780

QQ/微信:1074760229

QQ群:551893940

工作时间:工作日9:00-18:00,节假日休息

关注微信