本文篇幅较长,预计阅读时长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”,弹出右键菜单,选择“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”,弹出右键菜单,选择“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) {