千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(二):UI设计与搭建

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(二):UI设计与搭建,第1张

本文篇幅较长,预计阅读时长30-60min,欢迎收藏+点赞+关注。

这是《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成》的第二篇:《UI设计与搭建》

系列文章可参考:

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(一):功能设计与介绍

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(三):App内部流程与逻辑(上)

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(三):App内部流程与逻辑(下)

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结

二、UI 设计与搭建

基于第一篇的功能和模块规划,我们先来搭建UI,这样能尽快地看到软件最终的样子。
为了快速搭建和开发,本篇将以 iOS 为平台,选用 SwiftUI 和 Swift 进行开发。Android 平台上的 Kotlin 版本,请参见后续文章。

1. 创建iOS项目

万事开头难,我们先来创建一个基本的 SwiftUI 项目。
打开 XCode,出现XCode欢迎界面:

点击“Create a new Xcode project”,进入模板选择界面:

选择 “iOS”,“Application”部分选择“App”即可。
点击“Next”,进入项目选项页面:

“Interface”选择“SwiftUI”,“Language”选择“Swift”。
点击“Next”,进入项目存储选择页面:

选择合适的存储位置,点击“Create”,创建项目。进入开发界面:

界面中,XCode已经根据App模板,创建了“IMDemoApp”和“ContentView”两个页面。本篇及后续的讲解,将基于该项目,继续进行。

2. 登录页面

一般而言,打开一个App,用户最先看到的将是登录页面,所以我们将从登录页面,开始我们的讲解。
首先在左侧项目导航窗口中,右键点击“IMDemo”,d出右键菜单,选择“New File...”:

打开文件模板选择窗口。在“User Interface”部分,选择“SwiftUI View”,点击“Next”:

在文件存储对话框中,将文件改名为“LoginView”,点击“Create”创建文件:

创建后的 LoginView 如图所示:

登陆界面一般会有五个核心元素:

用户头像/默认头像用户账号输入控件用户登录密码输入控件“登陆”按钮对于非注册用户,转到注册界面的“注册”按钮

于是我们修改 LoginView 的代码如下:

import SwiftUI

struct LoginView: View {
    @State private var username: String = ""
    @State private var password: String = ""
    
    @State private var userImage: String = ""
    
    func changeToRegisterView() {
        //-- TODO: change to register view
    }
    
    func userLogin(){
        //-- TODO: user login
    }
    
    var body: some View {
        VStack(alignment: .center){
            
            Image(self.userImage)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                .padding()
            
            HStack(alignment: .center){
                Spacer()
                Text("用户名:")
                    .padding()
                TextField("用户名", text: $username)
                    .autocapitalization(.none)
                    .padding()
                Spacer()
            }
            HStack(alignment: .center){
                Spacer()
                Text("密 码:")
                    .padding()
                SecureField("登陆密码", text: $password)
                    .padding()
                Spacer()
            }
            HStack(alignment: .center){
                Button("登 陆"){
                    userLogin()
                }.frame(width: UIScreen.main.bounds.width/4,
                height: nil)
                .padding(10)
                .foregroundColor(.white)
                .background(.blue)
                .cornerRadius(10)
                
                Button("注册"){
                    changeToRegisterView()
                }
                .padding(10)
            }
        }
    }
}

struct LoginView_Previews: PreviewProvider {
    static var previews: some View {
        LoginView()
    }
}

我们用一个VStack对界面元素进行组织。从上到下依次是用户头像,用户账号输入部分,用户登录密码输入部分,以及“登陆”和“注册”按钮。

这个时候,我们突然发现一件事情,无论我们想要跳转到注册页面,还是想提取已注册的用户头像,都需要视图之外的模块进行辅助。于是,我们将先添加几个独立于页面视图的 Swift 代码,提供对相应功能的支持。

3. 独立于页面的支持代码

于是我们先添加两个额外的Swift文件,先添加相关的代码,以便为UI的搭建,提供对应的功能支持。
还是在XCode左侧项目导航窗口,右键点击“IMDemo”,d出右键菜单,选择“New File...”,打开文件模板选择窗口。在“Source”部分,选择“Swift File”,添加swift代码文件:

两个文件分别保存为“Config.swift”和“IMCenter.swift”。如图所示:

编辑 Config.swift,修改内容如下:

import Foundation

class IMDemoUIConfig {
    static let defaultIcon = "livedatalogo"
    static let topNavigationHight = 50.0
    static let bottomNavigationHight = 50.0
    static let navigationIconEdgeLen = 44.0
    
    static let contactItemHight = 60.0
    static let contactItemImageEdgeLen = 50.0
}

defaultIcon 是当用户还未选择头像时的默认头像。于是,我们索性先把需要的图像资源添加到位。

注:相关的图像资源可以从本篇末的项目地址中获取。

因为我们一共有6个页面,所以我们在 IMDemoApp.swift 中简单添加一个枚举,便于后续页面切换。

enum IMViewType {
    case LoginView
    case RegisterView
    case SessionView
    case ContactView
    case ProfileView
    case DialogueView
}

因为存在在页面中共享的数据,为了避免后续功能增改时分散传递和修改的麻烦,我们在 IMDemoApp.swift 中添加一个新的类,打包处理一下:

class ViewSharedInfo: ObservableObject {
    @Published var currentPage: IMViewType = .LoginView
}

替换 IMDemoApp.swift 中 IMDemoApp 的 body 相关代码,以便进行视图切换。最终修改的IMDemoApp.swift代码如下:

import SwiftUI

enum IMViewType {
    case LoginView
    case RegisterView
    case SessionView
    case ContactView
    case ProfileView
    case DialogueView
}

class ViewSharedInfo: ObservableObject {
    @Published var currentPage: IMViewType = .LoginView
}

@main
struct IMDemoApp: App {
    
    @StateObject var sharedInfo: ViewSharedInfo = IMCenter.viewSharedInfo
    
    var body: some Scene {
        WindowGroup {
            switch sharedInfo.currentPage {
            case .LoginView:
                LoginView()
            case .RegisterView:
                RegisterView()
            case .SessionView:
                SessionView()
            case .ContactView:
                ContactView()
            case .ProfileView:
                ProfileView()
            case .DialogueView:
                DialogueView()
            }
        }
    }
}

然后修改 IMCenter.swift代码如下:

import Foundation
import UIKit
import SwiftUI

class IMCenter {
    static var viewSharedInfo: ViewSharedInfo = ViewSharedInfo()
    
    //-- 存储用户属性
    class func storeUserProfile(key: String, value: String) {
        UserDefaults.standard.set(value, forKey: key)
    }
    //-- 获取用户属性
    class func fetchUserProfile(key: String) -> String {
        let value = UserDefaults.standard.string(forKey: key)
        return ((value == nil) ? "" : value!)
    }
    
    class func loadUIIMage(path:String)-> UIImage {
                
        let fullPath = NSHomeDirectory() + "/Documents/" + path
        let fileUrl = URL(fileURLWithPath:fullPath)
        let data = try? Data(contentsOf: fileUrl)
        if data == nil {
            print("load image from disk failed. path: \(fileUrl)")
            return UIImage(named: IMDemoUIConfig.defaultIcon)!
        }
        return UIImage(data: data!)!
    }
    
}

除了 storeUserProfile() 和 fetchUserProfile() 是存取用户属性外,我们再增加一个函数 loadUIIMage(),用于将保存在设备上的图像,加载到内存。

最后,创建项目时根据模版创建的 ContentView 此时已经不在有用,可以直接删除。

4. 登录页面(继续)

有了前面的基础,我们继续编辑 LoginView。

首先,我们希望如果之前有用户登录过,那就先默认显示上次登录用户的头像,以及预填用户名。如果之前没有用户登录过,再显示默认头像。于是增加初始化函数:

    init() {
        let username = IMCenter.fetchUserProfile(key: "username")
        self.userImage = IMCenter.fetchUserProfile(key: "\(username)-image")
    }

其次,当用户完成输入的时候,键盘有可能会挡住“登陆”按钮,所以我们需要添加 hideKeyboard() 函数。
而类似的需求只要有输入便必定存在。为了便于后续页面的开发,我们将 hideKeyboard() 函数添加到 IMDemoApp.swfit 中:

#if canImport(UIKit)
extension View {
    func hideKeyboard() {
        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}
#endif

再次修改 LoginView.swift:

struct LoginView: View {
    var body: some View {
        VStack(alignment: .center){
            
            ... ...
            
        }
        .onTapGesture {
            hideKeyboard()
        }
    }
}

然后,当用户登录的时候,用户账号/用户名和密码是不允许为空的。如果遇到类似的情况发生,我们需要给出提示:

@State private var alertTitle: String = ""
@State private var errorMessage: String = ""
@State private var showAlert = false

... ...

