feat: 优化线下收款日历交互与选中态
This commit is contained in:
@@ -5,10 +5,11 @@ import UIKit
|
||||
@MainActor
|
||||
final class OfflineCollectionCalendarView: UIView {
|
||||
var onDateSelected: ((String) -> Void)?
|
||||
var onModeChanged: (() -> 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] = []
|
||||
@@ -16,9 +17,15 @@ final class OfflineCollectionCalendarView: UIView {
|
||||
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: state.mode == .week ? 160 : 378)
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -64,14 +71,11 @@ final class OfflineCollectionCalendarView: UIView {
|
||||
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) }
|
||||
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, toggleButton, nextButton])
|
||||
let header = UIStackView(arrangedSubviews: [titleLabel, spacer, previousButton, toggleButton, nextButton])
|
||||
header.axis = .horizontal
|
||||
header.alignment = .center
|
||||
header.spacing = 8
|
||||
@@ -134,14 +138,31 @@ final class OfflineCollectionCalendarView: UIView {
|
||||
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() {
|
||||
titleLabel.text = state.monthTitle
|
||||
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")
|
||||
rowStacks.enumerated().forEach { $0.element.isHidden = !isMonth && $0.offset > 0 }
|
||||
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 = isMonth ? state.monthDates : state.weekDates
|
||||
let dates = state.monthDates
|
||||
let selectedMonth = calendar.component(.month, from: state.selectedDate)
|
||||
for (index, button) in dayButtons.enumerated() {
|
||||
guard index < dates.count else {
|
||||
@@ -162,19 +183,58 @@ final class OfflineCollectionCalendarView: UIView {
|
||||
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 animations = { [weak self] in
|
||||
self?.reloadDates()
|
||||
self?.superview?.layoutIfNeeded()
|
||||
let expanded = state.mode == .month
|
||||
let animated = !UIAccessibility.isReduceMotionEnabled
|
||||
|
||||
guard animated else {
|
||||
reloadDates()
|
||||
applyRowVisibility(expanded: expanded, selectedRow: selectedRow)
|
||||
invalidateIntrinsicContentSize()
|
||||
onModeChanged?(false)
|
||||
return
|
||||
}
|
||||
if UIAccessibility.isReduceMotionEnabled { animations() } else {
|
||||
UIView.animate(withDuration: 0.2, animations: animations)
|
||||
|
||||
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()
|
||||
}
|
||||
onModeChanged?()
|
||||
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) {
|
||||
@@ -184,21 +244,31 @@ final class OfflineCollectionCalendarView: UIView {
|
||||
}
|
||||
|
||||
@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))
|
||||
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(1)
|
||||
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))
|
||||
}
|
||||
@@ -206,20 +276,30 @@ final class OfflineCollectionCalendarView: UIView {
|
||||
|
||||
/// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。
|
||||
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)
|
||||
layer.cornerRadius = 10
|
||||
stateBackgroundView.isUserInteractionEnabled = false
|
||||
stateBackgroundView.layer.cornerRadius = 11
|
||||
dayLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
||||
dayLabel.textAlignment = .center
|
||||
pendingDot.layer.cornerRadius = 2.5
|
||||
pendingDot.layer.cornerRadius = 3.5
|
||||
addSubview(stateBackgroundView)
|
||||
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) }
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -239,12 +319,27 @@ private final class OfflineCollectionDayButton: UIControl {
|
||||
self.date = date
|
||||
isEnabled = enabled
|
||||
dayLabel.text = String(calendar.component(.day, from: date))
|
||||
let warningColor = UIColor(hex: 0xFF7600)
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user