UISlider addTarget and sendActions not working in UTs - ios

When I run this code in a standard MVC project, all works fine: I can programmatically send actions to my UISlider.
override func viewDidLoad() {
super.viewDidLoad()
slider = UISlider(frame: CGRect(x: 100, y: 100, width: 200, height: 50))
slider.minimumValue = 1
slider.maximumValue = 100
slider.addTarget(self, action: #selector(sliderTouch), for: .touchUpInside)
slider.sendActions(for: .touchUpInside)
}
func sliderTouch(sender: UISlider) {
print("value: \(sender.value)")
}
Now for some reason, when I want to simulate Slider behaviors in my UTs, it's not working. here is the code:
func testSlider() {
let slider = UISlider(frame: CGRect(x: 100, y: 100, width: 200, height: 50))
slider.minimumValue = 1
slider.maximumValue = 100
slider.addTarget(self, action: #selector(sliderTouch), for: .touchUpInside)
slider.sendActions(for: .touchUpInside)
}
func sliderTouch(sender: UISlider) {
print("value: \(sender.value)")
}
sliderTouch() is never called in the UT case and from my understanding, sendActions is not asnyc and should directly call the action methods.
So why am I getting this behavior, and how can I solve this?
Edit: I also need it to work for other UIKit controls such as UISwitch, UISegmentedControl, UIDatePicker etc...

I had a problem similar to yours when testing classes contained in a Framework target.
Here is an excerpt from Apple's documentation:
When a control-specific event occurs, the control calls any associated action methods right away. Action methods are dispatched through the current UIApplication object, which finds an appropriate object to handle the message, following the responder chain if needed.
When testing a classes in a framework there is no UIApplication object and thus the events can not be dispatched.
You can add a Host Application in your test target settings. If you do not have a host application, you can just create an empty iOS app and use that.
Also see this post.
Update:
Here is how to set a Host Application:
If there is no Application in the dropdown, simply create a new Target and use the Application > Single View Application template.
You are done and your tests should now work. There are no additional steps.

For swift 3:
https://github.com/ReactiveCocoa/ReactiveCocoa/blob/ae6ebb7725b3d5d33db039d456797c220720cb99/ReactiveCocoaTests/UIKit/UIControl%2BEnableSendActionsForControlEvents.swift
For Swift 2
https://github.com/RACCommunity/Rex/blob/master/Tests/Helpers/UIControl%2BEnableSendActionsForControlEvents.swift
Or try subclassing:
class TouchSlider: UISlider {
var touchUpInsideHandler: ((TouchSlider) -> Void)?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
touchUpInsideHandler?(self)
guard let touch = touches.first,
let imageWidth = currentThumbImage?.size.width else {
super.touchesBegan(touches, with: event)
return
}
let location = touch.location(in: self)
let newValue: Float = minimumValue + (maximumValue - minimumValue) * Float((location.x - imageWidth / 2.0) / (bounds.width - imageWidth))
setValue(newValue, animated: true)
super.touchesBegan(touches, with: event)
}
}
Use touchUpInsideHandler for managing touches outside.

Related

How do we prevent calling the method programmatically?

We need to prevent a method call programmatically. We just want to call method with user interaction from UI.
Actually, We're developing a SDK. We have some custom UI object classes. We want to avoid the user access to target methods without using our custom UI objects.
UIButton is just an example. It can be UISwitch or another UI element. Or maybe SwiftUI elements.
This is required as a security measure. It is a precaution we want to put so that malicious people do not call it as if it is an operation from the interface.
We want the operation to be performed only from the interface. So we check the information in Thread.callStackSymbols. But this code doesn't work in Testflight or release. It only works in debug mode.
You will see a UIButton below example. When clicked it’ll call clickedButton. But there is a method that call maliciousMethod. It can call clickedButton programmatically. We want to prevent it.
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let button = UIButton(frame: CGRect(x: 100, y: 100, width: 200, height: 50))
button.backgroundColor = .red
button.setTitle("Click Me", for: .normal)
self.view.addSubview(button)
button.addTarget(self, action: #selector(buttonClicked(_:)), for: .touchUpInside)
}
#objc func buttonClicked(_ sender : UIButton) {
/// We need to check action called from UI or another method here.
let symbols = Thread.callStackSymbols
let str: String = symbols[3]
if str.contains("sendAction") == false && str.contains("SwiftUI7Binding") == false {
print("It's called from programmatically. Abort")
return
}
}
/// We want to prevent this kind of call
func maliciousMethod() {
buttonClicked(UIButton())
}
}
There's not much you can do if someone has access to your code base, so I assume your ViewController is part of a binary framework to be distributed, that you want to secure against malicious programmers. In that case, you could store the button you want to allow as a private or fileprivate property in your ViewController. Then check for it in buttonClicked().
So something like this:
class ViewController: UIViewController {
fileprivate var secureButton: UIButton! // <-- Added this
override func viewDidLoad() {
super.viewDidLoad()
// Using/saving the secureButton here
secureButton = UIButton(frame: CGRect(x: 100, y: 100, width: 200, height: 50))
...
}
#objc func buttonClicked(_ sender : UIButton) {
/*
Check for the expected sender here. You probably don't want to
actually fatalError, but rather do something more sensible for
you app/framework
*/
guard sender === secureButton else {
fatalError("Strange things are afoot at the Circle-K")
}
...
}
/// This method will now trigger the guard in buttonClicked
func maliciousMethod() {
buttonClicked(UIButton())
}
}

