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

Add SwiftUI support #19

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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 @@ -37,7 +37,7 @@ extension EventGenerator {
/// - parameter index: The finger index to touch down.
/// - parameter location: The location where to touch down. Nil to use the center.
public func fingerDown(_ index: FingerIndex? = .automatic, at location: HammerLocatable? = nil) throws {
try self.fingerDown([index], at: [location ?? self.mainView])
try self.fingerDown([index], at: [location ?? self.rootView()])
}

/// Sends a finger up event.
Expand Down Expand Up @@ -217,7 +217,7 @@ extension EventGenerator {
angle radians: CGFloat = 0) throws
{
let indices = try self.fillNextFingerIndices(indices, withExpected: 2)
let location = try (location ?? self.mainView).windowHitPoint(for: self)
let location = try (location ?? self.rootView()).windowHitPoint(for: self)
try self.fingerDown(indices, at: location.twoWayOffset(distance, angle: radians))
}

Expand Down Expand Up @@ -300,7 +300,7 @@ extension EventGenerator {
angle radians: CGFloat = 0, duration: TimeInterval) throws
{
let indices = try self.fillNextFingerIndices(indices, withExpected: 2)
let location = try (location ?? self.mainView).windowHitPoint(for: self)
let location = try (location ?? self.rootView()).windowHitPoint(for: self)
let startLocations = location.twoWayOffset(startDistance, angle: radians)
let endLocations = location.twoWayOffset(endDistance, angle: radians)
try self.fingerDown(indices, at: startLocations)
Expand Down Expand Up @@ -413,7 +413,7 @@ extension EventGenerator {
duration: TimeInterval) throws
{
let indices = try self.fillNextFingerIndices(indices, withExpected: 2)
let location = try (location ?? self.mainView).windowHitPoint(for: self)
let location = try (location ?? self.rootView()).windowHitPoint(for: self)
try self.fingerDown(indices, at: location.twoWayOffset(distance, angle: startRadians))
try self.fingerPivot(indices, aroundAnchor: location, byAngle: endRadians - startRadians,
duration: duration)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension EventGenerator {
public func stylusDown(at location: HammerLocatable? = nil,
azimuth: CGFloat = 0, altitude: CGFloat = 0, pressure: CGFloat = 0) throws
{
let location = try (location ?? self.mainView).windowHitPoint(for: self)
let location = try (location ?? self.rootView()).windowHitPoint(for: self)
try self.checkPointsAreHittable([location])
try self.sendEvent(stylus: StylusInfo(location: location, phase: .began,
pressure: pressure, twist: 0,
Expand Down
52 changes: 40 additions & 12 deletions Sources/Hammer/EventGenerator/EventGenerator.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import CoreGraphics
import Foundation
import UIKit
#if canImport(SwiftUI)
import SwiftUI
#endif

private enum Storage {
static var latestEventId: UInt32 = 0
Expand All @@ -13,13 +16,9 @@ public final class EventGenerator {
/// The window for the events
public let window: UIWindow

/// The view that was used to create the event generator
private(set) var 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 @@ -39,7 +38,6 @@ public final class EventGenerator {
self.window = window
self.window.layoutIfNeeded()
self.debugWindow.frame = self.window.frame
self.mainView = window

UIApplication.swizzle()
UIApplication.registerForHIDEvents(ObjectIdentifier(self)) { [weak self] event in
Expand All @@ -63,12 +61,10 @@ public final class EventGenerator {
window.backgroundColor = .white
}

window.makeKeyAndVisible()
window.isHidden = false
window.layoutIfNeeded()

try self.init(window: window)
self.isUsingCustomWindow = true
self.mainView = viewController.view
}

/// Initialize an event generator for a specified UIView.
Expand All @@ -78,12 +74,21 @@ public final class EventGenerator {
/// - parameter view: The view to receive events.
public convenience init(view: UIView) throws {
try self.init(viewController: UIViewController(wrapping: view))
self.mainView = view
}

/// Initialize an event generator for a specified UIView.
///
/// Event Generator will temporarily create a wrapper UIWindow to send touches.
///
/// - parameter view: The view to receive events.
@available(iOS 13.0, *)
public convenience init<Content: View>(view: Content) throws {
try self.init(viewController: UIHostingController(wrapping: view))
}

deinit {
UIApplication.unregisterForHIDEvents(ObjectIdentifier(self))
if self.isUsingCustomWindow {
if self.window.isWrapper {
self.window.isHidden = true
self.window.rootViewController = nil
self.debugWindow.isHidden = true
Expand All @@ -98,9 +103,11 @@ public final class EventGenerator {
/// Waits until the window is ready to receive user interaction events.
///
/// - parameter timeout: The maximum time to wait for the window to be ready.
public func waitUntilWindowIsReady(timeout: TimeInterval = 2) throws {
public func waitUntilWindowIsReady(timeout: TimeInterval = 5) throws {
do {
try self.waitUntil(self.isWindowReady, timeout: timeout)
var initialMarkerEventReceived = false
try self.sendMarkerEvent { initialMarkerEventReceived = true }
try self.waitUntil(self.isWindowReady && initialMarkerEventReceived, timeout: timeout)
} catch {
throw HammerError.windowIsNotReadyForInteraction
}
Expand Down Expand Up @@ -134,6 +141,27 @@ public final class EventGenerator {
return true
}

/// The root view of the event generator
public func rootView() throws -> UIView {
if self.window.isWrapper {
guard let view = self.window.rootViewController?.view else {
throw HammerError.unableToFindMainView
}

if view.isWrapper {
guard let wrappedView = view.subviews.first else {
throw HammerError.unableToFindMainView
}

return wrappedView
}

return view
}

return self.window
}

/// Gets the next event ID to use. Event IDs are global and sequential.
///
/// - returns: The next event ID.
Expand Down
3 changes: 3 additions & 0 deletions Sources/Hammer/Utilties/HammerError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public enum HammerError: Error {
case viewIsNotHittable(UIView)
case pointIsNotHittable(CGPoint)

case unableToFindMainView
case unableToFindView(identifier: String)
case invalidViewType(identifier: String, type: String, expected: String)
case waitConditionTimeout(TimeInterval)
Expand Down Expand Up @@ -70,6 +71,8 @@ extension HammerError: CustomStringConvertible {
return "View is not in hittable: \(view.shortDescription)"
case .pointIsNotHittable(let point):
return "Point is not in hittable: \(point)"
case .unableToFindMainView:
return "Unable to find main view"
case .unableToFindView(let identifier):
return "Unable to find view: \"\(identifier)\""
case .invalidViewType(let identifier, let type, let expected):
Expand Down
30 changes: 0 additions & 30 deletions Sources/Hammer/Utilties/UIKit+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,33 +42,3 @@ extension UIDevice {
return self.userInterfaceIdiom == .pad
}
}

extension UIWindow {
convenience init(wrapping viewController: UIViewController) {
self.init(frame: UIScreen.main.bounds)
self.rootViewController = viewController
}
}

extension UIViewController {
convenience init(wrapping view: UIView) {
self.init(nibName: nil, bundle: nil)
view.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: self.view.topAnchor).priority(.defaultHigh),
view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).priority(.defaultHigh),
view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).priority(.defaultHigh),
view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).priority(.defaultHigh),
view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
])
}
}

extension NSLayoutConstraint {
fileprivate func priority(_ priority: UILayoutPriority) -> NSLayoutConstraint {
self.priority = priority
return self
}
}
53 changes: 53 additions & 0 deletions Sources/Hammer/Utilties/ViewWrapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import UIKit
#if canImport(SwiftUI)
import SwiftUI
#endif

private let kHammerWrapperIdentifier = "hammer_wrapper"

extension UIWindow {
convenience init(wrapping viewController: UIViewController) {
self.init(frame: UIScreen.main.bounds)
self.accessibilityIdentifier = kHammerWrapperIdentifier
self.rootViewController = viewController
}
}

extension UIViewController {
convenience init(wrapping view: UIView) {
self.init(nibName: nil, bundle: nil)
self.view.accessibilityIdentifier = kHammerWrapperIdentifier
view.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: self.view.topAnchor).priority(.defaultHigh),
view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).priority(.defaultHigh),
view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).priority(.defaultHigh),
view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).priority(.defaultHigh),
view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
])
}
}

@available(iOS 13.0, *)
extension UIHostingController {
convenience init(wrapping view: Content) {
self.init(rootView: view)
self.view.accessibilityIdentifier = kHammerWrapperIdentifier
}
}

extension UIView {
/// If the view is a wrapper created by Hammer
var isWrapper: Bool {
return self.accessibilityIdentifier == kHammerWrapperIdentifier
}
}

extension NSLayoutConstraint {
fileprivate func priority(_ priority: UILayoutPriority) -> NSLayoutConstraint {
self.priority = priority
return self
}
}
13 changes: 12 additions & 1 deletion Sources/Hammer/Utilties/Waiting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,17 @@ extension EventGenerator {
timeout: timeout, checkInterval: checkInterval)
}

/// Waits for the root view to be visible on screen within the specified time.
///
/// - parameter timeout: The maximum time to wait for the point to be hittable.
/// - parameter checkInterval: How often should the view be checked.
///
/// - throws: An error if the point is not hittable within the specified time.
public func waitUntilVisible(timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws {
try self.waitUntil(self.viewIsVisible(self.rootView()),
timeout: timeout, checkInterval: checkInterval)
}

/// Waits for a view with the specified identifier to be hittable within the specified time.
///
/// - parameter accessibilityIdentifier: The identifier of the view to wait for.
Expand Down Expand Up @@ -180,7 +191,7 @@ extension EventGenerator {
///
/// - throws: An error if the point is not hittable within the specified time.
public func waitUntilHittable(timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws {
try self.waitUntil(self.viewIsHittable(self.mainView),
try self.waitUntil(self.viewIsHittable(self.rootView()),
timeout: timeout, checkInterval: checkInterval)
}
}
38 changes: 38 additions & 0 deletions Tests/HammerTests/AccessibilityIdentifierTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Hammer
import UIKit
import XCTest
#if canImport(SwiftUI)
import SwiftUI
#endif

final class AccessibilityIdentifierTests: XCTestCase {
func testButtonSearch() throws {
let button = UIButton().size(width: 100, height: 100)
button.accessibilityIdentifier = "my_button"
button.backgroundColor = .green

let wrapperView = UIStackView(arrangedSubviews: [button])

let eventGenerator = try EventGenerator(view: wrapperView)
try eventGenerator.waitUntilVisible(timeout: 5)
let match = try eventGenerator.viewWithIdentifier("my_button")
XCTAssertEqual(button, match)
}

func testButtonSearch_SwiftUI() throws {
guard #available(iOS 13.0, *) else {
throw XCTSkip("SwiftUI tests require iOS 13 or later")
}

let view = HStack {
Button("Button", action: {})
.accessibility(identifier: "my_button")
.background(Color.green)
}

let eventGenerator = try EventGenerator(view: view)
try eventGenerator.wait(5)
let match = try eventGenerator.viewWithIdentifier("my_button")
print(match)
}
}
8 changes: 0 additions & 8 deletions Tests/HammerTests/HandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,3 @@ final class HandTests: XCTestCase {
XCTAssertEqual(view.zoomScale, 1, accuracy: 0.1)
}
}

extension UIView {
fileprivate func size(width: CGFloat, height: CGFloat) -> Self {
self.widthAnchor.constraint(equalToConstant: width).isActive = true
self.heightAnchor.constraint(equalToConstant: height).isActive = true
return self
}
}
Loading