                Button("登 陆"){
                    hideKeyboard()
                    userLogin()
                }.frame(width: UIScreen.main.bounds.width/4,
                height: nil)
                .padding(10)
                .foregroundColor(.white)
                .background(.blue)
                .cornerRadius(10)
                .alert(alertTitle, isPresented: $showAlert) {
                    Button("确认") {
                        self.showAlert = false
                    }
                } message: {
                    Text(errorMessage)
                }

... ...

第四,登陆是一个网络过程,期间可能会有数秒的等待。于是我们需要添加一个提示页面:

@State private var showLoginingHint = false

... ...

                Button("登 陆"){
                    hideKeyboard()
                    userLogin()
                }
                ... ...
                .alert(alertTitle, isPresented: $showAlert) {
                    ... ...
                }
                .fullScreenCover(isPresented: $showLoginingHint, onDismiss: {
                    //-- Do nothing.
                }) {
                    VStack {
                        Text("登录中,请等待....")
                            .font(.title)
                    
                        ProgressView()
                    }
                }

... ...

如果登录失败了,也需要有类似的提示:

@State private var loginFailed = false

... ...

                Button("登 陆"){
                    hideKeyboard()
                    userLogin()
                }
                ... ...
                .alert(alertTitle, isPresented: $showAlert) {
                    ... ...
                }
                .fullScreenCover(isPresented: $showLoginingHint, onDismiss: {
                    //-- Do nothing.
                }) {
                    ... ...
                }
                .fullScreenCover(isPresented: $loginFailed, onDismiss: {
                    //-- Do nothing.
                }) {
                    VStack {
                        Text("登录失败")
                            .font(.title)
                        
                        Text(errorMessage).padding()
                        
                        Button("确定") {
                            self.loginFailed = false
                        }
                        .frame(width: UIScreen.main.bounds.width/4,
                        height: nil)
                        .padding(10)
                        .foregroundColor(.white)
                        .background(.blue)
                        .cornerRadius(10)
                    }
                }

... ...

当我们修改用户账号的时候,如果新的账号在本机上曾经登陆过,那我们希望显示该账号最后一次登陆时的头像:

    ... ...

    func usernameEditing(editing: Bool) {
        if editing == false {
            self.userImage = IMCenter.fetchUserProfile(key: "\(self.username)-image")
        }
    }

    ... ...

    var body: some View {
        
        ... ...
        
                    TextField("用户名", text: $username, onEditingChanged: usernameEditing)
                    .autocapitalization(.none)
                    .onAppear() {
                        self.username = IMCenter.fetchUserProfile(key: "username")
                    }
                    .padding()
        
        ... ...
    }

    ... ...

最终 LoginView.swift 代码如下:

import SwiftUI

struct LoginView: View {
    @State private var username: String = ""
    @State private var password: String = ""
    
    @State private var alertTitle: String = ""
    @State private var errorMessage: String = ""
    @State private var showAlert = false
    @State private var showLoginingHint = false
    @State private var loginFailed = false
    
    @State private var userImage: String
    
    init() {
        let username = IMCenter.fetchUserProfile(key: "username")
        self.userImage = IMCenter.fetchUserProfile(key: "\(username)-image")
    }
    
    func changeToRegisterView() {
        IMCenter.viewSharedInfo.currentPage = .RegisterView
    }
    
    func userLogin(){
        
        if username.isEmpty {
            
            self.alertTitle = "无效输入"
            self.errorMessage = "用户名不能为空!"
            self.showAlert = true
            
            return
        }
        
        if password.isEmpty {
            
            self.alertTitle = "无效输入"
            self.errorMessage = "用户密码不能为空!"
            self.showAlert = true
            
            return
        }
        
        self.showLoginingHint = true
        
        //-- TODO: user login
        IMCenter.viewSharedInfo.currentPage = .SessionView
    }
    func usernameEditing(editing: Bool) {
        if editing == false {
            self.userImage = IMCenter.fetchUserProfile(key: "\(self.username)-image")
        }
    }
    
    var body: some View {
        VStack(alignment: .center){
            
            if self.userImage.isEmpty {
                Image(IMDemoUIConfig.defaultIcon)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                    .padding()
            } else {
                Image(uiImage: IMCenter.loadUIIMage(path: self.userImage))
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                    .padding()
            }
            
            HStack(alignment: .center){
                Spacer()
                Text("用户名:")
                    .padding()
                TextField("用户名", text: $username, onEditingChanged: usernameEditing)
                    .autocapitalization(.none)
                    .onAppear() {
                        self.username = IMCenter.fetchUserProfile(key: "username")
                    }
                    .padding()
                Spacer()
            }
            HStack(alignment: .center){
                Spacer()
                Text("密 码:")
                    .padding()
                SecureField("登陆密码", text: $password)
                    .padding()
                Spacer()
            }
            HStack(alignment: .center){
                Button("登 陆"){
                    hideKeyboard()
                    userLogin()
                }.frame(width: UIScreen.main.bounds.width/4,
                height: nil)
                .padding(10)
                .foregroundColor(.white)
                .background(.blue)
                .cornerRadius(10)
                .alert(alertTitle, isPresented: $showAlert) {
                    Button("确认") {
                        self.showAlert = false
                    }
                } message: {
                    Text(errorMessage)
                }
                .fullScreenCover(isPresented: $showLoginingHint, onDismiss: {
                    //-- Do nothing.
                }) {
                    VStack {
                        Text("登录中,请等待....")
                            .font(.title)
                    
                        ProgressView()
                    }
                }
                .fullScreenCover(isPresented: $loginFailed, onDismiss: {
                    //-- Do nothing.
                }) {
                    VStack {
                        Text("登录失败")
                            .font(.title)
                        
                        Text(errorMessage).padding()
                        
                        Button("确定") {
                            self.loginFailed = false
                        }
                        .frame(width: UIScreen.main.bounds.width/4,
                        height: nil)
                        .padding(10)
                        .foregroundColor(.white)
                        .background(.blue)
                        .cornerRadius(10)
                    }
                }
                
                Button("注册"){
                    changeToRegisterView()
                }
                .padding(10)
                
            }
        }
        .onTapGesture {
            hideKeyboard()
        }
    }
}

struct LoginView_Previews: PreviewProvider {
    static var previews: some View {
        LoginView()
    }
}

预览效果如下:

5.注册页面

接下来是注册页面。注册页面与登录页面类似,而且不需要检查和显示之前的用户信息。
此外,对于无效输入,注册中,注册失败等情况,也均需进行提示。于是最终的 RegisterView.swift 代码如下:

import SwiftUI

struct RegisterView: View {
    @State private var username: String = ""
    @State private var password: String = ""
    @State private var passwordAgain: String = ""
    
    @State private var alertTitle: String = ""
    @State private var errorMessage: String = ""
    @State private var showAlert = false
    @State private var showLoginingHint = false
    @State private var loginFailed = false
    
    func changeToLoginView() {
        IMCenter.viewSharedInfo.currentPage = .LoginView
    }
    
    func userRegister(){
        
        if username.isEmpty {
            self.alertTitle = "无效输入"
            self.errorMessage = "用户名不能为空!"
            self.showAlert = true
            
            return
        }
        
        if password.isEmpty {
            self.alertTitle = "无效输入"
            self.errorMessage = "用户密码不能为空!"
            self.showAlert = true
            
            return
        }
        
        if passwordAgain.isEmpty {
            self.alertTitle = "无效输入"
            self.errorMessage = "确认密码不能为空!"
            self.showAlert = true
            
            return
        }
        
        if password != passwordAgain {
            self.alertTitle = "无效输入"
            self.errorMessage = "确认密码不匹配!"
            self.showAlert = true
            
            return
        }

        self.showLoginingHint = true
        
        //-- TODO: user register & login
    }
    
