剖析:如何用 SwiftUI 5天組裝一個微信 —— 聊天界面篇


前置資源

GitHub: SwiftUI-WeChatDemo

第零章:用 SwiftUI 5天組裝一個微信

第二章:剖析:如何用 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

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,來實現布局排序方向變化。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM