仿钉钉日历页面,日视图下的一天日程安排视图

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两个属性,当两个日程的起止时间并不重叠时,那么他们就不可能是互为父子节点.
不过以上二叉树的情况还不满足我们,我们需要扩展成多叉树.布局所有日程视图的过程,就是构造一棵或多棵多叉树的过程,步骤如下
  1. 将数据源按起始时间从小到大排好序,取出第一个日程,
  2. 构造一个树根节点R,添加到数组T中,继续下一步
  3. 取出下一个日程,构造节点A,判断该节A点是否为第二步构造出来的树根节点R的子节点,这里分两种情况:
    a. 若是,递归查找节点A合适的父节点,再找到合适的父节点时,要更新根节点R的结束时间为所有子节点的最大值.同时这里要注意,若节点A是某个已有节点的唯一子节点,需要更新该节点上的所有父节点链上的度的值
    b. 若不是,将节点A添加到数组T中,继续下一轮循环

4.遍历结束,多棵多叉树构造完成
以上步骤的第三点需要注意,判断节点A是否为根节点R的子节点时,要用根节点的开始时间,和根节点的所有子节点中最大的结束时间组成的区间来比较;在递归查找节点A合适的父节点时,需要用各个节点自己的结束时间来判断
当多叉树构造完毕,就可以计算各个日程view的横坐标和宽度了.取出数组T的第一个节点

  1. 设置该节点关联的日程view的宽度为父视图宽度除以节点的度,并设置横坐标为0
  2. 判断该节点是否有孩子节点:
    a.若有,将父视图宽度减去此节点关联日程view的宽度,此为孩子节点所在树的最大宽度,之后重复步骤一,只不过宽度和横坐标都有所变化
    b. 若无,宽度无需平分,直接铺满剩下的宽度

当数组T遍历完毕,我们的所有日程View也都正确的设置了宽度和横坐标了
源码地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容