Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve lifecycle #60

Merged
merged 4 commits into from
Sep 22, 2024
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
4 changes: 2 additions & 2 deletions HammerTests.podspec
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
54 changes: 26 additions & 28 deletions Sources/Hammer/EventGenerator/EventGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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()
}

Expand All @@ -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.
Expand All @@ -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()
}
}

Expand Down
129 changes: 129 additions & 0 deletions Sources/Hammer/Utilties/HammerWindow.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
11 changes: 0 additions & 11 deletions Sources/Hammer/Utilties/UIKit+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion Tests/HammerTests/KeyboardTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading