253 lines
11 KiB
Swift
253 lines
11 KiB
Swift
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<String> = []
|
|
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<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"
|
|
|
|
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
|
|
}
|
|
}
|