#29 Stanford CS193p — Developing Apps for iOS using SwiftUI — Note of Lecture #2

來到了第二集,這集的一開始要來建立多張卡片,並介紹另一個 view combiner 叫作 HStack (Horizontal Stack),那就開始吧。

Build a single raw of card

其實很簡單,我們只需要建立一個 HStacks ,把我們做好的 ZStack塞進去,像底下的圖一樣,就搞定了。

當然,我們不會用這種方式去寫 code,想像如果今天我們要做 20 張卡片,難不成我們要這樣 command + c, command + v, 20 次嗎? No way! 這方法會讓你的程式變得不易讀而且也不好管理。

還記得先前提到模組化的概念,現在這個 ZStack 裡面的 RoundedRectangle 和 Text 就像樂高最小積木,我們用 ZStack 將他們打包好後,他們就像我們的廚房椅子,但我們要給這個打包好的東西一個名字,這樣我日後就可以直接呼叫這張椅子出來,而不是用從頭開使組裝。

所以我們會用以下的做法,先建立一個 struct (Data structure) 命名為 CardView 讓他擁有 View 的功能,再建立一個變數 body 讓他是 some of View 的屬性,裡面放入我們剛剛寫好的 ZStack。這樣我們回到原本的 ContentView 去新增剛剛寫好的 CardView 到 HStack ,就完成囉!

Customize your previewer

這裡教授先跳 tone 提到,iPhone 其實大家都知道現在有兩種模式,一個是 light mode 另一個是 dard mode,其時我們這樣的設計在 dark mode 的顯示效果是非常差的,但我們目前的 previewer 看不出來。

要怎麼把我們的 previewer 改成 dark mode 或是再新增一個 preview 去同時顯示 light/dark mode 呢?

程式碼往下滑,我們可以看到最下方還有個 struc ,這就是我們的 Previewer,當你把滑鼠游標點選到 ContentView 後會發現,右邊 Inspector 處就有可以調整 Color Scheme 的地方了。

永遠要記住一件事,就是當你在 SwiftUI 裡面想改什麼東西的屬性,游標只要點選到程式碼,三個畫面 (coding area, previewer, inspector) 就會被 link 在一起,就能直接在右邊的 Inspector 處直接修改了。 (有夠方便!)

所以可想而知,如果我們要顯示兩個 previewer ,同步顯示 light/dark mode 的話,我們只需要再新增一個 ContentView 即可。

Fill color in your card

回到剛剛的問題,目前我們的卡片是透明的,所以在 dark mode 底下我們會看到我們的卡片變成黑色的。但我們的期望應該是不管在哪個模式底下,我們的卡片都應該要是白色的才對。所以我們會用到 .fill

但問題來了,當我們在原本的 RoundedRectangle 後面同時使用 stroke().fill() 時,好像起不了作用?

其實用中文的思考邏輯的確好像有點衝突,你創造一個東西他要同時具備只有描繪的屬性但同時又要有填滿的屬性?好像怪怪的。

所以我們可以再創造一個 RoundedRectangle ,然後增加 .fill() 屬性,並把顏色改成白色,就完成了。

這邊在記錄一個觀念,可以看到我們的最外層 HStack 其實有設定一個顏色屬性 .foregroundColor(.red) 但我們的 ZStack 裡面其中一個的 RoundedRectangle 也有設定顏色屬性.foregroundColor(.white) ,當顏色有衝突時,最裡層會蓋掉最外層。

Face up/down your card

當然,這是一個卡牌遊戲,所以我們要設計這些卡牌能夠達到翻牌的效果,我們可以定義一個 boolean value 再加上 if 條件去達成。

再來,我們可能會想,boolean value 和 if 條件都寫好了,那是不是我再加個手勢去判斷就好了?

oh no, 怎麼出現紅色驚嘆號???

“Cannot assign to property: ‘self’ is immutable”

self 代表的是你整段 code,紅色驚嘆號指出你的整段 code 是不能被改變的。這裡講到一個很重要的觀念就是 — “在 SwiftUI 裡面,所有的 View 都是不能被改變的,所有的改變都是重建。”

而且我們也不能在 SwiftUI 裡面去改變變數,所以當我們寫上 isFaceUp = !isFaceUp 才會出錯。

所以一開始的第一堂課,教授才會說 UI design code 和 logic code 在 SwiftUI 裡頭是分開來的,下次才會教到寫邏輯的 code。

So? What’s next? 我如何在還沒學到 logic code 之前又達到翻牌效果呢?

