资讯专栏INFORMATION COLUMN

带你开发一个二维周视图日历

张金宝 / 3101人阅读

摘要:即之前实现了一个月视图日历,我们今天来实现一个二维周视图的日历。难点实现内容部件插入我们实现这个二维周视图日历的主要目的就是要支持插入任意的内容,上面已经准备好了插入内容的元素,这里要做的就是将数据绘制成放置在合适的位置。

即之前实现了一个月视图日历,我们今天来实现一个二维周视图的日历。

以下进行分析其中的关键部分。

结构准备

不同之处在于其在日历的基础上还有一个分类轴,用于展示不同的类目,主要用于一周内的日程安排、会议安排等。

二维则和之前多带带的有所不同,二维日历再切换日期时不用全部重新渲染,分类是不用变的,仅仅改变显示的日期即可。

而且由于是二维的,插入的内容必定是同时属于一个分类和一个时间段的,内容肯定是可以跨越时间(即日期轴)的,因此不能直接将插入的内容像开始的日历一样直接放置在日历的格子中。而要进行多带带的处理。

另外,只要分类不变,日期和分类构成的网格是不用重绘的。

考虑到以上情况,插入内容的和网格是需要分开来的,我将现成的日历弄成一下3D效果示意:

即插入内容的层是多带带放置在时间和分类构成的网格上方的。

基于以上分析,先构建如下基本结构:

2017年12月04日 - 10日
车辆

结构如上,实现代码就不用展示了。

绘制实现

初始好了必要的结构,我们接着进行日历的绘制工作。

分类绘制

首先要处理的是分类,周视图中,一周的天数是固定的,确定好分类才能绘制出主体部分的网格。

对于分类,暂时考虑如下必要数据格式:

{
    id: "cate-1", // 分类ID
    name: "法拉利", // 分类名称
    content: "苏E00000" // 分类的具体描述
}

实现如下:

{
    // 设置分类数据
    setCategory: function (data) {
        if (!(data instanceof Array)) {
            this.throwError("分类数据必须是一个数组");
            return;
        }
        this._categoryData = data;

        // 绘制分类
        this._renderCatagories();
        // 绘制其他需要改变的部分
        this._renderChanged();
    },
    // 左侧分类渲染
    _renderCatagories: function () {
        this._categoryListEl.innerHTML = "";

        var i = 0,
            data = this._categoryData,
            node = document.createElement("li"),
            cataEl;
        node.className = "ep-weekcalendar-category";

        // 用行作为下标记录当前分类id集合
        this._categoryIndexs = [];
        // id为键记录索引
        this._categoryReocrds = {};

        while (i < data.length) {
            this._categoryIndexs.push(data[i].id);
            this._categoryReocrds[data[i].id] = i;
            cataEl = node.cloneNode(true);
            this._rendercategory(data[i], cataEl);
            i++;
        }
        // 分类重绘必定重绘网格和内容
        this._renderGrid();
        this._rednerContent();
    },
    _rendercategory: function (cate, cateEl) {
        cateEl.setAttribute("data-cateid", cate.id);

        var titleEl = document.createElement("span"),
            contentEl = document.createElement("span");
        titleEl.className = "title";
        contentEl.className = "content";

        titleEl.innerHTML = cate.name;
        contentEl.innerHTML = cate.content;
        cateEl.appendChild(titleEl);
        cateEl.appendChild(contentEl);

        this.fire("categoryRender", {
            categoryEl: cateEl,
            titleEl: titleEl,
            contentEl: contentEl
        });

        this._categoryListEl.appendChild(cateEl);

        this.fire("agterCategoryRender", {
            categoryEl: cateEl,
            titleEl: titleEl,
            contentEl: contentEl
        });
    }
}

上面通过设置分类数据 setCategory 作为入口,调用绘制分类方法,其中还调用了 _renderChanged 此方法用于重新绘制日历的可变部分,如标题、日期和其中的内容,会在之后进行介绍。

