本文篇幅较长,预计阅读时长30-60min,欢迎收藏+点赞+关注。
这是《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成》的第二篇:《UI设计与搭建》
系列文章可参考:
千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(一):功能设计与介绍
千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(三):App内部流程与逻辑(上)
千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(三):App内部流程与逻辑(下)
千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结
二、UI 设计与搭建基于第一篇的功能和模块规划,我们先来搭建UI,这样能尽快地看到软件最终的样子。
为了快速搭建和开发,本篇将以 iOS 为平台,选用 SwiftUI 和 Swift 进行开发。Android 平台上的 Kotlin 版本,请参见后续文章。
万事开头难,我们先来创建一个基本的 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视图之下的内部逻辑。
若有收获,可以留下你的赞和收藏。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)