Files
suixinkan_uikit/suixinkan/UI/Home/Views/PermissionApplyViews.swift
汉秋 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
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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?()
}
}