日期绘制

上面已经准备好了分类轴,还需要绘制出日期轴,对于周视图而言,一周的实现就非常简单了,根据一周的开始日期,依次渲染7天即可。 注意在绘制过程中提供日期的必要信息给相应事件,一遍使用者能够在事件中进行个性化处理。

{
    // 渲染日历的星期
    _renderWeeks: function () {
        this._weeksEl.innerHTML = "";
        var i = 0,
            currDate = this._startDate.clone(),
            node = document.createElement("div"),
            week;
        node.className = "ep-weekcalendar-week";

        // 单元格列作为下标记录日期
        this._dateRecords = [];

        while (i++ < 7) {
            // 更新记录日期
            this._dateRecords.push(currDate.clone());

            week = node.cloneNode(true);
            this._renderWeek(currDate, week);
            currDate.add(1, "day");
        }

        // 切换日期 需要重绘内容区域
        this._rednerContent();
    },

    _renderWeek: function (date, node) {
        var dateText = date.format("YYYY-MM-DD"),
            day = date.isoWeekday();

        if (day > 5) {
            node.className += " weekend";
        }
        if (date.isSame(this.today, "day")) {
            node.className += " today";
        }

        node.setAttribute("data-date", dateText);
        node.setAttribute("date-isoweekday", day);

        var ev = this.fire("dateRender", {
            // 当前完整日期
            date: dateText,
            // iso星期
            isoWeekday: day,
            // 显示的文本
            dateText: "周" + this._WEEKSNAME[day - 1] + " " + date.format("MM-DD"),
            // classname
            dateCls: node.className,
            // 日历el
            el: this.el,
            // 当前el
            dateEl: node
        });

        // 处理事件的修改
        node.innerHTML = ev.dateText;
        node.className = ev.dateCls;

        this._weeksEl.appendChild(node);

        this.fire("afterDateRender", {
            // 当前完整日期
            date: dateText,
            // iso星期
            isoWeekday: day,
            // 显示的文本
            dateText: node.innerHTML,
            // classname
            dateCls: node.className,
            // 日历el
            el: this.el,
            // 当前el
            dateEl: node
        });
    }
}
网格和内容

上面已经准备好了二维视图中的两个轴,接着进行网格和内容层的绘制即可。

网格

此处以分类为Y方向(行),日期为X方向(列)来进行绘制:

{
    // 右侧网格
    _renderGrid: function () {
        this._gridEl.innerHTML = "";

        var rowNode = document.createElement("div"),
            itemNode = document.createElement("span"),
            rowsNum = this._categoryData.length,
            i = 0,
            j = 0,
            row, item;

        rowNode.className = "ep-weekcalendar-grid-row";
        itemNode.className = "ep-weekcalendar-grid-item";

        while (i < rowsNum) {
            row = rowNode.cloneNode();
            row.setAttribute("data-i", i);
            j = 0;

            while (j < 7) {
                item = itemNode.cloneNode();
                // 周末标识
                if (this.dayStartFromSunday) {
                    if (j === 0 || j === 6) {
                        item.className += " weekend";
                    }
                } else {
                    if (j > 4) {
                        item.className += " weekend";
                    }
                }

                item.setAttribute("data-i", i);
                item.setAttribute("data-j", j);
                row.appendChild(item);

                j++;
            }

            this._gridEl.appendChild(row);

            i++;
        }

        rowNode = itemNode = row = item = null;
    }
}

内容

理论上来说,二维要支持跨行、跨列两种情况,即内容区域应该为一整块元素。但是结合到实际情况,跨时间的需求普遍存在(一个东西在一段时间内被连续使用)。跨分类并没有多大的实际意义,本来就要分开以分类来管理,再跨分类,又变得复杂了。而且即使一定要实现一段时间内同时在使用多个东西,也是可以直接实现的(分类A在XX时间段内被使用,B在XX时间段内被使用,只是此时XX正好相同而已)。

