Get tap event for UIButton in UIControl/rotatable view
Asked Answered
D

4

12

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.

Doyen answered 4/10, 2017 at 21:9 Comment(0)
P
4

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.

Protomartyr answered 16/10, 2017 at 5:36 Comment(1)
Thanks, see my bounty comment. You get 200+ bounty after 23 hours. Thanks a lot!Doyen
C
6

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:

gif of working spinner with buttons

(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.

Counterwork answered 15/10, 2017 at 19:14 Comment(10)
Thanks for your response :) it does almost what I want to do. Only as you stated, the wheel can not rotate if the button is pressed. One thing I really want is that the wheel can be rotated regardless of where the touch is. I think this is pretty hard because there should be a check if the user wants to press the button to undertake a button action OR to just rotate the UIView. I used the pod to indicate what I want, I would not care if anything else is used. Is there any way you can make the UIView rotate regardless of the touch?Doyen
This brings together that the project should separate two user interactions: -he wants to spin the wheel, so he clicks on the button and move his finger up/down and release the button -> no button action and the view spins OR he clicks on the button, the finger did not go up or down (or slightly) -> button action. Thank you for what you have done already.Doyen
@J.Doe Yep I agree with your comments above about the optimal interaction. I could not find an easy way to solve the problem with spinning the wheel when the touch starts on the button. It may exist, but its going to add more complexity and hacky fixes. I'm not sure what your end goals are, but it seems like the best path forward is to use the pod as inspiration to build your own custom UI so you can have full control over the user experience.Counterwork
See this project: github.com/Jasperav/…. I made some changes, it now prints out a UIButton on touch down, it can spin while holding the button BUT... the clicked UIButton is always the same on the same position. You can check out the tags: spin the wheel and click the button far to the right: it is always the button without a tag. With the hitTest should be possible to find the clicked button (I hope). We can search for the button in this statement: if !isSpinning{ } in the UIView, but I am not very good with hitTest. I hope you can help.Doyen
With above comment I mean I gave each UIButton a tag to make the buttons unique. I want to print out the clicked unique UIButton. Now it just prints the same button if you click on the same spot, regardless if the wheel has spun.Doyen
@J.Doe I see what you mean. One solution (although not the cleanest), is to compare the tag value with the spin wheel control's selectedIndex. If you take the difference between the starting index and the current selectedIndex, you should be able to determine which button was pressed.Counterwork
Hmm I do not exactly get it what you mean. I am trying my best with the hitTests. This is really the final step in the answer, strange that the hitTests do not indicate the right button.Doyen
@J.Doe I added a better description of my most recent comment.Counterwork
Thanks Nathan! I rewarded you the existing bounty. See my bounty comment.Doyen
how you add spaces between wedges @nathanProsaism
P
4

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.

Protomartyr answered 16/10, 2017 at 5:36 Comment(1)
Thanks, see my bounty comment. You get 200+ bounty after 23 hours. Thanks a lot!Doyen
G
1

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.

Glengarry answered 8/10, 2017 at 13:43 Comment(1)
Can you show me an example of the first method you described? Isn't it hard to calculate speed with a UIPanGesture and rotate the view with it? Why do you not recommend the second method?Doyen
Q
1

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):

enter image description here

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()
Quattrocento answered 13/10, 2017 at 14:7 Comment(2)
Hi, thanks for your support. Maybe I was not clear enough in my question: the user should 'spin' the view by sliding his finger in the view and the view should move according to his finger movement. My project that is in my question describes the view I have in mind: a rotatable view which the user can spin, and the view only spins when the user is touching the view. I just want to add some buttons inside of it which can receive tap events. This code spins randomly a few times, and it does not contain buttons, altough they can be added easy and maybe they receive the tap event.Doyen
I hope you can make your view react to a users touch and let your view spin just like in the example I added with tappable buttonsDoyen

© 2022 - 2024 — McMap. All rights reserved.