You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 04:25:55