因此此处仅处理跨时间情况,可将内容按行即分类进行绘制,这样在插入内容部件时,可以简化很多计算。

{
    // 右侧内容
    _rednerContent: function () {
        this._contentEl.innerHTML = "";

        var i = 0,
            node = document.createElement("div"),
            row;

        node.className = "ep-weekcalendar-content-row";

        while (i < this._categoryData.length) {
            row = node.cloneNode();
            row.setAttribute("data-i", i);

            this._contentEl.appendChild(row);
            ++i;
        }

        row = node = null;

    },

    // 日期切换时清空内容
    _clearContent: function () {
        var rows = this._contentEl.childNodes,
            i = 0;

        while (i < rows.length) {
            rows[i].innerHTML && (rows[i].innerHTML = "");
            ++i;
        }

        // 部件数据清空
        this._widgetData = {};
    }
}

如果一定要实现跨行跨列的情况,直接将内容绘制成一整块元素即可,但是在点击事件和插入内容部件时,需要同时计算对应的分类和日期时间。

难点实现 内容部件插入

我们实现这个二维周视图日历的主要目的就是要支持插入任意的内容,上面已经准备好了插入内容的dom元素,这里要做的就是将数据绘制成dom放置在合适的位置。

考虑必要的内容部件数据结构如下:

{
    id: "数据标识",
    categoryId: "所属分类标识",
    title: "名称",
    content: "内容",
    start: "开始日期时间"
    end: "结束日期时间"
    bgColor: "展示的背景色"
}

由于上面在内容区域是直接按照分类作为绘制的,因此拿到数据后,对应的分类就已经存在了。重点要根据指定的开始和结束时间计算出开始和结束位置。

考虑如下:

考虑响应式,位置计算按照百分比计算

一周的总时间是固定的,开始日期时间和这周开始日期时间的差额占总时间的百分比即开始位置的百分比

结束日期时间和开始时间的差额占总时间的百分比即为结束时间距离最左侧的百分比

注意处理开始和结束时间溢出本周的情况

因此关于位置计算可以用如下代码处理:

{
    // 日期时间分隔符 默认为空 对应格式为 "2017-11-11 20:00"
    // 对于"2017-11-11T20:00" 这样的格式务必指定正确的日期和时间之间的分隔符T
    _dateTimeSplit:" ",
    // 一周分钟数
    _WEEKMINUTES: 7 * 24 * 60,
    // 一周秒数
    _WEEKSECONDS: 7 * 24 * 3600,
    // 一天的分钟数秒数
    _DAYMINUTES: 24 * 60,
    _DAYSCONDS: 24 * 3600,
    // 计算位置的精度 取值second 或 minute
    posUnit: "second",
    // 计算指定日期的分钟或秒数
    _getNumByUnits: function (dateStr) {
        var temp = dateStr.split(this._dateTimeSplit),
            date = temp[0];

        // 处理左侧溢出
        if (this._startDate.isAfter(date, "day")) {
            // 指定日期在开始日期之前
            return 0;
        }
        // 右侧溢出直接算作第7天即可
        var times = (temp[1] || "").split(":"),
            days = (function (startDate) {
                var currDate = startDate.clone(),
                    i = 0,
                    d = moment(date, "YYYY-MM-DD");
                while (i < 7) {
                    if (currDate.isSame(d, "day")) {
                        return i;
                    } else {
                        currDate.add(1, "day");
                        ++i;
                    }
                }

                console && console.error && console.error("计算天数时出错!");
                return i;
            }(this._startDate)),
            hours = parseInt(times[0], 10) || 0,
            minutes = parseInt(times[1], 10) || 0,
            seconds = parseInt(times[2], 10) || 0,
            // 对应分钟数
            result = days * this._DAYMINUTES + hours * 60 + minutes;

        return this.posUnit == "minute" ? result : (result * 60 + seconds);
    },
    // 计算日期时间的百分比位置
    _getPos: function (dateStr) {
        var p = this._getNumByUnits(dateStr) / (this.posUnit == "minute" ? this._WEEKMINUTES : this._WEEKSECONDS);

        return p > 1 ? 1 : p;
    }
}

