Skip to content

Commit

Permalink
Improve lifecycle (#60)
Browse files Browse the repository at this point in the history
Simulates view controller lifecycle more accurately
  • Loading branch information
gabriellanata authored Sep 22, 2024
1 parent 00b5164 commit 3210dd6
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 42 deletions.
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

0 comments on commit 3210dd6

Please sign in to comment.