    var body: some View {
        VStack(alignment: .center){
            Image(IMDemoUIConfig.defaultIcon)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                .padding()
            
            HStack(alignment: .center){
                Spacer()
                Text("注册用户:")
                    .padding()
                TextField("注册用户名称", text: $username)
                    .autocapitalization(.none)
                    .padding()
                Spacer()
            }
            HStack(alignment: .center){
                Spacer()
                Text("登陆密码:")
                    .padding()
                SecureField("登陆密码", text: $password)
                    .padding()
                Spacer()
            }
            HStack(alignment: .center){
                Spacer()
                Text("确认密码:")
                    .padding()
                SecureField("确认密码", text: $passwordAgain)
                    .padding()
                Spacer()
            }
            HStack(alignment: .center){
                Button("注 册"){
                    hideKeyboard()
                    userRegister()
                }.frame(width: UIScreen.main.bounds.width/4,
                height: nil)
                .padding(10)
                .foregroundColor(.white)
                .background(.blue)
                .cornerRadius(10)
                .alert(alertTitle, isPresented: $showAlert) {
                    Button("确认") {
                        self.showAlert = false
                    }
                } message: {
                    Text(errorMessage)
                }
                .fullScreenCover(isPresented: $showLoginingHint, onDismiss: {
                    //-- Do nothing.
                }) {
                    VStack {
                        Text("注册中,请等待....")
                            .font(.title)
                    
                        ProgressView()
                    }
                }
                .fullScreenCover(isPresented: $loginFailed, onDismiss: {
                    //-- Do nothing.
                }) {
                    VStack {
                        Text("注册失败")
                            .font(.title)
                        
                        Text(errorMessage).padding()
                        
                        Button("确定") {
                            self.loginFailed = false
                        }
                        .frame(width: UIScreen.main.bounds.width/4,
                        height: nil)
                        .padding(10)
                        .foregroundColor(.white)
                        .background(.blue)
                        .cornerRadius(10)
                    }
                }
                
                Button("登陆"){
                    changeToLoginView()
                }
                .padding(10)
                
            }
        }
        .onTapGesture {
            hideKeyboard()
        }
    }
}

struct RegisterView_Previews: PreviewProvider {
    static var previews: some View {
        RegisterView()
    }
}

预览效果如下:

6. 通用的页面组件

按理说,开发完登录和注册页面后,我们应该开始开发联系人列表页和会话列表页,但我们发现,在这几个页面上,有不少共同的地方。比如页面底部的导航条,点击不同的图标后会分别跳转到到会话列表页、联系人列表页和我的信息页。又比如页面上方的信息导航条,基本上都是一个标题加最右端一个按钮。有可能是菜单按钮,有可能是编辑按钮,反正共性十足。
出于偷懒的考虑,我们决定把这两个做成两个组件,以便后续使用。

底部的导航试图组件
添加 BottomNavigationView.swift,修改代码如下:

struct BottomNavigationView: View {
    var body: some View {
        HStack {
            
            Image("button_chat")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .onTapGesture {
                    IMCenter.viewSharedInfo.currentPage = .SessionView
                }
                .padding()
            
            Spacer()
            
            Divider()
            
            Spacer()
            
            Image("button_contact")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .onTapGesture {
                    IMCenter.viewSharedInfo.currentPage = .ContactView
                }
                .padding()
            
            Spacer()
            
            Divider()
            
            Spacer()
            
            Image("button_me")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .onTapGesture {
                    IMCenter.viewSharedInfo.currentPage = .ProfileView
                }
                .padding()
        }
    }
}

struct BottomNavigationView_Previews: PreviewProvider {
    static var previews: some View {
        BottomNavigationView()
    }
}

菜单视图

然后是顶部的信息导航条。但这里遇到一个新问题。诸如微信、钉钉等,右上角一般都是重要的必争之地。这里往往会d出一个菜单,进行添加啊、扫一扫啊诸如此类的 *** 作。于是我们把这里设计为一个d出菜单,进行添加好友、创建群组,创建聊天室等 *** 作。
因为菜单 *** 作繁多,不同的菜单项便是不同的 *** 作,所以我们现在IMDemoApp.swift中增加一个菜单选项的枚举:

enum MenuActionType {
    case AddFriend
    case CreateGroup
    case JoinGroup
    case CreateRoom
    case EnterRoom
    case HideMode
}

并在 class ViewSharedInfo 中增加菜单当前状态:

class ViewSharedInfo: ObservableObject {
    @Published var currentPage: IMViewType = .LoginView
    @Published var menuAction: MenuActionType = .HideMode
}

然后我们添加 MenuActionView.swift,编辑代码如下:

import SwiftUI

struct AddFriendView: View {
    @State private var username: String = ""
    @Binding var showProcessing: Bool
    @Binding var showError: Bool
    
    var body: some View {
        
        VStack {
            Text("添加好友").font(.system(size: 20.0)).bold()
            
            HStack(alignment: .center){
                Spacer()
                Text("好友用户名:")
                    .padding()
                TextField("好友用户名", text: $username)
                    .autocapitalization(.none)
                    .padding()
                Spacer()
            }.frame(width: UIScreen.main.bounds.width * 0.75)
        
            Button("添加") {
                
                //-- TODO: 添加好友
            }
            .frame(width: UIScreen.main.bounds.width/4,
            height: nil)
            .padding(10)
            .foregroundColor(.white)
            .background(.blue)
            .cornerRadius(10)
        }
    }
}

struct CreateGroupView: View {
    @State private var groupname: String = ""
    @Binding var showProcessing: Bool
    @Binding var showError: Bool
    
    var body: some View {
        
        VStack {
            Text("创建群组").font(.system(size: 20.0)).bold()
            
            HStack(alignment: .center){
                Spacer()
                Text("群组唯一名:")
                    .padding()
                TextField("群组唯一名称", text: $groupname)
                    .autocapitalization(.none)
                    .padding()
                Spacer()
            }.frame(width: UIScreen.main.bounds.width * 0.75)
        
            Button("创建") {
                //-- TODO: 创建群组
            }
            .frame(width: UIScreen.main.bounds.width/4,
            height: nil)
            .padding(10)
            .foregroundColor(.white)
            .background(.blue)
            .cornerRadius(10)
        }
    }
}

struct JoinGroupView: View {
    @State private var groupname: String = ""
    @Binding var showProcessing: Bool
    @Binding var showError: Bool
    
    var body: some View {
        
        VStack {
            Text("加入群组").font(.system(size: 20.0)).bold()
            
            HStack(alignment: .center){
                Spacer()
                Text("群组唯一名:")
                    .padding()
                TextField("群组唯一名称", text: $groupname)
                    .autocapitalization(.none)
                    .padding()
                Spacer()
            }.frame(width: UIScreen.main.bounds.width * 0.75)
        
            Button("加入") {
                //-- TODO: 加入群组
            }
            .frame(width: UIScreen.main.bounds.width/4,
            height: nil)
            .padding(10)
            .foregroundColor(.white)
            .background(.blue)
            .cornerRadius(10)
        }
    }
}

struct CreateRoomView: View {
    @State private var roomname: String = ""
    @Binding var showProcessing: Bool
    @Binding var showError: Bool
    
    var body: some View {
        
        VStack {
            Text("创建房间").font(.system(size: 20.0)).bold()
            
            HStack(alignment: .center){
                Spacer()
                Text("房间唯一名:")
                    .padding()
                TextField("房间唯一名称", text: $roomname)
                    .autocapitalization(.none)
                    .padding()
                Spacer()
            }.frame(width: UIScreen.main.bounds.width * 0.75)
        
            Button("创建") {
                //-- TODO: 创建房间
            }
            .frame(width: UIScreen.main.bounds.width/4,
            height: nil)
            .padding(10)
            .foregroundColor(.white)
            .background(.blue)
            .cornerRadius(10)
        }
    }
}

struct EnterRoomView: View {
    @State private var roomname: String = ""
    @Binding var showProcessing: Bool
    @Binding var showError: Bool
    
    var body: some View {
        
        VStack {
            Text("加入房间").font(.system(size: 20.0)).bold()
            
            HStack(alignment: .center){
                Spacer()
                Text("房间唯一名:")
                    .padding()
                TextField("房间唯一名称", text: $roomname)
                    .autocapitalization(.none)
                    .padding()
                Spacer()
            }.frame(width: UIScreen.main.bounds.width * 0.75)
        
            Button("加入") {
                //-- TODO: 加入房间
            }
            .frame(width: UIScreen.main.bounds.width/4,
            height: nil)
            .padding(10)
            .foregroundColor(.white)
            .background(.blue)
            .cornerRadius(10)
        }
    }
}

struct ErrorHintView: View {
    
    var errorInfo: ErrorInfo
    var sureAction: () -> Void
    
    var body: some View {
        Color.gray.opacity(0.5).edgesIgnoringSafeArea(.all)
        Color.white.frame(width: UIScreen.main.bounds.width * 0.75, height: UIScreen.main.bounds.height * 0.24, alignment: .center).cornerRadius(20)
        
        VStack {
            Text(errorInfo.title)
                .font(.title)
            
            Text(errorInfo.desc).padding()
            
            Button("确定") {
                sureAction()
            }
            .frame(width: UIScreen.main.bounds.width/4,
            height: nil)
            .padding(10)
            .foregroundColor(.white)
            .background(.blue)
            .cornerRadius(10)
        }
    }
}

struct MenuActionView: View {
    
    @State var showProcessing = false
    @State var showError = false
    @ObservedObject var viewInfo: ViewSharedInfo
    
