MahaloObama
MahaloObama

Reputation: 11

How to pass touch event to another view?

I have an UIView with two UIPickerView subviews, each rotated 60 degrees, one clockwise and one counter clock wise. I want to scroll each pickerview separately depending on which direction the user swipes. As one is on top of the other, only the top pickerview can be scrolled. So, I want to be able to scroll the bottom pickerview when the user swipes along its direction.

Screenshot

The closest answer I've found is overriding hitTest, but then I can't determine the swipe direction. I think I somehow have to use touchesBegan, touchesMoved and touchesEnded to determine the direction of the swipe.

My first idea was something like this, which didn't seem to work

var startPoint: CGPoint?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    startPoint = touches.first!.location(in: self)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {

    guard let startPoint = startPoint else { return }

    let endPoint = touches.first!.location(in: self)

    //This condition is arbitrary
    //The actual condition will be more complex
    if startPoint.y > endPoint.y {
        pickerViewA.isUserInteractionEnabled = true
    } else {
        pickerViewB.isUserInteractionEnabled = true
    }
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    pickerViewA.isUserInteractionEnabled = false
    pickerViewB.isUserInteractionEnabled = false
    startPoint = nil
}

Upvotes: 1

Views: 3228

Answers (3)

olha
olha

Reputation: 2262

One of the possible solutions of this problem is using a transparent view of the size of screen ( your root ViewController):

transparentView3 = MyTransparentView(frame: view.bounds)
transparentView3?.backgroundColor = UIColor.clear
view.addSubview(transparentView3!)

You could create a class for this transparent view and override touch events there:

class MyTransparentView: UIView {
    
    weak var delegate: MyTransparentViewDelegate?
    var swipePoint1: CGPoint?
    var swipeTime1: Double?
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        swipePoint1 = touches.first?.location(in: self)
        swipeTime1 = event?.timestamp
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesMoved(touches, with: event)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        
        if let swipePoint2 = touches.first?.location(in: self),
            let swipeTime2 = event?.timestamp,
            let swipePoint1 = swipePoint1,
            let swipeTime1 = swipeTime1 {
            
            if swipePoint1.x > swipePoint2.x && swipeTime2-swipeTime1 < 0.5  {
                delegate?.importantSwipeOccured()
            }
        }
    }
}

This will give much flexibility compared to using UIGestureRecognizers.

Now, your ViewController which holds two of your pickers and a transparent view, can be a delegate of a transparent view, and redirect messages to your pickers:

protocol MyTransparentViewDelegate: AnyObject {
    func importantSwipeOccured()
}

func importantSwipeOccured() {
    let color2 = rotatedView2!.backgroundColor
    rotatedView2!.backgroundColor = rotatedView1!.backgroundColor
    rotatedView1!.backgroundColor = color2
}

The full example can be found here, you can run the project or just play with the code: https://github.com/gatamar/so61033169/blob/master/so61033169/ViewController.swift

Upvotes: 0

Peter Parker
Peter Parker

Reputation: 2201

After trying lots of different things to no avail, I went back and reread the Touches chapter from Matt Neuburg's book on iOS, and a solution became clear.

Basically, an instance of a finger on the screen is represented by a UITouch. A multitouch sequence begins when the first finger comes down on the screen, and ends when there are no more fingers on the screen. One UITouch object essentially represents the same finger throughout the multitouch sequence. The system packages all these UITouch objects in an envelope called a UIEvent. This is sent to our UIWindow, who sends it to the correct view using hit testing and stuff. This is done in its sendEvent(:) method.

If we subclass UIWindow, we can override sendEvent(:) to intercept the event. All we have to do is look at the touches in that event and determine which picker view should scroll. We'll bring that picker view to the front, and then call super, which sends the event normally.

class MyWindow: UIWindow {

    override func sendEvent(_ event: UIEvent) {
        let touches = event.allTouches!.filter { $0.phase == .moved }

        if touches.isEmpty {
            super.sendEvent(event)
            return
        }

        let touch = touches.first!
        let vc = self.rootViewController as! ViewController

        // Use the touch to determine which view to put on top,
        // then call super.

        let loc_before = touch.previousLocation(in: vc.view)
        let loc_now = touch.location(in: vc.view)
        let delta = CGPoint(x: loc_now.x - loc_before.x, y: loc_now.y - loc_before.y)

        /*
         Q1    |    Q2
               |
         ------|------
               |
         Q3    |    Q4
         */

        let q1 = delta.x < 0 && delta.y < 0
        let q4 = delta.x > 0 && delta.y > 0

        let q2 = delta.x > 0 && delta.y < 0
        let q3 = delta.x < 0 && delta.y > 0

        if q1 || q4 {
            // Picker 1 should scroll
            vc.view.bringSubviewToFront(vc.picker1)
        }

        if q2 || q3 {
            // Picker 2 should scroll
            vc.view.bringSubviewToFront(vc.picker2)
        }
        super.sendEvent(event)
    }
}

Oh and, make sure your AppDelegate creates an instance of our UIWindow subclass, and assigns it a root view controller.

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.

        self.window = MyWindow()
        self.window?.rootViewController = ViewController()
        self.window?.makeKeyAndVisible()

        return true
    }
}

I tested it out and it works. Anyway, hope that helps :)

Upvotes: 1

Peter Parker
Peter Parker

Reputation: 2201

When a touch sequence occurs on a region of overlapping views, there's essentially a race between the gestures to acquire that touch sequence.

Maybe try this?

We put a transparent view on top, and assign it a custom gesture recognizer. The idea is to make this gesture act as a sort of broker that determines which picker view gets the touch sequence; rather than them fighting between each other and the top one always winning.

Each picker view has its own gesture, and we'll tell both to require our custom gesture to fail. Which means our custom gesture has to first fail before those gestures can have a chance of acquiring the touch sequence.

In our custom gesture's code, we'll always set it's state to failed, allowing the touch sequence to be passed to the other gestures. But before we fail, we'll determine which picker view should acquire this touch sequence by setting userInteractionEnabled on both picker views accordingly. This of course is in the hopes that if we disable userInteraction on a view, that view won't acquire a passed down touch sequence. Lol it might, in which case we'll come up with something else.

Here's the code for our custom gesture to get you started:

import UIKit.UIGestureRecognizerSubclass

class BrokerGesture: UIPanGestureRecognizer {

    private var startPoint: CGPoint!
    private var views: [UIView]

    override init(target: Any?, action: Selector?, views: [UIView]) {
        self.views = views
        super.init(target: target, action: action)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        self.startPoint = touches.first!.location(in: self.view!.superview)
        super.touchesBegan(touches, with: event)
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        if self.state == .possible {
            let loc = touches.first!.location(in: self.view!.superview)
            let deltaX = abs(loc.x - self.startPoint.x)
            let deltaY = abs(loc.y - self.startPoint.y)

            // Your code here:
            // - Use deltaX and deltaY
            // - Set userInteraction on both views accordingly
            self.state = .failed

        } else {
            super.touchesMoved(touches, with: event)
        }

        // Don't call super here! It will just set the state to .began! and we don't want to do that.

    }
}

Upvotes: 0

Related Questions