ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity日历组件开发:从日期计算到UI交互的完整实现方案

2026/8/10 7:48:15 拓冰建站 浏览量
Unity日历组件开发:从日期计算到UI交互的完整实现方案 1. 项目概述为什么要在Unity里做日历做游戏或者应用UI里加个日历功能听起来挺简单不就是画个格子填数字嘛。但真动手做你会发现一堆坑日期怎么算闰年怎么处理农历要不要支持点选日期怎么反馈选个时间段怎么高亮更别说还得适配不同屏幕尺寸性能还不能差。我之前接手过一个模拟经营项目里面有个“市场行情”系统需要玩家查看未来一周甚至一个月的商品价格走势日历就成了核心交互组件。市面上虽然有一些现成的插件但要么功能太臃肿要么定制性不够要么就是授权问题麻烦。所以自己动手实现一个轻量、灵活、性能可控的实时日历就成了刚需。这个“Unity实现实时日历”项目核心目标就是打造一个从底层日期逻辑到上层UI交互完全可控的解决方案。它不仅仅是静态显示更要能响应用户操作点击、拖拽选择支持公历农历切换能灵活初始化到任意时间点并且作为一个UI组件要能无缝集成到你的Canvas体系里。无论是用于游戏内的任务系统、签到功能、时间管理工具还是应用内的日期选择器这个自研的日历都能给你提供清晰的架构和充足的扩展点。2. 核心架构与设计思路拆解2.1 数据层日期计算的核心引擎日历的本质是时间数据的可视化。所以一切的基础是一个可靠、高效的日期计算模块。在Unity里我们直接使用C#内置的System.DateTime和System.Globalization.ChineseLunisolarCalendar用于农历作为计算核心但需要做一层封装。为什么不用简单的数组存储日期因为日期有很强的逻辑性。比如要获取2024年5月1日是星期几或者这个月有多少天如果只用数组存储数字每次查询都需要重新计算效率低下且代码混乱。正确的做法是构建一个“日期数据模型”。我通常会创建一个CalendarDayData类它不负责UI显示只纯粹承载数据public class CalendarDayData { public DateTime Date { get; private set; } // 公历日期 public ChineseLunisolarCalendar LunarCalendar { get; private set; } // 农历计算器 public string LunarDayString { get; private set; } // 农历日描述如“初一” public bool IsToday { get; private set; } // 是否是今天 public bool IsCurrentMonth { get; private set; } // 是否属于当前显示的月份 public bool IsSelectable { get; private set; } // 是否可被点击选择用于控制不可选未来/过去日期 // ... 其他业务相关属性如是否有事件标记、是否在选中范围内等 }然后会有一个CalendarMonthData类来管理整个月的数据它的核心方法是GenerateDaysForMonth(int year, int month)。这个方法要解决一个关键问题如何生成一个完整的月份视图包括上个月末的几天和下个月初的几天以保证每周都是从周日开始或周一根据文化习惯计算逻辑如下获取目标月份第一天的DateTime对象firstDayOfMonth。计算firstDayOfMonth是星期几假设周日为0。这个值决定了我们需要向前补多少天。向前补足天数生成上个月末尾的日期数据并标记IsCurrentMonth false。生成当前月所有日期数据标记IsCurrentMonth true并判断是否是今天 (IsToday)。向后补足天数直到补满一个完整的矩形网格通常是6行7列42天生成下个月初的日期数据标记IsCurrentMonth false。注意农历计算是相对耗时的操作。不要在每一天生成时都去计算农历。更好的做法是在CalendarMonthData初始化时一次性为所有需要显示农历的日期计算好并缓存起来。如果支持农历切换可以提供一个刷新农历显示的方法而不是每次切换都重新生成全部数据。2.2 表现层UI组件的组织与渲染数据有了怎么把它漂亮地画出来这里就是UGUI的舞台了。核心思路是“对象池数据驱动”。1. 网格布局最外层是一个GridLayoutGroup或HorizontalLayoutGroup嵌套VerticalLayoutGroup来排列星期标题日、一、二…。日期网格部分强烈建议使用GridLayoutGroup设置Constraint为Fixed Column Count 7它能自动帮你把子物体排列成整齐的7列无论容器大小如何变化都能保持等间距省去大量手动计算位置的工作。2. 日期项预制体创建一个DayItemPrefab它至少包含一个TextMeshProUGUI或Text用于显示公历日期。另一个TextMeshProUGUI用于显示农历可选可隐藏。一个Image作为背景用于显示选中、悬停、今天等状态。一个Button组件或自己用EventTrigger实现用于接收点击事件。3. 对象池管理日历视图通常要显示42个日期项。如果每次打开日历都实例化42个关闭时销毁在频繁打开关闭的场景下会产生GC垃圾回收压力。因此必须实现一个简单的对象池。在日历初始化时一次性实例化42个DayItemPrefab放入一个ListRectTransform池中。当需要刷新视图时不是销毁再创建而是从池中取出已有的项根据CalendarDayData更新其显示内容和状态然后设置到GridLayoutGroup下。这样能极大提升性能。4. 数据驱动更新为DayItemPrefab创建一个控制器脚本DayItemController。它有一个公共方法void BindData(CalendarDayData dayData)。在这个方法里根据传入的dayData设置文本、颜色、按钮交互状态等。这就是典型的数据驱动UI逻辑清晰易于维护。2.3 控制层逻辑与交互的枢纽这是连接数据和表现的大脑通常是一个单例或由更上层的UI管理器持有的CalendarController类。它负责持有当前的CalendarMonthData。管理日期项对象池。响应“上个月”、“下个月”按钮事件重新生成数据并刷新UI。处理日期项的点击事件更新选中状态并触发OnDaySelected这样的事件让业务逻辑层如签到系统去订阅。管理选择模式单选模式还是范围选择模式。范围选择模式需要记录开始日期和结束日期并在UI上高亮这个区间内的所有日期项。交互细节范围选择的高亮当在范围选择模式下用户点击第一个日期开始日期后鼠标移动经过的日期项都应该有“预高亮”效果。这需要在DayItemController里处理PointerEnter事件并通知CalendarController计算从开始日期到当前悬停日期的区间临时更新这个区间内所有日期的显示状态。当用户点击第二个日期结束日期时才确定最终范围并触发确认事件。性能优化频繁地更新42个UI元素的状态尤其是Image的颜色可能引起重建。可以考虑使用CanvasRenderer的SetColor来直接修改UI颜色而不是通过Image.color以减少Graphic的重建。对于文本如果内容没变就不要重复赋值。3. 关键功能模块的深度实现3.1 公历与农历的混合显示农历显示是中国特色需求也是难点。C#提供了ChineseLunisolarCalendar但它的API返回的是数字我们需要转换成“正月初一”、“腊月三十”这样的字符串。首先初始化一个全局的农历计算器private static ChineseLunisolarCalendar _lunarCalendar new ChineseLunisolarCalendar();然后在生成CalendarDayData时计算农历字符串public static string GetLunarDayString(DateTime date) { int lunarYear _lunarCalendar.GetYear(date); int lunarMonth _lunarCalendar.GetMonth(date); int lunarDay _lunarCalendar.GetDayOfMonth(date); bool isLeapMonth _lunarCalendar.IsLeapMonth(lunarYear, lunarMonth); // 处理闰月 if (isLeapMonth) { lunarMonth--; } string monthStr GetLunarMonthString(lunarMonth); // 将数字转为“正月”、“腊月” string dayStr GetLunarDayString(lunarDay); // 将数字转为“初一”、“十五” // 除夕特殊处理 if (lunarMonth 12 lunarDay _lunarCalendar.GetDaysInMonth(lunarYear, 12)) { return 除夕; } // 初一显示月份否则显示日期 return lunarDay 1 ? monthStr : dayStr; } // 月份和日期的映射表可以用数组或字典预定义 private static string[] LunarMonthNames new string[] { , 正月, 二月, /* ... */, 腊月 }; private static string[] LunarDayNames new string[] { , 初一, 初二, /* ... */, 三十 };实操心得农历计算有性能开销且映射表需要本地化。如果你的项目不需要农历或者仅针对海外市场一定要提供一个开关彻底关闭农历相关计算避免不必要的开销。同时农历字符串的数组最好做成可配置的ScriptableObject方便本地化团队修改。3.2 日期选择与范围选择逻辑单选逻辑相对简单CalendarController记录当前选中的DateTime当点击一个新日期时先清除旧日期项的高亮状态再设置新日期项为选中状态最后更新记录并触发事件。范围选择逻辑是复杂点其状态机如下初始状态_selectionMode RangeSelectionMode.None;_rangeStart null;_rangeEnd null;第一次点击进入RangeSelectionMode.SelectingStart记录_rangeStart高亮该单个日期项。鼠标移动/悬停进入RangeSelectionMode.SelectingEnd。实时计算从_rangeStart到当前悬停日期的区间并临时高亮该区间内所有日期项。这里的关键是效率不能每次悬停都全量刷新42个项。可以记录上一次高亮的区间本次只更新新增和移除的部分。第二次点击确认_rangeEnd将临时高亮变为永久高亮或另一种颜色触发OnRangeSelected事件状态回到None或保持为Selected。取消选择点击范围外区域或点击“取消”按钮清除所有高亮重置状态。区间高亮的算法private void HighlightRange(DateTime start, DateTime end) { // 确保 start end if (start end) (start, end) (end, start); foreach (var dayItem in _allDayItems) { bool inRange dayItem.Date start dayItem.Date end; dayItem.SetRangeHighlight(inRange); } }3.3 自适应布局与性能优化自适应布局依赖GridLayoutGroup的自动布局能力已经解决了大部分问题。但有时我们需要日期格子是正方形的。这可以通过在CalendarController的OnRectTransformDimensionsChange事件中动态计算来实现private void Update() { // 或者在UI尺寸变化时调用 if (_gridLayoutGroup ! null) { float containerWidth (_gridLayoutGroup.transform as RectTransform).rect.width; float cellSize (containerWidth - _gridLayoutGroup.spacing.x * 6) / 7; // 7列6个间隔 _gridLayoutGroup.cellSize new Vector2(cellSize, cellSize); // 强制宽高相等 } }性能优化要点对象池如前所述必须用。避免每帧查找将GridLayoutGroup、所有DayItemController的引用在Awake或Start时缓存起来。减少SetActive调用对象池中的项目不要用SetActive来显示隐藏而是通过将其移出/移入父节点或者设置其缩放为0来实现因为SetActive会触发一系列组件生命周期开销较大。文本优化使用TextMeshPro代替旧版Text性能更好效果更佳。对于固定不变的文本如星期标题使用单独的TextMeshPro组件不要和动态变化的日期文本混用同一个。Draw Call合并确保所有日期项使用的字体图集、背景Sprite都在同一个图集里这样可以合并Draw Call。如果使用了多种状态的不同颜色尽量使用CanvasRenderer.SetColor或材质属性块来改变颜色而不是切换不同的Sprite或Image。4. 实战构建一个可复用的日历预制体4.1 预制体结构与组件配置让我们一步步组装这个日历预制体。创建Canvas下的根节点UI_Calendar。给它添加Image组件作为背景添加CalendarController脚本。创建标题栏在根节点下创建Header。里面包含一个“上个月”按钮 (Button_LastMonth)一个显示“2024年5月”的TextMeshPro - MonthYearText一个“下个月”按钮 (Button_NextMonth)一个“今天”按钮 (Button_Today可选)一个“农历开关” Toggle (Toggle_Lunar可选)创建星期栏在根节点下创建Weekdays。添加HorizontalLayoutGroup下面放7个TextMeshPro分别写入“日”、“一”…“六”。创建日期网格容器在根节点下创建DaysGrid。添加GridLayoutGroup设置Cell Size为固定值或留空由脚本控制Spacing根据需要设置Constraint为Fixed Column Count 7。创建日期项预制体单独创建一个Prefabs/DayItem.prefab。其结构如下DayItem (RectTransform)Background (Image)- 用于状态高亮Text_Day (TextMeshProUGUI)- 显示公历日期Text_Lunar (TextMeshProUGUI)- 显示农历初始可设为不激活EventTrigger或Button组件 - 用于交互为这个预制体挂载DayItemController脚本。4.2 CalendarController 脚本核心代码框架using System; using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; public class CalendarController : MonoBehaviour { [Header(UI References)] [SerializeField] private Button _buttonLastMonth; [SerializeField] private Button _buttonNextMonth; [SerializeField] private TMP_Text _textMonthYear; [SerializeField] private Toggle _toggleLunar; [SerializeField] private GridLayoutGroup _daysGridLayout; [SerializeField] private DayItemController _dayItemPrefab; // 预制体引用 [SerializeField] private Transform _dayItemsPoolRoot; // 对象池存放的父节点可隐藏 [Header(Settings)] [SerializeField] private bool _showLunarByDefault false; [SerializeField] private bool _canSelectFutureDays true; private DateTime _currentMonth; // 当前视图聚焦的年月 private ListDayItemController _dayItemPool new ListDayItemController(); private const int TOTAL_DAY_ITEMS 42; // 6行*7列 // 当前选中状态 private DateTime? _selectedSingleDate null; private DateTime? _rangeStartDate null; private DateTime? _rangeEndDate null; // 事件定义 public event ActionDateTime OnDaySelected; public event ActionDateTime, DateTime OnRangeSelected; private void Awake() { // 绑定UI事件 _buttonLastMonth.onClick.AddListener(ShowPreviousMonth); _buttonNextMonth.onClick.AddListener(ShowNextMonth); _toggleLunar.onValueChanged.AddListener(SetLunarDisplay); // 初始化对象池 InitializePool(); // 初始化到当前月份 _currentMonth new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1); RefreshCalendarView(); } private void InitializePool() { for (int i 0; i TOTAL_DAY_ITEMS; i) { var dayItem Instantiate(_dayItemPrefab, _dayItemsPoolRoot); dayItem.gameObject.SetActive(false); // 先放在池里不显示 dayItem.OnClicked HandleDayItemClicked; // 订阅点击事件 _dayItemPool.Add(dayItem); } } private void RefreshCalendarView() { // 1. 更新标题 _textMonthYear.text _currentMonth.ToString(yyyy年MM月); // 2. 为当前月份生成日期数据列表 ListCalendarDayData monthData GenerateMonthData(_currentMonth.Year, _currentMonth.Month); // 3. 从对象池取出项目并绑定数据 for (int i 0; i TOTAL_DAY_ITEMS; i) { var dayItem _dayItemPool[i]; if (i monthData.Count) { dayItem.transform.SetParent(_daysGridLayout.transform); // 移到显示容器 dayItem.gameObject.SetActive(true); dayItem.BindData(monthData[i]); // 根据当前选择模式更新此项的选中/高亮状态 UpdateDayItemSelectionState(dayItem, monthData[i]); } else { // 如果数据不够42天理论上不会隐藏多余的项 dayItem.gameObject.SetActive(false); dayItem.transform.SetParent(_dayItemsPoolRoot); } } // 4. 强制布局重建有时需要 LayoutRebuilder.ForceRebuildLayoutImmediate(_daysGridLayout.GetComponentRectTransform()); } private ListCalendarDayData GenerateMonthData(int year, int month) { ListCalendarDayData dataList new ListCalendarDayData(); DateTime firstDayOfMonth new DateTime(year, month, 1); DayOfWeek firstDayWeek firstDayOfMonth.DayOfWeek; int daysInPrevMonth DateTime.DaysInMonth(year, month 1 ? 12 : month - 1); // 添加上个月末尾的几天 int daysToPrepend ((int)firstDayWeek - (int)DayOfWeek.Sunday 7) % 7; // 计算需要补几天 for (int i daysToPrepend - 1; i 0; i--) { DateTime date firstDayOfMonth.AddDays(-(i 1)); dataList.Add(CreateDayData(date, false)); } // 添加当前月所有天 int daysInMonth DateTime.DaysInMonth(year, month); for (int i 0; i daysInMonth; i) { DateTime date firstDayOfMonth.AddDays(i); dataList.Add(CreateDayData(date, true)); } // 添加下个月开头的几天补足42天 int totalCells 42; int daysToAppend totalCells - dataList.Count; for (int i 0; i daysToAppend; i) { DateTime date firstDayOfMonth.AddDays(daysInMonth i); dataList.Add(CreateDayData(date, false)); } return dataList; } private CalendarDayData CreateDayData(DateTime date, bool isCurrentMonth) { return new CalendarDayData { Date date, IsToday date.Date DateTime.Now.Date, IsCurrentMonth isCurrentMonth, IsSelectable _canSelectFutureDays || date.Date DateTime.Now.Date, LunarDayString _showLunarByDefault ? LunarCalendarHelper.GetLunarDayString(date) : string.Empty }; } private void HandleDayItemClicked(DateTime date) { // 处理单选或范围选择的点击逻辑 // ... 根据模式更新 _selectedSingleDate 或 _rangeStartDate/_rangeEndDate // ... 然后调用 RefreshSelectionHighlights() 来更新UI // ... 最后触发相应的事件 OnDaySelected?.Invoke(date); } private void ShowPreviousMonth() { _currentMonth _currentMonth.AddMonths(-1); RefreshCalendarView(); } private void ShowNextMonth() { _currentMonth _currentMonth.AddMonths(1); RefreshCalendarView(); } private void SetLunarDisplay(bool show) { _showLunarByDefault show; RefreshCalendarView(); } }4.3 DayItemController 脚本核心代码using TMPro; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class DayItemController : MonoBehaviour, IPointerClickHandler { [SerializeField] private Image _backgroundImage; [SerializeField] private TMP_Text _textDay; [SerializeField] private TMP_Text _textLunar; [SerializeField] private Color _normalColor Color.white; [SerializeField] private Color _otherMonthColor Color.gray; [SerializeField] private Color _todayColor Color.blue; [SerializeField] private Color _selectedColor Color.green; [SerializeField] private Color _inRangeColor new Color(0.8f, 1f, 0.8f, 1f); // 淡绿色 public CalendarDayData BoundData { get; private set; } public event ActionDateTime OnClicked; public void BindData(CalendarDayData data) { BoundData data; _textDay.text data.Date.Day.ToString(); // 设置农历文本 _textLunar.gameObject.SetActive(!string.IsNullOrEmpty(data.LunarDayString)); _textLunar.text data.LunarDayString; // 重置状态 UpdateVisualState(); } private void UpdateVisualState() { if (BoundData null) return; Color targetColor _normalColor; // 优先级选中 今天 非本月 if (BoundData.IsSelected) // 这个属性需要在CalendarController中设置 { targetColor _selectedColor; } else if (BoundData.IsToday) { targetColor _todayColor; } else if (!BoundData.IsCurrentMonth) { targetColor _otherMonthColor; } // 范围高亮可能和选中叠加 if (BoundData.IsInSelectedRange) { targetColor Color.Lerp(targetColor, _inRangeColor, 0.5f); // 混合颜色 } _backgroundImage.color targetColor; // 设置文本颜色和交互性 _textDay.color BoundData.IsSelectable ? Color.black : Color.gray; // 这里可以通过修改Button组件的interactable属性来控制是否可点击 // 或者通过EventTrigger的enabled来控制 } public void OnPointerClick(PointerEventData eventData) { if (BoundData ! null BoundData.IsSelectable) { OnClicked?.Invoke(BoundData.Date); } } // 供CalendarController调用来更新单项的选中/范围状态 public void SetSelectionStates(bool isSelected, bool isInRange) { if (BoundData ! null) { // 这里可以扩展CalendarDayData加入IsSelected和IsInRange属性 // 或者直接在这里控制为了清晰建议扩展数据类 // 假设我们扩展了 // BoundData.IsSelected isSelected; // BoundData.IsInSelectedRange isInRange; UpdateVisualState(); } } }5. 常见问题、调试技巧与进阶优化5.1 开发中常见问题与解决方案1. 日期错位或显示不全问题描述格子对不上或者最后一行没显示。排查首先检查GenerateMonthData函数确保它正好返回42个数据。在RefreshCalendarView中打印monthData.Count确认。其次检查GridLayoutGroup的约束设置是否正确Fixed Column Count 7。最后检查DayItem预制体的大小是否被意外修改或者父容器DaysGrid的尺寸是否足够。2. 农历显示错误或为空白排查首先确认_showLunarByDefault是否为true。然后在GetLunarDayString方法内部加Debug.Log看输入的DateTime和计算出的农历年月日是否正确。特别注意ChineseLunisolarCalendar的GetMonth返回值在闰月时会大于12需要按前述逻辑处理。确保农历名称数组索引正确通常数组索引从0开始但农历月份从1开始。3. 点击无响应排查检查DayItem上的EventTrigger或Button组件是否被禁用。检查DayItemController中的OnPointerClick方法是否被正确调用可以加Log。检查CalendarController中是否正确订阅了dayItem.OnClicked事件。确认BoundData.IsSelectable逻辑是否正确。比如你想禁止选择未来日期但_canSelectFutureDays被设为了false而当前日期又是未来那么IsSelectable就会是false。4. 性能问题滚动或切换月份卡顿排查首要怀疑对象对象池是否生效在RefreshCalendarView中是复用已有的DayItemController还是每次都Instantiate在Unity Profiler的Hierarchy面板查看日期项的数量是否恒定。布局计算频繁改变GridLayoutGroup的子物体顺序或数量会触发布局重建。我们的对象池方案已经避免了频繁的增删。但如果DayItem预制体包含复杂的布局组件嵌套的LayoutGroup也可能引起开销。尽量简化预制体结构。农历计算在GenerateMonthData中是否为42个日期都计算了农历如果农历开关关闭应该跳过此步骤。将农历计算放在CreateDayData内部并通过_showLunarByDefault判断是否执行。5.2 进阶优化与功能扩展1. 无限滚动月份对于需要连续查看多月日历的应用如项目甘特图可以实现无限滚动。思路是使用ScrollRect配合多个CalendarMonthView每个视图负责渲染一个月。监听滚动位置动态回收离开视口的月份视图并在另一端创建新的月份视图实现循环池。这比简单的翻页按钮复杂但体验更流畅。2. 标记与装饰在CalendarDayData中增加一个Liststring EventTags或ListSprite EventIcons字段。在DayItemController的BindData方法中根据这些数据在日期格子上方或角落实例化小图标。注意图标也要使用对象池管理。3. 动画与过渡为月份切换添加动画如淡入淡出、滑动。可以为整个DaysGrid添加一个Animator在ShowPreviousMonth和ShowNextMonth时先触发离场动画在动画事件中执行数据刷新和UI更新再触发入场动画。使用DoTween或UnityEngine.UI.Animation可以方便地实现。4. 时区与国际化DateTime默认使用本地时区。如果你的应用是全球化的需要存储和显示UTC时间并在显示时根据用户时区转换。星期和月份的名称也需要本地化。可以使用CultureInfo和资源本地化系统如I2 Localization或Unity Localization来管理。5. 与 Addressable 资源管理系统集成如果日历的皮肤背景、字体、颜色方案需要热更新可以将DayItemPrefab以及相关的Sprite、FontAsset 打上Addressable标签。在CalendarController的Start或Awake中使用Addressables.LoadAssetAsync来异步加载这些资源实现无缝的皮肤切换。实现一个健壮的Unity日历组件就像搭积木数据层、表现层、控制层各司其职边界清晰。从最简单的静态显示开始逐步添加交互、农历、范围选择、优化最终形成一个能够满足复杂业务需求的高性能组件。这个过程最宝贵的收获不是代码本身而是对UI系统、数据驱动、性能优化这些核心概念的深入理解。当你下次再遇到任何自定义UI需求时这套方法论都能让你快速拆解问题找到稳健的实现路径。