feat: 优化线下收款日历交互与选中态
This commit is contained in:
@@ -56,6 +56,28 @@ struct OfflineCollectionCalendarState: Sendable, Equatable {
|
|||||||
return selectedDate
|
return selectedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断指定方向是否存在不晚于服务端今日的周或月页面。
|
||||||
|
func canMovePage(_ offset: Int) -> Bool {
|
||||||
|
guard offset != 0 else { return false }
|
||||||
|
if offset < 0 { return true }
|
||||||
|
switch mode {
|
||||||
|
case .week:
|
||||||
|
guard let candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) else { return false }
|
||||||
|
let weekday = calendar.component(.weekday, from: candidate)
|
||||||
|
let daysFromMonday = (weekday + 5) % 7
|
||||||
|
let targetMonday = calendar.date(byAdding: .day, value: -daysFromMonday, to: candidate) ?? candidate
|
||||||
|
return calendar.startOfDay(for: targetMonday) <= maximumDate
|
||||||
|
case .month:
|
||||||
|
let current = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||||
|
guard let monthStart = calendar.date(from: current),
|
||||||
|
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) else { return false }
|
||||||
|
let maximumMonth = calendar.date(
|
||||||
|
from: calendar.dateComponents([.year, .month], from: maximumDate)
|
||||||
|
) ?? maximumDate
|
||||||
|
return targetMonth <= maximumMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前周的周一至周日。
|
/// 当前周的周一至周日。
|
||||||
var weekDates: [Date] {
|
var weekDates: [Date] {
|
||||||
let weekday = calendar.component(.weekday, from: selectedDate)
|
let weekday = calendar.component(.weekday, from: selectedDate)
|
||||||
@@ -74,6 +96,12 @@ struct OfflineCollectionCalendarState: Sendable, Equatable {
|
|||||||
return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
|
return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前选中日期所在周在固定六行月历中的零基行号。
|
||||||
|
var selectedWeekRowIndex: Int {
|
||||||
|
let index = monthDates.firstIndex { calendar.isDate($0, inSameDayAs: selectedDate) } ?? 0
|
||||||
|
return index / 7
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前选中月份标题。
|
/// 当前选中月份标题。
|
||||||
var monthTitle: String {
|
var monthTitle: String {
|
||||||
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ final class OfflineCollectionDailyViewController: BaseViewController {
|
|||||||
private let headerContainer = UIView()
|
private let headerContainer = UIView()
|
||||||
private let headerStack = UIStackView()
|
private let headerStack = UIStackView()
|
||||||
private let calendarView = OfflineCollectionCalendarView()
|
private let calendarView = OfflineCollectionCalendarView()
|
||||||
|
private var calendarHeightConstraint: Constraint?
|
||||||
|
private var isAnimatingHeaderResize = false
|
||||||
private let summaryCard = UIView()
|
private let summaryCard = UIView()
|
||||||
private let summaryDateLabel = UILabel()
|
private let summaryDateLabel = UILabel()
|
||||||
private let totalValue = UILabel()
|
private let totalValue = UILabel()
|
||||||
@@ -75,7 +77,7 @@ final class OfflineCollectionDailyViewController: BaseViewController {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.viewModel.selectBusinessDate(date) }
|
Task { await self.viewModel.selectBusinessDate(date) }
|
||||||
}
|
}
|
||||||
calendarView.onModeChanged = { [weak self] in self?.resizeHeader() }
|
calendarView.onModeChanged = { [weak self] animated in self?.resizeHeader(animated: animated) }
|
||||||
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,9 @@ final class OfflineCollectionDailyViewController: BaseViewController {
|
|||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
resizeHeader()
|
if !isAnimatingHeaderResize {
|
||||||
|
resizeHeader()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureTableView() {
|
private func configureTableView() {
|
||||||
@@ -116,6 +120,9 @@ final class OfflineCollectionDailyViewController: BaseViewController {
|
|||||||
make.leading.trailing.equalToSuperview().inset(16)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
}
|
}
|
||||||
headerStack.addArrangedSubview(calendarView)
|
headerStack.addArrangedSubview(calendarView)
|
||||||
|
calendarView.snp.makeConstraints { make in
|
||||||
|
calendarHeightConstraint = make.height.equalTo(calendarView.preferredHeight).constraint
|
||||||
|
}
|
||||||
configureSummaryCard()
|
configureSummaryCard()
|
||||||
headerStack.addArrangedSubview(summaryCard)
|
headerStack.addArrangedSubview(summaryCard)
|
||||||
|
|
||||||
@@ -351,9 +358,41 @@ final class OfflineCollectionDailyViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resizeHeader() {
|
private func resizeHeader(animated: Bool = false) {
|
||||||
guard tableView.bounds.width > 0 else { return }
|
guard tableView.bounds.width > 0 else { return }
|
||||||
|
if isAnimatingHeaderResize, !animated { return }
|
||||||
headerContainer.frame.size.width = tableView.bounds.width
|
headerContainer.frame.size.width = tableView.bounds.width
|
||||||
|
|
||||||
|
let targetCalendarHeight = calendarView.preferredHeight
|
||||||
|
if animated,
|
||||||
|
!UIAccessibility.isReduceMotionEnabled,
|
||||||
|
headerContainer.frame.height > 0,
|
||||||
|
calendarView.bounds.height > 0 {
|
||||||
|
let targetHeaderHeight = headerContainer.frame.height + targetCalendarHeight - calendarView.bounds.height
|
||||||
|
guard abs(headerContainer.frame.height - targetHeaderHeight) > 0.5 else { return }
|
||||||
|
calendarHeightConstraint?.update(offset: targetCalendarHeight)
|
||||||
|
isAnimatingHeaderResize = true
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.3,
|
||||||
|
delay: 0,
|
||||||
|
options: [.curveEaseInOut, .beginFromCurrentState],
|
||||||
|
animations: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
headerContainer.frame.size.height = targetHeaderHeight
|
||||||
|
headerContainer.layoutIfNeeded()
|
||||||
|
tableView.tableHeaderView = headerContainer
|
||||||
|
tableView.layoutIfNeeded()
|
||||||
|
},
|
||||||
|
completion: { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
isAnimatingHeaderResize = false
|
||||||
|
resizeHeader()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarHeightConstraint?.update(offset: targetCalendarHeight)
|
||||||
headerContainer.setNeedsLayout()
|
headerContainer.setNeedsLayout()
|
||||||
headerContainer.layoutIfNeeded()
|
headerContainer.layoutIfNeeded()
|
||||||
let height = headerContainer.systemLayoutSizeFitting(
|
let height = headerContainer.systemLayoutSizeFitting(
|
||||||
@@ -362,8 +401,13 @@ final class OfflineCollectionDailyViewController: BaseViewController {
|
|||||||
verticalFittingPriority: .fittingSizeLevel
|
verticalFittingPriority: .fittingSizeLevel
|
||||||
).height
|
).height
|
||||||
guard abs(headerContainer.frame.height - height) > 0.5 else { return }
|
guard abs(headerContainer.frame.height - height) > 0.5 else { return }
|
||||||
headerContainer.frame.size.height = height
|
let updates = { [weak self] in
|
||||||
tableView.tableHeaderView = headerContainer
|
guard let self else { return }
|
||||||
|
headerContainer.frame.size.height = height
|
||||||
|
tableView.tableHeaderView = headerContainer
|
||||||
|
tableView.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
updates()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor private func presentFeedbackIfNeeded() {
|
@MainActor private func presentFeedbackIfNeeded() {
|
||||||
@@ -371,15 +415,9 @@ final class OfflineCollectionDailyViewController: BaseViewController {
|
|||||||
switch viewModel.settlementState {
|
switch viewModel.settlementState {
|
||||||
case .idle, .processing:
|
case .idle, .processing:
|
||||||
return
|
return
|
||||||
case let .success(result):
|
case .success:
|
||||||
feedbackPresented = true
|
// 成功后页面数据已经刷新,直接清理反馈状态,不再阻断用户操作。
|
||||||
let alert = UIAlertController(
|
viewModel.clearSettlementFeedback()
|
||||||
title: "补缴成功",
|
|
||||||
message: "营业日:\(result.date)\n共结清 \(result.updatedCount) 笔",
|
|
||||||
preferredStyle: .alert
|
|
||||||
)
|
|
||||||
alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in self?.finishFeedback() })
|
|
||||||
present(alert, animated: true)
|
|
||||||
case let .failed(message, canRetry):
|
case let .failed(message, canRetry):
|
||||||
feedbackPresented = true
|
feedbackPresented = true
|
||||||
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
|
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
|
||||||
|
|||||||
@@ -370,7 +370,13 @@ final class OfflinePaymentMethodButton: UIControl {
|
|||||||
|
|
||||||
private let iconView = UIImageView()
|
private let iconView = UIImageView()
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark"))
|
private let selectionBadge = UIView()
|
||||||
|
private let checkmarkView = UIImageView(
|
||||||
|
image: UIImage(
|
||||||
|
systemName: "checkmark",
|
||||||
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
init(method: OfflineCollectionPaymentMethod) {
|
init(method: OfflineCollectionPaymentMethod) {
|
||||||
self.method = method
|
self.method = method
|
||||||
@@ -386,7 +392,7 @@ final class OfflinePaymentMethodButton: UIControl {
|
|||||||
backgroundColor = selected ? UIColor(hex: 0xF1F7FF) : .white
|
backgroundColor = selected ? UIColor(hex: 0xF1F7FF) : .white
|
||||||
layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor
|
layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor
|
||||||
layer.borderWidth = selected ? 1.5 : 1
|
layer.borderWidth = selected ? 1.5 : 1
|
||||||
checkmarkView.isHidden = !selected
|
selectionBadge.isHidden = !selected
|
||||||
titleLabel.textColor = UIColor(hex: 0x081739)
|
titleLabel.textColor = UIColor(hex: 0x081739)
|
||||||
accessibilityTraits = selected ? [.button, .selected] : .button
|
accessibilityTraits = selected ? [.button, .selected] : .button
|
||||||
}
|
}
|
||||||
@@ -400,11 +406,14 @@ final class OfflinePaymentMethodButton: UIControl {
|
|||||||
titleLabel.text = method.displayName
|
titleLabel.text = method.displayName
|
||||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
|
selectionBadge.backgroundColor = UIColor(hex: 0x1684FC)
|
||||||
|
selectionBadge.layer.cornerRadius = 10
|
||||||
|
selectionBadge.layer.borderColor = UIColor.white.cgColor
|
||||||
|
selectionBadge.layer.borderWidth = 2
|
||||||
|
selectionBadge.isHidden = true
|
||||||
|
selectionBadge.isUserInteractionEnabled = false
|
||||||
checkmarkView.tintColor = .white
|
checkmarkView.tintColor = .white
|
||||||
checkmarkView.backgroundColor = UIColor(hex: 0x1684FC)
|
checkmarkView.contentMode = .scaleAspectFit
|
||||||
checkmarkView.layer.cornerRadius = 11
|
|
||||||
checkmarkView.contentMode = .center
|
|
||||||
checkmarkView.isHidden = true
|
|
||||||
|
|
||||||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
@@ -412,12 +421,17 @@ final class OfflinePaymentMethodButton: UIControl {
|
|||||||
stack.spacing = 12
|
stack.spacing = 12
|
||||||
stack.isUserInteractionEnabled = false
|
stack.isUserInteractionEnabled = false
|
||||||
addSubview(stack)
|
addSubview(stack)
|
||||||
addSubview(checkmarkView)
|
addSubview(selectionBadge)
|
||||||
|
selectionBadge.addSubview(checkmarkView)
|
||||||
stack.snp.makeConstraints { make in make.center.equalToSuperview() }
|
stack.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||||
iconView.snp.makeConstraints { make in make.width.height.equalTo(46) }
|
iconView.snp.makeConstraints { make in make.width.height.equalTo(46) }
|
||||||
|
selectionBadge.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(20)
|
||||||
|
make.top.trailing.equalToSuperview().inset(6)
|
||||||
|
}
|
||||||
checkmarkView.snp.makeConstraints { make in
|
checkmarkView.snp.makeConstraints { make in
|
||||||
make.width.height.equalTo(22)
|
make.center.equalToSuperview()
|
||||||
make.top.trailing.equalToSuperview().inset(-2)
|
make.width.height.equalTo(11)
|
||||||
}
|
}
|
||||||
setSelected(false)
|
setSelected(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class OfflineCollectionCalendarView: UIView {
|
final class OfflineCollectionCalendarView: UIView {
|
||||||
var onDateSelected: ((String) -> Void)?
|
var onDateSelected: ((String) -> Void)?
|
||||||
var onModeChanged: (() -> Void)?
|
var onModeChanged: ((_ animated: Bool) -> Void)?
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let toggleButton = UIButton(type: .system)
|
private let toggleButton = UIButton(type: .system)
|
||||||
|
private let previousButton = UIButton(type: .system)
|
||||||
private let nextButton = UIButton(type: .system)
|
private let nextButton = UIButton(type: .system)
|
||||||
private let rowsStack = UIStackView()
|
private let rowsStack = UIStackView()
|
||||||
private var rowStacks: [UIStackView] = []
|
private var rowStacks: [UIStackView] = []
|
||||||
@@ -16,9 +17,15 @@ final class OfflineCollectionCalendarView: UIView {
|
|||||||
private var pendingDates: Set<String> = []
|
private var pendingDates: Set<String> = []
|
||||||
private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date())
|
private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date())
|
||||||
private let calendar = OfflineCollectionDate.calendar
|
private let calendar = OfflineCollectionDate.calendar
|
||||||
|
private var isAnimatingModeChange = false
|
||||||
|
|
||||||
|
/// 当前周/月模式期望占用的固定高度,供外层表头同步执行高度动画。
|
||||||
|
var preferredHeight: CGFloat {
|
||||||
|
state.mode == .week ? 160 : 378
|
||||||
|
}
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
CGSize(width: UIView.noIntrinsicMetric, height: state.mode == .week ? 160 : 378)
|
CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
@@ -64,14 +71,11 @@ final class OfflineCollectionCalendarView: UIView {
|
|||||||
toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
|
toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
|
||||||
toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle"
|
toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle"
|
||||||
|
|
||||||
nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal)
|
configureNavigationButton(previousButton, imageName: "chevron.left", action: #selector(previousPage))
|
||||||
nextButton.tintColor = UIColor(hex: 0x081739)
|
configureNavigationButton(nextButton, imageName: "chevron.right", action: #selector(nextPage))
|
||||||
nextButton.accessibilityLabel = "下一页"
|
|
||||||
nextButton.addTarget(self, action: #selector(nextPage), for: .touchUpInside)
|
|
||||||
nextButton.snp.makeConstraints { make in make.width.height.equalTo(44) }
|
|
||||||
|
|
||||||
let spacer = UIView()
|
let spacer = UIView()
|
||||||
let header = UIStackView(arrangedSubviews: [titleLabel, spacer, toggleButton, nextButton])
|
let header = UIStackView(arrangedSubviews: [titleLabel, spacer, previousButton, toggleButton, nextButton])
|
||||||
header.axis = .horizontal
|
header.axis = .horizontal
|
||||||
header.alignment = .center
|
header.alignment = .center
|
||||||
header.spacing = 8
|
header.spacing = 8
|
||||||
@@ -134,14 +138,31 @@ final class OfflineCollectionCalendarView: UIView {
|
|||||||
reloadDates()
|
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() {
|
private func reloadDates() {
|
||||||
titleLabel.text = state.monthTitle
|
UIView.performWithoutAnimation {
|
||||||
|
titleLabel.text = state.monthTitle
|
||||||
|
titleLabel.layoutIfNeeded()
|
||||||
|
}
|
||||||
let isMonth = state.mode == .month
|
let isMonth = state.mode == .month
|
||||||
toggleButton.configuration?.title = isMonth ? "周" : "月"
|
toggleButton.configuration?.title = isMonth ? "周" : "月"
|
||||||
toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down")
|
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)
|
let selectedMonth = calendar.component(.month, from: state.selectedDate)
|
||||||
for (index, button) in dayButtons.enumerated() {
|
for (index, button) in dayButtons.enumerated() {
|
||||||
guard index < dates.count else {
|
guard index < dates.count else {
|
||||||
@@ -162,19 +183,58 @@ final class OfflineCollectionCalendarView: UIView {
|
|||||||
calendar: calendar
|
calendar: calendar
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if !isAnimatingModeChange {
|
||||||
|
applyRowVisibility(expanded: isMonth, selectedRow: state.selectedWeekRowIndex)
|
||||||
|
}
|
||||||
invalidateIntrinsicContentSize()
|
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() {
|
@objc private func toggleMode() {
|
||||||
|
guard !isAnimatingModeChange else { return }
|
||||||
|
let selectedRow = state.selectedWeekRowIndex
|
||||||
state.toggleMode()
|
state.toggleMode()
|
||||||
let animations = { [weak self] in
|
let expanded = state.mode == .month
|
||||||
self?.reloadDates()
|
let animated = !UIAccessibility.isReduceMotionEnabled
|
||||||
self?.superview?.layoutIfNeeded()
|
|
||||||
|
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) {
|
@objc private func dayTapped(_ sender: OfflineCollectionDayButton) {
|
||||||
@@ -184,21 +244,31 @@ final class OfflineCollectionCalendarView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
|
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
|
||||||
let oldDate = state.selectedDate
|
|
||||||
let offset = gesture.direction == .left ? 1 : -1
|
let offset = gesture.direction == .left ? 1 : -1
|
||||||
let date = state.movePage(offset)
|
movePage(offset)
|
||||||
guard !calendar.isDate(oldDate, inSameDayAs: date) else { return }
|
}
|
||||||
let transition: UIView.AnimationOptions = gesture.direction == .left ? .transitionCrossDissolve : .transitionCrossDissolve
|
|
||||||
if UIAccessibility.isReduceMotionEnabled { reloadDates() } else {
|
@objc private func previousPage() {
|
||||||
UIView.transition(with: rowsStack, duration: 0.18, options: transition) { [weak self] in self?.reloadDates() }
|
movePage(-1)
|
||||||
}
|
|
||||||
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func nextPage() {
|
@objc private func nextPage() {
|
||||||
|
movePage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func movePage(_ offset: Int) {
|
||||||
|
guard !isAnimatingModeChange, state.canMovePage(offset) else { return }
|
||||||
let oldDate = state.selectedDate
|
let oldDate = state.selectedDate
|
||||||
let date = state.movePage(1)
|
let date = state.movePage(offset)
|
||||||
guard !calendar.isDate(oldDate, inSameDayAs: date) else { return }
|
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()
|
reloadDates()
|
||||||
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
|
onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar))
|
||||||
}
|
}
|
||||||
@@ -206,20 +276,30 @@ final class OfflineCollectionCalendarView: UIView {
|
|||||||
|
|
||||||
/// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。
|
/// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。
|
||||||
private final class OfflineCollectionDayButton: UIControl {
|
private final class OfflineCollectionDayButton: UIControl {
|
||||||
|
private let stateBackgroundView = UIView()
|
||||||
private let dayLabel = UILabel()
|
private let dayLabel = UILabel()
|
||||||
private let pendingDot = UIView()
|
private let pendingDot = UIView()
|
||||||
private(set) var date: Date?
|
private(set) var date: Date?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
layer.cornerRadius = 10
|
stateBackgroundView.isUserInteractionEnabled = false
|
||||||
|
stateBackgroundView.layer.cornerRadius = 11
|
||||||
dayLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
dayLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
||||||
dayLabel.textAlignment = .center
|
dayLabel.textAlignment = .center
|
||||||
pendingDot.layer.cornerRadius = 2.5
|
pendingDot.layer.cornerRadius = 3.5
|
||||||
|
addSubview(stateBackgroundView)
|
||||||
addSubview(dayLabel)
|
addSubview(dayLabel)
|
||||||
addSubview(pendingDot)
|
addSubview(pendingDot)
|
||||||
dayLabel.snp.makeConstraints { make in make.centerX.equalToSuperview(); make.centerY.equalToSuperview().offset(-2) }
|
stateBackgroundView.snp.makeConstraints { make in
|
||||||
pendingDot.snp.makeConstraints { make in make.top.equalTo(dayLabel.snp.bottom).offset(2); make.centerX.equalToSuperview(); make.width.height.equalTo(5) }
|
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) }
|
snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,12 +319,27 @@ private final class OfflineCollectionDayButton: UIControl {
|
|||||||
self.date = date
|
self.date = date
|
||||||
isEnabled = enabled
|
isEnabled = enabled
|
||||||
dayLabel.text = String(calendar.component(.day, from: date))
|
dayLabel.text = String(calendar.component(.day, from: date))
|
||||||
|
let warningColor = UIColor(hex: 0xFF7600)
|
||||||
pendingDot.isHidden = !pending
|
pendingDot.isHidden = !pending
|
||||||
pendingDot.backgroundColor = selected ? .white : AppColor.danger
|
pendingDot.backgroundColor = warningColor
|
||||||
backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF1F0) : .clear)
|
pendingDot.layer.borderWidth = selected ? 1.5 : 0
|
||||||
layer.borderWidth = pending && selected ? 2 : (today && !selected ? 1 : 0)
|
pendingDot.layer.borderColor = UIColor.white.cgColor
|
||||||
layer.borderColor = (pending && selected ? AppColor.danger : AppColor.primary).cgColor
|
backgroundColor = .clear
|
||||||
dayLabel.textColor = selected ? .white : (enabled ? (inCurrentMonth ? AppColor.textPrimary : AppColor.textTertiary) : AppColor.textTertiary)
|
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
|
alpha = enabled ? 1 : 0.35
|
||||||
accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")"
|
accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")"
|
||||||
accessibilityTraits = selected ? [.button, .selected] : .button
|
accessibilityTraits = selected ? [.button, .selected] : .button
|
||||||
|
|||||||
@@ -87,6 +87,35 @@ final class OfflineCollectionCalendarTests: XCTestCase {
|
|||||||
state.movePage(40)
|
state.movePage(40)
|
||||||
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-25")
|
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-25")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCalendarPageNavigationKeepsPositionAndUsesTodayAtFutureBoundary() throws {
|
||||||
|
let maximum = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-26"))
|
||||||
|
let friday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-21"))
|
||||||
|
var week = OfflineCollectionCalendarState(selectedDate: friday, maximumDate: maximum)
|
||||||
|
XCTAssertTrue(week.canMovePage(1))
|
||||||
|
week.movePage(1)
|
||||||
|
XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-26")
|
||||||
|
XCTAssertFalse(week.canMovePage(1))
|
||||||
|
|
||||||
|
let tuesday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25"))
|
||||||
|
week = OfflineCollectionCalendarState(selectedDate: tuesday, maximumDate: maximum)
|
||||||
|
week.movePage(-1)
|
||||||
|
XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-18")
|
||||||
|
|
||||||
|
let march = try XCTUnwrap(OfflineCollectionDate.date(from: "2025-03-31"))
|
||||||
|
var month = OfflineCollectionCalendarState(selectedDate: march, maximumDate: maximum, mode: .month)
|
||||||
|
month.movePage(-1)
|
||||||
|
XCTAssertEqual(OfflineCollectionDate.businessDate(for: month.selectedDate), "2025-02-28")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectedWeekRowIndexAndModeTogglePreserveDate() throws {
|
||||||
|
let selected = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-12"))
|
||||||
|
var state = OfflineCollectionCalendarState(selectedDate: selected, maximumDate: selected)
|
||||||
|
XCTAssertEqual(state.selectedWeekRowIndex, 2)
|
||||||
|
state.toggleMode()
|
||||||
|
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-12")
|
||||||
|
XCTAssertEqual(state.selectedWeekRowIndex, 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 线下收款 ViewModel 的并发与幂等行为测试。
|
/// 线下收款 ViewModel 的并发与幂等行为测试。
|
||||||
|
|||||||
Reference in New Issue
Block a user