UICollectionView实现的日历存在三类异常问题求助
日历选择组件问题修复
问题描述
我正在开发一款支持时间范围选择的日历,设计要求:选中时段的起止日期数字外显示蓝色圆圈,中间日期以同色半透明填充。我基于CalendarMonth(月份标题)和CalendarDate数组构建数据结构,通过generateDates()填充数据,点击单元格后调用updateSelection()更新UICollectionView。目前存在三类问题:
- 点击UICollectionView单元格时,日历数字随机消失;
- 选择时间范围时,中间单元格有时会出现仅应在起止日期显示的圆圈;
- 已在
UICollectionViewFlowLayout中设置minimumInteritemSpacing为0,但每两个单元格后仍存在微小间距。
问题截图:
复现代码(可直接粘贴到Xcode新项目的ViewController中运行):
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource { private var months = [CalendarMonth]() private var selectedRange: (start: Date?, end: Date?) = (nil, nil) private let collectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.minimumInteritemSpacing = 0 layout.minimumLineSpacing = 2 let size = floor(UIScreen.main.bounds.width / 7) layout.itemSize = CGSize(width: size, height: size) layout.headerReferenceSize = CGSize(width: UIScreen.main.bounds.width, height: 40) let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.register(CalendarDateCell.self, forCellWithReuseIdentifier: CalendarDateCell.identifier) collectionView.register(CalendarHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CalendarHeaderView.identifier) return collectionView }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(collectionView) collectionView.delegate = self collectionView.dataSource = self collectionView.frame = view.bounds generateDates() } private func generateDates() { let calendar = Calendar.current let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMMM yyyy" let currentDate = Date() guard let startDate = calendar.date(byAdding: .month, value: 0, to: currentDate), let endDate = calendar.date(byAdding: .month, value: 12, to: currentDate) else { return } var date = startDate var currentMonth: CalendarMonth? while date <= endDate { let components = calendar.dateComponents([.year, .month, .day], from: date) if let firstOfMonth = calendar.date(from: DateComponents(year: components.year, month: components.month, day: 1)) { let monthTitle = dateFormatter.string(from: firstOfMonth) if currentMonth == nil || currentMonth!.title != monthTitle { if var existingMonth = currentMonth { months.append(existingMonth) } currentMonth = CalendarMonth(title: monthTitle, dates: []) } } currentMonth?.dates.append(CalendarDate(date: date, isSelected: false)) date = calendar.date(byAdding: .day, value: 1, to: date)! } if var existingMonth = currentMonth { months.append(existingMonth) } } func numberOfSections(in collectionView: UICollectionView) -> Int { return months.count } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return months[section].dates.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CalendarDateCell.identifier, for: indexPath) as! CalendarDateCell let date = months[indexPath.section].dates[indexPath.item] cell.configure(with: date) return cell } func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarHeaderView.identifier, for: indexPath) as! CalendarHeaderView header.configure(with: months[indexPath.section].title) return header } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let selectedDate = months[indexPath.section].dates[indexPath.item].date if selectedRange.start == nil || selectedRange.end != nil { selectedRange = (start: selectedDate, end: nil) } else if let start = selectedRange.start, selectedDate >= start { selectedRange.end = selectedDate } updateSelection() } private func updateSelection() { guard let start = selectedRange.start else { return } for monthIndex in 0..<months.count { for dateIndex in 0..<months[monthIndex].dates.count { if let end = selectedRange.end { months[monthIndex].dates[dateIndex].isSelected = months[monthIndex].dates[dateIndex].date >= start && months[monthIndex].dates[dateIndex].date <= end months[monthIndex].dates[dateIndex].isStartRange = months[monthIndex].dates[dateIndex].date == start months[monthIndex].dates[dateIndex].isEndRange = months[monthIndex].dates[dateIndex].date == end } else { months[monthIndex].dates[dateIndex].isSelected = months[monthIndex].dates[dateIndex].date == start months[monthIndex].dates[dateIndex].isStartRange = months[monthIndex].dates[dateIndex].date == start months[monthIndex].dates[dateIndex].isEndRange = false } } } collectionView.reloadData() } } struct CalendarMonth { let title: String var dates: [CalendarDate] } class CalendarHeaderView: UICollectionReusableView { static let identifier = "CalendarHeaderView" private let titleLabel: UILabel = { let label = UILabel() label.textAlignment = .center label.font = UIFont.boldSystemFont(ofSize: 16) return label }() override init(frame: CGRect) { super.init(frame: frame) addSubview(titleLabel) titleLabel.frame = bounds } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(with title: String) { titleLabel.text = title } } struct CalendarDate { let date: Date var isSelected: Bool var isStartRange: Bool = false var isEndRange: Bool = false } class CalendarDateCell: UICollectionViewCell { static let identifier = "CalendarDateCell" private let dateLabel: UILabel = { let label = UILabel() label.textAlignment = .center return label }() override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(dateLabel) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() dateLabel.frame = contentView.bounds } func configure(with date: CalendarDate) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "d" dateLabel.text = dateFormatter.string(from: date.date) contentView.backgroundColor = .clear if date.isSelected { if date.isStartRange || date.isEndRange { dateLabel.textColor = .white dateLabel.backgroundColor = .blue dateLabel.layer.cornerRadius = dateLabel.bounds.width / 2 dateLabel.layer.masksToBounds = true } else { dateLabel.textColor = .black contentView.backgroundColor = UIColor.blue.withAlphaComponent(0.5) } } else { dateLabel.backgroundColor = .clear dateLabel.layer.cornerRadius = 0 dateLabel.layer.masksToBounds = false } } }
问题修复方案
1. 日历数字随机消失问题
原因:单元格复用后,configure方法中计算圆角时,dateLabel的bounds可能未完成布局更新,导致圆角设置异常,文字被裁剪。
修复:
- 在
configure方法中先强制更新布局,确保bounds准确; - 给
dateLabel添加自动布局约束,替代直接设置frame,避免布局时机问题。
修改CalendarDateCell的init方法:
override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(dateLabel) dateLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ dateLabel.topAnchor.constraint(equalTo: contentView.topAnchor), dateLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), dateLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), dateLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) }
修改CalendarDateCell的configure方法:
func configure(with date: CalendarDate) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "d" dateLabel.text = dateFormatter.string(from: date.date) // 强制更新布局确保bounds准确 layoutIfNeeded() contentView.backgroundColor = .clear dateLabel.textColor = .black // 默认文字颜色 if date.isSelected { if date.isStartRange || date.isEndRange { dateLabel.textColor = .white dateLabel.backgroundColor = .blue dateLabel.layer.cornerRadius = dateLabel.bounds.width / 2 dateLabel.layer.masksToBounds = true } else { contentView.backgroundColor = UIColor.blue.withAlphaComponent(0.5) dateLabel.backgroundColor = .clear dateLabel.layer.cornerRadius = 0 dateLabel.layer.masksToBounds = false } } else { dateLabel.backgroundColor = .clear dateLabel.layer.cornerRadius = 0 dateLabel.layer.masksToBounds = false contentView.backgroundColor = .clear } }
2. 中间单元格出现起止圆圈问题
原因:直接用==比较Date对象,而Date包含时分秒信息,生成的日期可能带有非零时间值,导致日期比较不准确,错误标记isStartRange或isEndRange。
修复:比较日期时忽略时分秒,仅对比年月日组件。修改updateSelection方法:
private func updateSelection() { guard let start = selectedRange.start else { return } let calendar = Calendar.current // 提取起止日期的年月日组件,忽略时分秒 let startComponents = calendar.dateComponents([.year, .month, .day], from: start) let startDateOnly = calendar.date(from: startComponents)! for monthIndex in 0..<months.count { for dateIndex in 0..<months[monthIndex].dates.count { let currentDate = months[monthIndex].dates[dateIndex].date let currentComponents = calendar.dateComponents([.year, .month, .day], from: currentDate) let currentDateOnly = calendar.date(from: currentComponents)! if let end = selectedRange.end { let endComponents = calendar.dateComponents([.year, .month, .day], from: end) let endDateOnly = calendar.date(from: endComponents)! months[monthIndex].dates[dateIndex].isSelected = currentDateOnly >= startDateOnly && currentDateOnly <= endDateOnly months[monthIndex].dates[dateIndex].isStartRange = currentDateOnly == startDateOnly months[monthIndex].dates[dateIndex].isEndRange = currentDateOnly == endDateOnly } else { months[monthIndex].dates[dateIndex].isSelected = currentDateOnly == startDateOnly months[monthIndex].dates[dateIndex].isStartRange = currentDateOnly == startDateOnly months[monthIndex].dates[dateIndex].isEndRange = false } } } collectionView.reloadData() }
3. 单元格间微小间距问题
原因:用floor取整后,7个单元格的总宽度小于屏幕宽度,导致出现间隙。
修复:使用精确的非整数宽度,确保7个单元格总宽度等于屏幕宽度,同时在布局更新时同步collectionView的frame。
修改collectionView初始化代码:
private let collectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.minimumInteritemSpacing = 0 layout.minimumLineSpacing = 2 let screenWidth = UIScreen.main.bounds.width // 精确计算单元格宽度,7个刚好填满屏幕 let itemWidth = screenWidth / 7 layout.itemSize = CGSize(width: itemWidth, height: itemWidth) layout.headerReferenceSize = CGSize(width: screenWidth, height: 40) let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.register(CalendarDateCell.self, forCellWithReuseIdentifier: CalendarDateCell.identifier) collectionView.register(CalendarHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CalendarHeaderView.identifier) return collectionView }()
在ViewController中添加布局更新方法:
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() collectionView.frame = view.bounds }
内容的提问来源于stack exchange,提问作者Heimdallr
相关产品推荐
相关产品推荐