    var body: some View {
        Color.gray.opacity(0.5).edgesIgnoringSafeArea(.all).onTapGesture {
            IMCenter.viewSharedInfo.menuAction = .HideMode
        }
        Color.white.frame(width: UIScreen.main.bounds.width * 0.80, height: UIScreen.main.bounds.height * 0.22, alignment: .center).cornerRadius(20)
        
        if viewInfo.menuAction == .AddFriend {
            AddFriendView(showProcessing: self.$showProcessing, showError: self.$showError)
        } else if viewInfo.menuAction == .CreateGroup {
            CreateGroupView(showProcessing: self.$showProcessing, showError: self.$showError)
        } else if viewInfo.menuAction == .JoinGroup {
            JoinGroupView(showProcessing: self.$showProcessing, showError: self.$showError)
        } else if viewInfo.menuAction == .CreateRoom {
            CreateRoomView(showProcessing: self.$showProcessing, showError: self.$showError)
        } else if viewInfo.menuAction == .EnterRoom {
            EnterRoomView(showProcessing: self.$showProcessing, showError: self.$showError)
        }
        
        if self.showProcessing {
            ProcessingView(info: "处理中,请等待……")
        }
        
        if self.showError {
            ErrorHintView(errorInfo: IMCenter.errorInfo, sureAction: {
                self.showProcessing = false
                self.showError = false
            })
        }
    }
}

struct MenuActionView_Previews: PreviewProvider {
    static var previews: some View {
        MenuActionView(viewInfo: ViewSharedInfo())
    }
}

可以看到,在这个文件里,我们加入了多个视图组件。
首先是添加好友的视图组件 AddFriendView。它包含一个标签、一个输入框和一个确定按钮。然后是创建群组视图组件 CreateGroupView、加入群组视图组件 JoinGroupView、创建房间视图组件 CreateRoomView 和进入房间视图组件 EnterRoomView。以上五个组件,分别对应着菜单的五个行为动作。但既然存在输入,便会存在错误。而且以上行为均须向服务器交互,也存在着网络错误,或者服务器返回错误的可能。
于是我们在 IMCenter.swift 中添加 ErrorInfo 的定义,并让 IMCenter 进行持有:

struct ErrorInfo {
    var title = ""
    var desc = ""
    var code = 0
}

class IMCenter {
    static var viewSharedInfo: ViewSharedInfo = ViewSharedInfo()
    static var errorInfo = ErrorInfo()
    
    ... ...
}

然后,我们还要在 MenuActionView.swift 中加入对应的错误提示组件 ErrorHintView。
代码的末尾,则是菜单视图的本体 MenuActionView。
因为五个菜单项对应的视图组件,我们都忽略了取消行为的按钮,所以我们在 MenuActionView 的底层视图空间中进行弥补:

struct MenuActionView: View {
    
    ... ...
    
    var body: some View {
        Color.gray.opacity(0.5).edgesIgnoringSafeArea(.all).onTapGesture {
            IMCenter.viewSharedInfo.menuAction = .HideMode
        }
        
        ... ...
    }
}

最后,因为以上五个行为,均为网络 *** 作,所以我们还需要添加一个等待提示页面。但因为这个页面会被其他页面共用,所以我们随后添加。

请求处理中提示页面

现在,我们来添加请求处理中提示页面。添加 ProcessingView.swift,代码如下:

import SwiftUI

struct ProcessingView: View {
    private var showInfo: String
    
    init(info: String) {
        self.showInfo = info
    }
    var body: some View {
        Color.gray.opacity(0.5).edgesIgnoringSafeArea(.all)
        Color.white.frame(width: UIScreen.main.bounds.width * 0.75, height: UIScreen.main.bounds.height * 0.16, alignment: .center).cornerRadius(20)
        VStack {
            Text(showInfo)
        
            ProgressView()
        }
    }
}

struct ProcessingView_Previews: PreviewProvider {
    static var previews: some View {
        ProcessingView(info: "更新中,请等待……")
    }
}

顶部导航视图组件

终于到了实现顶部导航视图组建的时候了。添加TopNavigationView.swift,修改代码如下:

import SwiftUI

struct TopNavigationView: View {
    let title: String
    let cornerIcon: String
    let buttonAction: ()-> Void
    let enableMenu: Bool
    
    init(title: String, icon: String) {
        self.title = title
        self.cornerIcon = icon
        self.buttonAction = {}
        self.enableMenu = true
    }
    
    init(title: String, icon: String, buttonAction action: @escaping ()->Void) {
        self.title = title
        self.cornerIcon = icon
        self.buttonAction = action
        self.enableMenu = false
    }
    var body: some View {
        HStack {
 
            Spacer()
            
            Text(title).position(x: UIScreen.main.bounds.width/2, y: IMDemoUIConfig.topNavigationHight/2)
            
            if self.enableMenu {
                Menu {
                    Section{
                        Button("添加联系人", action:{
                            IMCenter.viewSharedInfo.menuAction = .AddFriend
                        })
                    }
                    Section{
                        Button("创建群组", action:{
                            IMCenter.viewSharedInfo.menuAction = .CreateGroup
                        })
                        Button("加入群组", action:{
                            IMCenter.viewSharedInfo.menuAction = .JoinGroup
                        })
                    }
                    Section{
                        Button("创建房间", action:{
                            IMCenter.viewSharedInfo.menuAction = .CreateRoom
                        })
                        Button("加入房间", action:{
                            IMCenter.viewSharedInfo.menuAction = .EnterRoom
                        })
                    }
                } label: {
                    Image(cornerIcon)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                        .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                }
            } else {
                Image(cornerIcon)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                    .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                    .onTapGesture {
                        buttonAction()
                    }
            }
        }
    }
}

struct TopNavigationView_Previews: PreviewProvider {
    static var previews: some View {
        TopNavigationView(title:"test page", icon: "button_info")
    }
}

联系人条目视图组件

在我们开始继续开发核心页面视图之前,我们再回头看一下联系人列表页和会话列表页的设计,我们发现,会话列表页的条目和联系人列表页的条目大体上很像,只是会多几个处理:

显示最新的一条消息如果有新消息,需要显示未读标记
所以,本着偷懒的宗旨,我们提取共性,增加一个共用的视图组件 ContactItemView。

在动手添加前,我们需要先定义联系人,和最新聊天信息的结构,不然 ContactItemView 在页面展示的时候,无法获所需的信息。
我们将联系人分成四类:好友、群组、房间和陌生人。陌生人就是在群组或者房间中出现的,不是好友的人。
编辑 IMDemoApp.swift,加入以下代码:

enum ContactKind: Int {
    case Stranger = 0
    case Friend = 1
    case Group = 2
    case Room = 3
}

struct LastMessage {
    var timestamp: Int64 = 0
    var mid: Int64 = 0
    var message = ""
    var unread = false
}

class ContactInfo {
    var kind = ContactKind.Friend.rawValue
    var xid: Int64 = 0
    var xname = ""
    var nickname = ""
    var imageUrl = ""
    var imagePath = ""
    var showInfo = ""
    
    init() {}
    init(xid:Int64) {
        self.kind = ContactKind.Stranger.rawValue
        self.xid = xid
    }
    init(type:Int, xid:Int64) {
        self.kind = type
        self.xid = xid
    }
    init(type: Int, uniqueId: Int64, uniqueName: String, nickname: String) {
        self.kind = type
        self.xid = uniqueId
        self.xname = uniqueName
        self.nickname = nickname
    }
    convenience init(type: Int, uniqueId: Int64, uniqueName: String, nickname: String, imageUrl: String) {
        self.init(type: type, uniqueId: uniqueId, uniqueName: uniqueName, nickname: nickname)
        self.imageUrl = imageUrl
    }
    convenience init(type: Int, uniqueId: Int64, uniqueName: String, nickname: String, imageUrl: String, imagePath: String) {
        self.init(type: type, uniqueId: uniqueId, uniqueName: uniqueName, nickname: nickname, imageUrl:imageUrl)
        self.imagePath = imagePath
    }
}

LastMessage中的mid为云上曲率IM即时通讯服务所提供的messageId。这个id能在对应的会话中标志唯一一条聊天消息。
联系人中的 xid 即为IM即时通讯服务所需要的 userId 或 groupId 或 roomId。通过我们自己映射用户与ID的关系,我们可以有效地对云平台厂商隐藏自身应用用户的信息,避免免费为平台收集客户信息,沦为为平台打工的工具。
然后xname为用户注册的唯一名称,整个App内不允许重复。如果可以的话,使用手机号码,或者电子邮件地址,也是一个不错的选择。在这里为了避免不必要的开发,所以我们选择采用用户随意填写的用户名。
nickname则为IMDemo用户、群组、房间的展示名称。为了避免每次从网上下载用户头像,本IMDemo会将对应的用户头像存在本地,imageUrl和imagePath即为用户头像的网络地址,和本地存储路径。

之后,因为用户刚注册时,用户的nickname默认为空。如果用户不修改直接去和其他用户联系,则在对方的界面上,需要显示xname,否则就会出现一堆没有名字的联系人。此外,用户的注册名xname是存在我们自己的服务上,IM即时通讯服务推送陌生的会话仅会告知是哪一个xid过来的。所以当遇到陌生的回话出现时,xname可能还没被同步到App中,此时xname也会为空。在这种情况下,我们将需要显示用户的xid。因此,我们编辑 IMCenter.swift,增加函数 getContactDisplayName():

class IMCenter {
    
