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