Files
suixinkan_uikit/suixinkan/UI/Home/Views/PermissionApplyViews.swift
lujiuyinandCursor efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

514 lines
20 KiB
Swift

//
// PermissionApplyViews.swift
// suixinkan
//
import SnapKit
import UIKit
/// 权限申请模块视觉常量,严格对应 Android Compose 中的尺寸和颜色。
enum PermissionApplyUITokens {
static let pageBackground = UIColor(hex: 0xF5F5F5)
static let selectorBorder = UIColor(hex: 0xE0E3EA)
static let placeholder = UIColor(hex: 0xB3B8C2)
static let text = UIColor(hex: 0x111827)
static let existingBackground = UIColor(hex: 0xF8F9FB)
static let selectedBackground = UIColor(hex: 0xF4F9FF)
static let selectedBlue = UIColor(hex: 0x1677FF)
static let existingGreen = UIColor(hex: 0x52C41A)
static let existingGreenBackground = UIColor(hex: 0xE6F7E6)
static let disabledButton = UIColor(hex: 0xE0E0E0)
}
/// 权限申请页顶部“申请平台开通新景区”横幅。
final class PermissionApplyBannerView: UIView {
var onApplyNewScenic: (() -> Void)?
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let applyButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.warningBackground
iconView.image = UIImage(named: "permission_open_scenic")
?? UIImage(systemName: "building.2.fill")?.withTintColor(AppColor.warning, renderingMode: .alwaysOriginal)
iconView.contentMode = .scaleAspectFit
iconView.isAccessibilityElement = false
titleLabel.text = "没有找到心仪的景区"
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.warning
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
var configuration = UIButton.Configuration.plain()
configuration.title = "申请平台开通新景区"
configuration.image = UIImage(named: "permission_more_scenic")
?? UIImage(systemName: "chevron.right")
configuration.imagePlacement = .trailing
configuration.imagePadding = 2
configuration.contentInsets = .zero
configuration.baseForegroundColor = AppColor.warning
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 14, weight: .medium)
return outgoing
}
applyButton.configuration = configuration
applyButton.accessibilityLabel = "申请平台开通新景区"
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
[iconView, titleLabel, applyButton].forEach(addSubview)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(applyButton.snp.leading).offset(-12)
}
applyButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.height.greaterThanOrEqualTo(44)
}
snp.makeConstraints { make in make.height.equalTo(48) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func applyTapped() {
onApplyNewScenic?()
}
}
/// Android 表单标题、说明和锚定选择框组合视图。
final class PermissionFormSelectorRow: UIView {
var onTap: (() -> Void)?
private let titleLabel = UILabel()
private let tagLabel = UILabel()
private let descriptionLabel = UILabel()
let selectorControl = UIControl()
private let valueLabel = UILabel()
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
init(title: String, tag: String, description: String, placeholder: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = UIColor(hex: 0x333333)
tagLabel.text = tag
tagLabel.font = .systemFont(ofSize: 12)
tagLabel.textColor = AppColor.textTertiary
tagLabel.textAlignment = .right
descriptionLabel.text = description
descriptionLabel.font = .systemFont(ofSize: 12)
descriptionLabel.textColor = PermissionApplyUITokens.placeholder
descriptionLabel.numberOfLines = 0
selectorControl.backgroundColor = .white
selectorControl.layer.cornerRadius = 12
selectorControl.layer.borderWidth = 1
selectorControl.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor
selectorControl.accessibilityLabel = title
selectorControl.addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
valueLabel.text = placeholder
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = PermissionApplyUITokens.placeholder
valueLabel.lineBreakMode = .byTruncatingTail
chevron.tintColor = PermissionApplyUITokens.placeholder
chevron.contentMode = .scaleAspectFit
[titleLabel, tagLabel, descriptionLabel, selectorControl].forEach(addSubview)
[valueLabel, chevron].forEach(selectorControl.addSubview)
titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() }
tagLabel.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview()
}
selectorControl.snp.makeConstraints { make in
make.top.equalTo(descriptionLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(46)
}
valueLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
}
chevron.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新选择框显示值。
func setValue(_ text: String, isPlaceholder: Bool) {
valueLabel.text = text
valueLabel.textColor = isPlaceholder ? PermissionApplyUITokens.placeholder : PermissionApplyUITokens.text
selectorControl.accessibilityValue = text
}
@objc private func rowTapped() {
onTap?()
}
}
/// 权限标签的展示样式。
enum PermissionChipStyle: Hashable {
case selectedRemovable
case selectedStatic
case existingGreen
}
/// Diffable 标签网格中的稳定条目。
struct PermissionChipItem: Hashable {
let id: String
let title: String
}
/// Android `FlowRow` 的标签左对齐与换行坐标计算器。
struct PermissionChipLayoutMetrics {
static let itemHeight: CGFloat = 44
static let interitemSpacing: CGFloat = 12
static let lineSpacing: CGFloat = 2
/// 按容器宽度为标签生成从左到右、超宽换行的坐标。
static func makeFrames(itemWidths: [CGFloat], containerWidth: CGFloat) -> [CGRect] {
guard containerWidth > 0 else { return [] }
var x: CGFloat = 0
var y: CGFloat = 0
return itemWidths.map { rawWidth in
let width = min(containerWidth, max(44, rawWidth))
if x > 0, x + width > containerWidth + 0.5 {
x = 0
y += itemHeight + lineSpacing
}
let frame = CGRect(x: x, y: y, width: width, height: itemHeight)
x += width + interitemSpacing
return frame
}
}
}
/// 确定性计算左对齐换行坐标,等价于 Android `FlowRow`。
private final class PermissionChipFlowLayout: UICollectionViewLayout {
var itemWidthProvider: ((IndexPath) -> CGFloat)?
private var cachedAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
private var contentHeight: CGFloat = 1
override func prepare() {
super.prepare()
guard let collectionView, collectionView.bounds.width > 0 else {
cachedAttributes = [:]
contentHeight = 1
return
}
cachedAttributes = [:]
let availableWidth = collectionView.bounds.width
var indexPaths: [IndexPath] = []
var itemWidths: [CGFloat] = []
for section in 0..<collectionView.numberOfSections {
for item in 0..<collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(item: item, section: section)
indexPaths.append(indexPath)
itemWidths.append(itemWidthProvider?(indexPath) ?? 44)
}
}
let frames = PermissionChipLayoutMetrics.makeFrames(
itemWidths: itemWidths,
containerWidth: availableWidth
)
for (index, indexPath) in indexPaths.enumerated() {
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frames[index]
cachedAttributes[indexPath] = attributes
}
contentHeight = frames.last?.maxY ?? 1
}
override var collectionViewContentSize: CGSize {
CGSize(width: collectionView?.bounds.width ?? 0, height: contentHeight)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
cachedAttributes.values.filter { $0.frame.intersects(rect) }
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
cachedAttributes[indexPath]
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let collectionView else { return true }
return abs(collectionView.bounds.width - newBounds.width) > 0.5
}
}
/// 自适应换行的权限标签网格。
final class PermissionChipCollectionView: UIView {
private enum Section { case main }
var onRemove: ((Int) -> Void)?
private let style: PermissionChipStyle
private let layout: PermissionChipFlowLayout
private let collectionView: UICollectionView
private var dataSource: UICollectionViewDiffableDataSource<Section, PermissionChipItem>!
private var items: [PermissionChipItem] = []
private var heightConstraint: Constraint?
init(style: PermissionChipStyle) {
self.style = style
let layout = PermissionChipFlowLayout()
self.layout = layout
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
super.init(frame: .zero)
collectionView.backgroundColor = .clear
collectionView.isScrollEnabled = false
collectionView.register(PermissionChipCell.self, forCellWithReuseIdentifier: PermissionChipCell.reuseIdentifier)
addSubview(collectionView)
collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() }
snp.makeConstraints { make in heightConstraint = make.height.equalTo(1).constraint }
dataSource = UICollectionViewDiffableDataSource<Section, PermissionChipItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
guard let self,
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: PermissionChipCell.reuseIdentifier,
for: indexPath
) as? PermissionChipCell else { return UICollectionViewCell() }
cell.apply(title: item.title, style: self.style)
cell.onRemove = { [weak self] in
guard let self, let currentIndex = self.items.firstIndex(of: item) else { return }
self.onRemove?(currentIndex)
}
return cell
}
layout.itemWidthProvider = { [weak self] indexPath in
self?.itemWidth(at: indexPath) ?? 44
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使用 Diffable snapshot 更新标签。
func apply(titles: [String]) {
var occurrences: [String: Int] = [:]
items = titles.map { title in
let occurrence = occurrences[title, default: 0]
occurrences[title] = occurrence + 1
return PermissionChipItem(id: "\(title)#\(occurrence)", title: title)
}
var snapshot = NSDiffableDataSourceSnapshot<Section, PermissionChipItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
self?.updateHeight()
}
}
override func layoutSubviews() {
super.layoutSubviews()
updateHeight()
}
private func updateHeight() {
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutIfNeeded()
let height = max(1, collectionView.collectionViewLayout.collectionViewContentSize.height)
heightConstraint?.update(offset: height)
invalidateIntrinsicContentSize()
}
private func itemWidth(at indexPath: IndexPath) -> CGFloat {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return 44 }
let textWidth = ceil((item.title as NSString).size(withAttributes: [
.font: UIFont.systemFont(ofSize: 14),
]).width)
let horizontalChrome: CGFloat = style == .selectedRemovable ? 42 : 24
return textWidth + horizontalChrome
}
}
/// 带标题背景的“新增角色/新增景区”区域。
final class PermissionSelectedTagsView: UIView {
private let titleLabel = UILabel()
private let chips = PermissionChipCollectionView(style: .selectedRemovable)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = PermissionApplyUITokens.selectedBackground
layer.cornerRadius = 12
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = PermissionApplyUITokens.text
addSubview(titleLabel)
addSubview(chips)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(12)
}
chips.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新区域标题与标签内容。
func apply(title: String, names: [String], onRemove: ((Int) -> Void)? = nil) {
titleLabel.text = title
isHidden = names.isEmpty
chips.onRemove = onRemove
chips.apply(titles: names)
}
}
/// 权限申请页底部主按钮。
final class PermissionSubmitButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setTitle("提交审核", for: .normal)
setTitleColor(.white, for: .normal)
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
layer.cornerRadius = 8
accessibilityLabel = "提交审核"
snp.makeConstraints { make in make.height.equalTo(48) }
applyState(canSubmit: false, submitting: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新可提交与提交中状态。
func applyState(canSubmit: Bool, submitting: Bool) {
isEnabled = canSubmit && !submitting
backgroundColor = isEnabled ? PermissionApplyUITokens.selectedBlue : PermissionApplyUITokens.disabledButton
setTitle(submitting ? "提交中..." : "提交审核", for: .normal)
accessibilityValue = submitting ? "提交中" : nil
}
}
/// 权限标签 CollectionView 单元格。
private final class PermissionChipCell: UICollectionViewCell {
static let reuseIdentifier = "PermissionChipCell"
var onRemove: (() -> Void)?
private let chipBackground = UIView()
private let label = UILabel()
private let removeButton = UIButton(type: .system)
private let removeImageView = UIImageView(image: UIImage(systemName: "xmark"))
override init(frame: CGRect) {
super.init(frame: frame)
label.font = .systemFont(ofSize: 14)
label.lineBreakMode = .byTruncatingTail
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
removeButton.accessibilityLabel = "删除"
removeImageView.contentMode = .scaleAspectFit
removeImageView.isUserInteractionEnabled = false
contentView.addSubview(chipBackground)
chipBackground.addSubview(label)
chipBackground.addSubview(removeImageView)
contentView.addSubview(removeButton)
chipBackground.snp.makeConstraints { make in
make.leading.trailing.centerY.equalToSuperview()
make.height.equalTo(34)
}
removeButton.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.size.equalTo(44)
}
removeImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-10)
make.centerY.equalToSuperview()
make.size.equalTo(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, style: PermissionChipStyle) {
label.text = title
chipBackground.layer.cornerRadius = style == .existingGreen ? 4 : 8
chipBackground.layer.borderWidth = 1
switch style {
case .selectedRemovable, .selectedStatic:
chipBackground.backgroundColor = style == .selectedRemovable
? PermissionApplyUITokens.selectedBackground : .white
chipBackground.layer.borderColor = PermissionApplyUITokens.selectedBlue.cgColor
label.textColor = PermissionApplyUITokens.selectedBlue
removeImageView.tintColor = PermissionApplyUITokens.selectedBlue
case .existingGreen:
chipBackground.backgroundColor = PermissionApplyUITokens.existingGreenBackground
chipBackground.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor
label.textColor = PermissionApplyUITokens.existingGreen
removeImageView.tintColor = PermissionApplyUITokens.existingGreen
}
let removable = style == .selectedRemovable
removeButton.isHidden = !removable
removeImageView.isHidden = !removable
label.snp.remakeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
if removable {
make.trailing.equalTo(removeImageView.snp.leading).offset(-6)
} else {
make.trailing.equalToSuperview().offset(-12)
}
}
accessibilityLabel = title
}
@objc private func removeTapped() {
onRemove?()
}
}