仿釘釘日歷頁面,日視圖下的一天日程安排視圖

0.緒論

開始之前,先來個(gè)效果圖



這就是釘釘里面的一天日程安排視圖,主要功能點(diǎn)是:

  • 顯示一天內(nèi)0到24小時(shí)具體的每個(gè)時(shí)間段所有的日程
  • 點(diǎn)擊空白位置,可以新建某個(gè)日程,新建日程支持拖拽改變?nèi)粘唐鹬箷r(shí)間
  • 可長按某個(gè)已有日程,觸發(fā)編輯,可編輯日程的起止時(shí)間
  • 若某幾個(gè)日程時(shí)間有重疊,將不會(huì)相互在UI上相互重疊,而是各自計(jì)算寬度

1.設(shè)計(jì)方案

我們來討論下,要封裝一個(gè)這樣的視圖,需要怎么設(shè)計(jì)

首先第一個(gè)要求,我們的視圖別人可能會(huì)加到各種視圖上,所以我們先定為繼承自UIView,內(nèi)部的各個(gè)view要適配父視圖的寬度,不能依賴于屏幕寬度.所以內(nèi)部的各個(gè)子視圖需要用autolayout來布局,當(dāng)然手動(dòng)計(jì)算frame也可以,但是工作量比較大,我們暫不考慮

觀察每個(gè)日程,發(fā)現(xiàn)日程需要的基本信息是名稱,起止時(shí)間,也可能需要一個(gè)id來區(qū)別每個(gè)日程,所以這里我們給每個(gè)日程信息定義一個(gè)協(xié)議,所有日程都需要提供該協(xié)議必須的字段,如下所示:

    public protocol DDDayScheduleViewItemRepresentable {
    var id: Int { get }
    var name: String { get }
    var timeInfo: DDScheduleTimeInfo { get }
}

其中DDScheduleTimeInfo就是一個(gè)結(jié)構(gòu)體,包含日程的開始時(shí)間和結(jié)束時(shí)間,大概定義如下:

public struct DDScheduleTimeInfo: Equatable {
    /// 開始時(shí)間,為0~24之間的浮點(diǎn)數(shù)
    public var begin: CGFloat
    /// 結(jié)束時(shí)間,為0~24之間的浮點(diǎn)數(shù)
    public var end: CGFloat
}

單個(gè)日程,還支持點(diǎn)擊,長按編輯等功能,這里我們定義一個(gè)協(xié)議,將這些作為代理,告訴外部調(diào)用者需要處理這些事件,以下就是大概的協(xié)議內(nèi)容:

public protocol DDDayScheduleViewDelegate: AnyObject {
    ///點(diǎn)擊某個(gè)日程
    func dayScheduleView(_ dayScheduleView: DDDayScheduleView, didSelectItem item: DDDayScheduleViewItemRepresentable)
    ///新建日程
    func dayScheduleView(_ dayScheduleView: DDDayScheduleView, createNewItemWith timeInfo: DDScheduleTimeInfo)
    ///編輯已有日程,timeInfo是編輯后的時(shí)間
    func dayScheduleView(_ dayScheduleView: DDDayScheduleView, editItem item: DDDayScheduleViewItemRepresentable, timeInfo: DDScheduleTimeInfo)
}

這里沒有提供一個(gè)數(shù)據(jù)源的協(xié)議,是因?yàn)閮?nèi)部實(shí)現(xiàn)的原因,只能通過一個(gè)屬性來賦值數(shù)據(jù)源,而不是像UITableView那種用一個(gè)協(xié)議來提供數(shù)據(jù),因?yàn)楫?dāng)賦值一個(gè)數(shù)據(jù)的時(shí)候,我們會(huì)在內(nèi)部做排序,將日程的開始時(shí)間從小到大排序,用數(shù)據(jù)源協(xié)議無法實(shí)現(xiàn)這種效果.這里我們定義的數(shù)據(jù)源屬性是一個(gè)實(shí)現(xiàn)了上面定的DDDayScheduleViewItemRepresentable協(xié)議的對象的數(shù)據(jù),然后在該屬性的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()
  }
}

