使用 SwiftUI 創(chuàng)建一個靈活的選擇器
前言
最近,在我正在開發(fā)一個在 Dribbble 上找到的設計的 SwiftUI 實現(xiàn)時,我想到了一個點子,可以通過一些酷炫的篩選器擴展該項目以縮小結果列表。
我決定篩選視圖將由兩個獨立的篩選選項組成,兩者都有一些可選項可供選擇。但然后我遇到了一個問題。在使用 UIKit 時,我總是將這種類型的視圖實現(xiàn)為具有特定 UICollectionViewFlowLayout 的 UICollectionView。但在 SwiftUI 中該如何實現(xiàn)呢?
讓我們來看看使用 SwiftUI 創(chuàng)建靈活選擇器的實現(xiàn)!
可選擇協(xié)議
選擇器的最重要部分是,我們可以通過該視圖組件選擇一些所需的選項。因此,首先創(chuàng)建了一個 Selectable 協(xié)議。
所有符合該協(xié)議的對象必須實現(xiàn)兩個屬性:displayedName(在選擇器中顯示的名稱)和 isSelected(一個布爾值,指示特定選項是否已選擇)。
此外,為了能夠通過映射字符串值數(shù)組創(chuàng)建 Selectable 對象,實現(xiàn) Selectable 的對象必須提供帶 displayedName 作為參數(shù)的自定義初始化。
Identifiable 和 Hashable 協(xié)議確保我們可以輕松創(chuàng)建具有 ForEach 循環(huán)的 SwiftUI 視圖。此外,符合 Selectable 協(xié)議的所有對象都將實現(xiàn)存儲 UUID 值的常量 id。
我會故意省略符合 Selectable 協(xié)議的對象的實現(xiàn),因為我認為這是顯而易見的。核心代碼如下:
protocol Selectable: Identifiable, Hashable {
var displayedName: String { get }
var isSelected: Bool { get set }
init(displayedName: String)
}
自定義化
我的目標不僅是創(chuàng)建靈活的選擇器的實現(xiàn),還要盡量使其可自定義。
因此,將使用符合 Selectable 協(xié)議的泛型類型 T 創(chuàng)建 FlexiblePicker。這樣,以后更容易重用該組件,因為它將是獨立于類型的。
在實現(xiàn)選擇器本身之前,我列出了所有可自定義屬性。接下來,創(chuàng)建了用于計算特定字符串值的寬度和高度的字符串擴展。由于我的實現(xiàn)允許更改字體大小和權重,因此先前提到的兩個擴展都以由靈活選擇器使用的 UIFont 作為參數(shù)。
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 的實現(xiàn)了。
首先,我需要一個函數(shù)來計算并返回輸入數(shù)據(jù)的所有寬度。我通過將所有輸入值映射到元組中,其中包含輸入值和自身的寬度來完成。
在映射中,我使用 reduce 函數(shù)來總結與給定輸入值相關聯(lián)的所有寬度(文本寬度、邊框寬度、文本填充和間距)。
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)
}
}
現(xiàn)在,計算寬度的函數(shù)準備好了,我們可以遍歷所有輸入數(shù)據(jù)并將它們分成單獨的數(shù)組。每個數(shù)組包含能夠適應同一 HStack 中的項目的項目。邏輯很簡單。我們有兩個數(shù)組:
- singleLineResult 數(shù)組——負責存儲適合特定行的項目。
- allLinesResult 數(shù)組——負責存儲所有項目數(shù)組(每個數(shù)組都等同于一行項目)。
首先,我們檢查從 HStack 行寬中減去項寬的結果是否大于0。
如果滿足條件,我們將當前項附加到 singleLineResult 中,更新可用的 HStack 行寬,并繼續(xù)到下一個元素。
如果結果小于 0,這意味著我們無法將下一個元素放入給定行中,因此我們將 singleLineResult 附加到 allLinesResult 中,將 singleLineResult 設置為僅由當前元素組成的數(shù)組(不能適應上一行的元素),并通過減去當前項的寬度來更新 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 的高度是根據(jù)兩個值計算的:
- 輸入數(shù)據(jù)中任何項目的高度(類似于寬度的計算,通過使用 reduce 函數(shù),總結與項目相關的所有高度)。
- 將顯示在 VStack 中的行數(shù)。
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)
}
將這兩個數(shù)字相乘的結果將是我們的 VStack 的高度。
FlexiblePicker 視圖
最后,當所有邏輯準備好后,我們需要實現(xiàn)一個視圖主體。如我之前所提到的,視圖將使用嵌套的 ForEach 循環(huán)創(chuàng)建。
需要記住的是,F(xiàn)orEach 循環(huán)要求迭代的集合中的每個元素必須符合 Identifiable 協(xié)議,或者應該具有唯一的標識符。
這就是為什么我將分隔行的結果映射到元組中,其中包含每行和 UUID 值。
由于如此,我可以向 ForEach 循環(huán)提供 id 參數(shù)。另一點需要記住的是,F(xiàn)orEach 循環(huán)期望獲得一些 View 作為返回值。
如果我們只插入另一個 ForEach 循環(huán),我們將在視圖的適當功能性方面遇到問題,因為 ForEach 不是一種 View。
這就是為什么我首先將整個 ForEach 循環(huán)包裝在 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))
}
}
}
幾乎所有都已經(jīng)完成,我們只需添加一個函數(shù)來處理與按鈕的用戶交互。該函數(shù)只需切換特定數(shù)據(jù)的 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,高度則由先前創(chuàng)建的函數(shù)計算。
現(xiàn)在 FlexiblePicker 已經(jīng)完成,可以使用了!
總結
這篇文章介紹了如何使用 SwiftUI 構建一個靈活的選擇器(FlexiblePicker),用于選擇多個選項。
首先創(chuàng)建了一個 Selectable 協(xié)議,使得選擇的選項對象需要實現(xiàn) displayedName 和 isSelected 屬性。
然后,詳細介紹了實現(xiàn)該選擇器的邏輯,包括如何處理選項的布局、寬度和高度,以及如何處理用戶與按鈕的交互。
最后,提供了一個簡單的視圖實現(xiàn),可以在 SwiftUI 中使用該選擇器。這個選擇器可用于創(chuàng)建各種交互式選擇界面。