前置資源
GitHub: SwiftUI-WeChatDemo
第二章:剖析:如何用 SwiftUI 5天組裝一個微信 —— 通訊錄發現我篇
整體結構
UI 部分的代碼分布如上圖所示,App 的主入口類為 WeChatDemoApp。
該類在創建新的 SwiftUI 項目時會自動生成,這里只需要將其展示內容的部分變更為第一級容器:TabContainer,該容器包括微信的四個頁面,分別為 聊天界面部分、通訊錄、發現、我,這四個部分的主 UI 視圖分布如上圖標示,由各自的分級文件夾裝載,並統合到主目錄 UI 下面。
與 UI 目錄同級目錄有:
- Data:相當於 MVVM 中的 Repository 層級的簡單替代,因為該項目主要展示 SwiftUI 使用,因此該層最大限度精簡,只提供裝載必要數據的類
- Resources:資源目錄,包括 Image.xcassets、Color.xcassets、Localizable.strings 等
- Generated:通過 SwiftGen 自動為 Resources 目錄生成的資源索引類目錄,主要是為了給代碼編寫提供便捷的自動提示功能,提高工作效率,適配 SwiftUI 的一鍵配置及使用方法可參考 SwiftGenConfigForSwiftUI
- 配置文件下載:config files
UI 層級示意圖譜:
WeChatDemoApp
└── TabView
├── ChatsView
│ └── ChatDetailView
├── ContactsView
├── DiscoverView
└── MeView
第一級容器:TabContainer
App 所引入的第一級視圖容器,使用的是 TabView,通過自定義結構體 TabContainer 將其包裹封裝,實現代碼分離:
TabView 需要提供一個參數 selection,類型可隨意自定義,用於告訴 TabView 在初始化后應該展示哪個 Tab,以及當用戶選擇其他 Tab 時,將其反饋、存儲到該變量中。
此處用於 selection 的類型為自定義枚舉:
enum Tabs {
case Chats, Contacts, Discover, Me
}
傳遞給 selection 的變量需要聲明為 @State
類型,以實現 UI 視圖感知數據變化、自動刷新重繪的功能(數據綁定)。(同樣,將變量注入到 selection 參數時,語法上需要添加 $
的前綴):
@State var selectingTab: Tabs = Tabs.Chats
TabView(selection: $selectingTab) { ...
其他如 Picker 等可提供用戶 選擇 能力的 UI,也都需要與一個 selection 變量進行綁定。
在 TabView 下面按序放置 4 個 View 視圖,代表提供了 4 個 Tab 的界面,每個 Tab 視圖的結構如下:
ChatsView().tabItem {
Image(systemName: currentStateIconName(selecting: selectingTab))
Text("Chats")
}.tag(Tabs.Chats)
- 示例中自定義 ChatsView 為第一個 Tab 的視圖 View
.tabItem { ... }
用於描述 Tabbar 上該 Tab 按鈕的 UI 部分,僅可接受標准的 Image、Text 以及 Label 類型.tag(...)
表示該 Tab 的識別符號,數據類型需要與上面的 selection 變量一致,用戶點擊 Tab 按鈕時,該處的 tag 變量會被賦值到 selection 綁定的變量中
為了實現 Tab 按鈕在激活・非激活狀態下展示不同的圖標(實心與空心),此處通過一個函數動態返回對應圖標素材名字,傳遞到 Image 中,該函數接受一個指向當前正在選擇的 Tab 的 selecting 參數,當 @State var selectingTab
變量發生變化時,相關聯的 UI 將會重繪,並重新調用函數獲取新的圖片素材名字:
func currentStateIconName(selecting: Tabs) -> String {
selecting == self ? iconNameOn : iconNameOff
}
最后通過對 TabView 整體添加 .edgesIgnoringSafeArea(.all)
,使其可用空間擴展到劉海頂部實現全屏。
Tab 1:聊天列表
整一個 Tab 視圖的根容器是一個 NavigationView,該部件提供了標准的 Toolbar、子頁面跳轉、返回等功能。
在 NavigationView 之中包裹的是真正的視圖布局。
視圖布局三劍客
- HStack:提供一個橫向的自動布局容器,默認 UI 元素自左向右排布
- VStack:提供一個縱向的自動布局容器,默認 UI 元素自上向下排布
- ZStack:提供一個沿 Z 軸排布的布局容器,后面的 UI 元素將覆蓋前面的 UI 元素(相當於 Android 中的 FrameLayout)
(※ 這三個容器默認帶有元素間距,可使用參數 spacing: 0
消除)
在界面上,排除由 NavigationView 提供的 Toolbar 部分,視圖由一個自定義的搜索欄 SearchBar,以及一個列表組成,通過 VStack 縱向排列布局。
列表部分可使用 SwitchUI 中專用的循環體 ForEach,實現一個根據數據數組元素數量、內容,不斷添加 UI 元素的循環結構。(也可以考慮使用 List,但 List 帶有比較強烈的樣式傾向,不如 ForEach 容易控制)
ForEach([Chat], id: \.self) { chat in
NavigationLink(destination: createChatDetailView(with: chat)) {
ChatItemView(chat: chat)
}
}
※ 不能使用普通的 for 語法
ForEach 要求提供的數據類型遵循 Hashable 協議,同樣 id 也要求遵循 Hashable,以便其識別、追蹤每次循環所生成的 View,在數據發生變化時能夠在正確位置插入、刪除、修改對應的 UI。
id: \.self
使用的是一種名為 KeyPath 的類型及語法糖,該語法從 Swift5.2 開始提供,描述了一個從形式參數類型 KeyPath 所聲明綁定的 Root 泛型類型對象中獲取指定的某個屬性的 路徑,可類比於 Java 的反射或 JS 的 eval 功能等,提供了將某個屬性訪問的 動態操作 轉換為另一種體現在某個屬性值上的 靜態描述 能力。
此處 \.self
表示,id 參數使用前面的數組中 Chat 元素自身,相當於 chat.self
。
NavigationLink(destination: createChatDetailView(with: chat)) {
ChatItemView(chat: chat)
}
上述代碼中,ForEach 根據 Chat 數組,生成聊天列表中每一個聊天記錄項的 View,該 View 由 NavigationLink 所包裹,當用戶點擊該 View 時,將自動通過外層的 NavigationView 進行頁面導航,跳轉至此處指定的 destination 指向的 View(作為子頁布局展示)。
同時,NavigationView 會為該子頁面添加一個 Toolbar,以及一個用於返回的箭頭按鈕。
而 NavigationLink { ... }
中的 UI 則是這個聊天記錄項的布局(list item)。
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("微信")
.toolbar {
ToolbarItem(placement: .navigationBarLeading){
Button(action: {}) {
Image(systemName: "ellipsis")
}
}
...
}
這些部分描述當前界面的頂部的 Title 展示形式、標題文本、Toolbar構成等。
需要注意的是這部分描述需要在 NavigationView 內的 View 上書寫,而不是附加在 NavigationView 自身上。
聊天列表記錄 ItemView
聊天列表上的記錄 Item View:
Spacer 是一個可以依據剩余可用空間自動填充擴展的結構,用於自動撐開兩個 View 或將容器撐滿整個屏幕等。
頭像部分,顯示一個圖片素材,指定其可縮放至指定 frame 大小,並追加圓角角度:
let isShowBadge: Bool = Float.random(in: 0...1) > 0.45
Image("avatar01")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50, alignment: .center)
.cornerRadius(4.0)
.withBadge(isShowBadge)
最后的 withBadge(isShowBadge)
為自定義函數擴展:
該函數通過為 View 添加一個圓形的 overlay,指定放置在右上角並偏移一半尺寸到 View 外側,來實現信息紅點功能。
函數返回類型指定為 some View
,屬於 SwiftUI 中類型擦除的概念,在函數中使用 if、switch 等根據情況返回不同 View 的場合,需要使用 AnyView 進行包裹並返回,否則將出現代碼檢測錯誤:
Function declares an opaque return type, but the return statements in its body do not have matching underlying types
分割線可使用 Divider() 實現。
聊天窗口
數據部分
@State var chat: Chat
@StateObject var viewModel: ChatDetailViewModel
@Environment(\.presentationMode) var presentationMode
@State chat
:一個 struct 類型,包含對方最后一條聊天消息和聯系人(頭像)信息@StateObject viewModel
:用於承載復雜的使用場景,在該界面上 ChatDetailViewModel 托管了一個發送消息記錄的數組,以及通過 Combine 響應式框架模擬聊天對方在 1 秒后回信的功能@Environment presentationMode
:從環境變量中獲取當前的界面的展開模式控制對象,用於在 Toolbar 中賦予自定義返回按鈕的返回聊天列表能力
界面主體由兩大部分組成:
- 聊天信息記錄的 ChatFlowView
- 底部文字輸入部分的 ChatInputView
ChatFlowView 根據由 ViewModel 所托管的消息記錄 messageFlow 數組數據,使用 ForEach 生成每一條對話消息:
ScrollViewReader 用於提供對 ScrollView 的滑動控制能力,在不需要程序自動控制 ScrollView 滑動時,則不需要使用該部件。
ChatMessageView(message: message).onAppear() {
scrollView.scrollTo(message)
}
ChatMessageView 表示頭像+信息組成的一條聊天信息,使用一個 ChatMessage 類型的數據進行初始化,數據包含聊天信息內容的 String,以及發送方向、頭像。
其中的 MessageText 基本上是一個普通 Text,展示 ChatMessage 中的信息內容,並根據其中的發送方向(左側或是右側)決定 Text 的底色。
在外層 HStack 上通過改寫局部環境變量 layoutDirection,來實現布局排序方向變化。