SwiftUI 框架

利用 SwiftUI 實作一個 Flexible Picker 來篩選結果!

在 UIKit 實作篩選器 (filter) 來篩選結果時,我會實作有特定 UICollectionViewFlowLayout 的 UICollectionView。那如果在 SwiftUI,又應該如何實作呢?在這篇文章中,Jędrzej 會帶大家在 SwiftUI 實作一個 Flexible Picker!
利用 SwiftUI 實作一個 Flexible Picker 來篩選結果!
利用 SwiftUI 實作一個 Flexible Picker 來篩選結果!
In: SwiftUI 框架

本篇原文(標題:Build a Flexible Picker With SwiftUI)刊登於作者 Medium,由 Jędrzej Chołuj 所著,並授權翻譯及轉載。

最近,我在 SwiftUI 實作一個 Dribbble 上看到的設計時,腦海中浮現了一個想法,想要為專案添加一個功能,利用一些篩選器 (filter) 來篩選結果。

我希望篩選器視圖可以包含兩個單獨的篩選選項,兩個都有一些選項可供選擇。但後來我遇到一個問題:當我使用 UIKit 時,我會將這種視圖實作為有特定 UICollectionViewFlowLayoutUICollectionView;那如果在 SwiftUI,我應該怎樣做呢?

一起來看看我如何在 SwiftUI 實作一個 Flexible Picker 吧!

Selectable 協定

這個 Picker 最重要的部分,就是利用那個視圖元件,讓我們可以選擇需要的選項。因此,我在程式碼的一開始,就創建了一個 Selectable 協定。

所有符合這個協定的物件都需要實作兩個屬性 (property):

  • displayedName:顯示在 Picker 中的名稱
  • isSelected:一個 Bool 值,用來告知是否選擇了特定選項

另外,我們希望只需要 map 字串值的陣列 (array),就可以創建 Selectable 物件,因此我們需要利用遵從 Selectable 的物件,來提供客製化的 init 和作為引數 (argument) 的 displayedName

有了 IdentifiableHashable 協定,我們就可以使用 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 值(包括 textWidthborderWidthtextPaddingspacing)來計算。

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,而高度就由先前創建的函式計算得出。

swiftui-flexible-picker

完成了!現在我們的 FlexiblePicker 已經可以使用了!

希望你喜歡這篇文章。你可以在 GitHub 程式庫上查閱整個實作。謝謝你花時間閱讀這篇文章。

本篇原文(標題:Build a Flexible Picker With SwiftUI)刊登於作者 Medium,由 Jędrzej Chołuj 所著,並授權翻譯及轉載。

作者簡介:Jędrzej Chołuj,iOS 開發者,喜歡構建漂亮的手機 App!

譯者簡介:Kelly Chan-AppCoda 編輯小姐。

作者
AppCoda 編輯團隊
此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。
評論
更多來自 AppCoda 中文版
iOS 18 新API:使用 Navigation Transition 創建 Hero 動畫式過場
SwiftUI 框架

iOS 18 新API:使用 Navigation Transition 創建 Hero 動畫式過場

Apple 的工程師可能早已認識到,許多 iOS 開發者都希望能夠重現 App Store 應用程式中的優雅 Hero 動畫。由於從頭實現這種動畫通常需要耗費大量時間與精力,Apple 在 iOS 18 SDK 中納入了這項功能。 透過這次更新,你現在只需少量的程式碼就能在自己的應用程式中實現類似的動畫過渡效果。這項重大改進讓開發者能夠創造出更具視覺吸引力且流暢的過渡效果,
如何使用 Vision APIs 從圖像中辨識文字
AI

如何使用 Vision APIs 從圖像中辨識文字

Vision 框架長期以來一直包含文字識別功能。我們已經有詳細的教程,向你展示如何使用 Vision 框架掃描圖像並執行文字識別。之前,我們使用了 VNImageRequestHandler 和 VNRecognizeTextRequest 來從圖像中提取文字。 多年來,Vision 框架已經顯著演變。在 iOS 18 中,Vision
iOS 18更新:SwiftUI 新功能介紹
SwiftUI 框架

iOS 18更新:SwiftUI 新功能介紹

SwiftUI的技術不斷演進,每次更新都讓 iOS 應用程式開發變得更加便捷。隨著 iOS 18 Beta 的推出,SwiftUI 引入了多個令人興奮的新功能,使開發者僅需幾行程式碼即可實現出色的效果。 本教學文章旨在探索這個版本中的幾項主要改進,幫助你了解如何運用這些新功能。 浮動標籤列 (Floating Tab Bar)SwiftUI中的標籤視圖(Tab
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。