Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,79 +39,35 @@ struct BarcodeScannerContainerRepresentable: UIViewControllerRepresentable {
let configuration: HIDBarcodeParserConfiguration
let onScan: (Result<String, Error>) -> Void

func makeUIViewController(context: Context) -> BarcodeScannerHostingController {
let controller = BarcodeScannerHostingController(
func makeUIViewController(context: Context) -> UIViewController {
return GameControllerBarcodeScannerHostingController(
configuration: configuration,
onScan: onScan
)
return controller
}

func updateUIViewController(_ uiViewController: BarcodeScannerHostingController, context: Context) {}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

/// A UIHostingController that handles keyboard input events for barcode scanning.
/// This controller captures keyboard input and interprets it as barcode data when a terminating
/// character is detected.
class BarcodeScannerHostingController: UIHostingController<EmptyView> {
private let configuration: HIDBarcodeParserConfiguration
private let scanner: HIDBarcodeParser
/// A UIHostingController that handles GameController keyboard input events for barcode scanning.
/// This controller uses GameController framework exclusively for language-independent barcode scanning.
final class GameControllerBarcodeScannerHostingController: UIHostingController<EmptyView> {
private var gameControllerBarcodeObserver: GameControllerBarcodeObserver?

init(
configuration: HIDBarcodeParserConfiguration,
onScan: @escaping (Result<String, Error>) -> Void
) {
self.configuration = configuration
self.scanner = HIDBarcodeParser(configuration: configuration,
onScan: onScan)
super.init(rootView: EmptyView())

gameControllerBarcodeObserver = GameControllerBarcodeObserver(configuration: configuration, onScan: onScan)
}

@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override var canBecomeFirstResponder: Bool { true }

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
resignFirstResponder()
}

override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
/// We don't call super here because it helps prevent the system from hiding the software keyboard when
/// a textfield is next used.
}

/// Handles the end of keyboard press events, interpreting them as barcode input.
/// When a terminating character is detected, the accumulated buffer is treated as a complete
/// barcode and passed to the onScan callback.
/// We don't call `super` here because we don't other responder chain items to handle our barcode as well,
/// as this could cause unexpected behavior.
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
/// While a scanner should just "press" each key once, in theory it's possible for presses to be cancelled
/// or change between the `began` call and the `ended` call.
/// It's better practice for barcode scanning to only consider the presses when they end.
for press in presses {
guard let key = press.key else { continue }
scanner.processKeyPress(key)
}
}

override func pressesChanged(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
super.pressesChanged(presses, with: event)
}

/// `pressesCancelled` is rarely called, but Apple's documentation suggests it's possible and that crashes may occur if it's not handled.
/// It makes sense to clear the buffer when this happens.
/// We call super in case other presses are handled elsewhere in the responder chain.
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
scanner.cancel()
super.pressesCancelled(presses, with: event)
deinit {
gameControllerBarcodeObserver = nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation
import GameController

/// An observer that uses the `GameController` framework to monitor for connected barcode scanners/keyboards
/// and parse their input into barcode strings.
///
/// This class handles the low-level details of observing keyboard connections, processing raw `GCKeyCode` inputs,
/// and using the `GameControllerBarcodeParser` to produce a final barcode.
///
final class GameControllerBarcodeObserver {
/// A closure that is called when a barcode scan is completed.
/// The result will be a `success` with the barcode string or a `failure` with an error.
let onScan: (Result<String, Error>) -> Void

/// Track the coalesced keyboard and its parser
/// According to Apple's documentation, all connected keyboards are coalesced into one keyboard object
/// (GCKeyboard.coalesced), so notification about connection/disconnection will only be delivered once
/// until the last keyboard disconnects.
private var coalescedKeyboard: GCKeyboard?
private var barcodeParser: GameControllerBarcodeParser?
private let configuration: HIDBarcodeParserConfiguration

/// Tracks current shift state to be applied to the next character key
private var isShiftPressed: Bool = false

/// Initializes a new barcode scanner observer.
/// - Parameters:
/// - configuration: The configuration to use for the barcode parser. Defaults to the standard configuration.
/// - onScan: The closure to be called when a scan is completed.
init(configuration: HIDBarcodeParserConfiguration = .default, onScan: @escaping (Result<String, Error>) -> Void) {
self.onScan = onScan
self.configuration = configuration
addObservers()
setupCoalescedKeyboard()
}

deinit {
removeObservers()
cleanupKeyboard()
}

/// Starts observing for keyboard connection and disconnection events.
private func addObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardDidConnect),
name: .GCKeyboardDidConnect,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardDidDisconnect),
name: .GCKeyboardDidDisconnect,
object: nil
)
}

/// Stops observing for keyboard events.
private func removeObservers() {
NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidConnect, object: nil)
NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidDisconnect, object: nil)
}

/// Sets up the coalesced keyboard if one is available at initialization.
private func setupCoalescedKeyboard() {
if let keyboard = GCKeyboard.coalesced {
setupKeyboard(keyboard)
}
}

/// Handles the connection of a keyboard (coalesced).
@objc private func handleKeyboardDidConnect(_ notification: Notification) {
guard let keyboard = notification.object as? GCKeyboard else {
return
}
setupKeyboard(keyboard)
}

/// Handles the disconnection of the keyboard (coalesced).
@objc private func handleKeyboardDidDisconnect(_ notification: Notification) {
cleanupKeyboard()
}

/// Sets up the coalesced keyboard to handle key press events.
/// - Parameter keyboard: The coalesced `GCKeyboard` to set up.
private func setupKeyboard(_ keyboard: GCKeyboard) {
// Clean up any existing setup first
cleanupKeyboard()

coalescedKeyboard = keyboard
barcodeParser = GameControllerBarcodeParser(configuration: configuration, onScan: onScan)

keyboard.keyboardInput?.keyChangedHandler = { [weak self] _, _, keyCode, pressed in
guard let self = self else { return }

if keyCode == .leftShift || keyCode == .rightShift {
self.isShiftPressed = pressed
return
}

guard pressed else { return }

self.barcodeParser?.processKeyPress(keyCode, isShiftPressed: isShiftPressed)
}
}

/// Cleans up the coalesced keyboard and its parser.
private func cleanupKeyboard() {
coalescedKeyboard?.keyboardInput?.keyChangedHandler = nil
barcodeParser?.cancel()
coalescedKeyboard = nil
barcodeParser = nil
isShiftPressed = false
}
}
Loading