diff --git a/HammerTests.podspec b/HammerTests.podspec index 79a0951..fd8137c 100644 --- a/HammerTests.podspec +++ b/HammerTests.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |spec| spec.name = "HammerTests" - spec.version = "0.15.0" - spec.summary = "iOS touch and keyboard syntheis library for unit tests." + spec.version = "0.16.0" + spec.summary = "iOS touch and keyboard synthesis library for unit tests." spec.description = "Hammer is a touch and keyboard synthesis library for emulating user interaction events. It enables new ways of triggering UI actions in unit tests, replicating a real world environment as much as possible." spec.homepage = "https://github.com/lyft/Hammer" spec.screenshots = "https://user-images.githubusercontent.com/585835/116217617-ab410080-a6fe-11eb-9de1-3d42f7dd6037.gif" diff --git a/Sources/Hammer/EventGenerator/EventGenerator.swift b/Sources/Hammer/EventGenerator/EventGenerator.swift index 39767d2..61895d9 100644 --- a/Sources/Hammer/EventGenerator/EventGenerator.swift +++ b/Sources/Hammer/EventGenerator/EventGenerator.swift @@ -22,12 +22,11 @@ public final class EventGenerator { public let window: UIWindow /// The view that was used to create the event generator - public private(set) var mainView: UIView + public let mainView: UIView var activeTouches = TouchStorage() var debugWindow = DebugVisualizerWindow() var eventCallbacks = [UInt32: CompletionHandler]() - private var isUsingCustomWindow: Bool = false /// The default sender id for all events. /// @@ -42,18 +41,25 @@ public final class EventGenerator { /// Initialize an event generator for a specified UIWindow. /// - /// - parameter window: The window to receive events. - public init(window: UIWindow) throws { + /// - parameter window: The window to receive events. + /// - parameter mainView: The view that was used to create the event generator + private init(window: UIWindow, mainView: UIView) throws { self.window = window + self.mainView = mainView self.window.layoutIfNeeded() self.debugWindow.frame = self.window.frame - self.mainView = window UIApplication.swizzle() UIApplication.registerForHIDEvents(ObjectIdentifier(self)) { [weak self] event in self?.markerEventReceived(event) } + } + /// Initialize an event generator for a specified UIWindow. + /// + /// - parameter window: The window to receive events. + public convenience init(window: UIWindow) throws { + try self.init(window: window, mainView: window) try self.waitUntilWindowIsReady() } @@ -64,20 +70,15 @@ public final class EventGenerator { /// /// - parameter viewController: The viewController to receive events. public convenience init(viewController: UIViewController) throws { - let window = viewController.view.window ?? UIWindow(wrapping: viewController) - - if #available(iOS 13.0, *) { - window.backgroundColor = .systemBackground + if let window = viewController.view.window { + try self.init(window: window, mainView: viewController.view) } else { - window.backgroundColor = .white + let window = HammerWindow() + window.presentContained(viewController) + try self.init(window: window, mainView: viewController.view) } - window.makeKeyAndVisible() - window.layoutIfNeeded() - - try self.init(window: window) - self.isUsingCustomWindow = true - self.mainView = viewController.view + try self.waitUntilWindowIsReady() } /// Initialize an event generator for a specified UIView. @@ -88,25 +89,22 @@ public final class EventGenerator { /// - parameter alignment: The wrapping alignment to use. public convenience init(view: UIView, alignment: WrappingAlignment = .center) throws { if let window = view.window { - try self.init(window: window) + try self.init(window: window, mainView: view) } else { - try self.init(viewController: UIViewController(wrapping: view.topLevelView, alignment: alignment)) + let viewController = UIViewController(wrapping: view.topLevelView, alignment: alignment) + let window = HammerWindow() + window.presentContained(viewController) + try self.init(window: window, mainView: view) } - self.mainView = view + try self.waitUntilWindowIsReady() } deinit { UIApplication.unregisterForHIDEvents(ObjectIdentifier(self)) - if self.isUsingCustomWindow { - self.window.isHidden = true - self.window.rootViewController = nil - self.debugWindow.isHidden = true - self.debugWindow.rootViewController = nil - if #available(iOS 13.0, *) { - self.window.windowScene = nil - self.debugWindow.windowScene = nil - } + self.debugWindow.removeFromScene() + if let window = self.window as? HammerWindow { + window.dismissContained() } } diff --git a/Sources/Hammer/Utilties/HammerWindow.swift b/Sources/Hammer/Utilties/HammerWindow.swift new file mode 100644 index 0000000..62c1266 --- /dev/null +++ b/Sources/Hammer/Utilties/HammerWindow.swift @@ -0,0 +1,129 @@ +import UIKit + +// Custom Window to have proper simulation of presentation and dismissal lifecycle events +final class HammerWindow: UIWindow { + private let hammerViewController = HammerViewController() + + override var safeAreaInsets: UIEdgeInsets { + return .zero + } + + init() { + super.init(frame: UIScreen.main.bounds) + self.rootViewController = self.hammerViewController + + if #available(iOS 13.0, *) { + self.backgroundColor = .systemBackground + } else { + self.backgroundColor = .white + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func presentContained(_ viewController: UIViewController) { + self.makeVisibleAndKey() + self.hammerViewController.presentContained(viewController) + } + + func dismissContained() { + self.hammerViewController.dismissContained() + self.removeFromScene(removeViewController: false) + } +} + +private final class HammerViewController: UIViewController { + private let containerView = UIView() + + override var shouldAutomaticallyForwardAppearanceMethods: Bool { false } + override var prefersStatusBarHidden: Bool { true } + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .clear + self.containerView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.containerView) + + // We only activate the top and leading constraints to allow the content to size itself. + NSLayoutConstraint.activate([ + self.containerView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.containerView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + ]) + } + + func presentContained(_ viewController: UIViewController) { + viewController.beginAppearanceTransition(true, animated: false) + self.addChild(viewController) + + viewController.view.translatesAutoresizingMaskIntoConstraints = false + self.containerView.addSubview(viewController.view) + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: self.containerView.topAnchor), + viewController.view.bottomAnchor.constraint(equalTo: self.containerView.bottomAnchor), + viewController.view.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor), + ]) + + viewController.didMove(toParent: self) + viewController.endAppearanceTransition() + self.view.layoutIfNeeded() + } + + func dismissContained() { + for viewController in self.children { + viewController.beginAppearanceTransition(false, animated: false) + viewController.willMove(toParent: nil) + viewController.view.removeFromSuperview() + viewController.removeFromParent() + viewController.endAppearanceTransition() + } + } +} + +extension UIWindow { + func makeVisibleAndKey(file: StaticString = #file, line: UInt = #line) { + self.addToMainSceneIfNeeded(file: file, line: line) + self.makeKeyAndVisible() + } + + func addToMainSceneIfNeeded(file: StaticString = #file, line: UInt = #line) { + guard #available(iOS 13.0, *) else { + return + } + + guard self.windowScene == nil else { + return + } + + if let mainScene = UIScene.mainOrFirstConnectedScene { + self.windowScene = mainScene + } else { + assertionFailure("Unable to find main scene", file: file, line: line) + } + } + + func removeFromScene(removeViewController: Bool = true) { + self.isHidden = true + + if #available(iOS 13.0, *) { + self.windowScene = nil + } + + if removeViewController { + self.rootViewController = nil + } + } +} + +@available(iOS 13.0, *) +private extension UIScene { + static var mainOrFirstConnectedScene: UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.first { $0.screen == UIScreen.main } ?? scenes.first + } +} diff --git a/Sources/Hammer/Utilties/UIKit+Extensions.swift b/Sources/Hammer/Utilties/UIKit+Extensions.swift index 933b251..c9b10d3 100644 --- a/Sources/Hammer/Utilties/UIKit+Extensions.swift +++ b/Sources/Hammer/Utilties/UIKit+Extensions.swift @@ -43,17 +43,6 @@ extension UIDevice { } } -extension UIWindow { - convenience init(wrapping viewController: UIViewController) { - self.init(frame: UIScreen.main.bounds) - if #available(iOS 13.0, *), self.windowScene == nil { - self.windowScene = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene }.first - } - self.rootViewController = viewController - } -} - extension UIViewController { convenience init(wrapping view: UIView, alignment: EventGenerator.WrappingAlignment) { self.init(nibName: nil, bundle: nil) diff --git a/Tests/HammerTests/KeyboardTests.swift b/Tests/HammerTests/KeyboardTests.swift index 19f62f7..3fd23de 100644 --- a/Tests/HammerTests/KeyboardTests.swift +++ b/Tests/HammerTests/KeyboardTests.swift @@ -187,8 +187,11 @@ final class KeyboardTests: XCTestCase { view.centerXAnchor.constraint(equalTo: viewController.view.centerXAnchor), ]) - let window = UIWindow(wrapping: viewController) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = viewController + window.addToMainSceneIfNeeded() window.isHidden = false + defer { window.removeFromScene() } let eventGenerator = try EventGenerator(window: window) try eventGenerator.waitUntilHittable(timeout: 1)