當(dāng)重新賦值數(shù)據(jù)源屬性時(shí),我們需要重新布局,這里的layouted屬性是為了做標(biāo)記,讓在lauoutSubViews里面重新布局子視圖

觀察視圖發(fā)現(xiàn),每個(gè)小時(shí)的間隔都是固定的,而且一天的小時(shí)是24小時(shí),也是固定的,因此內(nèi)部可以用ScrollView來做內(nèi)容視圖,所有子view都放到scrollView上來實(shí)現(xiàn)上下滑動(dòng).

若從0到24小時(shí)為從上到下排列,假定一小時(shí)的高度是10,則總的ScrollView的ContentSize是240,并且一天中的每個(gè)小時(shí),分鐘,都可以精確計(jì)算出它的縱坐標(biāo),轉(zhuǎn)換公式就是:

//一小時(shí)的高度是a = 10,若現(xiàn)在時(shí)間是3點(diǎn)30分,則此時(shí)的縱坐標(biāo)Y為
Y = 3 * a + (30 / 60) * a

2.實(shí)現(xiàn)方案

2.1搭建內(nèi)部子視圖

讓我們在仔細(xì)觀察一下都需要什么內(nèi)容,如下所示


  • 最明顯的是底部的刻度,從0到24,是各個(gè)日程的參照位置,還有一條分割線,所有的日程都是在這條分割線的起始位置開始排列,并且不得超過該分割線的最大寬度
  • 有個(gè)紅色的當(dāng)前時(shí)間的指示器,當(dāng)頁面更新時(shí),會(huì)實(shí)時(shí)更改位置,他的中心縱坐標(biāo),就是按上面第五點(diǎn)說的方法計(jì)算的
  • 已有日程的顯示view,當(dāng)沒有重疊的日程時(shí),會(huì)顯示為最大寬度,并且位置正如本條第一點(diǎn)所說,高度跟該日程的時(shí)間跨度有關(guān),縱坐標(biāo)跟日程的開始時(shí)間有關(guān),
  • 一個(gè)創(chuàng)建日程計(jì)劃的視圖,如圖中深綠色所示,在點(diǎn)擊該視圖的空白處時(shí)即會(huì)出現(xiàn),可以上下拖動(dòng)更改起止時(shí)間,再次點(diǎn)擊即在當(dāng)前位置所在的時(shí)間創(chuàng)建日程,這個(gè)會(huì)通過代理回調(diào)給調(diào)用者,讓外部去真正的創(chuàng)建日程
  • 還有個(gè)長按已有計(jì)劃后出現(xiàn)的編輯視圖,該視圖跟創(chuàng)建視圖大同小異,功能也差不多,不同點(diǎn)是在退出編輯時(shí),會(huì)告訴外部代理更新了計(jì)劃時(shí)間,并且會(huì)把時(shí)間也從代理方法返回

暫時(shí)只需要這些了.
對此,我們新建幾個(gè)類,來規(guī)定一下各個(gè)類的職責(zé)

  • BaseLineView: 用于展示每小時(shí)刻度及分割線
  • TimeIndicatorView: 展示當(dāng)前時(shí)間的指示器view(即那個(gè)紅色的view),在內(nèi)部的layoutSubView方法里會(huì)自己主動(dòng)更新位置
  • ItemView:具體的計(jì)劃視圖(有綠色條紋的那個(gè)view),上面有點(diǎn)擊手勢和長按手勢,有個(gè)DDDayScheduleViewItemRepresentable的屬性,用來展示具體的日程信息
  • EditableView:可編輯視圖,包含創(chuàng)建日程可編輯日程兩種類型,并且可以改動(dòng)開始時(shí)間和結(jié)束時(shí)間,包含點(diǎn)擊手勢,用于確定編輯事件,當(dāng)為編輯類型時(shí),需要綁定關(guān)聯(lián)的日程信息
  • EditMaskView:編輯遮罩view,作為可編輯View的一個(gè)屬性,用于展示當(dāng)前編輯時(shí)間的信息(即上圖中的藍(lán)色時(shí)間部分,和深綠色部分),不可交互

