將集合裡的資料變成一個個 view 的SwiftUI ForEach

SwiftUI 提供 ForEach 型別幫我們將集合裡的東西生成一個個 view,然後再合併成一個 view,大大簡化我們需要自己輸入的程式,接下來我們將以國語流行歌為例說明 ForEach 主要的三種寫法。

  • ForEach & Range。
  • ForEach & id。
  • ForEach & Identifiable。

顯示多行重覆的內容

我們可以在 VStack 裡耐心地產生多個重覆的 Text("你從不知道 我因為你而煎熬")。(ps: 文字內容來自劉增瞳的歌詞)

struct ContentView: View {
var body: some View {
VStack {
Text("你從不知道 我因為你而煎熬")
Text("你從不知道 我因為你而煎熬")
Text("你從不知道 我因為你而煎熬")
Text("你從不知道 我因為你而煎熬")
Text("你從不知道 我因為你而煎熬")
}
}
}

搭配 ForEach & Range

不過每寫一行都讓我們更加煎熬,就讓我們用 ForEach 試試吧。透過 ForEach 我們只要寫一行 Text("你從不知道 我因為你而煎熬"),比較不煎熬。

我們可以透過選單快速輸入 ForEach。如下圖所示,從 Text 上叫出右鍵選單,然後點選 Repeat

此時 Text 將自動包在 ForEach 的 { } 裡。

以上的 ForEach 搭配的 init 如下。

init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content)

參數說明

  • data

型別 Range 的 data 控制元件的數量,因此剛剛的 0..<5 將產生 5 個參數 content 回傳的元件。

  • content

回傳產生的元件。剛剛程式傳入的 closure 如下。

{ item in
Text("你從不知道 我因為你而煎熬")
}

它回傳了 Text("你從不知道 我因為你而煎熬"),因此將產生文字。closure 的參數 item 將對應 range 的數字,由於 range 是 0..<5,所以第一個 Text 的 item 是 0,第二個 Text 的 item 是 1,其它以此類推。

值得注意的,data 的型別是 Range,因此只能用 ..<,不能使用 ClosedRange 的 ... 語法。

搭配 ForEach & Range 呈現 array 內容

很多時候我們將東西存在 array 裡,ForEach 也可以結合 array,將 array 的內容變成一個個 view,然後組合起來,例如以下呈現你從不知道副歌歌詞的例子。

struct ContentView: View {
let lyrics = ["你從不知道", "我因為你而煎熬", "心碎過的每一分每一秒"]

var body: some View {
VStack {
ForEach(0..<lyrics.count) { item in
Text(lyrics[item])
}
}
}
}

我們先將歌詞存在 array lyrics,然後在 ForEach 的參數傳入 0..<lyrics.count,接著利用 lyrics[item] 讀取 array 的每個成員。

雖然結果是對的,不過 Xcode 卻會顯示以下的警告訊息。

Non-constant range: argument must be an integer literal

這是因為 ForEach 搭配參數 Range 時,它要求 Range 最好要是固定的,否則可能會產生資料錯亂的問題。等下我們將介紹搭配 id 的寫法消除此警告。

ps: 當 array 的數量會變動時,使用 ForEach 搭配參數 Range 會出問題。相關說明可參考以下連結。

搭配 array 的 indices

剛剛的程式還可以更簡化, 我們不須自己手動輸入0..<lyrics.count,讀取 array 的 indices 即可取得它的數字範圍。

ForEach(lyrics.indices) { index in
Text(lyrics[index])
}

ps: 此寫法一樣會有黃色警告,等下我們將介紹搭配 id 的寫法消除此警告。

搭配 ForEach,array & id 呈現 array 內容

當我們想呈現的內容來自 array 時,也可以將 array 當參數傳入 ForEach,例如以下例子。

struct ContentView: View {
let lyrics = ["你從不知道", "我因為你而煎熬", "心碎過的每一分每一秒"]

var body: some View {
VStack {
ForEach(lyrics, id: \.self) { message in
Text(message)
}
}
}
}

以上的 ForEach 搭配的 init 如下。

init(_ data: Data, id: KeyPath<Data.Element, ID>, content: @escaping (Data.Element) -> Content)

當參數 data 傳入 array 時,我們需要額外設定型別 KeyPath 的參數 id。此 id 將設定 array 成員的 id,到時候 ForEach 將利用 id 區分 array 裡的成員。在此我們傳入 \.self,表示 array 成員自己就是 id,因此你從不知道的 id 是你從不知道我因為你而煎熬的 id 是我因為你而煎熬

\.self 的寫法跟 Swift KeyPath 有關,有興趣的朋友可進一步查詢相關說明。

