import SnapKit import UIKit /// 日清页顶部可横滑的周/月日历组件。 @MainActor final class OfflineCollectionCalendarView: UIView { var onDateSelected: ((String) -> Void)? var onModeChanged: (() -> Void)? private let titleLabel = UILabel() private let toggleButton = UIButton(type: .system) private let nextButton = UIButton(type: .system) private let rowsStack = UIStackView() private var rowStacks: [UIStackView] = [] private var dayButtons: [OfflineCollectionDayButton] = [] private var pendingDates: Set = [] private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date()) private let calendar = OfflineCollectionDate.calendar override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: state.mode == .week ? 160 : 378) } override init(frame: CGRect) { super.init(frame: frame) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 使用选中日、服务端今日和待补缴日期刷新组件。 func apply(selectedDate: String, maximumDate: String, pendingDates: Set) { guard let selected = OfflineCollectionDate.date(from: selectedDate), let maximum = OfflineCollectionDate.date(from: maximumDate) else { return } self.pendingDates = pendingDates state = OfflineCollectionCalendarState( selectedDate: selected, maximumDate: maximum, mode: state.mode, calendar: calendar ) reloadDates() } private func setupUI() { backgroundColor = .white layer.cornerRadius = AppRadius.lg layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor layer.shadowOpacity = 0.12 layer.shadowRadius = 10 layer.shadowOffset = CGSize(width: 0, height: 4) titleLabel.font = .systemFont(ofSize: 20, weight: .bold) titleLabel.textColor = UIColor(hex: 0x081739) toggleButton.configuration = .plain() toggleButton.configuration?.baseForegroundColor = UIColor(hex: 0x081739) toggleButton.configuration?.imagePlacement = .trailing toggleButton.configuration?.imagePadding = 8 toggleButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 12) toggleButton.layer.cornerRadius = 9 toggleButton.layer.borderWidth = 1 toggleButton.layer.borderColor = UIColor(hex: 0xE2E7EF).cgColor toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside) toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle" nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) nextButton.tintColor = UIColor(hex: 0x081739) nextButton.accessibilityLabel = "下一页" nextButton.addTarget(self, action: #selector(nextPage), for: .touchUpInside) nextButton.snp.makeConstraints { make in make.width.height.equalTo(44) } let spacer = UIView() let header = UIStackView(arrangedSubviews: [titleLabel, spacer, toggleButton, nextButton]) header.axis = .horizontal header.alignment = .center header.spacing = 8 let weekdayStack = UIStackView() weekdayStack.axis = .horizontal weekdayStack.distribution = .fillEqually ["一", "二", "三", "四", "五", "六", "日"].forEach { value in let label = UILabel() label.text = value label.font = .systemFont(ofSize: 13, weight: .medium) label.textColor = UIColor(hex: 0x657084) label.textAlignment = .center weekdayStack.addArrangedSubview(label) } rowsStack.axis = .vertical rowsStack.distribution = .fillEqually rowsStack.spacing = 1 for _ in 0 ..< 6 { let row = UIStackView() row.axis = .horizontal row.distribution = .fillEqually for _ in 0 ..< 7 { let button = OfflineCollectionDayButton() button.addTarget(self, action: #selector(dayTapped(_:)), for: .touchUpInside) dayButtons.append(button) row.addArrangedSubview(button) } rowStacks.append(row) rowsStack.addArrangedSubview(row) } addSubview(header) addSubview(weekdayStack) addSubview(rowsStack) header.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.equalToSuperview().offset(16) make.trailing.equalToSuperview().inset(10) make.height.equalTo(44) } weekdayStack.snp.makeConstraints { make in make.top.equalTo(header.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(10) make.height.equalTo(24) } rowsStack.snp.makeConstraints { make in make.top.equalTo(weekdayStack.snp.bottom).offset(4) make.leading.trailing.equalToSuperview().inset(10) make.bottom.equalToSuperview().inset(12) } let left = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))) left.direction = .left let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))) right.direction = .right addGestureRecognizer(left) addGestureRecognizer(right) reloadDates() } private func reloadDates() { titleLabel.text = state.monthTitle let isMonth = state.mode == .month toggleButton.configuration?.title = isMonth ? "周" : "月" toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down") rowStacks.enumerated().forEach { $0.element.isHidden = !isMonth && $0.offset > 0 } let dates = isMonth ? state.monthDates : state.weekDates let selectedMonth = calendar.component(.month, from: state.selectedDate) for (index, button) in dayButtons.enumerated() { guard index < dates.count else { button.isHidden = true continue } let date = dates[index] let key = OfflineCollectionDate.businessDate(for: date, calendar: calendar) button.isHidden = false button.apply( date: date, key: key, selected: calendar.isDate(date, inSameDayAs: state.selectedDate), pending: pendingDates.contains(key), today: calendar.isDate(date, inSameDayAs: state.maximumDate), enabled: date <= state.maximumDate, inCurrentMonth: !isMonth || calendar.component(.month, from: date) == selectedMonth, calendar: calendar ) } invalidateIntrinsicContentSize() } @objc private func toggleMode() { state.toggleMode() let animations = { [weak self] in self?.reloadDates() self?.superview?.layoutIfNeeded() } if UIAccessibility.isReduceMotionEnabled { animations() } else { UIView.animate(withDuration: 0.2, animations: animations) } onModeChanged?() } @objc private func dayTapped(_ sender: OfflineCollectionDayButton) { guard let date = sender.date, state.select(date) else { return } reloadDates() onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) } @objc private func swiped(_ gesture: UISwipeGestureRecognizer) { let oldDate = state.selectedDate let offset = gesture.direction == .left ? 1 : -1 let date = state.movePage(offset) guard !calendar.isDate(oldDate, inSameDayAs: date) else { return } let transition: UIView.AnimationOptions = gesture.direction == .left ? .transitionCrossDissolve : .transitionCrossDissolve if UIAccessibility.isReduceMotionEnabled { reloadDates() } else { UIView.transition(with: rowsStack, duration: 0.18, options: transition) { [weak self] in self?.reloadDates() } } onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) } @objc private func nextPage() { let oldDate = state.selectedDate let date = state.movePage(1) guard !calendar.isDate(oldDate, inSameDayAs: date) else { return } reloadDates() onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) } } /// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。 private final class OfflineCollectionDayButton: UIControl { private let dayLabel = UILabel() private let pendingDot = UIView() private(set) var date: Date? override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 10 dayLabel.font = .systemFont(ofSize: 17, weight: .medium) dayLabel.textAlignment = .center pendingDot.layer.cornerRadius = 2.5 addSubview(dayLabel) addSubview(pendingDot) dayLabel.snp.makeConstraints { make in make.centerX.equalToSuperview(); make.centerY.equalToSuperview().offset(-2) } pendingDot.snp.makeConstraints { make in make.top.equalTo(dayLabel.snp.bottom).offset(2); make.centerX.equalToSuperview(); make.width.height.equalTo(5) } snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply( date: Date, key: String, selected: Bool, pending: Bool, today: Bool, enabled: Bool, inCurrentMonth: Bool, calendar: Calendar ) { self.date = date isEnabled = enabled dayLabel.text = String(calendar.component(.day, from: date)) pendingDot.isHidden = !pending pendingDot.backgroundColor = selected ? .white : AppColor.danger backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF1F0) : .clear) layer.borderWidth = pending && selected ? 2 : (today && !selected ? 1 : 0) layer.borderColor = (pending && selected ? AppColor.danger : AppColor.primary).cgColor dayLabel.textColor = selected ? .white : (enabled ? (inCurrentMonth ? AppColor.textPrimary : AppColor.textTertiary) : AppColor.textTertiary) alpha = enabled ? 1 : 0.35 accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")" accessibilityTraits = selected ? [.button, .selected] : .button } }