上面就拿到了一个数据所对应的开始位置和结束位置。基本上是已经完成了,但是还需要再处理一个情况:相同分类下的时间冲突问题。

考虑以如下方式进行:

没添加一个就记录下其数据

新增的如果和当前分类下已有的存在时间重叠,则认为冲突。

实现如下:

{
    /**
     * 检查是否发生重叠
     *
     * @param {Object} data 当前要加入的数据
     * @returns false 或 和当前部件重叠的元素数组
     */
    _checkOccupied: function (data) {

        if (!this._widgetData[data.categoryId]) {
            return false;
        }

        var i = 0,
            cate = this._widgetData[data.categoryId],
            len = cate.length,
            result = false,
            occupied = [];

        for (; i < len; ++i) {
            // 判断时间是否存在重叠
            if (data.start < cate[i].end && data.end > cate[i].start) {
                occupied.push(cate[i]);
                result = true;
            }
        }

        return result ? occupied : false;
    }
}

完成以上两步就可以往我们的内容区域中插入了

{
    // 缓存widget数据
    _cacheWidgetData: function (data) {
        if (!this._widgetData[data.categoryId]) {
            this._widgetData[data.categoryId] = [];
        }
        // 记录当前的
        this._widgetData[data.categoryId].push(data);
    },
    // 新增一个小部件
    addWidget: function (data) {
        var row = this._contentEl.childNodes[this._categoryReocrds[data.categoryId]];

        if (!row) {
            this.throwError("对应分类不存在,添加失败");
            return false;
        }

        // 先查找是否含有
        var $aim = jQuery(".ep-weekcalendar-content-widget[data-id="" + data.id + ""]", row);

        if ($aim.length) {
            // 已经存在则不添加
            return $aim[0];
        }

        // 创建部件
        var widget = document.createElement("div"),
            title = document.createElement("span"),
            content = document.createElement("p"),
            startPos = this._getPos(data.start),
            endPos = this._getPos(data.end),
            _data = {
                categoryId: data.categoryId,
                id: data.id,
                start: startPos,
                end: endPos,
                el: widget,
                data: data
            };

        widget.className = "ep-weekcalendar-content-widget";
        title.className = "ep-weekcalendar-content-widget-title";
        content.className = "ep-weekcalendar-content-widget-content";

        widget.appendChild(title);
        widget.appendChild(content);

        // 通过绝对定位,指定其left和right来拉开宽度的方式来处理响应式
        // 可以通过样式设置一个最小宽度,来避免时间段过小时其中文本无法显示的问题
        widget.style.left = startPos * 100 + "%";
        widget.style.right = (1 - endPos) * 100 + "%";
        data.bgColor && (widget.style.backgroundColor = data.bgColor);

        data.id && widget.setAttribute("data-id", data.id);
        widget.setAttribute("data-start", data.start);
        widget.setAttribute("data-end", data.end);

        title.innerHTML = data.title;
        data.content && (content.innerHTML = data.content);
        widget.title = data.title;

        // 检查是否发生重叠
        var isoccupied = this._checkOccupied(_data);

        if (isoccupied) {
            // 触发重叠事件
            var occupiedEv = this.fire("widgetoccupied", {
                occupiedWidgets: (function () {
                    var arr = [];
                    for (var i = 0, l = isoccupied.length; i < l; ++i) {
                        arr.push(isoccupied[i].el);
                    }
                    return arr;
                })(),
                currWidget: widget,
                widgetData: data
            });

            // 取消后续执行
            if (occupiedEv.cancel) {
                return false;
            }
        }

        // 缓存数据
        this._cacheWidgetData(_data);

        var addEv = this.fire("widgetAdd", {
            widgetId: data.id,
            categoryId: data.categoryId,
            start: data.start,
            end: data.end,
            startPos: startPos,
            endPos: endPos,
            widgetEl: widget
        });

        if (addEv.cancel) {
            return false;
        }

        row.appendChild(widget);

        this.fire("afterWidgetAdd", {
            widgetId: data.id,
            categoryId: data.categoryId,
            start: data.start,
            end: data.end,
            startPos: startPos,
            endPos: endPos,
            widgetEl: widget
        });

        return widget;
    },
}
点击事件和范围选择