2.2組織視圖

粗略估計(jì),需要以下步驟

  • 我們需要先規(guī)定一下一小時(shí)的高度,其他視圖的布局計(jì)算,都是基于此的,這里我們用一個(gè)屬性來定義OneHourHeight,還有一天的小時(shí)數(shù),也是個(gè)常量,用HourAmoutOneDay表示,
  • 需要知道日程view的寬度,而日程view的寬度依賴于當(dāng)前控件視圖的寬度,所以布局日程view,需要放到layoutSubViews的方法里,在該方法里,已經(jīng)可以知道當(dāng)前視圖的寬度了
  • 添加ScrollView,設(shè)置ScrollView的contentSize,寬度隨superView寬度,高度等于OneHourHeight*HourAmoutOneDay
  • 添加baseline基尺view,基尺view內(nèi)部的每小時(shí)的文本的中心縱坐標(biāo)也是根據(jù)OneHourHeight計(jì)算的,
  • 添加scheduleItemSuperView,這是所有日程view的父視圖,再每次更新數(shù)據(jù)源時(shí),都會(huì)先移除就有的日程視圖,再添加新的日程視圖
  • 為scheduleItemSuperView添加點(diǎn)擊手勢和拖動(dòng)手勢,用于點(diǎn)擊時(shí)出現(xiàn)創(chuàng)建日程view,和拖動(dòng)時(shí)修改日程的時(shí)間
  • 添加timeIndicatorView,就是當(dāng)前時(shí)間指示器那個(gè)視圖,該視圖需要置于最頂層,不可被別的視圖覆蓋

內(nèi)容太多,就不一一介紹了,詳情請看文末的源碼

2.3布局視圖

接下來這個(gè)才是難點(diǎn).每個(gè)日程的開始時(shí)間,決定了它所在的縱坐標(biāo)位置,結(jié)束時(shí)間決定了它的高度,那寬度和橫坐標(biāo)呢,這得看它所在的那一行中,有沒有別的其他日程了.若有一個(gè),則兩個(gè)日程view平分父視圖的寬度,若有再多的,也是要各個(gè)view平分,但是這個(gè)寬度也不是簡單的計(jì)算同一行上有沒有其他日程,舉個(gè)例子:



上圖中,計(jì)劃1和計(jì)劃3時(shí)間并不重疊,計(jì)劃2和計(jì)劃4時(shí)間也不重疊,為什么這里的幾個(gè)日程寬度,需要平分呢,要是遇到其他更復(fù)雜的情況該怎么辦呢.像這樣子



在開始討論之前,讓我們先看一下下面這棵樹

為這顆樹定義一個(gè)節(jié)點(diǎn),如下

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是為了記錄該節(jié)點(diǎn)開始訪問和結(jié)束訪問的時(shí)間.
構(gòu)造如上的二叉樹結(jié)構(gòu),代碼如下

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
    }

在遍歷過程中,我們先設(shè)置節(jié)點(diǎn)的開始訪問時(shí)間,遍歷完所有子節(jié)點(diǎn)后再設(shè)置結(jié)束時(shí)間,然后我們假定,每次一進(jìn)來這個(gè)遍歷方法,時(shí)間就自增1
我們將訪問過的節(jié)點(diǎn)保存到result數(shù)組中,后續(xù)對它進(jìn)行遍歷,打印出每個(gè)節(jié)點(diǎn)的訪問開始時(shí)間和結(jié)束時(shí)間,如下所示



我們將打印結(jié)果顯示在圖上就是這樣:



看出來了嗎,如果把開始訪問時(shí)間和結(jié)束訪問時(shí)間做個(gè)區(qū)間的話,那么父節(jié)點(diǎn)的區(qū)間,是完全包含子節(jié)點(diǎn)的區(qū)間的,同理,兩個(gè)節(jié)點(diǎn)沒有相互包含,則他們不可能互為父子節(jié)點(diǎn).
我們按時(shí)間軸從左到右的形式把它畫成圖看看,就像下面這樣