    ... ...
    
        class func getContactDisplayName(contact:ContactInfo) ->String {
        if contact.nickname.isEmpty == false {
            return contact.nickname
        }
        
        if contact.xname.isEmpty == false {
            return contact.xname
        }
        
        return "ID: \(contact.xid)"
    }
    
    class func getSelfDispalyName() -> String {
        var dispalyname = fetchUserProfile(key: "nickname")
        if dispalyname.isEmpty {
            dispalyname = fetchUserProfile(key: "username")
        }
        
        return dispalyname
    }
    
    ... ...
}

最后,我们添加 ContactItemView.swift,修改代码如下:

import SwiftUI

struct ContactItemView: View {
    
    var contactInfo: ContactInfo
    var lastMesssage: LastMessage? = nil
    
    var body: some View {
        HStack {
            
            if contactInfo.imagePath.isEmpty == false {
                Image(uiImage: IMCenter.loadUIIMage(path:contactInfo.imagePath))
                    .resizable()
                    .frame(width: IMDemoUIConfig.contactItemImageEdgeLen, height: IMDemoUIConfig.contactItemImageEdgeLen)
                    .cornerRadius(10)
                
            } else if contactInfo.imageUrl.isEmpty {
                Image(IMDemoUIConfig.defaultIcon)
                    .resizable()
                    .frame(width: IMDemoUIConfig.contactItemImageEdgeLen, height: IMDemoUIConfig.contactItemImageEdgeLen)
                    .cornerRadius(10)
                
            } else {
                AsyncImage(url: URL(string: contactInfo.imageUrl)) { image in
                    image.resizable()
                } placeholder: {
                    ProgressView()
                }
                .frame(width: IMDemoUIConfig.contactItemImageEdgeLen, height: IMDemoUIConfig.contactItemImageEdgeLen)
                .cornerRadius(10)
            }
            
            if self.lastMesssage == nil {
                Text(IMCenter.getContactDisplayName(contact: contactInfo))
            } else {
                VStack(alignment: .leading) {
                    Text(IMCenter.getContactDisplayName(contact: contactInfo))
                    Text(lastMesssage!.message).foregroundColor(.gray)
                }
            }
            
            Spacer()
            
            if let unread = self.lastMesssage?.unread {
                if unread {
                    Image("button_mail")
                        .resizable()
                        .frame(width: IMDemoUIConfig.contactItemImageEdgeLen, height: IMDemoUIConfig.contactItemImageEdgeLen)
                        .cornerRadius(10)
                }
            }
            
        }.onTapGesture {
            //-- TODO: 打开对话窗口
            IMCenter.viewSharedInfo.currentPage = .DialogueView
        }
    }
}

struct ContactItemView_Previews: PreviewProvider {
    static var previews: some View {
        ContactItemView(contactInfo: ContactInfo())
    }
}
7. 联系人列表页

要展示联系人信息,我们先得要有数据源。
因为数据库 *** 作要到下篇才会涉及,因此简单起见,我们编辑 IMDemoApp.swift,向 class ViewSharedInfo 添加相关数据源:

class ViewSharedInfo: ObservableObject {
    @Published var currentPage: IMViewType = .LoginView
    @Published var menuAction: MenuActionType = .HideMode
    
    var contactList: [Int: [ContactInfo]] = [:]
}

添加 ContactView.swift,采用 List 和 Section 按类型对联系人进行分类。编辑代码如下:

import SwiftUI

struct ContactView: View {
    
    @ObservedObject var viewInfo: ViewSharedInfo
    
    var body: some View {
        ZStack {
            VStack {
                TopNavigationView(title: "联系人列表", icon: "button_add").frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.topNavigationHight), alignment: .center)
                
                Divider()
                
                List {
                    Section("联系人") {
                        let friends = viewInfo.contactList[ContactKind.Friend.rawValue]!
                        
                        if friends.count > 0 {
                            ForEach (0.. 0 {
                            ForEach (0.. 0 {
                            ForEach (0..

最后,编辑 IMDemoApp.swift,修改 ContactView 的创建方式:

struct IMDemoApp: App {
    
    ... ...
    
    var body: some Scene {
        
            ... ...
        
            case .ContactView:
                ContactView(viewInfo: IMCenter.viewSharedInfo)
            
            ... ...
            }
        }
    }
}
8. 会话列表页

与联系人列表类似,我们先要准备会话列表的数据源。
继续编辑 IMDemoApp.swift,增加会话信息条目类别 SessionItem 和聊天数据条目类别 ChatMessage:

class SessionItem: Identifiable {
    var lastMessage = LastMessage()
    var contact: ContactInfo
    
    init(contact: ContactInfo) {
        self.contact = contact
    }
}

class ChatMessage: Identifiable {
    var sender: Int64
    var mid: Int64
    var mtime: Int64
    var message: String
    var isChat = true
    
    init(sender:Int64, mid:Int64, mtime:Int64, message:String) {
        self.sender = sender
        self.mid = mid
        self.mtime = mtime
        self.message = message
    }
}

因为群组和房间需要有系统通知,所以为了和普通的聊天信息进行有效区别,我们对 ChatMessage增加 isChat 字段,进行标记。

继续编辑 IMDemoApp.swift,向 class ViewSharedInfo 添加相关数据源:

class ViewSharedInfo: ObservableObject {
    @Published var currentPage: IMViewType = .LoginView
    @Published var menuAction: MenuActionType = .HideMode
    @Published var sessions:[SessionItem] = []
    var contactList: [Int: [ContactInfo]] = [:]
}

添加 SessionView.swift,编辑代码如下:

import SwiftUI

struct SessionView: View {
    
    @ObservedObject var viewInfo: ViewSharedInfo
    
    var body: some View {
        ZStack {
            VStack {
                TopNavigationView(title: "会话列表", icon: "button_add").frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.topNavigationHight), alignment: .center)
                
                Divider()
                
                List(viewInfo.sessions) { session in
                    
                    ContactItemView(contactInfo: session.contact, lastMesssage: session.lastMessage)
                }//.listStyle(.plain)
                
                Spacer()
                
                Divider()
                
                BottomNavigationView().frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.bottomNavigationHight), alignment: .center)
            }
            
            //-- ZStack Area
            if viewInfo.menuAction != .HideMode {
                MenuActionView(viewInfo: IMCenter.viewSharedInfo)
            }
        }
        
    }
}

struct SessionView_Previews: PreviewProvider {
    static var previews: some View {
        SessionView(viewInfo: ViewSharedInfo())
    }
}

最后,编辑 IMDemoApp.swift,修改 SessionView 的创建方式:

struct IMDemoApp: App {
    
    ... ...
    
    var body: some Scene {
        
            ... ...
        
            case .SessionView:
                SessionView(viewInfo: IMCenter.viewSharedInfo)
            
            ... ...
            }
        }
    }
}
9. 聊天窗口

接下来的聊天窗口,应该是所有UI里面最复杂的一个。
聊天窗口大致可以分为三个部分:页头、对话消息显示列表、消息输入和发送组件。
页头要显示联系人的名称,以及跳转到联系人信息页的信息按钮。
对话消息显示列表,除了需要区别显示用户和联系人所对应的消息外,对于群组和房间,还需要区别显示聊天消息和系统通知。
最后消息输入和发送组件部分,因为本篇文章暂不演示文件发送、离线语音、实时音视频等高级功能,所以暂时从简。

与联系人列表页和会话列表页一样,对话消息的显示,也需要数据源。鉴于一切从简的原则,我们继续编辑 IMDemoApp.swift,编辑 class ViewSharedInfo 如下:

class ViewSharedInfo: ObservableObject {
    @Published var currentPage: IMViewType = .LoginView
    @Published var newMessageReceived = false
    @Published var menuAction: MenuActionType = .HideMode
    @Published var dialogueMesssages: [ChatMessage] = []
    @Published var sessions:[SessionItem] = []
    var contactList: [Int: [ContactInfo]] = [:]
    var lastPage: IMViewType = .SessionView
    
    var targetContact: ContactInfo? = nil
    var strangerContacts: [Int64:ContactInfo] = [:]
    @Published var newestMessage = ChatMessage(sender: 0, mid: 0, mtime: 0, message: "")
}

新增 dialogueMesssages: [ChatMessage] 作为对话消息列表的数据源;lastPage: IMViewType 标记聊天窗口是通过会话页面,还是联系人页面打开的。当关闭会话页面的时候,需要返回对应的上级页面中去。
targetContact: ContactInfo? 存储目标联系人的信息。如果联系人是群组或者房间,则群组或者房间中非好友的联系人将作为陌生人存储在 strangerContacts: [Int64:ContactInfo] 中。
最新收到的消息,将暂存于 newestMessage: ChatMessage 处,并使用 newMessageReceived 发布通知。

然后我们创建DialogueView.swift,编辑代码如下:

import SwiftUI

struct DialogueHeaderView: View {
    let title: String
    let infoAction: ()-> Void
    
    init(title: String, infoAction: @escaping ()->Void) {
        self.title = title
        self.infoAction = infoAction
    }
    
    var body: some View {
        HStack {
            Image("button_back")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                .onTapGesture {
                    //-- TODO: 更新联系人条目状态
                    
                    IMCenter.viewSharedInfo.currentPage = IMCenter.viewSharedInfo.lastPage
                }
 
            Spacer()
            
            Text(title).bold()
            
            Spacer()
                
            Image("button_info")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                .onTapGesture {
                    infoAction()
                }
        }
    }
}

struct DialogueFooterView: View {
    @State private var message: String = ""
    
    @ObservedObject var viewInfo: ViewSharedInfo
    var contact: ContactInfo
    
    var body: some View {
        HStack {
            TextField("说点什么吧……", text: $message)
                .autocapitalization(.none)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            
            Image("button_send")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                .onTapGesture {
                    
                    IMCenter.viewSharedInfo.newMessageReceived = false
                    
                    if self.message.isEmpty {
                        viewInfo.newestMessage = IMCenter.viewSharedInfo.dialogueMesssages.last!
                        hideKeyboard()
                        return
                    }
                    
                    //-- TODO: 发送消息
                    self.message = ""
                    
                    hideKeyboard()
                    viewInfo.newestMessage = IMCenter.viewSharedInfo.dialogueMesssages.last!
                }
        }
    }
}

struct DialogueCmdItemView: View {
    private var message: ChatMessage
    
    init(message:ChatMessage) {
        self.message = message
    }
    
    func getTimeString() -> String {
        
        let dateFormatter = DateFormatter()
        let now = Date().timeIntervalSince1970
        
        if Int64(now) - self.message.mtime/1000 < 4 * 3600 {    //-- 4 个小时前显示日期部分
            dateFormatter.dateFormat = "HH:mm:ss"
        } else {
            dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        }
        
       return dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(self.message.mtime)/1000))
    }
    
