// // 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! 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(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() 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: ",") } }