最近,我在 SwiftUI 實作一個 Dribbble 上看到的設計時,腦海中浮現了一個想法,想要為專案添加一個功能,利用一些篩選器 (filter) 來篩選結果。
我希望篩選器視圖可以包含兩個單獨的篩選選項,兩個都有一些選項可供選擇。但後來我遇到一個問題:當我使用 UIKit 時,我會將這種視圖實作為有特定 UICollectionViewFlowLayout
的 UICollectionView
;那如果在 SwiftUI,我應該怎樣做呢?
一起來看看我如何在 SwiftUI 實作一個 Flexible Picker 吧!
Selectable 協定
這個 Picker 最重要的部分,就是利用那個視圖元件,讓我們可以選擇需要的選項。因此,我在程式碼的一開始,就創建了一個 Selectable
協定。
所有符合這個協定的物件都需要實作兩個屬性 (property):
displayedName
:顯示在 Picker 中的名稱isSelected
:一個 Bool 值,用來告知是否選擇了特定選項
另外,我們希望只需要 map 字串值的陣列 (array),就可以創建 Selectable
物件,因此我們需要利用遵從 Selectable
的物件,來提供客製化的 init
和作為引數 (argument) 的 displayedName
。
有了 Identifiable
和 Hashable
協定,我們就可以使用 ForEach
循環來輕鬆創建 SwiftUI 視圖。此外,所有符合 Selectable
協定的物件,都會實作一個常數 (constant value) id
來存儲 UUID
值。
在這篇文章中,我不會詳述如何實作一個符合 Selectable
協定的物件,畢竟這個步驟十分簡單。如果你在興趣,可以在文末的 GitHub 程式庫中了解它的實作。
protocol Selectable: Identifiable, Hashable {
var displayedName: String { get }
var isSelected: Bool { get set }
init(displayedName: String)
}
客製化
我的目標不只是希望實作一個 Flexible Picker,更希望讓 Flexible Picker 可客製化的空間越大越好。
因此,我們會用符合 Selectable
協定的通用型別 T
來創建 FlexiblePicker
。如此一來,這個元件就與型別無關,以後要重用就更容易了。
在實作 Picker 之前,我記下了所有可以客製化的屬性。下一步,我們就要建立字串 (String) extension 來計算特定字串的寬度和高度。
我的實作允許大家修改字體大小和粗幼,因此前面提到的兩個 extension 都會使用 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
}
}
因為計算特定字串大小的 Extension 是以 UIFont
為 input 的,因此我們需要把所有 UIFont
的 weight 轉換為 SwiftUI。
為此,我添加了 FontWeight
列舉 (enum),當中包含了 UIFont
weight 的所有設定。
這個列舉有兩個屬性,一個用來回傳 UIFont
weight,另一個用來回傳 SwiftUI Font weight。如此一來,我們就只為 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
了。
首先,我們需要一個函式,來計算及回傳所有作為 input 傳遞的資料的寬度。我們利用 map
函式,把所有 input 值變成包含 input 值和其寬度的一個個 tuple。
在 map 裡面,我們會使用 reduct 函式,總括來說,就是所有寬度會按所有 input 值(包括 textWidth
、borderWidth
、textPadding
、spacing
)來計算。
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)
}
}
準備好計算寬度的函式之後,我們就可以檢閱所有 input 資料,並將它們分成不同的陣列,每個陣列由能夠放入同一個 HStack
的項目來組成。邏輯很簡單,我們有兩個陣列:
singleLineResult
陣列:負責儲存可放入特定行列的項目allLinesResult
陣列:負責儲存所有項目陣列(每個陣列等於一個行列的項目)
首先,讓我們用 HStack
行列的寬度減去項目的寬度,看看數值是否大於 0。
如果結果大於 0,我們就可以把這個項目添加到 singleLineResult
,更新餘下的 HStack
行列寬度,並處理下一個項目。
如果結果小於 0,也就代表我們無法再於同一行放入下一個元素,那我們就可以把 singleLineResult
添加到 allLinesResult
,把 singleLineResult
設定為只包含下一個元素(無法放入上一行的元素),用 HStack
行列的寬度減去項目的寬度,來更新餘下的寬度。
檢閱所有元素後,我們就要處理特別的情況。有可能 singleLineResult
不是空值,但也沒有添加到 allLinesResult
,因為我們只在計算結果小於 0 的時候,才把 singleLineResult
添加到 allLinesResult
。在這種情況下,我們必須檢查 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 可以更容易地直譯 (interpret) 視圖組件。VStack
的高度是基於以下兩個數值來計算的:
- Input 資料中所有項目的高度(與計算寬度的方法類似,都是利用 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 視圖
完成了整個邏輯後,最後一步就是要實作一個視圖 body。如前文所述,我們可以利用 ForEach
循環來創建視圖,並把視圖一個嵌套到另一個。
有一點非常重要,就是 ForEach
循環中,iterated collection 的每個元素都需要遵從 Identifiable
協定,或者應該有一個獨特的 identifier。
因此我就利用了 map
函式,把分好行列的結果變成 tuple,當中包含每個行列和 UUID
值的 id。
如此一來,我們就可以向 ForEach
循環提供一個 id 引數。另一個重點,就是 ForEach
循環需要的回傳值是視圖。
如果我們就這樣插入另一個 ForEach
循環,視圖的功能就會出現問題,因為 ForEach
不是視圖。
因此,我們應該把整個 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
的最後,我們設置了一個框架,框架的寬度取自 GeometryReader
,而高度就由先前創建的函式計算得出。
完成了!現在我們的 FlexiblePicker 已經可以使用了!
希望你喜歡這篇文章。你可以在 GitHub 程式庫上查閱整個實作。謝謝你花時間閱讀這篇文章。