    func buildShowInfo() ->String {
        let info = "\(getTimeString())\n\(self.message.message)"
        return info
    }
    
    var body: some View {
        HStack {
        
                Spacer()
                
                Text(buildShowInfo()).font(.system(size: 12)).foregroundColor(.gray)
            
                Spacer()
            
        }
    }
}

struct DialogueItemView: View {
    private var contact: ContactInfo
    private var message: ChatMessage
    private var isSelf: Bool
    private var infoAction: (_ contact: ContactInfo)->Void
    
    
    init(cotact:ContactInfo, message:ChatMessage, isSelf: Bool, infoAction: @escaping (_ contact: ContactInfo)->Void) {
        self.contact = cotact
        self.message = message
        self.isSelf = isSelf
        self.infoAction = infoAction
    }
    
    func getTimeString() -> String {
        
        let dateFormatter = DateFormatter()
        let now = Date().timeIntervalSince1970
        
        if Int64(now) - self.message.mtime/1000 < 4 * 3600 {    //-- 4 个小时前显示日期部分
            dateFormatter.dateFormat = "HH:mm:ss"
        } else {
            dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        }
        
       return dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(self.message.mtime)/1000))
    }
    
    var body: some View {
        HStack {
            if isSelf {
                Spacer()
                
                Text(getTimeString()).font(.system(size: 12)).foregroundColor(.gray)
                
                Text(self.message.message).padding(10)
                    .background(.cyan)
                    .cornerRadius(10)
            }
            
            if contact.imagePath.isEmpty == false {
                Image(uiImage: IMCenter.loadUIIMage(path:contact.imagePath))
                    .resizable()
                    .frame(width: IMDemoUIConfig.contactItemImageEdgeLen, height: IMDemoUIConfig.contactItemImageEdgeLen)
                    .cornerRadius(10)
                    .onTapGesture {
                        infoAction(contact)
                    }
                
            } else if contact.imageUrl.isEmpty {
                Image(IMDemoUIConfig.defaultIcon)
                    .resizable()
                    .frame(width: IMDemoUIConfig.contactItemImageEdgeLen, height: IMDemoUIConfig.contactItemImageEdgeLen)
                    .cornerRadius(10)
                    .onTapGesture {
                        infoAction(contact)
                    }
                
            } else {
                AsyncImage(url: URL(string: contact.imageUrl)) { image in
                    image.resizable()
                } placeholder: {
                    ProgressView()
                }
                .frame(width: IMDemoUIConfig.contactItemImageEdgeLen, height: IMDemoUIConfig.contactItemImageEdgeLen)
                .cornerRadius(10)
                .onTapGesture {
                    infoAction(contact)
                }
            }
            
            if self.isSelf == false {
                Text(self.message.message).padding(10)
                    .background(.mint)
                    .cornerRadius(10)
                
                Text(getTimeString()).font(.system(size: 12)).foregroundColor(.gray)
            
                Spacer()
            }
            
        }
    }
}

struct DialogueView: View {
    private var contact: ContactInfo
    private var selfId: Int64
    
    @ObservedObject var viewInfo: ViewSharedInfo
    @State var showInfoPage = false
    @State var contactForInfoPage: ContactInfo
    
    init() {
        self.contact = IMCenter.viewSharedInfo.targetContact!
        self.selfId = 0 //-- TODO: 显示真实User ID
        self.viewInfo = IMCenter.viewSharedInfo
        self.contactForInfoPage = self.contact
    }
    
    var body: some View {
        ZStack {
            VStack {
                DialogueHeaderView(title: IMCenter.getContactDisplayName(contact: contact), infoAction: {
                    self.showInfoPage = true
                })
                
                Divider()
                
                ScrollViewReader { scrollViewReader in
                    
                    ScrollView {
                        
                        ForEach(viewInfo.dialogueMesssages) {
                            chatMessage in
                            
                            if chatMessage.isChat {
                                DialogueItemView(cotact: IMCenter.findContact(chatMessage: chatMessage), message: chatMessage, isSelf: (chatMessage.sender == selfId), infoAction: {
                                    contact in
                                    self.contactForInfoPage = contact
                                    self.showInfoPage = true
                                })
                            } else {
                                DialogueCmdItemView(message: chatMessage)
                            }
                        }
                    }
                    .frame(width: UIScreen.main.bounds.width)
                    .onReceive(viewInfo.$newestMessage) {
                        sentMesssage in
                        if sentMesssage.mtime != 0 {
                            scrollViewReader.scrollTo(sentMesssage.id)
                        }
                    }
                    .onAppear {
                        if IMCenter.viewSharedInfo.dialogueMesssages.last != nil {
                            viewInfo.newestMessage = IMCenter.viewSharedInfo.dialogueMesssages.last!
                        }
                    }
                }
                
                Spacer()
                
                Divider()
                
                DialogueFooterView(viewInfo: viewInfo, contact: contact)
            }
            //-- ZStack Area
            
            if self.viewInfo.newMessageReceived {
                HStack {
                    Spacer()
                    
                    Image("button_received")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                        .padding()
                        .onTapGesture {
                            viewInfo.newMessageReceived = false
                            hideKeyboard()
                            viewInfo.newestMessage = IMCenter.viewSharedInfo.dialogueMesssages.last!
                        }
                }
            }
            
            //-- ZStack Area

            if self.showInfoPage {
                //-- TODO: 显示联系人信息详情页
            }
        }
        .onTapGesture {
            hideKeyboard()
        }
    }
}

struct DialogueView_Previews: PreviewProvider {
    static var previews: some View {
        DialogueView()
    }
}

其中 DialogueHeaderView 作为页头,显示返回按钮、联系人展示名称,以及跳转到详情页的按钮。DialogueFooterView 则处理用户的输入和发送行为。当用户发送出一条消息后,对话列表将跳转到用户最新发送的消息处。
而 DialogueCmdItemView 和 DialogueItemView 则负责按照不同的形式,展示聊天消息和系统通知。最后,DialogueView 通过 ScrollView 控件,将 DialogueCmdItemView 和 DialogueItemView 进行展示。此外,如果用户在浏览历史消息的时候,收到了新的消息,则 DialogueView 将通过 newMessageReceived 收到通知,并展示 button_received 图标,提示用户。当用户点击 button_received 图标后,窗口将自动滚动到收到的最新消息处。

此外,因为在展示消息的时候,我们需要根据ChatMessage找到对应的联系人信息,所以在 IMCenter.swift 增加占位函数 findContact():

class IMCenter {
    
    ... ...
    
    class func findContact(chatMessage: ChatMessage) -> ContactInfo {
        //-- TODO: 加入实际内容
        let contact = ContactInfo()
        return contact
    }
    
}

