一个 app 通常会有很多类型的弹出窗口,但是当这些弹出窗口一起出现时候就会造成差用户体验。
本章期望对这些弹窗做一个整理,寻找一种好的设计来整理这方面的代码。
场景
副标题已经列出了常见的几种类型:
- HUD,通常用于请求成功或者失败的提醒
- Alert,通常用于用户退出、取消和其他动作的确认
- Sheet,通常用于给用户提供不同的选择,包含 分享弹出
- 权限申请,info.plist管理的权限弹窗
- 活动、红包的弹出
- 新消息、口令码,邀请用户跳转
可以看出,除了 HUD 的提醒用户是不需要操作的,其他提醒都是用户需要交互的 ,相应的,HUD 的层叠或者更新并不会影响用户的体验,而其他有用户交互的弹窗多个层叠,会严重影响用户体验
HUD
对于 HUD 类的,我们不需要担心相互影响的问题。我们只需要注意,HUD 可能在很多页面同时出现, HUD 会发生状态切换。例如很多页面会有一个 转圈的动作表示正在加载,加载完成会切换为成功的提醒。有一些含有加载状态的控件(例如按钮)也是如此
为此,我们定义一个通用协议
1
2
3
4
5
6
7
8
9
10
11
12
enum HUDProgressType {
case success
case failed
case progress
}
protocol HUDProtocol {
func show()
func hide()
func changeTo(_ type: HUDProgressType)
}
一个 HUD类的创建 ,只需要 遵守这个协议,实现其中的方法 即可。例如 加载型页面和加载型按钮,就可以分别实现。
对于一个对象,使用 时候通常 初始化一个 lazy 对象,在合适的时候调用 show(),hide(),changeTo(:) 即可
交互弹窗
交互弹窗通常定义为 打断或者影响当前的操作,所以多个打断的出现会造成错乱。常见的是 APP 首次安装启动时候弹出很多窗口,APP 启动时候用户弹窗和活动弹窗的影响,键盘输入和消息跳转的影响,正在进行分享操作受到消息提醒的影响等。
为了解决这一问题我们同样使用一个简单的 协议来规范弹窗接口 ,使用一个 队列来集中处理弹窗,只有当上一个弹窗小事,才会出现另外一个弹窗。
大概是以下样式的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protocol AlertProtocol {
var didHide: () -> Void { get set }
var priority: Int { get set }
func show()
}
class AlertManager {
private var alerts = [AlertProtocol]()
func showAlert(_ alert: AlertProtocol) {
//...
}
}
协议和遵守协议的类
协议由一个 show() 方法和 var priority 和 var didHide Closure 构成。
通过开放这些个接口,他的管理者就可以适时的将窗口弹出。使用 priority 就可以控制弹窗弹出的顺序。
重新提一句,遵守协议的类可能是以下这些类:
- Alert,通常用于用户退出、取消和其他动作的确认
- Sheet,通常用于给用户提供不同的选择,包含 分享弹出
- 权限申请,info.plist管理的权限弹窗
- 活动、红包的弹出
- 新消息、口令码,邀请用户跳转
这样我们可以 分别创建这些类的父类,并且在父类遵守这些协议,实现通用的接口部分。
通常我们还在父类中实现一些关于UI、动画的等基础操作,让整个 app 的格调更加相似。
Sheet 父类可以创建 back view,用于解决异形屏的底部视图
整理对外开放的部分
每一个类型中的内容即可使用一个枚举来定义,这样最终添加类型就只需要在这里修改就可以了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum AlertType {
case questionExit
case answerExit
}
extension AlertType: TargetXxxxxxType {
var title: String {
switch self {
case .questionExit:
return "确定放弃提问吗"
case .answerExit:
return "确定放弃回答吗"
default:
return "确定退出吗"
}
}
var cancleTitle: String {
// ...
}
}
Mangger 部分
Mangger 部分承担了大量的调剂工作,可以预料会比较复杂,包含很多参数。一个简略的 Manager 类应该是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class AlertManager {
private var timer: Timer?
private var isShow = false
private var alerts = [AlertProtocol]()
private var didAddPrama = [Int: Int]()
func showAlert(_ alert: inout AlertProtocol) {
let value = didAddPrama[alert.priority]
let newValue = value ?? -1 + 1
didAddPrama[alert.priority] = newValue
alert.priority = alert.priority * 100 + newValue
alerts.insert(alert, at: 0)
alerts = alerts.sorted(by: { $0.priority < $1.priority })
timeRepeat()
}
init() {
timer = Timer.init(timeInterval: Double(1), target: self, selector: #selector(timeRepeat), userInfo: nil, repeats: true)
RunLoop.main.add(timer!, forMode: RunLoop.Mode.default)
}
deinit {
timer?.invalidate()
timer = nil
}
@objc func timeRepeat() {
guard alerts.count > 0, !isShow else { return }
guard var alert = alerts.first else { return }
alert.show()
alert.didHide = { [weak self] in
self?.isShow = false
}
isShow = true
self.alerts.removeFirst()
}
}
整体流程是从队伍中拿出一个 Alert 显示,让后当 Alert 显示结束时候,从队伍中拿出第一个继续显示。 要点两点一是 队列调整 部分,和 轮询显示 部分。
队列部分 :为了遵循先来后到的队列方式,我使用了 didAddPrama 这个键值对用来记录已经添加的 priority 的个数,然后将下一个 priority 相同值的 标记,在数组重新排序时候排到相同值的最后一个。
轮询显示 :将计时器加入 RunLoop.Mode.default 模式,这样用户进行 table 滑动的时候就不会弹窗了
正在滑动等手势 : 通过将 time 加入 RunLoop.Mode.default,即可在用户交互时停止 timer
正在输入 : 如果需要控制在输入的时候不展示弹窗可以在 AlertManager 中开放一个 isEditing 的属性,并且在 time 方法中跳过显示即可。
其他
队列 的调整有很多解决方法,文中的方法应该是最懒的一种方法了。最速度最快的应该是遍历一遍,然后插入相应的位置,只是代码写起来参数多些,项目中也是推荐这样。还可以考虑跟时间戳挂钩,如果项目已经有全局运行的时间戳的话,这也不失为一个好方法。
输入 输入除了对外开放属性,另外一种方式是可以动态的查找正在编辑的窗口,只是这样效率就低了,不再展开。
总结
整个框架搭下来项目的弹窗这一块就变的清晰了,同样一些东西可以以组件的形式复用和扩展,组件不一定是小颗粒,也可以是通用协议框架。
而新加类型只需要添加一个枚举,并且实现相应的方法即可。最终版可以参考 Moya。