UICollectionView 单元格的未定义行为

问题描述 投票:0回答:1

我正在制作一个日历,您可以在其中选择时间范围。日历的设计方式是,在所选时间段的开始和结束日期,数字周围会出现一个圆圈,并且该范围的中间日期以相同的颜色绘制,不透明度为 0.5。

我创建了一个由

CelendarMonth
(用于带有月份名称的标题)组成的数据结构,其中包含一个
CalendarDate
数组。

我在

generateDates()
中填充集合的数据结构,并在单击
updateSelection()
方法中的一个单元格后更新集合

这段代码有几个问题(所有这些问题都可以在所附的屏幕截图中看到):

  1. 当您单击集合的单元格时,数字会随机从日历中消失
  2. 有时,当您选择范围时,中间单元格中可能会出现一个圆圈(应该仅适用于范围的开始和结束日期)
  3. 虽然在
    UICollectionViewFlowLayout
    中单元格之间的空间设置为
    0
    ,但每两个单元格之后我们可以注意到最小空间

您可以通过将我提供的列表粘贴到新

ViewController
项目的
Xcode
文件中来运行此代码

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
        }
    }
}

这就是错误的样子:

swift uicollectionview uikit
1个回答
0
投票

正如 Larme 指出的,在设置其外观时需要考虑单元格的重用。

所以,而不是:

if date.isSelected {
    // set selected colors
} else {
    // set not selected colors
}

这样做更容易:

// start with appearance for "not in selected range"
contentView.backgroundColor = .clear
dateLabel.backgroundColor = .clear
dateLabel.layer.cornerRadius = 0
dateLabel.layer.masksToBounds = false
dateLabel.textColor = .black

// now, if the date IS in the selected range, update the appearance
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)
    }
}

至于每两个单元格之后我们可以注意到一个最小空间...

注意属性:

.minimumInteritemSpacing = 0

最小值,而不是绝对

例如,在 iPhone 15 Pro 上运行代码,视图宽度(我们不应该使用

UIScreen.main.bounds.width
)是
393.0
...然后将
itemSize
宽度设置为:

let size = floor(UIScreen.main.bounds.width / 7)

// size now == 56.0

但是...集合视图宽度是

393.0
56.0 * 7.0 == 392.0
——这意味着 UIKit 将在某些单元格之间“添加空间”。

为了解决这个问题,我们可以将集合视图的

frame.size.width
更新为
size * 7.0
。不幸的是,这在右侧留下了 1 分的空间:

enter image description here

所以,我们可以将集合视图center...在这种情况下,我们将

collectionView.frame.origin.x
设置为
0.5
——这不是一个整数,但UIKit会给我们一个相等的1.5像素空间每侧:

enter image description here

如果我们真的想要完整的“边缘到边缘”覆盖:

enter image description here

我们可以计算

cellSize
以及需要加宽 1 点的单元格数量:

layout.estimatedItemSize = CGSize(width: cellSize, height: cellSize)
let totalCellsWidth: CGFloat = cellSize * 7.0
numberOfPlusOneCells = Int(collectionView.frame.width - totalCellsWidth)

符合流程布局委托:

class ViewController: UIViewController, 
                        UICollectionViewDelegate, UICollectionViewDataSource, 
                        UICollectionViewDelegateFlowLayout {

并实施

sizeForItemAt
:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    // account for non-even cell widths
    if indexPath.item % 7 < numberOfPlusOneCells {
        return .init(width: cellSize + 1.0, height: cellSize)
    }
    return .init(width: cellSize, height: cellSize)
}

这是您的完整代码,进行了上述更改:

class ViewController: UIViewController, 
                        UICollectionViewDelegate, UICollectionViewDataSource,
                        UICollectionViewDelegateFlowLayout {
    
    private var months = [CalendarMonth]()
    private var selectedRange: (start: Date?, end: Date?) = (nil, nil)

    // track the current view width, so we can update the collection view properties
    //  when layout gives us the correct size
    private var curViewWidth: CGFloat = 0.0
    
    // these will be set in viewDidLayoutSubviews()
    private var cellSize: CGFloat = 0.0
    private var numberOfPlusOneCells: Int = 0

    private let collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.minimumInteritemSpacing = 0
        layout.minimumLineSpacing = 2
        // sizes will be updated in viewDidLayoutSubviews()
        let size = floor(UIScreen.main.bounds.width / 7)
        layout.estimatedItemSize = 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()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        // this will be called on first layout - and possibly (probably) additional times
        // so only execute this code if the view width has changed...
        if curViewWidth != view.frame.width {
            curViewWidth = view.frame.width

            if let fl = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {

                // update collection view frame width
                collectionView.frame.size.width = curViewWidth

                // we can't "draw on partial pixels" so
                //  get a whole number of width / 7 for item size
                let size = floor(curViewWidth / 7.0)
                cellSize = size
                
                // now, the actual collection view width needs to be
                //  size * 7, NOT the full view width
                //  so get the number of cells that need width to be cellSize+1
                let totalCellsWidth: CGFloat = cellSize * 7.0
                numberOfPlusOneCells = Int(curViewWidth - totalCellsWidth)
                
                // update flow layout's estimatedItemSize
                fl.estimatedItemSize = CGSize(width: cellSize, height: cellSize)
                
                // update headerReferenceSize tp be the collection view width
                fl.headerReferenceSize = CGSize(width: curViewWidth, height: 40)
                
            }

        }
        
    }
    
    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 collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        // account for non-even cell widths
        if indexPath.item % 7 < numberOfPlusOneCells {
            return .init(width: cellSize + 1.0, height: cellSize)
        }
        return .init(width: cellSize, height: cellSize)
    }
    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)

        // start with appearance for "not in selected range"
        contentView.backgroundColor = .clear
        dateLabel.backgroundColor = .clear
        dateLabel.layer.cornerRadius = 0
        dateLabel.layer.masksToBounds = false
        dateLabel.textColor = .black
        
        // now, if the date IS in the selected range, update the appearance
        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)
            }
        }

    }
}
© www.soinside.com 2019 - 2024. All rights reserved.