此控件不仅用于结果展示,还要可用于点击进行添加,需要处理其点击事件,但是由于要展示内容,内容是覆盖在分类和日期构成的网格之上的,用户的点击是点击不到网格元素的,必须要根据点击的位置进行计算来获取所点击的日期和所在分类。

同时,由于展示的部件都是时间范围的,因此点击返回某天和某个分类是不够的,还需要能够支持鼠标按下拖动再松开,来直接选的一段时间。

考虑到以上需求,点击事件不能直接使用 click 来实现,考虑使用 mousedownmouseup 来处理点击事件,同时需要在 mousemove 中实时给出用户响应。

{
    _initEvent: function () {
        var me = this;
        // 点击的行索引
        var row,
            // 开始列索引
            columnStart,
            // 结束列索引
            columnEnd,
            // 是否在按下、移动、松开的click中
            isDurringClick = false,
            // 是否移动过 用于处理按下没有移动直接松开的过程
            isMoveing = false,
            $columns,
            // 网格左侧宽度
            gridLeft,
            // 每列的宽度
            columnWidth
        jQuery(this.el)
            // 按下鼠标 记录分类和开始列
            .on("mousedown.weekcalendar", ".ep-weekcalendar-content-row", function (e) {
                isDurringClick = true;
                gridLeft = jQuery(me._gridEl).offset().left;
                columnWidth = jQuery(me._gridEl).width() / 7;
                jQuery(me._gridEl).find(".ep-weekcalendar-grid-item").removeClass(me._selectedCls);

                row = this.getAttribute("data-i");
                $columns = jQuery(me._gridEl).find(".ep-weekcalendar-grid-row").eq(row).children();

                columnStart = (e.pageX - gridLeft) / columnWidth >> 0;

            });
        // 移动和松开 松开鼠标 记录结束列 触发点击事件 
        // 不能直接绑定在日期容器上 否则鼠标移出日历后,松开鼠标,实际点击已经结束,但是日历上处理不到。
        jQuery("body")
            // 点击移动过程中 实时响应选中状态
            .on("mousemove.weekcalendar", function (e) {
                if (!isDurringClick) {
                    return;
                }
                isMoveing = true;

                // 当前列索引
                var currColumn;

                // mousemoveTimer = setTimeout(function () {
                currColumn = (e.pageX - gridLeft) / columnWidth >> 0;

                // 修正溢出
                currColumn = currColumn > 6 ? 6 : currColumn;
                currColumn = currColumn < 0 ? 0 : currColumn;

                $columns.removeClass(me._selectedCls);

                // 起止依次选中
                var start = Math.min(columnStart, currColumn),
                    end = Math.max(columnStart, currColumn);

                do {
                    $columns.eq(start).addClass(me._selectedCls);
                } while (++start <= end);
            })
            // 鼠标松开
            .on("mouseup.weekcalendar", function (e) {
                if (!isDurringClick) {
                    return;
                }

                var startIndex = -1,
                    endIndex = -1;

                columnEnd = (e.pageX - gridLeft) / columnWidth >> 0;

                columnEnd = columnEnd > 6 ? 6 : columnEnd;

                // 没有移动过时
                if (!isMoveing) {
                    startIndex = endIndex = columnEnd;
                    // 直接down up 没有move的过程则只会有一个选中的,直接以结束的作为处理即可
                    $columns.eq(columnEnd).addClass(me._selectedCls)
                        .siblings().removeClass(me._selectedCls);
                } else {
                    startIndex = Math.min(columnStart, columnEnd);
                    endIndex = Math.max(columnStart, columnEnd);
                }

                // 触发点击事件
                me.fire("cellClick", {
                    // 分类id 
                    categoryId: me._categoryIndexs[row],
                    // 时间1
                    startDate: me._dateRecords[startIndex].format("YYYY-MM-DD"),
                    // 日期2
                    endDate: me._dateRecords[endIndex].format("YYYY-MM-DD"),
                    // 行索引
                    rowIndex: row,
                    // 列范围
                    columnIndexs: (function (i, j) {
                        var arr = [];
                        while (i <= j) {
                            arr.push(i++);
                        }
                        return arr;
                    }(startIndex, endIndex))
                });

                row = columnStart = columnEnd = isMoveing = isDurringClick = false;
            });
    }
}