把它逆時(shí)針旋轉(zhuǎn)90度,是不是很眼熟



原來這里可以用一顆樹結(jié)構(gòu)來表示,而樹節(jié)點(diǎn)的視圖寬度,跟該樹節(jié)點(diǎn)子節(jié)點(diǎn)個(gè)數(shù)有關(guān)
這里日程的開始和結(jié)束時(shí)間,就對應(yīng)著上面樹節(jié)點(diǎn)的begin和end兩個(gè)屬性,當(dāng)兩個(gè)日程的起止時(shí)間并不重疊時(shí),那么他們就不可能是互為父子節(jié)點(diǎn).
不過以上二叉樹的情況還不滿足我們,我們需要擴(kuò)展成多叉樹.布局所有日程視圖的過程,就是構(gòu)造一棵或多棵多叉樹的過程,步驟如下
  1. 將數(shù)據(jù)源按起始時(shí)間從小到大排好序,取出第一個(gè)日程,
  2. 構(gòu)造一個(gè)樹根節(jié)點(diǎn)R,添加到數(shù)組T中,繼續(xù)下一步
  3. 取出下一個(gè)日程,構(gòu)造節(jié)點(diǎn)A,判斷該節(jié)A點(diǎn)是否為第二步構(gòu)造出來的樹根節(jié)點(diǎn)R的子節(jié)點(diǎn),這里分兩種情況:
    a. 若是,遞歸查找節(jié)點(diǎn)A合適的父節(jié)點(diǎn),再找到合適的父節(jié)點(diǎn)時(shí),要更新根節(jié)點(diǎn)R的結(jié)束時(shí)間為所有子節(jié)點(diǎn)的最大值.同時(shí)這里要注意,若節(jié)點(diǎn)A是某個(gè)已有節(jié)點(diǎn)的唯一子節(jié)點(diǎn),需要更新該節(jié)點(diǎn)上的所有父節(jié)點(diǎn)鏈上的度的值
    b. 若不是,將節(jié)點(diǎn)A添加到數(shù)組T中,繼續(xù)下一輪循環(huán)

4.遍歷結(jié)束,多棵多叉樹構(gòu)造完成
以上步驟的第三點(diǎn)需要注意,判斷節(jié)點(diǎn)A是否為根節(jié)點(diǎn)R的子節(jié)點(diǎn)時(shí),要用根節(jié)點(diǎn)的開始時(shí)間,和根節(jié)點(diǎn)的所有子節(jié)點(diǎn)中最大的結(jié)束時(shí)間組成的區(qū)間來比較;在遞歸查找節(jié)點(diǎn)A合適的父節(jié)點(diǎn)時(shí),需要用各個(gè)節(jié)點(diǎn)自己的結(jié)束時(shí)間來判斷
當(dāng)多叉樹構(gòu)造完畢,就可以計(jì)算各個(gè)日程view的橫坐標(biāo)和寬度了.取出數(shù)組T的第一個(gè)節(jié)點(diǎn)

  1. 設(shè)置該節(jié)點(diǎn)關(guān)聯(lián)的日程view的寬度為父視圖寬度除以節(jié)點(diǎn)的度,并設(shè)置橫坐標(biāo)為0
  2. 判斷該節(jié)點(diǎn)是否有孩子節(jié)點(diǎn):
    a.若有,將父視圖寬度減去此節(jié)點(diǎn)關(guān)聯(lián)日程view的寬度,此為孩子節(jié)點(diǎn)所在樹的最大寬度,之后重復(fù)步驟一,只不過寬度和橫坐標(biāo)都有所變化
    b. 若無,寬度無需平分,直接鋪滿剩下的寬度

當(dāng)數(shù)組T遍歷完畢,我們的所有日程View也都正確的設(shè)置了寬度和橫坐標(biāo)了
源碼地址

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容