对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
514 lines
20 KiB
Swift
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?()
|
||
}
|
||
}
|