我正在制作一个日历,您可以在其中选择时间范围。日历的设计方式是,在所选时间段的开始和结束日期,数字周围会出现一个圆圈,并且该范围的中间日期以相同的颜色绘制,不透明度为 0.5。
我创建了一个由
CelendarMonth
(用于带有月份名称的标题)组成的数据结构,其中包含一个 CalendarDate
数组。
我在
generateDates()
中填充集合的数据结构,并在单击 updateSelection()
方法中的一个单元格后更新集合
这段代码有几个问题(所有这些问题都可以在所附的屏幕截图中看到):
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
}
}
}
这就是错误的样子:
正如 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 分的空间:
所以,我们可以将集合视图center...在这种情况下,我们将
collectionView.frame.origin.x
设置为0.5
——这不是一个整数,但UIKit会给我们一个相等的1.5像素空间每侧:
如果我们真的想要完整的“边缘到边缘”覆盖:
我们可以计算
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)
}
}
}
}