Files
suixinkan_uikit/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift
T

348 lines
14 KiB
Swift

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<String> = []
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<String>) {
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
}
}