import SnapKit import UIKit /// 日清页顶部可横滑的周/月日历组件。 @MainActor final class OfflineCollectionCalendarView: UIView { var onDateSelected: ((String) -> Void)? var onModeChanged: ((_ animated: Bool) -> Void)? private let titleLabel = UILabel() private let toggleButton = UIButton(type: .system) private let previousButton = 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 private var isAnimatingModeChange = false /// 当前周/月模式期望占用的固定高度,供外层表头同步执行高度动画。 var preferredHeight: CGFloat { state.mode == .week ? 160 : 378 } override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight) } 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" configureNavigationButton(previousButton, imageName: "chevron.left", action: #selector(previousPage)) configureNavigationButton(nextButton, imageName: "chevron.right", action: #selector(nextPage)) let spacer = UIView() let header = UIStackView(arrangedSubviews: [titleLabel, spacer, previousButton, 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 configureNavigationButton(_ button: UIButton, imageName: String, action: Selector) { button.setImage(UIImage(systemName: imageName), for: .normal) button.tintColor = UIColor(hex: 0x081739) button.addTarget(self, action: action, for: .touchUpInside) button.snp.makeConstraints { make in make.width.height.equalTo(44) } } private func reloadDates() { UIView.performWithoutAnimation { titleLabel.text = state.monthTitle titleLabel.layoutIfNeeded() } let isMonth = state.mode == .month toggleButton.configuration?.title = isMonth ? "周" : "月" toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down") toggleButton.accessibilityLabel = isMonth ? "切换为周历" : "切换为月历" previousButton.accessibilityLabel = isMonth ? "上一月" : "上一周" nextButton.accessibilityLabel = isMonth ? "下一月" : "下一周" nextButton.isEnabled = state.canMovePage(1) && !isAnimatingModeChange nextButton.alpha = nextButton.isEnabled ? 1 : 0.35 previousButton.isEnabled = !isAnimatingModeChange previousButton.alpha = previousButton.isEnabled ? 1 : 0.35 toggleButton.isEnabled = !isAnimatingModeChange let dates = state.monthDates 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 ) } if !isAnimatingModeChange { applyRowVisibility(expanded: isMonth, selectedRow: state.selectedWeekRowIndex) } invalidateIntrinsicContentSize() } private func applyRowVisibility(expanded: Bool, selectedRow: Int) { for (index, row) in rowStacks.enumerated() { let visible = expanded || index == selectedRow row.isHidden = !visible row.alpha = visible ? 1 : 0 } } @objc private func toggleMode() { guard !isAnimatingModeChange else { return } let selectedRow = state.selectedWeekRowIndex state.toggleMode() let expanded = state.mode == .month let animated = !UIAccessibility.isReduceMotionEnabled guard animated else { reloadDates() applyRowVisibility(expanded: expanded, selectedRow: selectedRow) invalidateIntrinsicContentSize() onModeChanged?(false) return } isAnimatingModeChange = true rowsStack.isUserInteractionEnabled = false reloadDates() invalidateIntrinsicContentSize() onModeChanged?(true) let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { [weak self] in guard let self else { return } for (index, row) in rowStacks.enumerated() where index != selectedRow { row.isHidden = !expanded row.alpha = expanded ? 1 : 0 } layoutIfNeeded() superview?.layoutIfNeeded() } animator.addCompletion { [weak self] _ in guard let self else { return } isAnimatingModeChange = false rowsStack.isUserInteractionEnabled = true applyRowVisibility(expanded: expanded, selectedRow: selectedRow) reloadDates() } animator.startAnimation() } @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 offset = gesture.direction == .left ? 1 : -1 movePage(offset) } @objc private func previousPage() { movePage(-1) } @objc private func nextPage() { movePage(1) } private func movePage(_ offset: Int) { guard !isAnimatingModeChange, state.canMovePage(offset) else { return } let oldDate = state.selectedDate let date = state.movePage(offset) guard !calendar.isDate(oldDate, inSameDayAs: date) else { return } if !UIAccessibility.isReduceMotionEnabled { let transition = CATransition() transition.duration = 0.22 transition.type = .push transition.subtype = offset > 0 ? .fromRight : .fromLeft transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) rowsStack.layer.add(transition, forKey: "offlineCollection.calendar.page") } reloadDates() onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) } } /// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。 private final class OfflineCollectionDayButton: UIControl { private let stateBackgroundView = UIView() private let dayLabel = UILabel() private let pendingDot = UIView() private(set) var date: Date? override init(frame: CGRect) { super.init(frame: frame) stateBackgroundView.isUserInteractionEnabled = false stateBackgroundView.layer.cornerRadius = 11 dayLabel.font = .systemFont(ofSize: 17, weight: .medium) dayLabel.textAlignment = .center pendingDot.layer.cornerRadius = 3.5 addSubview(stateBackgroundView) addSubview(dayLabel) addSubview(pendingDot) stateBackgroundView.snp.makeConstraints { make in make.center.equalToSuperview() make.width.height.equalTo(36) } dayLabel.snp.makeConstraints { make in make.center.equalTo(stateBackgroundView) } pendingDot.snp.makeConstraints { make in make.top.trailing.equalTo(stateBackgroundView) make.width.height.equalTo(7) } 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)) let warningColor = UIColor(hex: 0xFF7600) pendingDot.isHidden = !pending pendingDot.backgroundColor = warningColor pendingDot.layer.borderWidth = selected ? 1.5 : 0 pendingDot.layer.borderColor = UIColor.white.cgColor backgroundColor = .clear stateBackgroundView.backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF0E2) : .clear) stateBackgroundView.layer.cornerRadius = today && !selected && !pending ? 18 : 11 stateBackgroundView.layer.borderWidth = today && !selected ? 1 : 0 stateBackgroundView.layer.borderColor = AppColor.primary.cgColor if selected { dayLabel.textColor = .white } else if !enabled || !inCurrentMonth { dayLabel.textColor = AppColor.textTertiary } else if pending { dayLabel.textColor = warningColor } else if today { dayLabel.textColor = AppColor.primary } else { dayLabel.textColor = AppColor.textPrimary } alpha = enabled ? 1 : 0.35 accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")" accessibilityTraits = selected ? [.button, .selected] : .button } }