对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。 Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
5.7 KiB
Swift
156 lines
5.7 KiB
Swift
//
|
||
// SampleManagementUI.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 样片管理模块视觉常量,严格对应 Android Compose 页面中的 dp、颜色与圆角。
|
||
enum SampleManagementUITokens {
|
||
static let pageBackground = UIColor(hex: 0xF4F4F4)
|
||
static let inputBackground = UIColor(hex: 0xF4F4F4)
|
||
static let border = UIColor(hex: 0xEEEEEE)
|
||
static let placeholder = UIColor(hex: 0xB6BECA)
|
||
static let text563 = UIColor(hex: 0x4B5563)
|
||
static let statText = UIColor(hex: 0x6B7280)
|
||
static let screenInset: CGFloat = 16
|
||
static let sectionSpacing: CGFloat = 12
|
||
static let cardRadius: CGFloat = 12
|
||
static let fieldRadius: CGFloat = 8
|
||
}
|
||
|
||
/// 样片管理页面基类,复用“空间设置”的原生导航栏外观与返回行为。
|
||
class SampleBaseViewController: BaseViewController {
|
||
override func setupNavigationBar() {
|
||
navigationItem.backButtonDisplayMode = .minimal
|
||
}
|
||
|
||
override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||
ProfileNavigationStyle.apply(to: navigationController, showsDivider: false)
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
ProfileNavigationStyle.restore(navigationController)
|
||
}
|
||
|
||
/// 设置与“空间设置”一致的原生导航栏标题。
|
||
func setSampleTitle(_ title: String) {
|
||
self.title = title
|
||
}
|
||
}
|
||
|
||
/// 锚定在输入框下方的 Android 风格下拉菜单,支持滚动加载更多。
|
||
final class SampleAnchoredDropdownView: UIControl, UIScrollViewDelegate {
|
||
/// 下拉菜单选项。
|
||
struct Option {
|
||
let title: String
|
||
let isSelected: Bool
|
||
let isLoadMore: Bool
|
||
|
||
/// 创建普通或“加载更多”选项。
|
||
init(title: String, isSelected: Bool = false, isLoadMore: Bool = false) {
|
||
self.title = title
|
||
self.isSelected = isSelected
|
||
self.isLoadMore = isLoadMore
|
||
}
|
||
}
|
||
|
||
private let cardView = UIView()
|
||
private let scrollView = UIScrollView()
|
||
private let stackView = UIStackView()
|
||
private var onSelect: ((Int) -> Void)?
|
||
private var onReachedBottom: (() -> Void)?
|
||
private var didRequestBottom = false
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
cardView.backgroundColor = .white
|
||
cardView.layer.cornerRadius = 4
|
||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||
cardView.layer.shadowOpacity = 0.14
|
||
cardView.layer.shadowRadius = 8
|
||
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||
scrollView.delegate = self
|
||
stackView.axis = .vertical
|
||
addSubview(cardView)
|
||
cardView.addSubview(scrollView)
|
||
scrollView.addSubview(stackView)
|
||
addTarget(self, action: #selector(dismissMenu), for: .touchUpInside)
|
||
isHidden = true
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
/// 在锚点下方展示菜单。
|
||
func show(
|
||
anchor: UIView,
|
||
options: [Option],
|
||
maximumHeight: CGFloat = 200,
|
||
onReachedBottom: (() -> Void)? = nil,
|
||
onSelect: @escaping (Int) -> Void
|
||
) {
|
||
guard !options.isEmpty else { return }
|
||
self.onSelect = onSelect
|
||
self.onReachedBottom = onReachedBottom
|
||
didRequestBottom = false
|
||
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
|
||
for (index, option) in options.enumerated() {
|
||
let button = UIButton(type: .system)
|
||
button.tag = index
|
||
button.contentHorizontalAlignment = .leading
|
||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||
button.setTitle(option.title, for: .normal)
|
||
let color = option.isSelected ? AppColor.primary : (option.isLoadMore ? AppColor.textSecondary : .black)
|
||
button.setTitleColor(color, for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: option.isSelected ? .medium : .regular)
|
||
button.backgroundColor = .white
|
||
button.accessibilityLabel = option.title
|
||
button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside)
|
||
button.snp.makeConstraints { make in make.height.equalTo(28) }
|
||
stackView.addArrangedSubview(button)
|
||
}
|
||
|
||
isHidden = false
|
||
superview?.bringSubviewToFront(self)
|
||
layoutIfNeeded()
|
||
let anchorFrame = anchor.convert(anchor.bounds, to: self)
|
||
let contentHeight = CGFloat(options.count) * 28
|
||
let menuHeight = min(maximumHeight, contentHeight)
|
||
cardView.frame = CGRect(x: anchorFrame.minX, y: anchorFrame.maxY, width: anchorFrame.width, height: menuHeight)
|
||
scrollView.frame = cardView.bounds
|
||
stackView.frame = CGRect(x: 0, y: 0, width: cardView.bounds.width, height: contentHeight)
|
||
scrollView.contentSize = stackView.frame.size
|
||
}
|
||
|
||
/// 隐藏菜单。
|
||
func hide() {
|
||
isHidden = true
|
||
}
|
||
|
||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||
guard !didRequestBottom, scrollView.contentSize.height > scrollView.bounds.height else { return }
|
||
let remaining = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height
|
||
guard remaining <= 10 else { return }
|
||
didRequestBottom = true
|
||
onReachedBottom?()
|
||
}
|
||
|
||
@objc private func optionTapped(_ sender: UIButton) {
|
||
isHidden = true
|
||
onSelect?(sender.tag)
|
||
}
|
||
|
||
@objc private func dismissMenu() {
|
||
isHidden = true
|
||
}
|
||
}
|