I am trying to build the iOS app that will track the user interactions with screen, what user clicks and what gestures user does. My plan was to create it as a OverlayView that could be put over any iOS app in a form of a View, without breaking any previous functionalities. I am currently testing it with the button that has another View on top of it.
Currently I am managing to either register click in top-layer View and track the gesture, or in button action function, but couldn't manage to get them both triggered at once.
override func viewDidLoad() {
super.viewDidLoad()
let button = UIButton(frame: CGRect(x: 100, y: 100, width: 100, height: 50))
button.backgroundColor = .green
button.addTarget(self, action: #selector(buttonClicked), for: .touchUpInside)
self.view.addSubview(button)
let topLayerView = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 900))
self.view.addSubview(topLayerView)
topLayerView.backgroundColor = .yellow
self.view.bringSubviewToFront(topLayerView)
topLayerView.alpha = 0.1
let singleTap: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(singleTap))
singleTap.numberOfTapsRequired = 1
topLayerView.addGestureRecognizer(singleTap)
topLayerView.isUserInteractionEnabled = true
}
This is code that I have, and I can get the call to either buttonClicked function or singleTap function.
I looked over the web and found many similar questions, but nothing worked for me.
I have tried creating the new class that conforms to UIView protocol, and overrides 'point' function, but that didn't change a thing.
CodePudding user response:
I don't think you can distribute a single touch to multiple views. However, I really don't think you need to.
You can subclass UIView and override hitTest
to detect events and forward the events to the subviews you are interested in. If the event doesn't correspond to a subview, then you just return the superview so that the superview can process the event.
Here is what this method does:
Filters for subviews that are not itself and that are valid for touches by checking if the view is hidden, transparent, of if user interaction is disabled. Yes this is necessary in order to be compliant with the documentation: https://developer.apple.com/documentation/uikit/uiview/1622469-hittest
Propagate events to subviews of the superview.
Triggers an event if a subview has been touched.
Miscellaneous error checking
Note that this method will propagate events subviews that are nested inside other subviews. This works recursively.
The other thing I want to point out is how the subviews property is reversed. This is because the topmost subviews are stored at the end of the array, and those are the ones we want to process first.
class TrackerView: UIView {
var trackerAction: (TrackerEvent) -> Void = { _ in }
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let superview = superview else {
return nil
}
lazy var superviewPoint = convert(point, to: superview)
let topMostViews = superview.subviews.reversed()
let subview = topMostViews.first(where: {
let subviewPoint = self.convert(point, to: $0)
let valid = $0.isUserInteractionEnabled && !$0.isHidden && $0.alpha >= 0.01
return valid && $0.point(inside: subviewPoint, with: event) && $0 !== self
})
guard let subview = subview else {
return processHit(for: superview, event: event, at: superviewPoint)
}
let subviewPoint = self.convert(point, to: subview)
guard let subviewHit = subview.hitTest(subviewPoint, with: event) else {
return processHit(for: superview, event: event, at: superviewPoint)
}
return processHit(for: subviewHit, event: event, at: superviewPoint)
}
private func processHit(for view: UIView?, event: UIEvent?, at point: CGPoint) -> UIView? {
guard let view = view, let event = event else { return view }
trackerAction(TrackerEvent(
viewType: type(of: view),
touchPoint: point,
timestamp: event.timestamp
))
return view
}
}
The event structure just stores some info that may be useful to you. Modify as needed. However, the reason why I include the timestamp is because hitTest is called more than once for a single event. The timestamp will allow you to weed out duplicates. It will also allow you to sort the events.
struct TrackerEvent: Hashable, Comparable, CustomStringConvertible {
let viewType: UIView.Type
let touchPoint: CGPoint
let timestamp: TimeInterval
func hash(into hasher: inout Hasher) {
hasher.combine(timestamp)
}
static func < (lhs: TrackerEvent, rhs: TrackerEvent) -> Bool {
lhs.timestamp < rhs.timestamp
}
static func == (lhs: TrackerEvent, rhs: TrackerEvent) -> Bool {
lhs.timestamp == rhs.timestamp
}
var description: String {
let elements = [
"viewType: \(viewType)",
"touchPoint: \(touchPoint)",
"timestamp: \(String(format: "%.02f", timestamp))",
]
let elementsJoined = elements.joined(separator: ", ")
return "[\(elementsJoined)]"
}
}
You may want a utility class to weed out duplicates every so often before processing them.
class TrackerEventHandler {
var events: Set<TrackerEvent> = []
private var timer: Timer?
func queueEvent(_ event: TrackerEvent) {
print("Queueing event", event)
events.insert(event)
}
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
self?.events.sorted().forEach { event in
print("Handling event: \(event)")
}
self?.events.removeAll()
}
}
func stop() {
timer?.invalidate()
}
}
Usage is as follows:
class ViewController: UIViewController {
let handler = TrackerEventHandler()
override func viewDidLoad() {
super.viewDidLoad()
self.handler.start()
let topLayerView = TrackerView(frame: CGRect(x: 0, y: 0, width: 400, height: 900))
topLayerView.trackerAction = handler.queueEvent(_:)
self.view.addSubview(topLayerView)
// ...
}
// ...
}
Note that this is not a perfect implementation. There are limitations, but it might be a good starting point.