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

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

361 lines
14 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.

//
// PermissionDropdownViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
enum PermissionDropdownMode {
case roles([RoleOption])
case scenics([ScenicOption], isLoading: Bool)
}
/// Android
final class PermissionDropdownViewController: UIViewController, UITableViewDelegate, UITextFieldDelegate {
private enum Section { case main }
fileprivate enum Item: Hashable {
case role(RoleOption)
case scenic(ScenicOption)
case loading
case empty(String)
}
var onDismiss: (() -> Void)?
var onRoleSelect: ((RoleOption) -> Void)?
var onScenicToggle: ((ScenicOption) -> Void)?
var onSearchChange: ((String) -> Void)?
private let anchorFrame: CGRect
private var mode: PermissionDropdownMode
private var allScenics: [ScenicOption] = []
private var query = ""
private let dismissControl = UIControl()
private let cardView = UIView()
private let searchContainer = UIView()
private let searchField = UITextField()
private let tableView = UITableView(frame: .zero, style: .plain)
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var cardHeightConstraint: Constraint?
///
init(anchor: UIView, mode: PermissionDropdownMode) {
anchorFrame = anchor.convert(anchor.bounds, to: nil)
self.mode = mode
if case .scenics(let scenics, _) = mode {
allScenics = scenics
}
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
dismissControl.backgroundColor = .clear
dismissControl.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
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)
cardView.clipsToBounds = false
searchContainer.backgroundColor = .white
searchContainer.layer.cornerRadius = 8
searchContainer.layer.borderWidth = 1
searchContainer.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor
let searchIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
searchIcon.tintColor = AppColor.textTertiary
searchIcon.contentMode = .scaleAspectFit
searchField.placeholder = "搜索景区名称"
searchField.font = .systemFont(ofSize: 14)
searchField.textColor = PermissionApplyUITokens.text
searchField.returnKeyType = .search
searchField.clearButtonMode = .whileEditing
searchField.delegate = self
searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged)
searchField.accessibilityLabel = "搜索景区名称"
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.keyboardDismissMode = .onDrag
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 52
tableView.delegate = self
tableView.register(PermissionDropdownCell.self, forCellReuseIdentifier: PermissionDropdownCell.reuseIdentifier)
view.addSubview(dismissControl)
view.addSubview(cardView)
cardView.addSubview(searchContainer)
searchContainer.addSubview(searchIcon)
searchContainer.addSubview(searchField)
cardView.addSubview(tableView)
dismissControl.snp.makeConstraints { make in make.edges.equalToSuperview() }
cardView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(anchorFrame.minX)
make.top.equalToSuperview().offset(anchorFrame.maxY + 4)
make.width.equalTo(anchorFrame.width)
cardHeightConstraint = make.height.equalTo(80).constraint
}
searchContainer.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview().inset(12)
make.height.equalTo(38)
}
searchIcon.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
searchField.snp.makeConstraints { make in
make.leading.equalTo(searchIcon.snp.trailing).offset(10)
make.trailing.equalToSuperview().offset(-8)
make.top.bottom.equalToSuperview()
}
tableView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.equalTo(searchContainer.snp.bottom).offset(4)
}
configureDataSource()
applyMode(animated: false)
}
///
func updateScenics(_ scenics: [ScenicOption], isLoading: Bool) {
allScenics = scenics
mode = .scenics(scenics, isLoading: isLoading)
guard isViewLoaded else { return }
applyMode(animated: false)
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) {
tableView, indexPath, item in
guard let cell = tableView.dequeueReusableCell(
withIdentifier: PermissionDropdownCell.reuseIdentifier,
for: indexPath
) as? PermissionDropdownCell else { return UITableViewCell() }
cell.apply(item: item)
return cell
}
}
private func applyMode(animated: Bool) {
var items: [Item]
switch mode {
case .roles(let roles):
searchContainer.isHidden = true
tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() }
items = roles.map(Item.role)
if items.isEmpty { items = [.empty("暂无可用角色")] }
case .scenics(_, let isLoading):
searchContainer.isHidden = isLoading
if isLoading {
tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() }
items = [.loading]
} else {
tableView.snp.remakeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.equalTo(searchContainer.snp.bottom).offset(4)
}
let filtered = filteredScenics()
items = filtered.map(Item.scenic)
if items.isEmpty {
items = [.empty(query.isEmpty ? "暂无可用景区" : "未找到相关景区")]
}
}
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
let completion: () -> Void = { [weak self] in
guard let self else { return }
self.updateCardHeight(itemCount: items.count)
}
if animated {
dataSource.apply(snapshot, animatingDifferences: true, completion: completion)
} else {
UIView.performWithoutAnimation {
dataSource.apply(snapshot, animatingDifferences: false, completion: completion)
tableView.layoutIfNeeded()
}
}
}
private func filteredScenics() -> [ScenicOption] {
let value = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return allScenics }
return allScenics.filter { $0.name.localizedCaseInsensitiveContains(value) }
}
private func updateCardHeight(itemCount: Int) {
tableView.layoutIfNeeded()
let hasSearch = !searchContainer.isHidden
let searchHeight: CGFloat = hasSearch ? 58 : 0
let estimatedListHeight = max(64, min(262, tableView.contentSize.height > 0 ? tableView.contentSize.height : CGFloat(itemCount) * 52))
cardHeightConstraint?.update(offset: min(320, searchHeight + estimatedListHeight))
view.layoutIfNeeded()
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .role(let role):
onRoleSelect?(role)
dismissMenu(notify: false)
case .scenic(let scenic):
guard !scenic.isDisabled else { return }
onScenicToggle?(scenic)
if let index = allScenics.firstIndex(where: { $0.id == scenic.id }) {
allScenics[index].isSelected.toggle()
}
mode = .scenics(allScenics, isLoading: false)
applyMode(animated: false)
case .loading, .empty:
break
}
}
@objc private func searchChanged() {
query = searchField.text ?? ""
onSearchChange?(query)
applyMode(animated: false)
}
@objc private func dismissTapped() {
dismissMenu(notify: true)
}
private func dismissMenu(notify: Bool) {
searchField.resignFirstResponder()
dismiss(animated: true) { [weak self] in
if notify { self?.onDismiss?() }
}
}
}
///
private final class PermissionDropdownCell: UITableViewCell {
static let reuseIdentifier = "PermissionDropdownCell"
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let checkCircle = UIView()
private let checkImage = UIImageView(image: UIImage(systemName: "checkmark"))
private let activityIndicator = UIActivityIndicatorView(style: .medium)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .white
selectionStyle = .none
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = PermissionApplyUITokens.text
titleLabel.numberOfLines = 1
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = PermissionApplyUITokens.placeholder
detailLabel.numberOfLines = 0
checkCircle.frame = CGRect(x: 0, y: 0, width: 20, height: 20)
checkCircle.layer.cornerRadius = 10
checkImage.tintColor = .white
checkImage.contentMode = .scaleAspectFit
activityIndicator.color = PermissionApplyUITokens.selectedBlue
[titleLabel, detailLabel, activityIndicator].forEach(contentView.addSubview)
accessoryView = checkCircle
checkCircle.addSubview(checkImage)
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().offset(-12)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-12)
make.bottom.equalToSuperview().offset(-10)
}
checkImage.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(14) }
activityIndicator.snp.makeConstraints { make in make.center.equalToSuperview() }
contentView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(48) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: PermissionDropdownViewController.Item) {
activityIndicator.stopAnimating()
titleLabel.textAlignment = .left
titleLabel.font = .systemFont(ofSize: 14)
detailLabel.isHidden = true
checkCircle.isHidden = true
isUserInteractionEnabled = true
switch item {
case .role(let role):
titleLabel.text = role.name
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = PermissionApplyUITokens.text
detailLabel.text = role.description
detailLabel.isHidden = role.description.isEmpty
checkCircle.isHidden = false
checkCircle.backgroundColor = role.isSelected
? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB)
checkImage.isHidden = !role.isSelected
case .scenic(let scenic):
titleLabel.text = scenic.name
titleLabel.textColor = scenic.isDisabled ? AppColor.textTertiary : PermissionApplyUITokens.text
checkCircle.isHidden = false
checkCircle.backgroundColor = scenic.isDisabled && scenic.isSelected
? AppColor.textTertiary
: (scenic.isSelected ? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB))
checkImage.isHidden = !scenic.isSelected
isUserInteractionEnabled = !scenic.isDisabled
case .loading:
titleLabel.text = nil
activityIndicator.startAnimating()
isUserInteractionEnabled = false
case .empty(let message):
titleLabel.text = message
titleLabel.textAlignment = .center
titleLabel.textColor = AppColor.textTertiary
isUserInteractionEnabled = false
}
if detailLabel.isHidden {
titleLabel.snp.remakeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().offset(12)
make.bottom.lessThanOrEqualToSuperview().offset(-12)
}
} else {
titleLabel.snp.remakeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().offset(-12)
}
}
accessibilityLabel = [titleLabel.text, detailLabel.isHidden ? nil : detailLabel.text]
.compactMap { $0 }.joined(separator: "")
}
}