不過並非任何東西都可以當 id,依據 ForEach 的定義,id 的型別必須遵從 protocol Hashable,比方我們熟悉的 Int & String 資料都可以當 id,因為它們有遵從 protocol Hashable。

struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable

也許有人有疑問,剛剛 array 裡很明顯每個歌詞的字串就能區分彼此,為何還需要特別告訴 ForEach 用 \.self 當區分的 id 呢 ? 這其實跟 array 成員的型別有關,當型別比較複雜時,我們得特別指定 id 它才知道如何區分,例如以下的例子。

ForEach 搭配成員是自訂型別的 array

當 array 的成員是比較複雜的型別時,我們可以用 KeyPath 指定以成員的某個 property 當 id,例如以下例子的 \.name 表示以 Song 的 name 當 id。

struct Song {
let name: String
let singer: String
}

struct ContentView: View {
let songs = [
Song(name: "小酒窩", singer: "林俊傑"),
Song(name: "你從不知道", singer: "劉增瞳"),
Song(name: "對的時間點", singer: "林俊傑")
]

var body: some View {
VStack {
ForEach(songs, id: \.name) { song in
Text("\(song.name) by \(song.singer)")
}
}
}
}

若我們改用 \.singer,以歌手名當 id,將會產生特別的問題。

VStack {
ForEach(songs, id: \.singer) { song in
Text("\(song.name) by \(song.singer)")
}
}

array songs 裡有兩首歌的歌手都是林俊傑,當 id 為林俊傑時,它將無法區分小酒窩對的時間點,因此最後畫面上變成顯示兩首小酒窩。

利用 id 消除警告 Non-constant range: argument must be an integer literal

struct ContentView: View {
let lyrics = ["你從不知道", "我因為你而煎熬", "心碎過的每一分每一秒"]

var body: some View {
VStack {
ForEach(lyrics.indices, id: \.self) { index in
Text(lyrics[index])
}
}
}
}

ps: 若是搭配動畫,使用 array 的 index 當 ForEach 的 id 可能會有特別的問題

搭配 ForEach & Identifiable 呈現 array 內容

剛剛產生 ForEach 時,型別 KeyPath 的參數 id 是為了區分 array 成員。不過當成員的型別遵從 protocol Identifiable 時,它本身就會宣告用來區分的 property id,因此我們生成 List 時可以省略參數 id,不用再指定 id。

以剛剛的型別 Song 為例,我們讓它遵從 protocol Identifiable,在其中宣告 property id。id 的型別必須遵從 Hashable protocol,因此最簡單的莫過於用整數或字串當 id,比方用 Song 的 name 當 id。

import Foundation

struct Song: Identifiable {
let id: String { name }
let name: String
let singer: String
}

不過 id 要確保不會重覆,而 Song 的 name 有可能重覆,因此更常見的方法是利用 UUID 當 id,因為它可以幫我們產生獨一無二,不會跟別人同名的 ID。

import Foundation

struct Song: Identifiable {
let id = UUID()
let name: String
let singer: String
}

此時我們傳入 songs 生成 ForEach 時將可省略參數 id,因為 Song 的 id 就可以區分了。

VStack {
ForEach(songs) { song in
Text("\(song.name) by \(song.singer)")
}
}

此 ForEach 搭配的 init 宣告如下。

extension ForEach where ID == Data.Element.ID, Content : View, Data.Element : Identifiable {
public init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content)
}

List & ForEach 搭配 array 的自動完成

SwiftUI ForEach 搭配 array 時,如何取得成員的位置(index)

SwiftUI ForEach 搭配 array 時,若想同時取得 array 的成員跟位置,可參考以下連結。

讓 String & Int 遵從 protocol Identifiable

當 array 的內容是簡單的字串或數字時,我們也可以搭配 Identifiable,比方以下例子:

利用 extension 擴充 String,讓它遵從 protocol Identifiable,定義它的 id 是自己本身的字串內容。

extension String: Identifiable {
public var id: String { self }
}

因為 String 遵從 protocol Identifiable,所以現在搭配 ForEach 時不用再傳入參數 id 了。

struct ContentView: View {
let lyrics = ["你從不知道", "我因為你而煎熬", "心碎過的每一分每一秒"]

var body: some View {
VStack {
ForEach(lyrics) { message in
Text(message)
}
}
}
}

補充: 如何將 array 的成員變 Binding

補充: 為什麼 ForEach & List 需要區分 array 裡的成員 ?

前面介紹了 ForEach 如何區分 array 裡的成員,但到底為什麼它需要區分呢 ? 有興趣的朋友可再參考以下連結的說明。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com