完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
360
suixinkan/UI/Home/Views/PermissionDropdownViewController.swift
Normal file
360
suixinkan/UI/Home/Views/PermissionDropdownViewController.swift
Normal file
@ -0,0 +1,360 @@
|
||||
//
|
||||
// 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: ",")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user