前置資源

GitHub: SwiftUI-WeChatDemo

第零章:用 SwiftUI 五天組裝一個微信 - wavky - 部落格園

整體結構

UI 部分程式碼分佈如上圖所示,App 的主入口類為 WeChatDemoApp。

該類在建立新的 SwiftUI 專案時會自動生成,這裡只需要將其展示內容部分變更為第一級容器:TabContainer,該容器包括微信的四個頁面,分別為 聊天介面部分、通訊錄、發現、我,這四個部分的主 UI 檢視分佈如上圖示示,由各自的資料夾裝載,並統合到主目錄 UI 下面。

與 UI 目錄同級目錄有:

  • Data:相當於 MVVM 中的 Repository 層級的簡單替代,因為該專案主要展示 SwiftUI 使用,因此該層最大限度精簡,只提供裝載必要資料的類
  • Resources:資源目錄,包括 Image.xcassets、Color.xcassets、Localizable.strings 等
  • Generated:通過 SwiftGen 自動為 Resources 目錄生成的資源索引類目錄,主要是為了給程式碼編寫提供自動提示的便利,提高工作效率,適配 SwiftUI 的配置及使用可參考 SwiftGenConfigForSwiftUI

UI 層級示意圖:

WeChatDemoApp
└── TabView
├── ChatsView
│ └── ChatDetailView
├── ContactsView
├── DiscoverView
└── MeView

第一級容器:TabView

App 之下的第一級容器,使用的是 TabView,並通過自定義結構體 TabContainer 將其包裹封裝,實現程式碼分離。

TabView 需要提供一個引數 selection,型別可隨意自定義,用於告訴 TabView 在初始化後應該展示哪個 Tab,以及當用戶選擇其他 Tab 時,將其反饋、儲存到該變數中。

該變數需要宣告為 @State 型別,以實現 UI 檢視感應資料變化,自動重新整理的功能(資料繫結)。(同樣,語法上需要以 $ 的字首方式將變數注入到 selection 引數)

其他如 Picker 等可提供使用者選擇能力的 UI,也都需要與一個 selection 變數進行繫結。

在 TabView 下面按序放置四個 View 檢視,代表提供了 4 個 Tab 的介面,結構如下:

ChatsView().tabItem {
Image(systemName: currentStateIconName(selecting: selectingTab))
Text("Chats")
}.tag(Tabs.Chats) func currentStateIconName(selecting: Tabs) -> String {
selecting == self ? iconNameOn : iconNameOff
}
  • 示例中自定義 ChatsView 為第一個 Tab 的檢視 View
  • .tabItem { ... } 用於描述 Tabbar 上該 Tab 按鈕的 UI 部分,僅可接受標準的 Image、Text 以及 Label 型別
  • .tag(...) 表示該 Tab 的識別符號,型別上需要與上面的 selection 變數一致,使用者點選 Tab 按鈕時,該處的 tag 變數會被賦值到 selection 中
  • 為了實現 Tab 按鈕在啟用・非啟用狀態下展示不同的圖示(實心與空心),此處將一個根據 selecting 引數返回對應圖片名字的函式傳遞到 Image 中,當 @State var selectingTab 變數發生變化時,相關聯的 UI 將會重繪,並重新呼叫函式獲取新的圖片名字

最後通過對 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(作為子頁佈局展示)。

NavigationLink { ... } 中的 UI 則是這個聊天記錄項的佈局(list item)。

.navigationBarTitleDisplayMode(.inline)
.navigationTitle("微信")
.toolbar {
ToolbarItem(placement: .navigationBarLeading){
Button(action: {}) {
Image(systemName: "ellipsis")
}
}
...
}

這些部分描述當前介面的頂部的 Title 展示形式、字串資源、Toolbar構成等。

需要注意的是這部分描述需要在 NavigationView 內的元素上書寫,而不是附加在 NavigationView 自身上。

聊天記錄 View

Spacer 是一個可以依據剩餘控制元件自動填充的結構,用於自動撐開兩個 View 或將容器撐滿整個螢幕等。

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)

頭像部分,顯示一個圖片素材,指定其可縮放至指定 frame 大小,並追加圓角角度,最後的 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,來實現佈局排序方向變化。