此过程要注意的问题是:mousedown 必须绑定在日历上,而 mouseupmousemove 则不能绑定在日历上,具体原因已经写在上面代码注释中了。

另外需要注意,由于范围点击选择使用了 mousedownmouseup 来模拟,那么日历内容区域中插入的数据部件的点击事件也要用 mousedownmouseup 来模拟,因为 mouseup 触发比 click 早,如果使用 click ,会导致先触发日历上的日期点击或日期范围点击。

使用

此日历实现基于一个控件基类扩展而来,其必要功能仅为一套事件机制,可参考实现一套自定义事件机制

实测一下效果吧:

github

demo

本文首发我的博客:https://blog.cdswyda.com/post/2017121022

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/92057.html

相关文章

  • 带你开发一个日历控件

    摘要:直接使用事件代理机制,将事件绑定在整个日历的上即可,这样事件只用在创建时初始化一次即可,简单高效省内存。 首发我的博客 - https://blog.cdswyda.com/post/2017121010 日历控件多的不胜枚举,为什么我们还要再造一个轮子呢? 因为大多数日历控件都是用于选择日期的,有种需求是要在日历上展示各种各样的内容,这样的日历控件较少,而且试用下来并不满意。 因此就...

    shiina 评论0 收藏0
  • FullCalendar插件的基本使用

    摘要:插件的依赖文件插件的使用页面结构设置日历头部信息,如果设置为,则不显示头部信息。设置日历的高度,包括日历头部,默认未设置,高度根据值自适应。 先说一下我的另一博客地址: https://home.cnblogs.com/u/bllx/ FullCalendar的选择 前段时间,一直在开发考勤系统,当时为满足设计的需求,选了好几个插件,最后决定采用Fullcanlendar的插件。感觉这...

    import. 评论0 收藏0
  • Django 学习小组:博客开发实战第二教程 —— 实现博客详情页面和分类页面

    摘要:本节接上周的文档学习小组博客开发实战第一周教程编写博客的首页面,我们继续给博客添加功能,以及改善前面不合理的部分。返回该视图要显示的对象。目前小组正在完成第一个项目,本文即是该项目第二周的相关文档。 本教程内容已过时,更新版教程请访问: django 博客开发入门教程。 上周我们完成了博客的 Model 部分,以及 Blog 的首页视图 IndexView。 本节接上周的文档 Djan...

    ingood 评论0 收藏0
  • Flutter日历,可以自定义风格UI

    摘要:日历的项目地址示例上的一个日历控件,可以定制成自己想要的样子。跳转到指定日期,默认支持动画切换自定义日历,支持组合的方式和利用绘制的方式自定义顶部的可以给添加自定义的额外数据,实现各种额外的功能。介绍 最近自己写了个demo,用到了日历方面的东西,然后实现来一下,最后打算封装一下,以后可以直接拿来使用。 参考了Android的一个开源日历库github.com/huanghaibin…,实现...

    icyfire 评论0 收藏0

发表评论

0条评论

张金宝

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<