這邊教授提到一個方法 “You can think as like, mini logic. But it’s really just creating a tiny little outside of the View storage space, and have the View just look at that. And when that changes, the View will get rebuilt, but still pointing to that little piece of memory.”

只需要在你的變數 var isFaceUp 寫成 @State var isFaceUp 。這代表什麼?“It just turns this variable instead of really being a Boolean, it’s actually a pointer to some Boolean somewhere else, somewhere in memory. And that’s where the value can change. But this pointer doesn't change. It’s always pointing to that same little space over there. ”

其實我在 Swift 裡面並沒有接觸過這樣類似的寫法,不是很理解教授說的,我回放好幾次,並把教授所說的這段解釋打下來才有點明白。大概意思就是雖然我不能在 SwiftUI 裡面去改變這個布林變數,但我可以創造一個 View 以外的小空間,這小空間是可以容許變數被改變的,並把我原本的布林變數變成指向箭頭的概念,只要那裡改變了,我的 View 就重建。

還有個小技巧,當你修正好後想要在 Previewer 看到效果的話,直接點選 Previewer 的卡牌是沒有任何作用的,記得要先按下 Previewer 左上方的 play 鍵喔。

ForEach to create new card

再來,講到了 ForEach,這有點像 Swift 的 For loop 的概念,今天如果我們卡片很多的時候總不可能一直 command + c/v ,這樣程式碼會無止盡地變長,所以我們會先建立一個 emojis Array 來裝我們要使用的 emoji,然後用 ForEach 來生出每個 View。

注意一件事情, ForEach 並不是一個 View combiner ,頂多只是一個 View maker 而已,所以他會依附在 HStack 底下。

OK, 當我們打完後,又出現紅色驚嘆號了,完整的敘述如下:

“Referencing initializer ‘init(_:content:)’ on ‘ForEach’ requires that ‘String’ conform to ‘Identifiable’”

我把教授的說明節錄下來 “You’re going to see this phrase ’conform to’ quite a bit in this class, and it’s really the same as what I was saying when I said, behaves like(就像之前說的這個 ContentView 要 behaves like a View,這個 CardView 要 behaves like a View)”

“These things in this Array, if you want to do a ForEach, they must behave like an Identifiable.”

所以,什麼是 Identifiable?

“Any kind of struct can be an Identifiable, but it has to have a var called id, which uniquely identifies it”

為什麼 ForEach 需要 Array 裡面的東西是 uniquely Identifiable ?

“Because it’s going to create a View for each of them. And if, for example this Array right here should be reordered, or new things added to it or things removed from it, etc., it needs to know which things changed in the Array, and then adjust the Views accordingly.”

聽起來很合理,Array 內容要是 Identifiable ,這樣不管以後重新排列、新增或減少,你的 ForEach 才能夠根據變化來創造 Views。

但但但,字串沒辦法呀!我如果在我的 Array 裡面放了兩個一模一樣的火車 emoji,基本上是無法識別出來的。(因為他們就真的一模一樣)

基本上這不影響我們真正在做這個 Concentration card game,為什麼呢?因為這個遊戲我們是 ForEach through cards 而不是 ForEach through emojis。

“So, it’s not going to be a problem when we have the logic of our game, but in our little demo here where we are just making Strings, it’s a problem”

所以,既然只是 Demo,那就別強求了,全部挑選不一樣的 emoji 吧 😂

“And if we do that, then we can go back to ForEach and tell the ForEach, “Yeah, just use the String itself as the unique identifier, even thought it’s not actually unique, do it anyway”” (就是告訴 ForEach “你給我做就對了”😂)

所以我們會在 emojis 和 content 之間加上 id: \.self ,意思就是 struct 本身。

“So that’s perfect for String. If I say, .self on a String, Iget the String itself. This String is in fact the identifier that the emoji is going to use to identify them.”

但,如果我真的放了兩個一模一樣的 emoji 在我的 Array,執行起來會發生什麼事呢?

你可以看到,確實我們的第一台第二台火車事實上就是一模一樣,所以我們用 ForEach through emojis 就會有這樣奇怪的結果。

Add button to control the quantity of cards

再來,我們要擴充我們的卡片了,擴充卡片也就是意味著我要擴充我 Array 裡面的資料內容,所以我先生了 24 台車出來。

