之前有8篇斯坦福CS193p的SwiftUI的筆記, 這篇來自于Ray Wenderlish的祖傳by Tutories系列, 記錄一些可以互為補充的內容.
Group
Group {
if configuration.isPressed {
Capsule()
.fill(Color.element)
} else {
Capsule()
.fill(Color.element)
.northWestShadow()
}
}
- Group is another SwiftUI container.
- It doesn't do any layout. It's just useful when you need to wrap code that's more complicated than a single view.
- 也就是組織代碼用的, 放心使用
模擬器黑暗模式
- 在視圖debug鍵的右方, 兩個switch豎向排列的按鈕, 即是
Environment Overrides
, 打開Apperance
開關 - 更多built-in EnvironmentValues, Many of these correspond to device user settings like accessibility, locale, calendar and color scheme.
View-level environment value
- 在視圖容器上設置
.font(.headline)
, 則所有child view里的文字都會使用這個配置 - 在里層配置則會覆蓋父級的配置, 實現個性化
GeometrReader
GeometryReader provides you with a GeometryProxy
object that has a frame
method and size
and safeAreaInset
properties.
GeometryReader { proxy in
ZStack {
...
}
}
同時預覽多個設備:
Group {
ContentView(guess: RGB()).previewDevice("iPhone 8")
ContentView(guess: RGB())
}
ViewModifier
cs193里學到的是這樣的, 要繼承一個ViewModifier
:
struct Cardify: ViewModifier {
var isFaceUp: Bool
func body(content: Content) -> some View {
ZStack {
Group {
RoundedRectangle(cornerRadius: 10.0).fill(Color.white)
RoundedRectangle(cornerRadius: 10.0).stroke(lineWidth: 3.0)
content // 正面卡片內容
}.opacity(isFaceUp ? 1.0 : 0.0)
RoundedRectangle(cornerRadius: 10.0)
.opacity(isFaceUp ? 0.0 : 1.0) // 反面卡片內容
}
}
}
然后再擴展
extension View {
func cardify(isFaceUp: Bool) -> some View {
self.modifier(Cardify(isFaceUp: isFaceUp))
}
}
- 其實是不必要的, 這么寫只是讓你能用
view.modifier(Cardify(isFaceUp: true))
來使用 - 你期望的只是
view.cardify(isFaceUp: true)
的話, 它只是一個普通的extension
, 并不是說一定要modifier
才能調用 - 用modifier只是為了語義上表示這是一個modifier, 與extension的用法沒半毛錢關系, quick demo的話, 并不需要這么寫
順便了解下最完整的形態, 其實是一個ModifiedContent
方法:
ModifiedContent(
content: TextField("Type your name...", text: $name),
modifier: BorderedViewModifier()
)
用style自定義控件
不管是button, 還是label, 都接受一個modifier來傳入一個style, 這是一個繼承ButtonStyle
或LabelStyle
的結構體
// button
struct NeuButtonStyle: ButtonStyle {
let width: CGFloat
let height: CGFloat
// 這個方法在寫make開頭時會自動感應出來, 不需要自己寫
func makeBody(configuration: Self.Configuration)
-> some View {
// button自帶的幾個子控件都在configuration里,
// 取出來組合和自定義即可
// 比如這里我們只取了label出來
configuration.label
.frame(width: width, height: height)
.background(
Capsule()
.fill(Color.element)
.northWestShadow()
)
}
}
// 使用
Button().buttonStyle(NeuButtonStyle(width: 327, height: 48))
- When you create a custom button style, you lose the default label color (變回黑色) and the default visual feedback when the user taps the button.
- 恢復顏色:
.foregroundColor(Color(UIColor.systemBlue))
- 添加動效:
.opacity(configuration.isPressed ? 0.2 : 1)
// Label
// SwiftUI的Label包含一個圖標和一個文本(根據style不同可以只顯示其中一個), 但是豎向排列很奇怪
// 這里演示把它手動用HStack包起來, 而不用默認的布局
func makeBody(configuration: Configuration) -> some View {
// 同樣, 用configuration取出來自定義即可
HStack {
configuration.icon
configuration.title
}
}
// 用法是一樣的
Label().labelStyle(HorizontallyAlignedLabelStyle())
特殊情況, 下面這種情況不是用的makeBody
而是_body
方法, 最好找找出處:
// 1. 不是覆蓋makeBody方法, 而是_body方法
// 2. 入參不再是configuration, 而是TextField自己(雖然形參還是叫這個)
// 3. 但_body沒法自動感應出來, 教程也沒說為啥要這樣寫, debug進別的原生style, 也是寫makeBody方法的
// 3.1 更神奇的是, makeBody方法也感應不出來
// 4. 因此不是從configuration里面取控件, 而是直接對整個控件寫modifier
public func _body(
configuration: TextField<Self._Label>) -> some View {
return configuration
.padding(EdgeInsets(top: 8, leading: 16,
bottom: 8, trailing: 16))
.background(Color.white)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(lineWidth: 2)
.foregroundColor(.blue)
)
.shadow(color: Color.gray.opacity(0.4),
radius: 3, x: 1, y: 2)
}
// 使用
TextField("Type your name...", text: $name).textFieldStyle(KuchiTextStyle())
用ZStack還是background做背景圖
- ZStack會根據子視圖的大小而擴展, 如果你添加了一張大于屏幕的圖片, 那么這個ZStack其實也大于屏幕了
- 會使得一些繪制屬性為"fill"的元素也超出屏幕
- 如果你添加了其它會充滿容器的控件(比如
TextField
會橫向填充) - background modifier則不會更改其修飾的對象的大小
- 這樣如果你需要全屏的background, 你得保證修飾的視圖本身是全屏的(至少能用padding填滿)
Button
- SwiftUI中Button的定義:
struct Button<Label> where Label : View
- 其中
Label
是個泛型, 只需要是個View
就行了
構造方法:
init(
action: @escaping () -> Void,
@ViewBuilder label: () -> Label
)
可見:
- action不是trailing closure, 跟
UIKit
習慣相反, SwiftUI中最后一個closure通常是為了聲明視圖 -
Label
修飾為@ViewBuilder
, 意思是返回一些views(默認豎向排列)
- 關于要點1, 其實在SwiftUI中也有點妥協, 允許像trailing closuer一樣直接用雙括號語法, 也不要寫參數名
- 但是這樣的話第二個參數名就不能省了
觀察下面的兩種寫法, 在SwiftUI中是等效的
Button {
print("aa")
} label: {
Text("bb")
}
Button(action: {
print("aa")
}) {
Text("bb")
}
child view chose it's own size
Views choose their own size; their parents cannot impose size but only propose instead.
Text("lone text").background(Color.red) // 生成一段文字, 底色是紅色
Text("lone text").background(Color.red)
.frame(width: 150, height: 50, alignment: .center)
.background(Color.yellow)
- 生成一段文字, 并用150x50的視圖框起來
- 記住, 任何modifier都是新view, 即便是frame, 不要以為這是在為老view設置frame屬性, 沒這種東西
2.1 所以, 現在視圖層級成了 Text - Frame - Root - 這段文字在150x50的空間里用最小的空間布局(這是它的特性, 跟有沒有frame無關, 恰巧這里它的parent是framel罷了)
3.1 所以, 黃色和藍色不是完全重合的, 黃色嚴格修飾的是frame視圖 - 如果frame空間小于文字, 還有一個配置
.minimumScaleFactor(0.5)
, 可以讓文字自動縮放, 你給一個最小比例即可
上述例子如果換成一張巨大的圖片, 則會無視100x50的空間, 因為完全不夠(這就叫chose its own size)
- 即 it ignores the size proposed by its parent.
- 除非加一個修飾
.resizable()
, 則會在有限們之間內盡可能充滿
所以image和text就是兩個極端, 一個最適配, 一個最不適配.
.frame(maxWidth: .infinity, alignment: .leading)
里的.infinity
表示有多寬就擺多寬
size原則
像padding, stack
這樣的修飾器, 是沒有自身的大小的, 完全看child
比較下面兩段代碼
// 左邊短, 右邊長
HStack {
Text("A great and warm welcome to Kuchi")
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.background(Color.red)
}
.background(Color.yellow)
// 左邊長, 右邊短
HStack {
Text("A great and warm welcome to Kuchi")
.background(Color.red)
Text("A great and warn welcome to Kuchi")
.background(Color.red)
}
.background(Color.yellow)
- 首先, 它會根據child個數平均分配
- 第一段左邊比右邊短, 因為兩段文字一樣, 左邊文字發現一半屏幕放不下, 折行后就放下了, 而且折行后用不著一半的空間, 就縮減了空間
- 右邊文字發現空間足夠
- 第二段右邊文字一個m變成了n, 所以屬于小一點的child, 布局系統優先算出它的空間, 發現也是兩行可以排滿, 于是用了最小的空間, 剩下的給了左邊
通過.layoutPriority(n)
可以定義child之間計算空間的優先級 (n: -1 到 1), 以HStack
為例
- 一般是大的先算
- 但是有小于0的值的話, 則優先計算最小的寬(對于Text, 基本就是一個字的寬度)
- 順便, 最小的寬(一般)也能確定最大的高, 這樣整個stack的大小可以初步確定
- 有了最小的寬, HStack會把低于最高優先級的所有child都賦予這個寬度, 剩出最多的空間以讓最高優先級的child能優先布局
- 如果最高優先級的child布局后還有空間, 則減出來, 依此類推
觀察此圖offered 和 claimed的寬度區別
LazyList
- 循環里不能用
for-in
, 崦要用forEach
, 因為它不支持表達式, 而forEach
事實上就是一個view, 因而能寫到some view里去 - List不能滾動起來, 要包到Scroll里去
- 需要表頭, 就包到Section里去
- 需要固定表頭, 則配置list的
pinnedViews
入參
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section(header: header) {
ForEach(history, id: \.self) { element in
createView(element)
}
}
}
}
Functional user interface
- Being functional, rendering now always produces the same result given the same input,
- and changing the input automatically triggers an update. Connecting the right wires pushes data to the user interface, rather than the user interface having to pull data.
SwiftUI:
- Declarative: 聲明式的UI
- Functional: 相同輸入產生相同輸出, 完全取決于狀態
- Reactive: 響應式
State是什么
一個簡單程序:
struct ContentView: View {
@State private var isTapped = false
// 1. var ctr = 0
/* 2. 包到Struct里去
struct mystruct {
var ctr = 0
}
var state = mystruct()
3. 改成class, 略
4. 用一個包裝器
class Box<T> {
var wrappedValue: T
init(initialValue value: T) {
self.wrappedValue = value
}
}
var state = Box<Int>(initialValue: 0)
5. 用State
var state = State<Int>(initiaValue: 0) // 注意, State是一個struct, 比demo里用class的box要復雜
6. 換個寫法
@State var state = 0
*/
var body: some View {
Button(action: {
// 1. self.ctr += 1 // 報錯, 因為不能從body內部改變屬性的狀態
// 2. self.state.ctr += 1 // 報錯, struct仍然是value type
// 3. struct變成class, 不報錯了, 但是顯示的文字沒有變化
// 但是ctr的值確實變了, 因為指針指向的對象還是可變的
// 如果這個視圖有別的控件觸發了這個視圖的重繪, 會發現UI確實變了
// 4. self.state.wrappedValue += 1 // 不報錯, 但是顯示的文字沒有變化
// 但是與3一樣, 能在別的UI刷新后自身也刷新, 其實原理是一樣的
// 5. self.state.wrappedValue += 1 // 能響應點擊事件并刷新UI了
// 6. 最終寫法, 所以6就是5的語法糖而已
self.state += 1
}) {
// Text("\(self.ctr)")
// Text("\(self.state.ctr)")
// Text("\(self.state.wrappedValue)")
Text("\(self.state)")
}
}
}
綜上, State
就跟我們模擬的Box一樣, 封裝了一個不可變對象, 但本身是一個class(不是的, 見下方注釋), 所以能在view的body被改變它的成員變量(主要就是wrappedValue
), 而且在body被改變時, 會自動觸發UI的更新(這個是我們用Box
)沒有模擬出來的
即:
-
@State
修飾的變量, 是一個可觀察對象(能invalidate view) -
@State
修飾的變量, 是不可變的(所以由State出面來包裝) - 當它的值改變時, 會自動觸發UI的更新
- 它會生成
State<T>
的代碼 - 并生成一個同名的帶下劃線的變量
- 也就是說, 你可以用
self.state
來使用, 也可以用self._state.wrappedValue
來使用
- 也就是說, 你可以用
官方定義: A property wrapper type that can read and write a value managed by SwiftUI.
SwiftUI manages the storage of any property you declare as a state. When the state value changes, the view invalidates its appearance and recomputes the body. Use the state as the single source of truth for a given view.
注意, Demo中的Box
需要是一個對象, 但State
是一個struct
, 之所以能對struct的State進行變更, SwiftUI還做了別的工作.
Binding
SwiftUI希望你只有一份數據, 所有的地方都去讀取它, 而不是復制它的值自己去用, 這樣才能做到這個值改變的時候, 觀察它的對象也能更新. 顯然值類型就做不到這一點了, (事實上
Binding
,State
是特殊處理過的值類型)
- In SwiftUI, components don’t own the data — instead, they hold a reference to data that’s stored elsewhere.
- A binding is a two-way connection between a property that stores data, and a view that displays and changes the data.
- A binding connects a property to a source of truth stored elsewhere, instead of storing data directly.
-
@State
用wrappedValue
來讀封裝的值, 但要用projectdValue
來bind
view和數據源, 這樣它接受來自UI的變更, 并且把數據源更新 - 要傳遞一個狀態對象(即不在本類定義, 而是別的地方定義的), 則要用
@Binding
, 因為State
仍然是一個值類型, 通過特殊處理, 能改變它的值了, 但是仍然會在傳遞的時候復制, 而@Binding
則通過構造方法傳入getter
和setter
的方式支持了讀和寫都對應同一個數據源
observation
- 值類型如struct改變任何一個屬性都是一個全新的實例, 如果對它進行觀察, 那所有的觀察者都會重繪, 哪怕沒有變動的屬性
- 引用類型只有改變了指針才算改變, 對它進行觀察則跟蹤不到屬性的變化
為了解決上面的問題, 引入了新的類型, 實現三個方向:
- 是一個引用類型
- 是一個可觀察的類型
- 能定制可觀察的屬性
Sharing in the environment
Using
environmentObject(_:)
, you inject an object into the environment.Using
@EnvironmentObject
, you pull an object (actually a reference to an object) out of the environment and store it in a property.注入后, 所有的子級及嵌套都能看到, 但父級及以上看不到
-
如果你注入的是未命名的對象, 則取出來的時候用類型即可
- 注入:
.environmentObject(ChallengesViewModel())
- 取出:
@EnvironmentObject var challengesViewModel: ChallengesViewModel
- 注入:
When you want a view to own an observable object, because it conceptually belongs to it, your tool is
@StateObject
.When an observable object is owned elsewhere, either
@ObservedObject
or@EnvironmentObject
are your tools — choosing one or the other depends from each specific case.
一些環境變量
@Environment(\.verticalSizeClass) var verticalSizeClass
if verticalSizeClass == .compact { // 橫屏, 因為vertical compact的話, 就是豎向高度不夠的意思
} else {}
// 你也可以隨時改變環境變量
view.environment(\.verticalSizeClass, .compact)
- colorScheme
- locale
- 文檔
注入命名環境變量
上面說的是未命名的, 你只能注入一個對象, 對類型取出來, 那么像verticalSizeClass
這樣的用keyPath
類似的語法取出來的話, 這么做:
- 一個服從
EnvironmentKey
的結構體(它只有一個defaultValue
) - 在
EnvironmentValues
的擴展里, 增加你要取的名字(keypath)的getter/setter
// 1.
struct QuestionsPerSessionKey: EnvironmentKey {
static var defaultValue: Int = 5
}
// 2.
extension EnvironmentValues {
var questionsPerSession: Int { // questionsPerSession 就是你要取的名字
get { self[QuestionsPerSessionKey.self] }
set { self[QuestionsPerSessionKey.self] = newValue }
}
}
// 注入
someview().environment(\.questionsPerSession, 15)
// 使用(在someview里)
@Environment(\.questionsPerSession) var questionsPerSession
但是根據這個文檔, 自定義環境變量更簡單了, 使用Entry()
宏即可
extension EnvironmentValues {
@Entry var myCustomValue: String = "Default value" // 在我的15.4的xcode報錯
}
extension View {
func myCustomValue(_ myCustomValue: String) -> some View {
environment(\.myCustomValue, myCustomValue)
}
}
Controllers
DatePicker(
"",
selection: $dailyReminderTime,
displayedComponents: .hourAndMinute
).datePickerStyle()
// CompactDatePickerStyle() -> (iOS default), 兩個button, 點擊后展開日歷
// WheelDatePickerStyle
// GraphicalDatePickerStyle 日歷, Mac下有個時鐘
// FieldDatePickerStyle Mac, 文本框
// StepperFieldDatePickerStyle Mac, 可步進 (Mac default)
Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
// 等同于如下, 如果有額外操作, 需要這樣展開
Toggle("Daily Reminder", isOn:
Binding(
get: { dailyReminderEnabled },
set: { newValue in
dailyReminderEnabled = newValue
// other biz
}
)
)
ColorPicker(
"Card Background Color",
selection: $cardBackgroundColor
)
// picker style: https://apple.co/3nyViIG
// 注意每個選項的label和id的傳入方式
Picker("", selection: $appearance) {
Text(Appearance.light.name).tag(Appearance.light)
Text(Appearance.dark.name).tag(Appearance.dark)
Text(Appearance.automatic.name).tag(Appearance.automatic)
}.pickerStyle(SegmentedPickerStyle()) // 默認是個list
// 如果是caseiterable:
ForEach(Appearance.allCases) { appearance in
Text(appearance.name).tag(appearance)
}
TabView
TabView { // tabview
SettingsView() // 具體頁面
.tabItem({ // 配置tab圖標
VStack {
Image(systemName: "gear")
Text("Settings")
}
})
.tag(2)
}
.accentColor(.orange) // 高亮色
UserDefaults / App storage
@AppStorage("numberOfQuestions") var numberOfQuestions = 6
// 下面這種寫法是只讀的, 至于為什么也要初始化一下, 看后面有沒有解答
@AppStorage("numberOfQuestions")
private(set) var numberOfQuestions = 6
以下類型能存到UserDefaults
- Basic types: Int, Double, String, Bool
- Composite types: Data, URL
- adopting
RawRepresentable
這是你支持自定義類型存入的方法:
- Make the type RawRepresentable
- Use a shadow property
RawRepresentbale
- 如果一個枚舉的類型被定義為基礎類型, 那么它自動服從了
RawRepresentable
- 別的類型怎么實現
RawRepresentable
尚未講到
Shadow Property
比如一個Date類型, 是存不進的, 我們增加一個Double類型
@AppStorage("dailyReminderTime") var dailyReminderTimeShadow: Double = 0
// 上面實例化過一個DatePicker, 我們在setter里增加一個轉換
DatePicker(
"",
selection: Binding(
get: { dailyReminderTime },
set: { newValue in
dailyReminderTime = newValue
dailyReminderTimeShadow = newValue.timeIntervalSince1970 // date -> double
configureNotification()
}
),
displayedComponents: .hourAndMinute
)
// 在什么時候轉回日期? .onAppear在每次顯示的時候調用
.onAppear {
dailyReminderTime = Date(timeIntervalSince1970: dailyReminderTimeShadow)
}
這么看來其實沒什么新語法上的支持, 就是你只存UserDefaults支持的類型就好了, 由開發者自己來做這個轉化的意思
Gesture
@GestureState
會在手勢完成后自動重置, @State
不會
@GestureState var isLongPressed = false
let longPress = LongPressGesture()
.updating($isLongPressed) { value, state, transition in
state = value // 注意, binding value to state(你updating誰誰就是state)
}
.simultaneously(with: drag)
上面演示了綁定兩個手勢, 但如果是在不同的視圖內的兩個手勢呢?
.gesture(TapGesture()
...
)
// 改為
.simultaneousGesture(TapGesture()
...
)
Navigation
- SwiftUI navigation organizes around two styles:
flat
andhierarchical
. - 分別對應
TabView
和NavigationView
- TabView
- tab圖標只支持文字, 圖片或者圖片+文字(不需要用
VStack
), 其它方式都會顯示為空占位 - 所以對圖片用modifier(比如旋轉)也不行
- 假如要記下當前tab:
“@AppStorage("FlightStatusCurrentTab") var selectedTab = 1
- 使用
TabView(selection: $selectedTab)
會用指定的tab來初始化, 并且在tab切換的時候更新新的值 - 更新的值是每個view的
tag
- 使用
-
tabViewStyle(_:)
可以改變轉場方式
- tab圖標只支持文字, 圖片或者圖片+文字(不需要用
- NavigationView
- 用
navigationBarTitle(_:)
定義當前頁標題 - 用
NavigationLink(destination:)
導航- 導航鏈接用文字的話在第一參數, 用view的話是第二參數, 服從SwiftUI的規范
- 小屏
NavigationView
默認用stack堆疊, 大屏默認用split分屏- 可以用
.navigationViewStyle(StackNavigationViewStyle())
修改
- 可以用
- 環境變量要加給
NavigationView
, 而不是任何一個子view
- 用
List
ForEach
List之前我們先看看ForEach
-
ForEach
: provide datas output views (via clsoure)- It doesn't provide any structure
- so you should place it into a
VStack
, and aScroll
- so you should place it into a
- 需要指定一個
Hashable
的鍵(Swift的String
和Int
就可以)- 如果整個對象是
Hashable
的, 那么\.self
也行 - 如果整個對象是
Identifiable
(from Swift5.1)的, 那么可以忽視掉這個參數
- 如果整個對象是
- 自行橫向或縱向stack是沒有內存優化的, 有多少實例化多少
-
Lazy
版本就是解決這個的(首次appears
實例化, 但不再會消失和復用) -
Lazy
版本在垂直方向上是鋪滿空間的, 既如果是VStack
, 那么橫向是鋪滿的
-
-
ScrollView
需要包一層ScrollViewReader
來增強功能, 比如滾動定位- 也適用于Lazy版本, 即你能滾動到還沒有渲染的元素去
- It doesn't provide any structure
ScrollViewReader demo
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack {
ForEach(flights) { flight in
NavigationLink(
destination: FlightDetails(flight: flight)) {
FlightRow(flight: flight)
}
}
}
}
.onAppear {}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
scrollProxy.scrollTo(nextFlightId, anchor: .center) // 用你for-each時候的id定位
}
}
其實這種延遲0.05秒再運行的例子是很壞的實踐, 因為這個0.05其實并沒有任何保證
List
上面的例子用List改造一下
ScrollViewReader { scrollProxy in
List(flights) { flight in // 幫助做了Scroll+LazyVStack
NavigationLink(
destination: FlightDetails(flight: flight)) {
FlightRow(flight: flight)
}
}.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
scrollProxy.scrollTo(nextFlightId, anchor: .center)
}
}
}
-
ForEach
allows you to iterate over almost any collection of data and create a view for each element. -
List
acts much as a more specific case of ForEach to display rows of one-column data
層級List, 看示例, 簡單到犯規(前提是結構是遞歸的),
當然要自定義還是有點功夫的, 至少目錄和內容的行為是不可能一致的, 所以你在List的view builder里, 至少要做一個if-else
分組和更多個性化, 就不能用上面的全自動代碼了, 改一下:
List {
ForEach(data) { item in
Section(header: Text(item.label), footer: HStack {
Spacer()
Text("footer")
}) {
ForEach(item.children!) { child in
Text(child.label)
}
}
}
}.listStyle(InsetGroupedListStyle())
- List后不跟數據, 而是自行
ForEach
>>> 最終還得靠ForEach
- 想要分組, 就再跟上
Section
, 這樣就把title和children分離了 - 標題, 頁腳等, 屬于
Section
的內容
Grid
-
LazyVGrid
和LayHGrid
, 本質上就是一個主軸和交叉軸分別應用LazyVStack
和LazyHStack
var awardColumns: [GridItem] {
[GridItem(.flexible(minimum: 150)), // .fixed, .flexible
GridItem(.flexible(minimum: 150))] // 表示了能做多寬做多寬
}
LazyVGrid(columns: awardColumns) {
ForEach(awardArray, id: \.self) { award in
NavigationLink(destination: AwardDetails(award: award)) {
AwardCardView(award: award)
.foregroundColor(.black)
.frame(width: 150, height: 220) // view本身限制了150寬,與column配置不沖突
}
}
}
- 上例中, 用最小值150 + 自定義值150 限定了cell的寬度, 結果跟直接用
.fixed(150)
是一致的 - 但是這種寫法就能支持不同cell有不同的寬度
- 如果你設置了最大寬, 但自定義值大于最大值怎么辦?
- 元素會保持設置的大小, 但是布局系統會按griditem的配置來布局
- 內容是縮放還是裁剪, 取決于aspectRatio配置
GridItem(.flexible(minimum: 150, maximum: 170))
card.aspectRatio(0.67, contentMode: .fit)
思考:
columns
(HGrid中則是rows
)數組的個數決定了每一行擺放的元素個數, 那么如果需要不定個數的自動折行怎么實現?
[GridItem(.adaptive(minimum: 150, maximum: 170))]
但是實測不盡如人意:
- 注意到重疊了沒? 不知道為什么它一排總要放5個
- 而且每行數量是一樣的
- 通過更改min/max的大小, 一行的個數也會增減, 可見應該是由第一行的個數決定的
說明.adaptive
并不能像CollectionView
的FlowLayout
一樣計算每個元素的位置
原因是
grid
畢竟是grid
, 它是一個表格, 不可能每行的列數不一樣, 我想要的流式布局, 一般理解為"可換行的HStack", 以下有幾個三方庫和幾個so討論可以借鑒下:
- 討論1
- 討論2
- SwiftUI Flow, 支持HFlow和VFlow
- WrappingHStack
嵌套使用
如果你寫了一個grid, 想給它分組怎么辦? 之前是一個LazyVGrid
里直接添加N個View, 現在用Section分一下組就行
struct AwardGrid: View {
// 1
var title: String
var awards: [AwardInformation]
var body: some View {
// 2
Section(
// 3
header: Text(title)
.font(.title)
.foregroundColor(.white)
) {
// 4
ForEach(awards, id: \.self) { award in
NavigationLink(
destination: AwardDetails(award: award)) {
AwardCardView(award: award)
.foregroundColor(.black)
.aspectRatio(0.67, contentMode: .fit)
}
}
}
}
}
// 使用
LazyVGrid(columns: awardColumns) {
AwardGrid(
title: "Awarded",
awards: activeAwards
)
AwardGrid(
title: "Not Awarded",
awards: inactiveAwards
)
}
-
AwardGrid
只是封裝出來了, 本質上還是一個Section
, 它的有效元素仍然是一堆View - 所以就把原始結構由views變成了sections,
LazyVGrid
的所有屬性會透過section
傳給view來布局, 而不是去布局section - 但是section就是簡單地從上到下排列, 可以理解為
LazyVStack
教程里有這么一句話, 但沒有實例: You can mix different types of grid items in the same row or column.
如何能做到.fixed
,.flexible
和.adaptive
作用在同一行的?
Sheets & Alert Views
- 是在導航邏輯之外的獨立UI
- 目的就是阻斷用戶的操作, 引起用戶必要的注意
- SwiftUI provides two ways to display a modal, both based on a
@State
variable in the view.- 一種是
Bool
值, 為True
就顯示 - 一種是為
non nil
就顯示
- 一種是
- 共提供了四種modal:
- sheet
- alert
- action sheet (deprecated) -> confirmationDialog
- popover (大屏才有意義, 小屏直接全屏sheet就好了)
// sheet
Button(
action: {
isPresented.toggle()
}, label: {
Text("toggle sheet")
})
.sheet(
isPresented: $isPresented,
onDismiss: {
print("Modal dismissed. State now: \(self.isPresented)")
},
content: {
EmptyView()
}
)
- 如果是第一次使用, 那你只能習慣這種用法, 在很久以前的
bootstrap
就用了這種方式來做交互 -
sheet
沒法獨立定義在哪供你show
出來, 只能用modifier
的方式掛在一個視圖后面 - 但是掛在任一視圖后面就行了, 不是一定要像demo那樣跟在觸發的按鈕后面
- 其實你也能猜到, 任何地方都吧可以觸發
isPresented
的變化
- 其實你也能猜到, 任何地方都吧可以觸發
- You can create a ne?w navigation view on the modal, but it makes an entirely new navigation view stack.
// alert
Button("toggle alert") {
isPresented.toggle()
}
.alert(
isPresented: $isPresented {
Alert(
title: Text("Alert"),
message: Text("This is an alert"),
dismissButton: .default(Text("OK"))
)
}
)
用法是一樣的, 你只需要把它掛到一個view語句后面, 聲明有這個么視圖即可
// action sheet
Button("toggle action sheet") {
isPresented.toggle()
}
.actionSheet(
isPresented: $isPresented,
buttons: [
.default(Text("Default")),
.destructive(Text("Destructive")),
.cancel(Text("Cancel"))
]
)
-
actionSheet
的buttons
是一個數組, 你可以定義多個按鈕, 每個按鈕可以定義Text
和style
-
style
有三種,default
,destructive
,cancel
, 其中cancel
是默認的, 不用定義 -
default
和destructive
的區別是顏色,destructive
是紅色,default
是藍色
但是actionSheet已經過時了, 用confirmationDialog
// confirmation dialog
Button("toggle action sheet") {
isAction.toggle()
}
.confirmationDialog("action", isPresented: $isAction, titleVisibility: .visible) {
Button("one"){}
Button("two"){}
Button("cancel", role: .cancel){}
Button("delete", role: .destructive){}
}
-
confirmationDialog
的actions
閉包里返回一個數組, 數組里是多個Button
- 參考這篇文章看個性化的sheet action
custom action sheet
// popover
Button("toggle popover") {
isPresented.toggle()
}
.popover(
isPresented: $isPresented,
attachmentAnchor: .point(.bottom, alignment: .center),
arrowEdge: .bottom,
content: {
Text("Popover") // popover的視圖是自定義的, 就是一個小彈窗而已
}
)
Drawing & Custom Graphics
- One of the basic drawing structures in SwiftUI is the Shape
- A shape is a special type of view.
- By default, SwiftUI renders graphics and animations using
CoreGraphics
.
如果因為繪制造成效率低下:
you can use thedrawingGroup()
modifier on your view. This modifier tells SwiftUI to combine the view’s contents into an offscreen image before the final display. (Metal的特性)
- drawingGroup() modifier only works for graphics — shapes, images, text, etc.
- offscreen composition adds overheard and results in slower performance for simple graphics
Using GeometryReader
The GeometryReader
container provides a way to get the size and shape of a view from within it.
HStack {
Text("\(history.day) day(s) ago")
.frame(width: 110, alignment: .trailing)
// 只在需要的時候才包GeometryReader, 沒必要包在最外層
GeometryReader { proxy in
Rectangle()
.foregroundColor(history.delayColor)
.frame(width: minuteLength(history.timeDifference, proxy: proxy))
.offset(x: minuteOffset(history.timeDifference, proxy: proxy))
}
}
.padding()
.background(
Color.white.opacity(0.2)
)
上例是一個bar chart的demo, 左邊text, 右邊矩形做bar, 為了讓每個值對應成屏幕上的像素點(類似于比例尺), 就需要知道容器的真實大小.
有這么句話: There's no need to wrap the two elements inside a ZStack when using shapes inside a GeometryReader.
書中的例子是給bar上加刻度條, 因為是在GeometryReader
里, 給了offset和frame就行了, 都會在bar上面繪制, 個人認為就是在GeometryReader
的size里繪制的意思, 因為是繪制, 所以就無所謂ZStack
了, 關心的只有繪制的坐標.
Gradients
LinearGradient(gradient: Gradient(colors: [.red, .yellow]), startPoint: .leading, endPoint: .trailing)
-
LinearGradient
是線性漸變,RadialGradient
是徑向漸變 - 你需要構造一個
Gradient
對象, 然后傳給LinearGradient
或RadialGradient
, 等于一個是配置顏色, 一個是配置如何用這些顏色
Shapes
Rectangle
Circle
Ellipse
RoundedRectangle
-
Capsule
以下這些shape是AI自動生成的, 我保留下來以后看看有沒有生造出一些shape出來 Triangle
RegularPolygon
Polygon
Arc
BezierPath
Path
Shape
InsettableShape
ShapeStyle
PathStyle
ShapeView
ShapeViewStyle
ShapeStyleView
圓角邊框
要實現圓角邊框, 你能用到的方式有:
-
CornerRadius
+ overlayRoundedRectangle.stroke
-
CornerRadius
+ border -
ClipShape
RoundedRectangle + overlayRoundedRectangle.stroke
其實就是圓角, 你是選擇ClipShape
還是CornerRadius
; 邊框, 你是選擇Border
還是Overlay
.
Paths
因為用的都是CoreGraphics
, 語法都差不多:
GeometryReader { proxy in
let radius = min(proxy.size.width, proxy.size.height) / 2.0
let center = CGPoint(x: proxy.size.width / 2.0, y: proxy.size.height / 2.0)
var startAngle = 360.0
ForEach(pieElements) { segment in
let endAngle = startAngle - segment.fraction * 360.0
Path { pieChart in
pieChart.move(to: center)
pieChart.addArc(
center: center,
radius: radius,
startAngle: .degrees(startAngle),
endAngle: .degrees(endAngle),
clockwise: true
)
pieChart.closeSubpath()
startAngle = endAngle
}
.foregroundColor(segment.color)
}
}
連續畫折線的話, 可以直接傳入一個坐標數組
Path { path in
path.addLines([
CGPoint(x: 0, y: 128),
CGPoint(x: 142, y: 128),
CGPoint(x: 142, y: 70)
])
}.stroke(Color.blue, lineWidth: 3.0)
Animations & View Transitions
- In SwiftUI, you just tell SwiftUI the type of animation, and it handles the interpolation for you.
Image()
.rotationEffect(.degrees(showTerminal ? 90 : -90)) // 沒有動畫
.animation(.linear(duration: 1.0)) // 對上面的effect進行動畫
.animation(Animation.default.speed(0.33)) // 減慢速度
'animation' was deprecated in iOS 15.0: Use withAnimation or animation(_:value:) instead.
Eased animations
-
Animation.default
就是easeInOut
(默認時間是0.35秒) - If you need fine control over the animation curve's shape, you can use the
timingCurve(_:_:_:_)
type method.- 四個參數就是塞塞爾曲線的兩個控制點的坐標, 范圍是0到1
Spring animations
- eased animations是單向的, 在快結束的時候加點bounce, 就叫sping
.animation(
.interpolatingSpring(
mass: 1,
stiffness: 100,
damping: 10,
initialVelocity: 0
)
)
mass
: Controls how long the system "bounces".stiffness
: Controls the speed of the initial movement.damping
: Controls how fast the system slows down and stops.initialVelocity
: Gives an extra initial motion.質量越大,動畫持續的時間越長,在端點兩側彈跳的距離越遠。質量越小,停止的速度越快,每次彈跳經過端點的距離也越短。
增加剛度會使每次彈跳都更遠地越過端點,但對動畫長度的影響較小。
增加阻尼會使動畫更快平滑和結束。
增加初速度會使動畫彈跳得更遠。負的初速度會使動畫向相反方向移動,直到克服初速度為止
.animation(
.spring(
response: 0.55, // 定義一個周期的時長
dampingFraction: 0.45, // 控制彈力的停止速度, 0是不停止, 1等于彈不動
blendDuration: 0
)
)
"blendDuration "參數用于控制不同動畫之間的混合過渡長度。只有在動畫過程中更改參數或組合多個彈簧動畫時才會使用該參數。如果值為零,則會關閉混合功能。
- 如果你又加了個effect:
.scaleEffect(showTerminal ? 1.5 : 1.0)
, 那么這個scaleEffect也會被動畫化, - 你想要立刻生效, 不要動畫, 那就得注意先后, 把不需要動畫的effect寫在前面, 然后跟上
.animation(nil)
- 如果你把nil動畫改成了另一個動畫, 比如
.animation(.linear(duration: 1.0))
, 那么兩個effect就應用了各自的動畫simultaneously and blend smoothly
也就是說, 為每個effect做一個animation
Animating multiple properties
- 如果你想讓兩個屬性同時動畫化, 那么需要用
withAnimation
來包裹這兩個屬性
withAnimation(.spring(response: 0.55, dampingFraction: 0.45, blendDuration: 0)) {
// 兩個動畫同步
}
Animating from state changes
除了對effect動畫, 你也可以對state變化進行動畫化. 上面的條形圖例子中, 圖形是立即繪制的, 我們加個條件:
@State private var showBars = CGFloat(0)
// 改一個通過geometryProxy來獲取長度的方法, 即原本計算的長度, 再乘這個showbars(要么是0, 要么是1), 略
// appear的時候加入這個條件, 即對showBars這個屬性的變化進行相應的動畫
// 到了bar布局容器VStack上:
.onAppear {
withAnimation(Animation.default.delay(0.5)) {
self.showBars = CGFloat(1)
}
}
// 或者手動觸發
Button(action: {
withAnimation {
self.showBars = CGFloat(1)
}
}) {
Text("Show Bars")
}
Animating changes to the view's appearance
- The
delay()
method also gives you a method to make animations appear to connect.
// 把上面在`onAppear`方法里寫動畫的代碼改為只設屬性
.onAppear {
showBars = true
}
// 然后再對每條bar animation的時候延遲一點
// 順便對index進行迭代, 這樣越靠后的bar動畫延遲得越久, 造成先后繪制的效果
.animation(
Animation.easeInOut.delay(index * 0.1)
)
自定義動畫
- 主要就是通過控制動畫的進度來實現
- SwiftUI提供
Animatable protocol
, 實現animatableData
來描述當前進度即可 - 它是一個服務
VectorArithmetic
協議的類型 - 但是對于
Path
, 它有一個trim
方法能控制path繪制的進度,trim
方法接受一個from和一個to, 任意一個是state
的話, 就能在state變化的時候觸發動畫
@State private var showPath = false
Path { path in
path.addLines([
CGPoint(x: 0, y: 0),
CGPoint(x: 0, y: 128),
CGPoint(x: 142, y: 128),
CGPoint(x: 142, y: 70)
])
}
.trim(to: showPath ? 1.0 : 0.0) // 這里
.stroke(Color.blue, lineWidth: 3.0)
.animation(.easeInOut(duration: 3.0), value: UUID())
.onAppear {
showPath = true
}
靈活運用trim的
from
和to
的組合, 可以實現很多效果, 比如倒放, 消除等, 自己多試試, 對from進行切換會有很多意想不到的效果哦
-
.trim(from: 0.0, to: showPath ? 1.0 : 0.0)
正向繪制 -
.trim(from: showPath ? 0.0 : 1.0, to: 1.0)
逆向繪制 -
.trim(from: 0.0, to: showPath ? 0.0 : 1.0)
擦除
Animating view transitions
Note: Transitions often render incorrectly in the preview. If you do not see what you expect, try running the app in the simulator or on a device.
-
transition
是動畫化view hierarchy的變化, 比如一個view從屏幕上消失, 另一個view出現, 或者一個view被替換成另一個view - Transitions are specific animations that occur when showing and hiding views.
// 這個叫State change
Text(
showTerminal ?
"Hide Terminal Map" :
"Show Terminal Map"
)
// 這個叫View transition
if showTerminal {
Text("Hide Terminal Map")
} else {
Text("Show Terminal Map")
}
任意可以選擇性選擇不同view的地方, 都可以加上transition
Group { // 首先用Group包一下
if showTerminal {
Text("Hide Terminal Map")
} else {
Text("Show Terminal Map")
}
}
.transition(.slide)
-
opacity
: 淡入或淡出(默認) -
slide
: 從屏幕的一側滑入或滑出 -
scale
: 縮放進入或縮放離開,scale
入參是initial
value,anchor
是錨點, 默認是.center
-
move(edge: .bottom)
: 從屏幕的底部滑入或滑出
但是它不會自動在屬性變化的時候生效, 需要手動觸發
Button(action: {
withAnimation { // 需要用withAnimation來包裹
self.showTerminal.toggle()
}
}) {
// 剛剛那個group的views可以放這里
}
這個就有點像UIKit
的animation方法了, 把屬性的變化包到動畫方法里.
Customizing transitions
-
transition
可以接受一個參數,transition(_:animation:)
方法, 第一個參數是transition的類型, 第二個參數是動畫的配置 -
transition(_:animation:)
方法可以接受一個Animation
對象, 也可以接受一個Animation
的閉包
// 傳入一個Animation對象
.transition(.slide, animation: .easeInOut(duration: 1.0))
// 傳入一個Animation的閉包
.transition(.slide, animation: Animation.easeInOut(duration: 1.0))
組合
extension AnyTransition {
static var buttonNameTransition: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale(scale: 0.0)
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}
- 用
bombined
來支持多個動畫的組合 - 用
asymmetric
來配置呈現和消失時不同的動畫
Linking view transitions
兩個視圖, 在同一個state切換狀態時, 一個顯示, 一個消失, 這兩個動畫沒關關聯, 可以用matchedGeometryEffect
讓它同步起來
You only must specify the first two parameters.
- The
id
uniquely identifies a connection and giving two items the same id links their animations. - You pass a Namespace to the in property. The namespace groups related items, and the two together define unique links between views.
- 定義:
@Namespace var namespace
- 接參:
var namespace: Namespace.ID
- preview里需要手動傳下:
@Namespace static var namespace
- 定義:
添加這個方法的仍然是你想要動畫的View上, 下面的截圖演示了它的位置并不影響別的modifier:
ViewBuilder
如果想把這個視圖改造成組件:
ForEach(flights) { flight in
FlightCardView(flight: flight)
}
簡單自定義一個view就行, 把視圖寫到body
方法里, 但是如果FlightCardView
這個也要拿出去自定義怎么辦? 其實就是把block用ViewBuilder
標記一下來做入參:
struct GenericTimeline<Content>: View where Content: View {
let flights: [FlightInformation]
let content: (FlightInformation) -> Content
init(
flights: [FlightInformation],
@ViewBuilder content: @escaping (FlightInformation) -> Content
)
var body: some View {
ScrollView {
VStack {
ForEach(flights) { flight in
content(flight)
}
}
}
}
}
- 以上做了一個視圖, 接受一個數組, 但是沒有幫你生成視圖, 而是讓你傳入應該生成怎樣的視圖
- 這在用同樣的數據源產生不同的UI的場景適用
-
<Content>
是泛型, 字面文字并不重要, 主要是個占位, 有多個泛型就在<>
里寫多個占位符
使用
GenericTimeline(
flights: mydata
) { flight in
FlightCardView(flight: flight) // create your view
}
多個泛型:
struct GenericTimeline<Content, T>: View where Content: View {
var events: [T]
let content: (T) -> Content
init(
events: [T],
@ViewBuilder content: @escaping (T) -> Content
) {
self.events = events
self.content = content
}
var body: some View {
ScrollView {
VStack {
ForEach(events.indices) { index in
content(events[index])
}
}
}
}
}
-
ForEach
的是events
的indices
而不是它本身, 因為泛型T不能保證Identifiable
- 所以也可以在
where
時約束一下:where Content: View, T: Identifiable
- 上面有了兩個泛型, 再次聲明, 泛型的名字不重要, 自己試下, 把
Content
全部換成V
, 這樣就是V, T
兩個泛型, 一個是View, 一個是identifiable.
使用
GenericTimeline(events: flights) { flight in
FlightCardView(flight: flight) // create your view
}
KeyPaths
KeyPath
是Swift的反射機制, 可以用來獲取對象的屬性, 比如獲取FlightInformation
的id
屬性:
struct FlightInformation: Identifiable {
let id = UUID()
let name: String
let origin: String
let destination: String
let departure: Date
let arrival: Date
}
用KeyPath
獲取id
屬性:
let idKeyPath = \FlightInformation.id
KeyPath
可以用來做ForEach
的id
參數:
GenericTimeline(events: flights, id: \.id) { flight in
FlightCardView(flight: flight) // create your view
}
如果用的是\.id
, 則可以省略.
說回demo, 如果我們UI需要取泛型T
的一個字段來呈現, 但又不確定是哪個字段(一般這種情況, 可能直接設計為傳值, 而不是字段), 我們可以把keypath傳進來:
let timeProperty: KeyPath<T, Date>
聲明keypath
需要兩個屬性:
-
T
是說明查找keypath的對象的類型 -
Date
的意思是T
的keypath的目標類型是Date
所以添加一個屬性:
struct GenericTimeline<Content, T>: View where Content: View, T: Identifiable, T: Comparable {
var events: [T]
let timeProperty: KeyPath<T, Date>
let content: (T) -> Content
init(
events: [T],
timeProperty: KeyPath<T, Date>,
@ViewBuilder content: @escaping (T) -> Content
) {
...
}
}
// 實例化時多了一個屬性:
timeProperty: \.localTime
傳進來是為了用, 直接看看截圖吧
如果是OC, 可能要簡單很多, 直接用字符串就行了, swift的更安全.
個人覺得例子舉得不好, 都泛型了, 還一定要用它的某個屬性來寫邏輯, 那有何意義? 不過教程只是為了演示用法, 真實場景還得自己把握.
Integrating with other frameworks
- To work with UIViews and UIViewControllers in SwiftUI, you must create types that conform to the
UIViewRepresentable
andUIViewControllerRepresentable
protocols. (取決于三方組件是view還是controller) - There are two methods in the
UIViewControllerRepresentable
protocol you will need to implement:makeUIViewController(context:)
, andupdateUIViewController(_:context:)
.- 其實是三個, 概述一下就是
makeView
,makeCoordinator
和updateUIView
- 其實是三個, 概述一下就是
以連接MapKit為例:
- makeUIView里需要返回mapkit
- updateUIView里需要更新mapkit
- makeCoordinator里需要返回一個coordinator, 這個coordinator需要實現
MKMapViewDelegate
協議
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
let coordinate = CLLocationCoordinate2D(latitude: 34.011286, longitude: -116.166868)
// 定義和添加一系列coordinate, overlay和polyline
// 以期在coordinator的代理方法里處理成真實的繪制
}
func makeCoordinator() -> Coordinator {
MapCoordinator(self)
}
class MapCoordinator: NSObject, MKMapViewDelegate {
var control: MapView // 這里一定要注意, 指回去了
init(_ control: MapView) {
self.control = control
}
}
extension MapCoordinator: MKMapViewDelegate {
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
// 處理繪制
}
// 繪制circle和連線的代理方法
func mapView(
_ mapView: MKMapView,
rendererFor overlay: MKOverlay
) -> MKOverlayRenderer {
if overlay is MKCircle {
let renderer = MKCircleRenderer(overlay: overlay)
renderer.fillColor = UIColor.black
renderer.strokeColor = UIColor.black
return renderer
}
if overlay is MKGeodesicPolyline {
let renderer = MKPolylineRenderer(overlay: overlay)
renderer.strokeColor = UIColor(
red: 0.0,
green: 0.0,
blue: 1.0,
alpha: 0.3
)
renderer.lineWidth = 3.0
renderer.strokeStart = 0.0
renderer.strokeEnd = fraction
return renderer
}
return MKOverlayRenderer()
}
}
}
MacOS app
略