Ant Design Calendar 实战:用 cellRender 绘制跨天事件范围(Event Range)
【免费下载链接】ant-designAn enterprise-class UI design language and React UI library项目地址: https://gitcode.com/GitHub_Trending/an/ant-design
Ant Design 的 Calendar 组件通过cellRender属性开放了自定义日期格内容的扩展点。本文围绕官方示例 event-range 演示 展开,讲解如何根据每个日期判定事件的"开始、中间、结束、单日"四种范围状态,并用带负边距的连续色条把跨天事件(如发布窗口、维护窗口)绘制成视觉上一条连贯的横幅。读完本文,你将掌握cellRender的调用机制、事件范围定位的纯函数写法,以及色条圆角/负边距的 CSS 细节,可以直接迁移到排期看板、发布日历等场景。
一、事件数据模型:一个 CalendarEvent 就是一个范围
示例源码位于 event-range.tsx。整个方案建立在一条简单的心智模型上:事件不是挂在某一天的,而是挂在日期区间上的,日历的每一格只负责回答"我今天和这条事件是什么关系"。
export interface CalendarEvent { key: string; title: string; start: Dayjs; end: Dayjs; color: string; } const getEvents = (token: ReturnType<typeof theme.useToken>['token']): CalendarEvent[] => [ { key: 'release', title: 'Release window', start: dayjs('2026-01-08'), end: dayjs('2026-01-10'), color: token.colorPrimary, }, { key: 'design-review', title: 'Design review', start: dayjs('2026-01-14'), end: dayjs('2026-01-14'), color: token.colorSuccess, }, { key: 'maintenance', title: 'Maintenance', start: dayjs('2026-01-21'), end: dayjs('2026-01-24'), color: token.colorWarning, }, { key: 'bug-fix', title: 'Bug fix', start: dayjs('2026-01-30'), end: dayjs('2026-01-31'), color: token.colorError, }, ];几个值得注意的设计:
start与end都是闭区间端点(isBefore/isAfter判定均含边界),单日事件的写法就是start === end,不需要单独的类型字段;- 颜色来自 Design Token(
token.colorPrimary/colorSuccess/colorWarning/colorError),事件颜色自动跟随主题与暗色模式,而不是写死十六进制色值; - 事件列表通过
React.useMemo(() => getEvents(token), [token])缓存,token变化(如切换主题)时重新计算。
由于事件都落在 2026 年 1 月,组件必须把日历定位到该月,否则打开示例一片空白:
<Calendar classNames={{ itemContent: styles.itemContent }} defaultValue={dayjs('2026-01-01')} cellRender={cellRender} />二、核心逻辑:两个纯函数判定范围状态
cellRender会对面板中的每一个日期格各调用一次,因此判定逻辑必须是无状态的纯函数。示例把判定拆成两步:
const isInRange = (current: Dayjs, event: CalendarEvent) => { return !current.isBefore(event.start, 'day') && !current.isAfter(event.end, 'day'); }; const getRangePosition = (current: Dayjs, event: CalendarEvent) => { const starts = current.isSame(event.start, 'day'); const ends = current.isSame(event.end, 'day'); if (starts && ends) { return 'single'; } if (starts) { return 'start'; } if (ends) { return 'end'; } return 'middle'; };isInRange以'day'为粒度做闭区间比较,先把"与我无关的日期"过滤掉;getRangePosition对落在区间内的日期再细分出start/middle/end/single四种位置。注意starts && ends的分支必须放在最前面——单日事件同时满足 starts 和 ends,若先判starts会把它误标为开端。
这两步构成start(左圆角、显示标题)→middle(贯通两端、不显示标题)→end(右圆角)的完整状态机。跨月场景下,如果面板跨月,middle/end状态自然会延续到下一个月,逻辑无需任何额外处理。
三、视觉表达:负边距 + 圆角拼出连续色条
"一条横幅横跨多个日期格"的关键技巧在于:每个格子只渲染自己那一小段色条,再用负边距把相邻格子的色条在视觉上接成一条。示例使用antd-style的createStyles从 CSS 变量读取 Design Token:
const useStyle = createStyles(({ cssVar, css }) => { const barRadius = 999; const { controlHeight, marginXXS, controlHeightSM, colorTextLightSolid, fontSizeSM, paddingXS, marginXS, paddingXXS, } = cssVar; return { itemContent: css` overflow: visible; `, cell: css` min-height: ${controlHeight}; `, list: css` display: flex; flex-direction: column; gap: ${marginXXS}; margin-top: ${marginXXS}; `, bar: css` display: block; height: calc(${controlHeightSM} - ${marginXXS}); overflow: hidden; color: ${colorTextLightSolid}; font-size: ${fontSizeSM}; white-space: nowrap; text-overflow: ellipsis; `, barStart: css` margin-inline-end: calc(-1 * (${paddingXS} + ${marginXS} / 2)); padding-inline-start: calc(${paddingXXS} + ${paddingXXS}); border-start-start-radius: ${barRadius}px; border-end-start-radius: ${barRadius}px; `, barMiddle: css` margin-inline: calc(-1 * (${paddingXS} + ${marginXS} / 2)); `, barEnd: css` margin-inline-start: calc(-1 * (${paddingXS} + ${marginXS} / 2)); border-start-end-radius: ${barRadius}px; border-end-end-radius: ${barRadius}px; `, barSingle: css` padding-inline-start: calc(${paddingXXS} + ${paddingXXS}); border-radius: ${barRadius}px; `, }; });逐项拆解这套样式与日历内部布局的配合关系:
- 负边距的数值从哪来。日历格内容(
-date-content)本身带有内边距,色条左右各用calc(-1 * (paddingXS + marginXS / 2))向两侧"伸出",恰好抵消单元格内边距与相邻单元格间的间距(日历格的水平间距是marginXS / 2,见 style/index.ts 中${calendarCls}-date的margin定义)。这样start段延伸到本格右边界、middle段同时伸出左右两边、end段补齐左边界,三段在像素上首尾相接,观感上就是一条完整横幅。 - 圆角只在两端出现。
barStart只给左侧两个圆角,barEnd只给右侧两个圆角,barMiddle无圆角,barSingle四角全圆——999的超大半径保证色条两端呈半圆胶囊形。 overflow: visible是前提。Calendar 的日期内容区-date-content默认有固定高度与滚动裁剪(overflowY: auto,见 style/index.ts),如果不把itemContent的 overflow 放开,向右伸出的负边距部分会被格子裁掉,色条就接不上了。这也是示例中classNames={{ itemContent: styles.itemContent }}这一行的真正作用——它通过 6.0 的语义化 DOM 结构(Semantic DOM)精确命中了内容层。- 多事件用纵向 flex 堆叠。
list是flex-direction: column加gap: marginXXS,同一天命中多条事件时色条自上而下排列,互不干扰。 - 文案防溢出。
bar上用white-space: nowrap+text-overflow: ellipsis+overflow: hidden,长标题自动省略号;标题只在start或single位置渲染,避免同一事件文案沿范围重复出现。
四、源码视角:cellRender 在 Calendar 内部如何被调用
cellRender是 Calendar 在 5.4.0 引入的统一单元格渲染扩展点。查看组件实现 generateCalendar.tsx 可以确认其调用链:
// generateCalendar.tsx 内部(节选) const dateRender = React.useCallback( (date: DateType, info: CellRenderInfo<DateType>): React.ReactNode => { if (isFunction(fullCellRender)) { return fullCellRender(date, info); } // ... return ( <div className={clsx(`${prefixCls}-cell-inner`, `${calendarPrefixCls}-date`, { /* ... */ })}> <div className={`${calendarPrefixCls}-date-value`}> {String(generateConfig.getDate(date)).padStart(2, '0')} </div> <div className={clsx(`${calendarPrefixCls}-date-content`, mergedItemContentClassName)} style={mergedItemContentStyle} > {isFunction(cellRender) ? cellRender(date, info) : dateCellRender?.(date)} </div> </div> ); }, [/* ... */], );由此可以得到几个实现层面的结论:
cellRender的返回值只填充日期格的内容区(-date-content),日期数字那一行(-date-value)仍然由组件渲染;如果想要"整格接管"(连日期数字一起覆盖),应改用fullCellRender。- 第二个参数
info是CellRenderInfo,来自@rc-component/picker的CellRenderInfo类型(见 generateCalendar.tsx 的导入),包含prefixCls、originNode、today、type、locale等字段。示例中的info.type !== 'date'守卫正是用它区分日期格与月份格——cellRender在 year 模式下会被用于月份格(monthRender分支,见 generateCalendar.tsx),若不做类型过滤,事件条会出现在不该出现的位置。 classNames.itemContent/styles.itemContent会被组件透传。mergedItemContentClassName与mergedItemContentStyle直接挂在内容层的div上(上方代码中可见),这正是示例能覆盖 overflow 的机制来源;完整的语义结构定义见CalendarSemanticType(generateCalendar.tsx:root/header/body/content/item/itemContent)。- 旧 API 已弃用。
dateCellRender、dateFullCellRender、monthCellRender、monthFullCellRender在开发模式下会触发deprecated警告(generateCalendar.tsx),官方文档(index.en-US.md)也明确建议统一使用cellRender/fullCellRender。 - 同目录下的 notice-calendar 示例 展示了
cellRender的另一典型用法——按info.type分发dateCellRender/monthCellRender,未匹配的分支返回info.originNode以保留默认渲染;对比两者可以更清楚地理解info参数的用途。
五、完整渲染流程与 cellRender 实现
把范围判定与样式组合起来,示例的cellRender全貌如下:
const cellRender = React.useCallback<NonNullable<CalendarProps<Dayjs>['cellRender']>>( (current, info) => { if (info.type !== 'date') { return null; } const currentEvents = events.filter((event) => isInRange(current, event)); return ( <div className={styles.cell}> <div className={styles.list}> {currentEvents.map((event) => { const position = getRangePosition(current, event); const rangeClassName = { start: styles.barStart, middle: styles.barMiddle, end: styles.barEnd, single: styles.barSingle, }[position]; return ( <span key={event.key} className={clsx(styles.bar, rangeClassName)} style={{ backgroundColor: event.color }} > {position === 'start' || position === 'single' ? event.title : null} </span> ); })} </div> </div> ); }, [events, styles], );整体数据流可以概括为:
RCPickerPanel遍历当月(或当年)的每个日期格,逐格调用cellRender(date, info);info.type === 'date'过滤出日期格,否则返回null;events.filter(isInRange)得到当天命中的全部事件——注意这里是"多条",因此渲染的是列表而非单一条目;- 对每条事件用
getRangePosition取位置,映射到对应的圆角/负边距样式类; - 背景色由内联
style={{ backgroundColor: event.color }}提供,事件与事件之间互不影响。
React.useCallback把cellRender稳定为只依赖events与styles的引用,避免面板每次重渲染都拿到新函数引用;NonNullable<CalendarProps<Dayjs>['cellRender']>则让函数体内省去空值判断,类型上锁定为必填版本。
六、落地时的注意点
结合源码与样式实现,把该方案迁移到自己的项目时有几点值得留意:
- 负边距数值必须与单元格实际内边距/间距匹配。示例的
paddingXS + marginXS / 2对应的是当前版本日历格-date节点的padding与margin(见 style/index.ts)。如果通过 ConfigProvider 或语义化样式修改了日历的间距 Token,这套负边距需要同步调整,否则色条会出现重叠或留缝。 classNames/styles语义化 API 从 6.0 起可用(见 index.en-US.md 的属性表),低版本可通过全局样式覆盖ant-picker-calendar-date-content的 overflow 达到同样效果。- 单日事件务必单独分支。若缺少
starts && ends的优先判断,单日事件会被渲染成只有左圆角的start段,右端"悬空"。 - 性能上
isInRange是 O(事件数) 的线性过滤,每月最多渲染 42 格左右,事件量在数百条以内时没有压力;若事件量很大,可以先按月份建立索引或把"当月事件"在面板切换时(onPanelChange)预筛一次。 - 组件文档提示:Calendar 的部分 locale 信息会读取
value,请在全局入口正确设置 dayjs 的 locale(见 index.en-US.md 的 Note 与 FAQ)。
小结
这个事件范围示例的核心价值不在于"画了几个色条",而是示范了一套日历扩展点的标准范式:用纯函数把"日期与区间的数据关系"(isInRange/getRangePosition)和"视觉表达"(四种圆角/负边距样式类)彻底解耦,再借cellRender的info.type守卫保证只在正确的格子类型上生效。配合classNames.itemContent放开 overflow,就能让每格独立渲染的片段拼接成连贯的跨天横幅——这套模式同样适用于甘特式排期、值班表、发布窗口等任何"日期 × 事件"的展示场景。
更多 Calendar 用法(周数显示、迷你模式、语义化 DOM 结构等)可参考组件文档 index.en-US.md 与 index.zh-CN.md。
【免费下载链接】ant-designAn enterprise-class UI design language and React UI library项目地址: https://gitcode.com/GitHub_Trending/an/ant-design
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考