Files
suixinkan_uikit/suixinkan/UI/Home/Views/PermissionDropdownViewController.swift
lujiuyinandCursor 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: ",")
}
}