Make view draggable but not tappable

I have a label in my view controller that I set like this in viewDidLoad:
var label: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
label = UILabel()
view.add(label)
label.frame = CGRect(x: 0, y, 0, width: 20, height: 20)
label.text = "A"
let recognizer = UIPanGestureRecognizer(target: self, action: #selector(didDrag(_:))
label.addGestureRecognizer(recognizer)
label.isUserInteractionEnabled = true
}
I want the label to be draggable, so I implemented didDrag like this:
#objc func didDrag(_ gesture: UIPanGestureRecognizer) {
let center = label.center
let translation = gesture.translation(in: view)
label.center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
gesture.setTranslation(.zero, in: view)
}
It works perfectly and I can drag my label around.
However, if the label is over a button in my view and I try to tap the button, the button does not get the touch. I have tried:
label.isUserInteractionEnabled = false // then I can't also drag around
recognizer.cancelsTouchesInView = false // nothing changes
label.isMultipleTouchEnabled = false // nothing changes
Any idea how I can let single or double taps be passed/be ignored by the label's pan recognisers, but still be able to drag it?
I can delete this question if it's a duplicate, but I have not found any other that asks exactly the same.
Edit
Following Pass taps through a UIPanGestureRecognizer, the closest question I've found so far, I also tried:
recognizer.delaysTouchesBegan = true
recognizer.maximumNumberOfTouches = 1
recognizer.cancelsTouchesInView = true
But it, unfortunately, did not work.
Update
The view has all sorts of button and textfields, so comparing with each of them is not really possible, mostly because there are also stack views that contain buttons and sometimes they are there and somethings they are not.
After thinking I came up with the solution that might work. The idea is playing around with touch events. Every view has a set of methods like touchesBegan(), touchesMoved() etc. Now what you can do is when touchesBegan is invoked you can check the location of the touch that is happening and if this location contained in the frame of the button, call the function manually.
Here's the pseudocode so you get the concept. Not tested but from my experience something like that should work
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
touches.forEach { touch in
let touchLocation = touch.location(in: myButton) // that may need to be self instead of myButton
if myButton.frame.contains($0.location) {
//invoke button method here
}
}
}

SciChart SCIRolloverModifier on tap

I'm using SciChart on iOS application.
is it possible to show SCIRolloverModifier only on tap gesture? I want to avoid that fact that when dragging the chart, it automatically shows the Rollover.
Thanks!!
There is now an example of how to do this in SciChart iOS v2.x at the SciChart.iOS.Examples Github repository
SciChart.iOS.Examples > v2.x > Sandbox > LeaveRolloverOnScreen_Swift
How this works.
There is a class called CustomRollover which inherits SCIRolloverModifier. This overrides the onPanGesture and only calls the base class onPanGesture (which removes the rollover on touch end) if the gesture action has not ended
class CustomRollover : SCIRolloverModifier {
override func onPanGesture(_ gesture: UIPanGestureRecognizer!, at view: UIView!) -> Bool {
if (gesture.state != .ended) {
return super.onPanGesture(gesture, at: view)
}
return true ;
}
}
In the main view there is a button which adds the Rollover to the chart and removes it by adding/removing the CustomRollover from the SCIChartSurface.ChartModifiers collection.
let button = UIButton(frame: CGRect(x: 100, y: 5, width: 200, height: 50))
button.setTitle("Add/Remove Rollover", for: .normal)
button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
self.addSubview(button)
#objc func buttonAction(sender: UIButton!) {
rollover.isEnabled = !rollover.isEnabled
if (rollover.isEnabled) {
self.chartModifiers.add(rollover)
} else {
self.chartModifiers.remove(rollover)
}
}
This is the easiest way to remove the rollover from the screen, and it lets you decide when you want it to be removed or hidden.

