0.绪论
开始之前,先来个效果图
这就是钉钉里面的一天日程安排视图,主要功能点是:
- 显示一天内0到24小时具体的每个时间段所有的日程
- 点击空白位置,可以新建某个日程,新建日程支持拖拽改变日程起止时间
- 可长按某个已有日程,触发编辑,可编辑日程的起止时间
- 若某几个日程时间有重叠,将不会相互在UI上相互重叠,而是各自计算宽度
1.设计方案
我们来讨论下,要封装一个这样的视图,需要怎么设计
首先第一个要求,我们的视图别人可能会加到各种视图上,所以我们先定为继承自UIView,内部的各个view要适配父视图的宽度,不能依赖于屏幕宽度.所以内部的各个子视图需要用autolayout来布局,当然手动计算frame也可以,但是工作量比较大,我们暂不考虑
观察每个日程,发现日程需要的基本信息是名称,起止时间,也可能需要一个id来区别每个日程,所以这里我们给每个日程信息定义一个协议,所有日程都需要提供该协议必须的字段,如下所示:
public protocol DDDayScheduleViewItemRepresentable {
var id: Int { get }
var name: String { get }
var timeInfo: DDScheduleTimeInfo { get }
}
其中DDScheduleTimeInfo
就是一个结构体,包含日程的开始时间和结束时间,大概定义如下:
public struct DDScheduleTimeInfo: Equatable {
/// 开始时间,为0~24之间的浮点数
public var begin: CGFloat
/// 结束时间,为0~24之间的浮点数
public var end: CGFloat
}
单个日程,还支持点击,长按编辑等功能,这里我们定义一个协议,将这些作为代理,告诉外部调用者需要处理这些事件,以下就是大概的协议内容:
public protocol DDDayScheduleViewDelegate: AnyObject {
///点击某个日程
func dayScheduleView(_ dayScheduleView: DDDayScheduleView, didSelectItem item: DDDayScheduleViewItemRepresentable)
///新建日程
func dayScheduleView(_ dayScheduleView: DDDayScheduleView, createNewItemWith timeInfo: DDScheduleTimeInfo)
///编辑已有日程,timeInfo是编辑后的时间
func dayScheduleView(_ dayScheduleView: DDDayScheduleView, editItem item: DDDayScheduleViewItemRepresentable, timeInfo: DDScheduleTimeInfo)
}
这里没有提供一个数据源的协议,是因为内部实现的原因,只能通过一个属性来赋值数据源,而不是像UITableView那种用一个协议来提供数据,因为当赋值一个数据的时候,我们会在内部做排序,将日程的开始时间从小到大排序,用数据源协议无法实现这种效果.这里我们定义的数据源属性是一个实现了上面定的DDDayScheduleViewItemRepresentable
协议的对象的数据,然后在该属性的set方法,我们重新排序,如下代码所示:
public var datasource: [DDDayScheduleViewItemRepresentable] {
get {
return _datasource
}
set {
_datasource = newValue.sorted(by: { (l, r) -> Bool in
return l.timeInfo.begin < r.timeInfo.begin
})
layouted = false
setNeedsLayout()
}
}
当重新赋值数据源属性时,我们需要重新布局,这里的layouted属性是为了做标记,让在lauoutSubViews里面重新布局子视图
观察视图发现,每个小时的间隔都是固定的,而且一天的小时是24小时,也是固定的,因此内部可以用ScrollView来做内容视图,所有子view都放到scrollView上来实现上下滑动.
若从0到24小时为从上到下排列,假定一小时的高度是10,则总的ScrollView的ContentSize是240,并且一天中的每个小时,分钟,都可以精确计算出它的纵坐标,转换公式就是:
//一小时的高度是a = 10,若现在时间是3点30分,则此时的纵坐标Y为
Y = 3 * a + (30 / 60) * a
2.实现方案
2.1搭建内部子视图
让我们在仔细观察一下都需要什么内容,如下所示
- 最明显的是底部的刻度,从0到24,是各个日程的参照位置,还有一条分割线,所有的日程都是在这条分割线的起始位置开始排列,并且不得超过该分割线的最大宽度
- 有个红色的当前时间的指示器,当页面更新时,会实时更改位置,他的中心纵坐标,就是按上面第五点说的方法计算的
- 已有日程的显示view,当没有重叠的日程时,会显示为最大宽度,并且位置正如本条第一点所说,高度跟该日程的时间跨度有关,纵坐标跟日程的开始时间有关,
- 一个创建日程计划的视图,如图中深绿色所示,在点击该视图的空白处时即会出现,可以上下拖动更改起止时间,再次点击即在当前位置所在的时间创建日程,这个会通过代理回调给调用者,让外部去真正的创建日程
- 还有个长按已有计划后出现的编辑视图,该视图跟创建视图大同小异,功能也差不多,不同点是在退出编辑时,会告诉外部代理更新了计划时间,并且会把时间也从代理方法返回
暂时只需要这些了.
对此,我们新建几个类,来规定一下各个类的职责
- BaseLineView: 用于展示每小时刻度及分割线
- TimeIndicatorView: 展示当前时间的指示器view(即那个红色的view),在内部的layoutSubView方法里会自己主动更新位置
- ItemView:具体的计划视图(有绿色条纹的那个view),上面有点击手势和长按手势,有个DDDayScheduleViewItemRepresentable的属性,用来展示具体的日程信息
- EditableView:可编辑视图,包含创建日程可编辑日程两种类型,并且可以改动开始时间和结束时间,包含点击手势,用于确定编辑事件,当为编辑类型时,需要绑定关联的日程信息
- EditMaskView:编辑遮罩view,作为可编辑View的一个属性,用于展示当前编辑时间的信息(即上图中的蓝色时间部分,和深绿色部分),不可交互
2.2组织视图
粗略估计,需要以下步骤
- 我们需要先规定一下一小时的高度,其他视图的布局计算,都是基于此的,这里我们用一个属性来定义OneHourHeight,还有一天的小时数,也是个常量,用HourAmoutOneDay表示,
- 需要知道日程view的宽度,而日程view的宽度依赖于当前控件视图的宽度,所以布局日程view,需要放到layoutSubViews的方法里,在该方法里,已经可以知道当前视图的宽度了
- 添加ScrollView,设置ScrollView的contentSize,宽度随superView宽度,高度等于OneHourHeight*HourAmoutOneDay
- 添加baseline基尺view,基尺view内部的每小时的文本的中心纵坐标也是根据OneHourHeight计算的,
- 添加scheduleItemSuperView,这是所有日程view的父视图,再每次更新数据源时,都会先移除就有的日程视图,再添加新的日程视图
- 为scheduleItemSuperView添加点击手势和拖动手势,用于点击时出现创建日程view,和拖动时修改日程的时间
- 添加timeIndicatorView,就是当前时间指示器那个视图,该视图需要置于最顶层,不可被别的视图覆盖
内容太多,就不一一介绍了,详情请看文末的源码
2.3布局视图
接下来这个才是难点.每个日程的开始时间,决定了它所在的纵坐标位置,结束时间决定了它的高度,那宽度和横坐标呢,这得看它所在的那一行中,有没有别的其他日程了.若有一个,则两个日程view平分父视图的宽度,若有再多的,也是要各个view平分,但是这个宽度也不是简单的计算同一行上有没有其他日程,举个例子:
上图中,计划1和计划3时间并不重叠,计划2和计划4时间也不重叠,为什么这里的几个日程宽度,需要平分呢,要是遇到其他更复杂的情况该怎么办呢.像这样子
在开始讨论之前,让我们先看一下下面这棵树
为这颗树定义一个节点,如下
class TreeNode {
var left: TreeNode?
var right: TreeNode?
var data: String?
var begin: Int?
var end: Int?
init(_ data: String) {
self.data = data
}
}
其中begin和end是为了记录该节点开始访问和结束访问的时间.
构造如上的二叉树结构,代码如下
let a = TreeNode("A")
let b = TreeNode("B")
let c = TreeNode("C")
let d = TreeNode("D")
let e = TreeNode("E")
let f = TreeNode("F")
let g = TreeNode("G")
a.left = b
a.right = c
b.left = d
b.right = e
c.left = f
c.right = g
root = a
preorderVisit(root)
for node in result {
print("\(node.data!): {\(node.begin ?? 0)-\(node.end ?? 0)}")
}
其中preorderVisit是前序遍历方法,代码如下
func preorderVisit(_ node: TreeNode?) {
guard let node = node else { return }
time+=1
node.begin = time
result.append(node)
print("nodeData: \(node.data ?? "")")
preorderVisit(node.left)
preorderVisit(node.right)
node.end = time
}
在遍历过程中,我们先设置节点的开始访问时间,遍历完所有子节点后再设置结束时间,然后我们假定,每次一进来这个遍历方法,时间就自增1
我们将访问过的节点保存到result数组中,后续对它进行遍历,打印出每个节点的访问开始时间和结束时间,如下所示
我们将打印结果显示在图上就是这样:
看出来了吗,如果把开始访问时间和结束访问时间做个区间的话,那么父节点的区间,是完全包含子节点的区间的,同理,两个节点没有相互包含,则他们不可能互为父子节点.
我们按时间轴从左到右的形式把它画成图看看,就像下面这样
把它逆时针旋转90度,是不是很眼熟
原来这里可以用一颗树结构来表示,而树节点的视图宽度,跟该树节点子节点个数有关
这里日程的开始和结束时间,就对应着上面树节点的begin和end两个属性,当两个日程的起止时间并不重叠时,那么他们就不可能是互为父子节点.
不过以上二叉树的情况还不满足我们,我们需要扩展成多叉树.布局所有日程视图的过程,就是构造一棵或多棵多叉树的过程,步骤如下
- 将数据源按起始时间从小到大排好序,取出第一个日程,
- 构造一个树根节点R,添加到数组T中,继续下一步
- 取出下一个日程,构造节点A,判断该节A点是否为第二步构造出来的树根节点R的子节点,这里分两种情况:
a. 若是,递归查找节点A合适的父节点,再找到合适的父节点时,要更新根节点R的结束时间为所有子节点的最大值.同时这里要注意,若节点A是某个已有节点的唯一子节点,需要更新该节点上的所有父节点链上的度的值
b. 若不是,将节点A添加到数组T中,继续下一轮循环
4.遍历结束,多棵多叉树构造完成
以上步骤的第三点需要注意,判断节点A是否为根节点R的子节点时,要用根节点的开始时间,和根节点的所有子节点中最大的结束时间组成的区间来比较;在递归查找节点A合适的父节点时,需要用各个节点自己的结束时间来判断
当多叉树构造完毕,就可以计算各个日程view的横坐标和宽度了.取出数组T的第一个节点
- 设置该节点关联的日程view的宽度为父视图宽度除以节点的度,并设置横坐标为0
- 判断该节点是否有孩子节点:
a.若有,将父视图宽度减去此节点关联日程view的宽度,此为孩子节点所在树的最大宽度,之后重复步骤一,只不过宽度和横坐标都有所变化
b. 若无,宽度无需平分,直接铺满剩下的宽度
当数组T遍历完毕,我们的所有日程View也都正确的设置了宽度和横坐标了
源码地址