findContact() 的具体内容,我们将在下篇添加。

10. 用户信息页

联系人信息页和用户信息页结构几乎一样,但鉴于用户信息的存储方式与联系人从根本上不同,为了简单起见,我们将用户信息页和联系人信息页分开处理。

添加 ProfileView.swift,编辑代码如下:

import SwiftUI

struct ProfileView: View {
    @State private var editMode = false
    @State private var newNickname = ""
    @State private var newImageUrl = ""
    @State private var newShowInfo = ""
    
    @ObservedObject var viewInfo: ViewSharedInfo
    
    @State private var userImagePath: String
    private var userImageUrl: String
    private var username: String
    @State private var nickname: String
    @State private var showInfo: String
    
    init(viewInfo: ViewSharedInfo) {
        self.viewInfo = viewInfo
        self.username = IMCenter.fetchUserProfile(key: "username")
        self.userImagePath = IMCenter.fetchUserProfile(key: "\(self.username)-image")
        self.userImageUrl = IMCenter.fetchUserProfile(key: "\(self.username)-image-url")
        self.nickname = IMCenter.fetchUserProfile(key: "nickname")
        self.showInfo = IMCenter.fetchUserProfile(key: "showInfo")
        
        self.newNickname = self.nickname
        self.newImageUrl = self.userImageUrl
        self.newShowInfo = self.showInfo
    }
    
    func updateCallback(imagePath: String) {
        if self.newNickname.isEmpty == false {
            self.nickname = self.newNickname
        }
        
        if self.newShowInfo.isEmpty == false {
            self.showInfo = self.newShowInfo
        }
        
        if imagePath.isEmpty == false {
            self.userImagePath = imagePath
            IMCenter.storeUserProfile(key: "\(self.username)-image-url", value: imagePath)
        }
    }
    
    var body: some View {
        ZStack {
        VStack {
            if self.editMode == false {
                TopNavigationView(title: "我的信息", icon: "button_edit", buttonAction: {
                    
                    self.editMode = true
                    
                }).frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.topNavigationHight), alignment: .center)
            } else {
                TopNavigationView(title: "修改我的信息", icon: "button_ok", buttonAction: {
                    
                    self.editMode = false
                    self.viewInfo.inProcessing = true
                    
                    if self.newNickname.isEmpty {
                        self.newNickname = self.nickname
                    }
                    
                    if self.newImageUrl.isEmpty {
                        self.newImageUrl = self.userImageUrl
                    }
                    
                    if self.newShowInfo.isEmpty {
                        self.newShowInfo = self.showInfo
                    }
                    
                    //-- TODO: 更新用户信息
                    
                }).frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.topNavigationHight), alignment: .center)
            }
            
            
            Divider()
            
            Spacer()
            
            VStack {
                
                Spacer()
                
                if self.userImagePath.isEmpty {
                    Image(IMDemoUIConfig.defaultIcon)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                        .padding()
                } else {
                    Image(uiImage: IMCenter.loadUIIMage(path: self.userImagePath))
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                        .padding()
                }
                
                LazyVGrid(columns:[GridItem(.fixed(UIScreen.main.bounds.width * 0.4)), GridItem()]) {
                    HStack {
                        Spacer()
                        Text("用户ID:")
                            .padding()
                    }
                    HStack {
                        Text("TODO: user ID")
                            .padding()
                        Spacer()
                    }
                    
                    
                    HStack {
                        Spacer()
                        Text("用户名:")
                            .padding()
                    }
                    HStack {
                        Text(self.username)
                            .padding()
                        Spacer()
                    }
                    
                    HStack {
                        Spacer()
                        Text("用户昵称:")
                            .padding()
                    }
                    HStack {
                        if self.editMode == false {
                            if self.nickname.isEmpty {
                                Text(self.username)
                                    .padding()
                            } else {
                                Text(self.nickname)
                                    .padding()
                            }
                            
                        } else {
                            TextField(self.nickname.isEmpty ? "给自己取个昵称" : self.nickname, text: $newNickname)
                                .autocapitalization(.none)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: nil)
                                .padding()
                        }
                        
                        Spacer()
                    }
                    
                    if self.editMode {
                        HStack {
                            
                            Spacer()
                            Text("头像地址:")
                                .padding()
                        }
                        HStack {
                            TextField(self.userImageUrl.isEmpty ? "更改头像地址" : self.userImageUrl, text: $newImageUrl)
                                .autocapitalization(.none)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: nil)
                                .padding()
                            
                            Spacer()
                        }
                    }
                    
                    HStack {
                        Spacer()
                        Text("用户签名:")
                            .padding()
                    }
                    HStack {
                        if self.editMode == false {
                            TextEditor(text: $showInfo).disabled(true)
                                    .padding()
                        } else {
                            TextEditor(text: $showInfo)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: 80)
                                .ignoresSafeArea(.keyboard)
                                .padding()
                                .overlay(RoundedRectangle(cornerRadius: 8)
                                        .stroke(Color.secondary).opacity(0.5))
                        }
                        
                        Spacer()
                    }
                }
                
                if self.editMode == false {
                    Button("退出登录") {
                        
                        //-- TODO: 退出登录
                    }
                    .frame(width: UIScreen.main.bounds.width/4,
                    height: nil)
                    .padding(10)
                    .foregroundColor(.white)
                    .background(.blue)
                    .cornerRadius(10)
                }
                
                Spacer()
            }
            
            Spacer()
            
            Divider()
            
            BottomNavigationView().frame(width: UIScreen.main.bounds.width, height: CGFloat(IMDemoUIConfig.bottomNavigationHight), alignment: .center)
        }
        .onTapGesture {
            hideKeyboard()
        }
         
            if self.viewInfo.inProcessing {
                ProcessingView(info: "更新中,请等待……")
            }
        }
    }
}

struct ProfileView_Previews: PreviewProvider {
    static var previews: some View {
        ProfileView(viewInfo: ViewSharedInfo())
    }
}

右上角显示编辑按钮,当处于编辑状态的时候,显示当前头像的网络地址,且可以进行编辑。而当处于非编辑状态时,则不显示当前头像的网络地址。此外,当前头像的本地存储路径属于App内部信息,也不进行显示。此外,用户信息页与联系人信息页最大的不同就在于,当处于非编辑状态时,页面下方会有一个退出登录的按钮,而联系人信息页则没有该功能。

编辑 IMDemoApp.swift,于 class ViewSharedInfo 加入 inProcessing 状态通知:

class ViewSharedInfo: ObservableObject {
    ... ...
    @Published var inProcessing = false
    ... ...
}

最后,编辑 IMDemoApp.swift,修改 ProfileView 的创建方式:

struct IMDemoApp: App {
    
    ... ...
    
    var body: some Scene {
        
            ... ...
        
            case .ProfileView:
                ProfileView(viewInfo: IMCenter.viewSharedInfo)
            
            ... ...
            }
        }
    }
}
11. 联系人信息页

联系人信息页相比用户信息页,需要根据联系人不同的类型,进行不同的处理。如果联系人是其他用户,则联系人信息只可观看,不可编辑;如果联系人是群组或者房间,则用户可以直接进行编辑。且编辑提交后,需要发出系统通知,告诉群组或者房间内其他用户,有人编辑了群组/房间的公共信息。

添加 ContactInfoView.swift,编辑代码如下:

import SwiftUI

struct ContactInfoHeaderView: View {
    let title: String
    let backAction: ()-> Void
    let editAction: (_ editing: Bool)-> Void
    let canBeEdit: Bool
    
    @State private var editMode = false
    
    init(title: String, canBeEdit: Bool, backAction: @escaping ()->Void, editAction: @escaping (_ editing: Bool)->Void) {
        self.title = title
        self.backAction = backAction
        self.editAction = editAction
        self.canBeEdit = canBeEdit
    }
    
    var body: some View {
        HStack {
            Image("button_back")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                .onTapGesture {
                    backAction()
                }
 
            Spacer()
            
            Text(title).bold()
            
            Spacer()
            
            if canBeEdit {
                if editMode == false {
                    Image("button_edit")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                        .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                        .onTapGesture {
                            editMode = true
                            editAction(true)
                        }
                } else {
                    Image("button_ok")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:IMDemoUIConfig.navigationIconEdgeLen, height: IMDemoUIConfig.navigationIconEdgeLen, alignment: .center)
                        .padding((IMDemoUIConfig.topNavigationHight - IMDemoUIConfig.navigationIconEdgeLen)/2)
                        .onTapGesture {
                            editMode = false
                            editAction(false)
                        }
                }
            }
        }
    }
}

struct ContactInfoView: View {
    var contact:ContactInfo
    let backAction: ()->Void
    
    @State private var editMode = false
    @State private var newNickname = ""
    @State private var newImageUrl = ""
    @State private var newShowInfo = ""
    
    @ObservedObject var viewInfo: ViewSharedInfo
    
