From 45f03994cc3cc3af1de3a1123b6619e5262c0406 Mon Sep 17 00:00:00 2001 From: Tal Aviram Date: Mon, 3 Feb 2025 11:15:26 +0200 Subject: [PATCH 1/2] feat: improve iOS detected devices view prior to this, it used Alert view that could easily be cluttered when scanning without filters in a modern enviornment with > 10 devices. this now uses UIPopoverPresentationController and UITableView to allow scrollable list. --- ios/Plugin.xcodeproj/project.pbxproj | 4 + ios/Plugin/DeviceListView.swift | 120 +++++++++++++++++++++++++++ ios/Plugin/DeviceManager.swift | 23 +++-- 3 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 ios/Plugin/DeviceListView.swift diff --git a/ios/Plugin.xcodeproj/project.pbxproj b/ios/Plugin.xcodeproj/project.pbxproj index a51695b..a142fd9 100644 --- a/ios/Plugin.xcodeproj/project.pbxproj +++ b/ios/Plugin.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 34ECC0E425BE199F00881175 /* Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ECC0E125BE199F00881175 /* Conversion.swift */; }; 34ECC0E525BE199F00881175 /* DeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ECC0E225BE199F00881175 /* DeviceManager.swift */; }; 34ECC2D725C0BAEC00881175 /* ConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ECC2D625C0BAEC00881175 /* ConversionTests.swift */; }; + 36EC9FF52D50C166009750C9 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EC9FF42D50C166009750C9 /* DeviceListView.swift */; }; 50ADFF92201F53D600D50D53 /* Plugin.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50ADFF88201F53D600D50D53 /* Plugin.framework */; }; 50ADFF97201F53D600D50D53 /* PluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFF96201F53D600D50D53 /* PluginTests.swift */; }; 50ADFF99201F53D600D50D53 /* Plugin.h in Headers */ = {isa = PBXBuildFile; fileRef = 50ADFF8B201F53D600D50D53 /* Plugin.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -40,6 +41,7 @@ 34ECC0E125BE199F00881175 /* Conversion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Conversion.swift; sourceTree = ""; }; 34ECC0E225BE199F00881175 /* DeviceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceManager.swift; sourceTree = ""; }; 34ECC2D625C0BAEC00881175 /* ConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversionTests.swift; sourceTree = ""; }; + 36EC9FF42D50C166009750C9 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = ""; }; 3B2A61DA5A1F2DD4F959604D /* Pods_Plugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Plugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 50ADFF88201F53D600D50D53 /* Plugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Plugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 50ADFF8B201F53D600D50D53 /* Plugin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Plugin.h; sourceTree = ""; }; @@ -106,6 +108,7 @@ 34E541D52962E2EA007544B1 /* Logging.swift */, 34ECC0E125BE199F00881175 /* Conversion.swift */, 34ECC0E025BE199F00881175 /* Device.swift */, + 36EC9FF42D50C166009750C9 /* DeviceListView.swift */, 34ECC0E225BE199F00881175 /* DeviceManager.swift */, 50E1A94720377CB70090CE1A /* Plugin.swift */, 50ADFF8B201F53D600D50D53 /* Plugin.h */, @@ -326,6 +329,7 @@ 34ECC0E425BE199F00881175 /* Conversion.swift in Sources */, 50E1A94820377CB70090CE1A /* Plugin.swift in Sources */, 34ECC0E525BE199F00881175 /* DeviceManager.swift in Sources */, + 36EC9FF52D50C166009750C9 /* DeviceListView.swift in Sources */, 50ADFFA82020EE4F00D50D53 /* Plugin.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Plugin/DeviceListView.swift b/ios/Plugin/DeviceListView.swift new file mode 100644 index 0000000..63b9345 --- /dev/null +++ b/ios/Plugin/DeviceListView.swift @@ -0,0 +1,120 @@ +import UIKit + +class DeviceListView: UIViewController, UITableViewDelegate, UITableViewDataSource { + + private let tableView = UITableView() + private let cancelButton = UIButton(type: .system) + private let titleLabel = UILabel() + private let progressIndication = UIActivityIndicatorView() + private var onCancel: (() -> Void)? + private var devices: [(name: String, action: () -> Void)] = [] + + override func viewDidLoad() { + super.viewDidLoad() + isModalInPresentation = true // don't allow drag to dismiss + setupUI() + addBlurBackground() + } + + func setTitle(_ title: String?) { + titleLabel.text = title + } + + func setCancelButton(_ title: String?, action: @escaping () -> Void) { + cancelButton.setTitle(title, for: .normal) + } + + func addItem(_ name: String, action: @escaping () -> Void) { + devices.append((name, action)) + tableView.reloadData() + } + + private func addBlurBackground() { + let blurEffect = UIBlurEffect(style: .systemChromeMaterial) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.frame = view.bounds + blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.insertSubview(blurView, at: 0) + } + + private func setupUI() { + titleLabel.textAlignment = .center + titleLabel.font = UIFont.boldSystemFont(ofSize: 22) + + let titleStack = UIStackView(arrangedSubviews: [titleLabel, progressIndication]) + titleStack.axis = .horizontal + titleStack.alignment = .center + titleStack.distribution = .fill + titleStack.spacing = 8 + titleStack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleStack) + + progressIndication.translatesAutoresizingMaskIntoConstraints = false + progressIndication.startAnimating() + progressIndication.hidesWhenStopped = true + progressIndication.setContentHuggingPriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + progressIndication.widthAnchor.constraint(equalToConstant: 22), + progressIndication.heightAnchor.constraint(equalToConstant: 22) + ]) + + tableView.backgroundColor = .clear + tableView.delegate = self + tableView.dataSource = self + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + view.addSubview(tableView) + + // Cancel Button + cancelButton.setTitleColor(.systemRed, for: .normal) + cancelButton.addTarget(self, action: #selector(dismissPopover), for: .touchUpInside) + cancelButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(cancelButton) + + NSLayoutConstraint.activate([ + titleStack.topAnchor.constraint(equalTo: view.topAnchor, constant: 10), + titleStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30), + titleStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30), + titleStack.heightAnchor.constraint(equalToConstant: 30), + + // TableView + tableView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), + tableView.bottomAnchor.constraint(equalTo: cancelButton.topAnchor, constant: -10), + + // Cancel Button at the Bottom + cancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor), + cancelButton.trailingAnchor.constraint(equalTo: view.trailingAnchor), + cancelButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10), + cancelButton.heightAnchor.constraint(equalToConstant: 40) + ]) + } + + @objc private func dismissPopover() { + onCancel?() + dismiss(animated: true) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return devices.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + cell.textLabel?.text = devices[indexPath.row].name + cell.textLabel?.textColor = .systemBlue + cell.textLabel?.backgroundColor = .clear + cell.backgroundColor = .clear + cell.contentView.backgroundColor = .clear + cell.textLabel?.textAlignment = .center + cell.selectionStyle = .default + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + devices[indexPath.row].action() // Execute closure action + dismiss(animated: true) // Optionally close the popover + } +} diff --git a/ios/Plugin/DeviceManager.swift b/ios/Plugin/DeviceManager.swift index 88713e4..f060877 100644 --- a/ios/Plugin/DeviceManager.swift +++ b/ios/Plugin/DeviceManager.swift @@ -14,7 +14,8 @@ class DeviceManager: NSObject, CBCentralManagerDelegate { private var stateReceiver: StateReceiver? private var timeoutMap = [String: DispatchWorkItem]() private var stopScanWorkItem: DispatchWorkItem? - private var alertController: UIAlertController? + private var deviceListView: DeviceListView? + private var popoverController: UIPopoverPresentationController? private var discoveredDevices = [String: Device]() private var deviceNameFilter: String? private var deviceNamePrefixFilter: String? @@ -129,9 +130,9 @@ class DeviceManager: NSObject, CBCentralManagerDelegate { self.stopScanWorkItem = nil DispatchQueue.main.async { [weak self] in if self?.discoveredDevices.count == 0 { - self?.alertController?.title = self?.displayStrings["noDeviceFound"] + self?.deviceListView?.setTitle (self?.displayStrings["noDeviceFound"]) } else { - self?.alertController?.title = self?.displayStrings["availableDevices"] + self?.deviceListView?.setTitle (self?.displayStrings["availableDevices"]) } } } @@ -168,11 +169,11 @@ class DeviceManager: NSObject, CBCentralManagerDelegate { if shouldShowDeviceList { DispatchQueue.main.async { [weak self] in - self?.alertController?.addAction(UIAlertAction(title: device.getName() ?? "Unknown", style: UIAlertAction.Style.default, handler: { (_) in + self?.deviceListView?.addItem(device.getName() ?? "Unknown", action: { log("Selected device") self?.stopScan() self?.resolve("startScanning", device.getId()) - })) + }) } } else { if self.scanResultCallback != nil { @@ -183,13 +184,17 @@ class DeviceManager: NSObject, CBCentralManagerDelegate { func showDeviceList() { DispatchQueue.main.async { [weak self] in - self?.alertController = UIAlertController(title: self?.displayStrings["scanning"], message: nil, preferredStyle: UIAlertController.Style.alert) - self?.alertController?.addAction(UIAlertAction(title: self?.displayStrings["cancel"], style: UIAlertAction.Style.cancel, handler: { (_) in + self?.deviceListView = DeviceListView() + if #available(macCatalyst 15.0, iOS 15.0, *) { + self?.deviceListView?.sheetPresentationController?.detents = [.medium()] + } + self?.viewController?.present((self?.deviceListView)!, animated: true, completion: nil) + self?.deviceListView?.setTitle(self?.displayStrings["scanning"]) + self?.deviceListView?.setCancelButton(self?.displayStrings["cancel"], action: { log("Cancelled request device.") self?.stopScan() self?.reject("startScanning", "requestDevice cancelled.") - })) - self?.viewController?.present((self?.alertController)!, animated: true, completion: nil) + }) } } From d24fe56fcfff39db346e818c7f04ccc0dd432c98 Mon Sep 17 00:00:00 2001 From: Tal Aviram Date: Sat, 25 Oct 2025 11:14:23 +0200 Subject: [PATCH 2/2] fixup: missing call to cancel clousre. --- ios/Plugin/DeviceListView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/Plugin/DeviceListView.swift b/ios/Plugin/DeviceListView.swift index 63b9345..e68ce8c 100644 --- a/ios/Plugin/DeviceListView.swift +++ b/ios/Plugin/DeviceListView.swift @@ -22,6 +22,7 @@ class DeviceListView: UIViewController, UITableViewDelegate, UITableViewDataSour func setCancelButton(_ title: String?, action: @escaping () -> Void) { cancelButton.setTitle(title, for: .normal) + self.onCancel = action } func addItem(_ name: String, action: @escaping () -> Void) {