// // StatisticsDateRangeViewController.swift // suixinkan // import SnapKit import UIKit /// 数据统计日期范围选择 Sheet,对齐 Android `DateRangeBottomSheet`。 final class StatisticsDateRangeViewController: UIViewController { var onShortcutSelected: ((String) -> Void)? var onRangeSelected: ((Date, Date) -> Void)? private var currentMonth: Date private var tempStart: Date? private var tempEnd: Date? private var selectedShortcut: String? private let shortcutStack = UIStackView() private let monthTitleLabel = UILabel() private let weekdayStack = UIStackView() private let daysCollectionView: UICollectionView private var shortcutButtons: [StatisticsPeriodButton] = [] private let calendar: Calendar = { var calendar = Calendar(identifier: .gregorian) calendar.locale = Locale(identifier: "zh_CN") calendar.firstWeekday = 2 return calendar }() init(rangeStart: Date?, rangeEnd: Date?) { self.tempStart = rangeStart self.tempEnd = rangeEnd self.currentMonth = rangeStart ?? Date() let layout = UICollectionViewFlowLayout() layout.minimumInteritemSpacing = 0 layout.minimumLineSpacing = 8 daysCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) let today = Calendar.current.startOfDay(for: Date()) let initCalendar = Calendar(identifier: .gregorian) if rangeStart == nil, rangeEnd == nil { selectedShortcut = StatisticsRangeShortcut.all.rawValue } else if rangeStart == today, rangeEnd == today { selectedShortcut = StatisticsRangeShortcut.today.rawValue } else if let start = rangeStart, let end = rangeEnd, start == initCalendar.date(byAdding: .day, value: -1, to: today), end == initCalendar.date(byAdding: .day, value: -1, to: today) { selectedShortcut = StatisticsRangeShortcut.yesterday.rawValue } else if let start = rangeStart, let end = rangeEnd, start == initCalendar.date(byAdding: .day, value: -6, to: today), end == today { selectedShortcut = StatisticsRangeShortcut.last7Days.rawValue } super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white setupUI() updateMonthTitle() } private func setupUI() { shortcutStack.axis = .horizontal shortcutStack.distribution = .equalSpacing StatisticsRangeShortcut.allCases.forEach { shortcut in let button = StatisticsPeriodButton() button.periodTitle = shortcut.rawValue button.isSelected = shortcut.rawValue == selectedShortcut button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside) shortcutButtons.append(button) shortcutStack.addArrangedSubview(button) } let previousButton = UIButton(type: .system) previousButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) previousButton.tintColor = UIColor(hex: 0x9CA3AF) previousButton.addTarget(self, action: #selector(previousMonth), for: .touchUpInside) let nextButton = UIButton(type: .system) nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) nextButton.tintColor = UIColor(hex: 0x9CA3AF) nextButton.addTarget(self, action: #selector(nextMonth), for: .touchUpInside) monthTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold) monthTitleLabel.textAlignment = .center let monthRow = UIStackView(arrangedSubviews: [previousButton, monthTitleLabel, nextButton]) monthRow.axis = .horizontal monthRow.alignment = .center monthRow.distribution = .equalCentering weekdayStack.axis = .horizontal weekdayStack.distribution = .fillEqually ["一", "二", "三", "四", "五", "六", "日"].forEach { title in let label = UILabel() label.text = title label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hex: 0x9CA3AF) label.textAlignment = .center weekdayStack.addArrangedSubview(label) } daysCollectionView.backgroundColor = .clear daysCollectionView.dataSource = self daysCollectionView.delegate = self daysCollectionView.register(DateCell.self, forCellWithReuseIdentifier: DateCell.reuseIdentifier) view.addSubview(shortcutStack) view.addSubview(monthRow) view.addSubview(weekdayStack) view.addSubview(daysCollectionView) shortcutStack.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.leading.trailing.equalToSuperview().inset(16) } monthRow.snp.makeConstraints { make in make.top.equalTo(shortcutStack.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(32) } previousButton.snp.makeConstraints { make in make.size.equalTo(24) } nextButton.snp.makeConstraints { make in make.size.equalTo(24) } weekdayStack.snp.makeConstraints { make in make.top.equalTo(monthRow.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(16) } daysCollectionView.snp.makeConstraints { make in make.top.equalTo(weekdayStack.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) make.height.equalTo(240) } } @objc private func shortcutTapped(_ sender: StatisticsPeriodButton) { onShortcutSelected?(sender.periodTitle) } @objc private func previousMonth() { currentMonth = calendar.date(byAdding: .month, value: -1, to: currentMonth) ?? currentMonth updateMonthTitle() daysCollectionView.reloadData() } @objc private func nextMonth() { currentMonth = calendar.date(byAdding: .month, value: 1, to: currentMonth) ?? currentMonth updateMonthTitle() daysCollectionView.reloadData() } private func updateMonthTitle() { let components = calendar.dateComponents([.year, .month], from: currentMonth) monthTitleLabel.text = "\(components.year ?? 0)年\(components.month ?? 0)月" } private func monthDates() -> [Date?] { guard let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: currentMonth)), let dayRange = calendar.range(of: .day, in: .month, for: monthStart) else { return [] } let firstWeekday = calendar.component(.weekday, from: monthStart) let leadingEmpty = (firstWeekday + 5) % 7 var dates: [Date?] = Array(repeating: nil, count: leadingEmpty) for day in dayRange { if let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) { dates.append(date) } } while dates.count % 7 != 0 { dates.append(nil) } return dates } private func handleDateTap(_ date: Date) { selectedShortcut = nil shortcutButtons.forEach { $0.isSelected = false } if tempStart == nil || (tempStart != nil && tempEnd != nil) { tempStart = date tempEnd = nil } else if let start = tempStart { if date < start { tempEnd = start tempStart = date } else { tempEnd = date } if let start = tempStart, let end = tempEnd { onRangeSelected?(start, end) } } daysCollectionView.reloadData() } private func isInRange(_ date: Date) -> Bool { guard let start = tempStart else { return false } if let end = tempEnd { return date >= min(start, end) && date <= max(start, end) } return calendar.isDate(date, inSameDayAs: start) } private final class DateCell: UICollectionViewCell { static let reuseIdentifier = "StatisticsDateCell" private let label = UILabel() override init(frame: CGRect) { super.init(frame: frame) label.textAlignment = .center label.font = .systemFont(ofSize: 14) contentView.addSubview(label) label.snp.makeConstraints { make in make.edges.equalToSuperview() } contentView.layer.cornerRadius = 16 contentView.clipsToBounds = true } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(text: String?, selected: Bool, inRange: Bool) { label.text = text if selected { contentView.backgroundColor = AppColor.primary label.textColor = .white } else if inRange { contentView.backgroundColor = UIColor(hex: 0xEFF6FF) label.textColor = AppColor.primary } else { contentView.backgroundColor = .clear label.textColor = .black } } } } extension StatisticsDateRangeViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { monthDates().count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell( withReuseIdentifier: DateCell.reuseIdentifier, for: indexPath ) as! DateCell let dates = monthDates() guard indexPath.item < dates.count, let date = dates[indexPath.item] else { cell.configure(text: nil, selected: false, inRange: false) return cell } let day = calendar.component(.day, from: date) let selected = (tempStart != nil && calendar.isDate(date, inSameDayAs: tempStart!)) || (tempEnd != nil && calendar.isDate(date, inSameDayAs: tempEnd!)) cell.configure(text: "\(day)", selected: selected, inRange: isInRange(date)) return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let dates = monthDates() guard indexPath.item < dates.count, let date = dates[indexPath.item] else { return } handleDateTap(date) } func collectionView( _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath ) -> CGSize { let width = collectionView.bounds.width / 7 return CGSize(width: width, height: 32) } }