    init(contct:ContactInfo, viewInfo: ViewSharedInfo, backAction: @escaping ()->Void) {
        self.contact = contct
        self.backAction = backAction
        
        self.newNickname = self.contact.nickname
        self.newImageUrl = self.contact.imageUrl
        self.newShowInfo = self.contact.showInfo
        
        self.viewInfo = viewInfo
    }
    
    func getXidTitle() -> String {
        switch self.contact.kind {
        case ContactKind.Group.rawValue:
            return "群组ID:"
        case ContactKind.Room.rawValue:
            return "房间ID:"
        default:
            return "用户ID:"
        }
    }
    
    func getXnameTitle() -> String {
        switch self.contact.kind {
        case ContactKind.Group.rawValue:
            return "群组注册名:"
        case ContactKind.Room.rawValue:
            return "房间注册名:"
        default:
            return "用户名:"
        }
    }
    
    func getNicknameTitle() -> String {
        switch self.contact.kind {
        case ContactKind.Group.rawValue:
            return "群组名称:"
        case ContactKind.Room.rawValue:
            return "房间名称:"
        default:
            return "用户昵称:"
        }
    }
    
    func getShowInfoTitle() -> String {
        switch self.contact.kind {
        case ContactKind.Group.rawValue:
            return "群组描述:"
        case ContactKind.Room.rawValue:
            return "房间描述:"
        default:
            return "用户签名:"
        }
    }
    
    func getNicknameHint() -> String {
        switch self.contact.kind {
        case ContactKind.Group.rawValue:
            return "给群组取个名称"
        case ContactKind.Room.rawValue:
            return "给房间取个名称"
        default:
            return "给自己取个昵称"
        }
    }
    
    func getImageUrlTitle() -> String {
        switch self.contact.kind {
        case ContactKind.Group.rawValue:
            return "群组标志地址:"
        case ContactKind.Room.rawValue:
            return "房间标志地址:"
        default:
            return "头像地址:"
        }
    }
    
    func getImageUrlChangeTitle() -> String {
        switch self.contact.kind {
        case ContactKind.Group.rawValue:
            return "更改群组标志地址"
        case ContactKind.Room.rawValue:
            return "更改房间标志地址"
        default:
            return "更改头像地址"
        }
    }
    
    func updateCallback(imagePath: String) {
        if self.newNickname.isEmpty == false {
            self.contact.nickname = self.newNickname
        }
        
        if self.newShowInfo.isEmpty == false {
            self.contact.showInfo = self.newShowInfo
        }
        
        if imagePath.isEmpty == false {
            self.contact.imagePath = imagePath
        }
    }
    
    var body: some View {
        ZStack {
            
            Color.white.edgesIgnoringSafeArea(.all)
            
            VStack {
                ContactInfoHeaderView(title: IMCenter.getContactDisplayName(contact: contact), canBeEdit: (contact.kind == ContactKind.Group.rawValue || contact.kind == ContactKind.Room.rawValue), backAction: {
                    backAction()
                }, editAction: {
                    inEditing in
                    
                    self.editMode = inEditing
                    
                    
                    if inEditing == false {
                        self.viewInfo.inProcessing = true
                        
                        let newContact = ContactInfo()
                        newContact.kind = contact.kind
                        newContact.xid = contact.xid
                        newContact.xname = contact.xname
                        newContact.nickname = self.newNickname.isEmpty ? contact.nickname : self.newNickname
                        newContact.showInfo = self.newShowInfo.isEmpty ? contact.showInfo : self.newShowInfo
                        
                        if self.newImageUrl.isEmpty {
                            newContact.imageUrl = self.contact.imageUrl
                        } else {
                            newContact.imageUrl = self.newImageUrl
                        }
    
                        //-- TODO: 更新群组或者房间公共信息
                    }
                })
                
                Divider()
                
                Spacer()
                
                if self.contact.imagePath.isEmpty == false {
                    Image(uiImage: IMCenter.loadUIIMage(path:contact.imagePath))
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                        .cornerRadius(10)
                        .padding()
                    
                } else if self.contact.imageUrl.isEmpty {
                    Image(IMDemoUIConfig.defaultIcon)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                        .cornerRadius(10)
                        .padding()
                    
                } else {
                    AsyncImage(url: URL(string: contact.imageUrl)) { image in
                        image.resizable()
                    } placeholder: {
                        ProgressView()
                    }
                    .aspectRatio(contentMode: .fit)
                    .frame(width:UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width/2, alignment: .center)
                    .cornerRadius(10)
                    .padding()
                }
                
                LazyVGrid(columns:[GridItem(.fixed(UIScreen.main.bounds.width * 0.4)), GridItem()]) {
                    HStack {
                        Spacer()
                        
                        Text(getXidTitle())
                            .padding()
                    }
                    HStack {
                        Text(String(self.contact.xid))
                            .padding()
                        Spacer()
                    }
                    
                    
                    HStack {
                        Spacer()
                        Text(getXnameTitle())
                            .padding()
                    }
                    HStack {
                        Text(self.contact.xname)
                            .padding()
                        Spacer()
                    }
                    
                    HStack {
                        Spacer()
                        Text(getNicknameTitle())
                            .padding()
                    }
                    HStack {
                        if self.editMode == false {
                            if self.contact.nickname.isEmpty {
                                Text(self.contact.xname)
                                    .padding()
                            } else {
                                Text(self.contact.nickname)
                                    .padding()
                            }
                            
                        } else {
                            TextField(self.contact.nickname.isEmpty ? getNicknameHint() : self.contact.nickname, text: $newNickname)
                                .autocapitalization(.none)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: nil)
                                .padding()
                        }
                        
                        Spacer()
                    }
                    
                    if self.editMode {
                        HStack {
                            
                            Spacer()
                            Text(getImageUrlTitle())
                                .padding()
                        }
                        HStack {
                            TextField(self.contact.imageUrl.isEmpty ? getImageUrlChangeTitle() : self.contact.imageUrl, text: $newImageUrl)
                                .autocapitalization(.none)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: nil)
                                .padding()
                            
                            Spacer()
                        }
                    }
                    
                    HStack {
                        Spacer()
                        Text(getShowInfoTitle())
                            .padding()
                    }
                    HStack {
                        if self.editMode == false {
                            TextEditor(text: $newShowInfo).disabled(true)
                                    .padding()
                        } else {
                            TextEditor(text: $newShowInfo)
                                .frame(width: UIScreen.main.bounds.width/3,
                                height: 80)
                                .ignoresSafeArea(.keyboard)
                                .padding()
                                .overlay(RoundedRectangle(cornerRadius: 8)
                                        .stroke(Color.secondary).opacity(0.5))
                        }
                        
                        Spacer()
                    }
                }
                            
                Spacer()
            }
            .onTapGesture {
                hideKeyboard()
            }
            
            //--------- processing view ----------//
            
            if self.viewInfo.inProcessing {
                ProcessingView(info: "更新中,请等待……")
            }
        }
    }
}

struct ContactInfoView_Previews: PreviewProvider {
    static var previews: some View {
        ContactInfoView(contct: ContactInfo(), viewInfo: ViewSharedInfo(), backAction: {})
    }
}

整体代码与用户信息页相差不大,这里就不再过多说明。

编辑 DialogueView.swift,添加 ContactInfoView 的展现方式:

... ...

            if self.showInfoPage {
                ContactInfoView(contct: self.contactForInfoPage, viewInfo: IMCenter.viewSharedInfo, backAction: {
                    self.contactForInfoPage = self.contact
                    self.showInfoPage = false
                })
            }

... ...
12. 连接断开提示页

最后,还差一步才能完成所有的视图页面。
试想,如果用户在使用途中,无论是因为网络原因,还是其他原因,断线了怎么办?是不是应该给一个提示?

所以这里我们添加 BrokenView.swift,修改代码如下:

import SwiftUI

struct BrokenView: View {
    var info: String
    
    var body: some View {
        VStack {
            Text("连接断开")
                .font(.title)
            
            Text(info).padding()
            
            Button("确定") {
                IMCenter.viewSharedInfo.currentPage = .LoginView
            }
            .frame(width: UIScreen.main.bounds.width/4,
            height: nil)
            .padding(10)
            .foregroundColor(.white)
            .background(.blue)
            .cornerRadius(10)
        }
    }
}

struct BrokenView_Previews: PreviewProvider {
    static var previews: some View {
        BrokenView(info: "测试")
    }
}

到此,IMDemo 的所有UI就准备完成了。
注意:因为相关的页面还没有数据注入,App内部逻辑还未完成,因此运行后,一旦登陆跳转,即会进入崩溃状态。

下篇,我们将开始开发 IMDemoApp视图之下的内部逻辑。

若有收获,可以留下你的赞和收藏。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/web/990330.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-21
下一篇 2022-05-21

发表评论

登录后才能评论

评论列表(0条)