Get tap event for UIButton in UIControl/rotatable view

Edited
See the comment section with Nathan for the latest project. There is only problem remaining: getting the right button.
Edited
I want to have a UIView that the user can rotate. That UIView should contain some UIButtons that can be clicked. I am having a hard time because I am using a UIControl subclass to make the rotating view and in that subclass I have to disable user interactions on the subviews in the UIControl (to make it spin) which may cause the UIButtons not be tappable. How can I make a UIView that the user can spin and contains clickable UIButtons? This is a link to my project which gives you a head start: it contains the UIButtons and a spinnable UIView. I can however not tap the UIButtons.
Old question with more details
I am using this pod: https://github.com/joshdhenry/SpinWheelControl and I want to react to a buttons click. I can add the button, however I can not receive tap events in the button. I am using hitTests but they never get executed. The user should spin the wheel and be able to click a button in one of the pie's.
Get the project here: https://github.com/Jasperav/SpinningWheelWithTappableButtons
See the code below what I added in the pod file:
I added this variable in SpinWheelWedge.swift:
let button = SpinWheelWedgeButton()
I added this class:
class SpinWheelWedgeButton: TornadoButton {
public func configureWedgeButton(index: UInt, width: CGFloat, position: CGPoint, radiansPerWedge: Radians) {
self.frame = CGRect(x: 0, y: 0, width: width, height: 30)
self.layer.anchorPoint = CGPoint(x: 1.1, y: 0.5)
self.layer.position = position
self.transform = CGAffineTransform(rotationAngle: radiansPerWedge * CGFloat(index) + CGFloat.pi + (radiansPerWedge / 2))
self.backgroundColor = .green
self.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside)
}
#IBAction func pressed(_ sender: TornadoButton){
print("hi")
}
}
This is the class TornadoButton:
class TornadoButton: UIButton{
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let pres = self.layer.presentation()!
let suppt = self.convert(point, to: self.superview!)
let prespt = self.superview!.layer.convert(suppt, to: pres)
if (pres.hitTest(suppt)) != nil{
return self
}
return super.hitTest(prespt, with: event)
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let pres = self.layer.presentation()!
let suppt = self.convert(point, to: self.superview!)
return (pres.hitTest(suppt)) != nil
}
}
I added this to SpinWheelControl.swift, in the loop "for wedgeNumber in"
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 2, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
wedge.addSubview(wedge.button)
This is where I thought I could retrieve the button, in SpinWheelControl.swift:
override open func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let p = touch.location(in: touch.view)
let v = touch.view?.hitTest(p, with: nil)
print(v)
}
Only 'v' is always the spin wheel itself, never the button. I also do not see the buttons print, and the hittest is never executed. What is wrong with this code and why does the hitTest not executes? I rather have a normal UIBUtton, but I thought I needed hittests for this.
Here is a solution for your specific project:
Step 1
In the drawWheel function in SpinWheelControl.swift, enable user interaction on the spinWheelView. To do this, remove the following line:
self.spinWheelView.isUserInteractionEnabled = false
Step 2
Again in the drawWheel function, make the button a subview of the spinWheelView, not the wedge. Add the button as a subview after the wedge, so it will appear on top of the wedge shape layer.
Old:
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 0.45, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
wedge.addSubview(wedge.button)
spinWheelView.addSubview(wedge)
New:
wedge.button.configureWedgeButton(index: wedgeNumber, width: radius * 0.45, position: spinWheelCenter, radiansPerWedge: radiansPerWedge)
spinWheelView.addSubview(wedge)
spinWheelView.addSubview(wedge.button)
Step 3
Create a new UIView subclass that passes touches through to its subviews.
class PassThroughView: UIView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for subview in subviews {
if !subview.isHidden && subview.alpha > 0 && subview.isUserInteractionEnabled && subview.point(inside: convert(point, to: subview), with: event) {
return true
}
}
return false
}
}
Step 4
At the very beginning of the drawWheel function, declare the spinWheelView to be of type PassThroughView. This will allow the buttons to receive touch events.
spinWheelView = PassThroughView(frame: self.bounds)
With those few changes, you should get the following behavior:
(The message is printed to the console when any button is pressed.)
Limitations
This solution allows the user to spin the wheel as usual, as well as tap any of the buttons. However, this might not be the perfect solution for your needs, as there are some limitations:
The wheel cannot be spun if the users touch down starts within the bounds of any of the buttons.
The buttons can be pressed while the wheel is in motion.
Depending on your needs, you might consider building your own spinner instead of relying on a third-party pod. The difficulty with this pod is that it is using the beginTracking(_ touch: UITouch, with event: UIEvent?) and related functions instead of gesture recognizers. If you used gesture recognizers, it would be easier to make use of all the UIButton functionality.
Alternatively, if you just wanted to recognize a touch down event within the bounds of a wedge, you could pursue your hitTest idea further.
Edit: Determining which button was pressed.
If we know the selectedIndex of the wheel and the starting selectedIndex, we can calculate which button was pressed.
Currently, the starting selectedIndex is 0, and the button tags increase going clockwise. Tapping the selected button (tag = 0), prints 7, which means that the buttons are "rotated" 7 positions in their starting state. If the wheel started in a different position, this value would differ.
Here is a quick function to determine the tag of the button that was tapped using two pieces of information: the wheel's selectedIndex and the subview.tag from the current point(inside point: CGPoint, with event: UIEvent?) implementation of the PassThroughView.
func determineButtonTag(selectedIndex: Int, subviewTag: Int) -> Int {
return subviewTag + (selectedIndex - 7)
}
Again, this is definitely a hack, but it works. If you are planning to continue to add functionality to this spinner control, I would highly recommend creating your own control instead so you can design it from the beginning to fit your needs.
I was able to tinker around with the project and I think I have the solution to your problem.
In your SpinWheelControl class, you are setting the userInteractionEnabled property of the spinWheelViews to false. Note that this is not what you exactly want, because you are still interested in tapping the button which is inside the spinWheelView. However, if you don't turn off user interaction, the wheel won't turn because the child views mess up the touches!
To solve this problem, we can turn off the user interaction for the child views and manually trigger only the events that we are interested in - which is basically touchUpInside for the innermost button.
The easiest way to do that is in the endTracking method of the SpinWheelControl. When the endTracking method gets called, we loop through all the buttons manually and call endTracking for them as well.
Now the problem about which button was pressed remains, because we just sent endTracking to all of them. The solution to that is overriding the endTracking method of the buttons and trigger the .touchUpInside method manually only if the touch hitTest for that particular button was true.
Code:
TornadoButton Class: (the custom hitTest and pointInside are no longer needed since we are no longer interested in doing the usual hit testing; we just directly call endTracking)
class TornadoButton: UIButton{
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
if let t = touch {
if self.hitTest(t.location(in: self), with: event) != nil {
print("Tornado button with tag \(self.tag) ended tracking")
self.sendActions(for: [.touchUpInside])
}
}
}
}
SpinWheelControl Class: endTracking method:
override open func endTracking(_ touch: UITouch?, with event: UIEvent?) {
for sv in self.spinWheelView.subviews {
if let wedge = sv as? SpinWheelWedge {
wedge.button.endTracking(touch, with: event)
}
}
...
}
Also, to test that the right button is being called, just set the tag of the button equal to the wedgeNumber when you are creating them. With this method, you will not need to use the custom offset like #nathan does, because the right button will respond to the endTracking and you can just get its tag by sender.tag.
The general solution would be to use a UIView and place all your UIButtons where they should be, and use a UIPanGestureRecognizer to rotate your view, calculate speed and direction vector and rotate your view. For rotating your view I suggest using transform because it's animatable and also your subviews will be also rotated. (extra: If you want to set direction of your UIButtons always downward, just rotate them in reverse, it will cause them to always look downward)
Hack
Some people also use UIScrollView instead of UIPanGestureRecognizer. Place described View inside the UIScrollView and use UIScrollView's delegate methods to calculate speed and direction then apply those values to your UIView as described. The reason for this hack is because UIScrollView decelerates speed automatically and provides better experience. (Using this technique you should set contentSize to something very big and relocate contentOffset of UIScrollView to .zero periodically.
But I highly suggest the first approach.
As for my opinion, you can use your own view with few sublayers and all other stuff you need.
In this case u will get full flexibility but you also should write a little bit more code.
If you like this option u can get something like on gif below (you can customize it as u wish - add text, images, animations etc):
Here I show you 2 continuous pan and one tap on purple section - when tap is detected6 bg color changed to green
To detect tap I used touchesBegan as shown below.
To play with code for this you can copy-paste code below in to playground and modify as per your needs
//: A UIKit based Playground for presenting user interface
import UIKit import PlaygroundSupport
class RoundView : UIView {
var sampleArcLayer:CAShapeLayer = CAShapeLayer()
func performRotation( power: Float) {
let maxDuration:Float = 2
let maxRotationCount:Float = 5
let currentDuration = maxDuration * power
let currrentRotationCount = (Double)(maxRotationCount * power)
let fromValue:Double = Double(atan2f(Float(transform.b), Float(transform.a)))
let toValue = Double.pi * currrentRotationCount + fromValue
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = fromValue
rotateAnimation.toValue = toValue
rotateAnimation.duration = CFTimeInterval(currentDuration)
rotateAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
rotateAnimation.isRemovedOnCompletion = true
layer.add(rotateAnimation, forKey: nil)
layer.transform = CATransform3DMakeRotation(CGFloat(toValue), 0, 0, 1)
}
override func layoutSubviews() {
super.layoutSubviews()
drawLayers()
}
private func drawLayers()
{
sampleArcLayer.removeFromSuperlayer()
sampleArcLayer.frame = bounds
sampleArcLayer.fillColor = UIColor.purple.cgColor
let proportion = CGFloat(20)
let centre = CGPoint (x: frame.size.width / 2, y: frame.size.height / 2)
let radius = frame.size.width / 2
let arc = CGFloat.pi * 2 * proportion / 100 // i.e. the proportion of a full circle
let startAngle:CGFloat = 45
let cPath = UIBezierPath()
cPath.move(to: centre)
cPath.addLine(to: CGPoint(x: centre.x + radius * cos(startAngle), y: centre.y + radius * sin(startAngle)))
cPath.addArc(withCenter: centre, radius: radius, startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
cPath.addLine(to: CGPoint(x: centre.x, y: centre.y))
sampleArcLayer.path = cPath.cgPath
// you can add CATExtLayer and any other stuff you need
layer.addSublayer(sampleArcLayer)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let point = touches.first?.location(in: self) {
if let layerArray = layer.sublayers {
for sublayer in layerArray {
if sublayer.contains(point) {
if sublayer == sampleArcLayer {
if sampleArcLayer.path?.contains(point) == true {
backgroundColor = UIColor.green
}
}
}
}
}
}
}
}
class MyViewController : UIViewController {
private var lastTouchPoint:CGPoint = CGPoint.zero
private var initialTouchPoint:CGPoint = CGPoint.zero
private let testView:RoundView = RoundView(frame:CGRect(x: 40, y: 40, width: 100, height: 100))
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
testView.layer.cornerRadius = testView.frame.height / 2
testView.layer.masksToBounds = true
testView.backgroundColor = UIColor.red
view.addSubview(testView)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(MyViewController.didDetectPan(_:)))
testView.addGestureRecognizer(panGesture)
}
#objc func didDetectPan(_ gesture:UIPanGestureRecognizer) {
let touchPoint = gesture.location(in: testView)
switch gesture.state {
case .began:
initialTouchPoint = touchPoint
break
case .changed:
lastTouchPoint = touchPoint
break
case .ended, .cancelled:
let delta = initialTouchPoint.y - lastTouchPoint.y
let powerPercentage = max(abs(delta) / testView.frame.height, 1)
performActionOnView(scrollPower: Float(powerPercentage))
initialTouchPoint = CGPoint.zero
break
default:
break
}
}
private func performActionOnView(scrollPower:Float) {
testView.performRotation(power: scrollPower)
} } // Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

Nested UIViews do not receive touch events

I have a custom UIView that can be simplified down to:
class Node: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
let tapGR = UITapGestureRecognizer(target: self, action: "createChildNode")
addGestureRecognizer(tapGR)
userInteractionsEnabled = true
}
/* ... */
func createChildNode() {
let childNode = Node(frame: self.bounds.offset(dx: 100, dy: 100))
self.addSubview(childNode)
}
}
The first (root) node is created in the view controller:
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(Node(x: 100, y: 100, width: 150, height: 40))
}
For the root node, everything works as expected. However, for the children of the root node, nothing works.
To make sure that I wasn't using the UITapGestureRecognizer incorrectly, I tried to override the raw touchesBegan function for the Node class and do a simple println("tapped"). However the problem persisted and seems to be that the subviews do not receive any touches at all.
What could be causing this?
Sounds like you just need to pass the touch events through the view hierarchy. This answer should help:
https://stackoverflow.com/a/21436355/3259329

Resources