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