var emojis = ["🚗", "🚕", "🚙", "🚌", "🚎", "🏎", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🛴", "🚲", "🛵", "🏍", "🛺", "🚔", "🚍", "🚘", "🚖", "🚝"]

然後因為我們要有個 Button 來去增減卡片數量,那勢必這兩個增減數量的 Button 要去控制一個變數,所以先新增一個變數到我們的程式裡面,寫好之後可以先稍微測試一下。

沒問題之後就把 Button 給加進來,因為我們還沒學到 logic code,所以如果要在 SwiftUI 裡面改變變數的話,就會出現以下紅字 “Left side of mutating operator isn’t mutable: ‘self’ is immutable” 。

先在將 var emojiCount 改成 @State var emojiCount 頂著用先。改好後就可以在 Previewer 確認看看效果囉~

當然我們可以再精簡一些我們的 code,把 Plus button 和 Minus button 從 Body 拉出來另外定義一個變數來寫,寫完之後在 Body 裡面用變數替代這一長串的程式碼就好~時不時整理一下程式碼,才不會到後面程式越寫越多時,萬一出錯,就不知道從哪裡開始 debug 了~

但這裡還是有點 Bug,如果我們的 emojiCount 變成負數或是超過 Array 的資料數, Previewer 會發生什麼事情呢?

Preview Crashed! 因為我們的 emojiCount 變成負數了,ForEach 生不出東西來,所以記得加上條件,設好保護機制喔~

然後,時不時地整理一下你的 Code,才不會最後出問題不知道從何改起喔~這真的是必要習慣,以前寫完如果有問題真的讓我很頭大 orz

Make cards more like cards

第二堂課最後要來讓我們的卡片更像卡片,現在可以看到如果卡片數量變多了之後,會變成細條狀而且全部塞在同一列上,這時候要來用一個新的 ViewCombiner 來取代我們的 HStack 叫作 “LazyVGrid”

“A LazyVGrid basically lets you specify a number of columns, and it’s going to make that many columns here, and then it will make as many rows as necessary”

但可以看到的是,我們不能在 columns 裡面直接輸入一個數字來告訴 Swift 我們一行要幾列,輸入數字後跳出的紅色驚嘆號告訴我們要使用 [GridItem] 來表示。

紀錄一下 HStack 和 LazyVGrid 的運用策略

HStack: 占用到上下左右邊界所有可用的空間,讓所有的卡片 (View) 平均分配這些空間

LazyVGrid: 設定 Columns 後,裡面有幾個 GridItem 一列就會有幾張卡片 (View),至於卡片的高度則會縮到最小。

紀錄一下 Spacer() 的用途,可以在上面的動畫中看到,當我把 Spacer() 拿掉之後,第一個 LazyVGrid 所包的東西和第二個 HStack 所包的 button 黏在一起並且置中於螢幕。

“Spacer() take any open spaces available that no one else wants”

再來,我們在 CardView 後面新增一個 modifier 叫作 .aspectRation 裡面打上卡片比例和 contentMode: .fit 後,可以快速地看到我們的卡片形狀已經變成短邊2長邊3的比例了。

但但但,還是有個問題,當我一直按新增按鈕增加卡片時,如果卡片數量超出螢幕,我的按鈕們全部都被推到螢幕外了。該怎麼辦呢?這時候 ScrollView 該出場了。

這邊教授解釋了一下為何他叫做 “Lazy”VGrid

“LazyVGrid is lazy about accessing the body vars of all of its View. We only get the value of a body var in a LazyVGrid for Views that actually appear on screen, that scroll on screen.”

“So this LazyVGrid could scale to having thousands of cards, because in general, creating Views is really lightweight. Usually a View is just a few vars, like isFaceUp and content in our CardView, but accessing a View’s body is another story. That’s going to create a whole bunch of other Views, and potentially cause some of their body vars to get accessed. So there’s a lot of infrastructure in SwiftUI to only access a View’s body var when absolutely necessary.”

“This laziness we see in LazyVGrid. That’s only a minor example of that.”

文章的最後提到一個小問題,就是當我們把模擬器擺橫的時候會發現卡片過大,就像下面的動畫一樣。我該怎麼告訴這個 LazyVGrid 如果螢幕變橫的時,一列的卡片自動變多呢?

其實問題出在,我們把 GridItem() 的數量寫死了,我們如果寫了三個 GridItem(),那不管直的橫的,一列就只會出現三個。

我們可以只保留一個 GridItem(),然後將 GridItem(),改成,GridItem(.adaptive(minimum: 100)),告訴 LazyVGrid 我生成的 CardView 的寬,最小應該要保持在多少,這樣就完美解決囉!

第二堂課也就~~~~~~到這邊啦~~~~~~

才怪!乖乖先寫完第二堂課的作業再來聽第三堂課吧!(☍﹏⁰)

Free talk

雖然還沒看作業內容,但我猜第三篇大概沒這麼快更新了 zzz

--

--