UITapGestureRecognizer Programmatically trigger a tap in my view

前端 未结 8 1067
耶瑟儿~
耶瑟儿~ 2020-12-09 10:05

Edit: Updated to make question more obvious

Edit 2: Made question more accurate to my real-world problem. I\'m actually looking to take a

相关标签:
8条回答
  • 2020-12-09 10:17

    If used in tests you can use either a test library called SpecTools which helps with all this and more or use it's code directly:

    // Return type alias
    public typealias TargetActionInfo = [(target: AnyObject, action: Selector)]
    
    // UIGestureRecognizer extension
    extension  UIGestureRecognizer {
    
        // MARK: Retrieving targets from gesture recognizers
    
        /// Returns all actions and selectors for a gesture recognizer
        /// This method uses private API's and will most likely cause your app to be rejected if used outside of your test target
        /// - Returns: [(target: AnyObject, action: Selector)] Array of action/selector tuples
        public func getTargetInfo() -> TargetActionInfo {
            var targetsInfo: TargetActionInfo = []
    
            if let targets = self.value(forKeyPath: "_targets") as? [NSObject] {
                for target in targets {
                    // Getting selector by parsing the description string of a UIGestureRecognizerTarget
                    let selectorString = String.init(describing: target).components(separatedBy: ", ").first!.replacingOccurrences(of: "(action=", with: "")
                    let selector = NSSelectorFromString(selectorString)
    
                    // Getting target from iVars
                    let targetActionPairClass: AnyClass = NSClassFromString("UIGestureRecognizerTarget")!
                    let targetIvar: Ivar = class_getInstanceVariable(targetActionPairClass, "_target")
                    let targetObject: AnyObject = object_getIvar(target, targetIvar) as! AnyObject
    
                    targetsInfo.append((target: targetObject, action: selector))
                }
            }
    
            return targetsInfo
        }
    
        /// Executes all targets on a gesture recognizer
        public func execute() {
            let targetsInfo = self.getTargetInfo()
            for info in targetsInfo {
                info.target.performSelector(onMainThread: info.action, with: nil, waitUntilDone: true)
            }
        }
    
    }
    

    Both, library as well as the snippet use private API's and will probably cause a rejection if used outside of your test suite ...

    0 讨论(0)
  • 2020-12-09 10:20

    I was facing the same issue, trying to simulate a tap on a table cell to automate a test for a view controller which handles tapping on a table.

    The controller has a private UITapGestureRecognizer created as below:

    gestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
      action:@selector(didRecognizeTapOnTableView)];
    

    The unit test should simulate a touch so that the gestureRecognizer would trigger the action as it was originated from the user interaction.

    None of the proposed solutions worked in this scenario, so I solved it decorating UITapGestureRecognizer, faking the exact methods called by the controller. So I added a "performTap" method that call the action in a way the controller itself is unaware of where the action is originated from. This way, I could make a test unit for the controller independent of the gesture recognizer, just of the action triggered.

    This is my category, hope it helps someone.

    CGPoint mockTappedPoint;
    UIView *mockTappedView = nil;
    id mockTarget = nil;
    SEL mockAction;
    
    @implementation UITapGestureRecognizer (MockedGesture)
    -(id)initWithTarget:(id)target action:(SEL)action {
        mockTarget = target;
        mockAction =  action;
        return [super initWithTarget:target action:action];
        // code above calls UIGestureRecognizer init..., but it doesn't matters
    }
    -(UIView *)view {
        return mockTappedView;
    }
    -(CGPoint)locationInView:(UIView *)view {
        return [view convertPoint:mockTappedPoint fromView:mockTappedView];
    }
    -(UIGestureRecognizerState)state {
        return UIGestureRecognizerStateEnded;
    }
    -(void)performTapWithView:(UIView *)view andPoint:(CGPoint)point {
        mockTappedView = view;
        mockTappedPoint = point;
        [mockTarget performSelector:mockAction];
    }
    
    @end
    
    0 讨论(0)
  • 2020-12-09 10:20

    Okay, I've turned the above into a category that works.

    Interesting bits:

    • Categories can't add member variables. Anything you add becomes static to the class and thus is clobbered by Apple's many UITapGestureRecognizers.
      • So, use associated_object to make the magic happen.
      • NSValue for storing non-objects
    • Apple's init method contains important configuration logic; we could guess at what is set (number of taps, number of touches, what else?
      • But this is doomed. So, we swizzle in our init method that preserves the mocks.

    The header file is trivial; here's the implementation.

    #import "UITapGestureRecognizer+Spec.h"
    #import "objc/runtime.h"
    
    /*
     * With great contributions from Matt Gallagher (http://www.cocoawithlove.com/2008/10/synthesizing-touch-event-on-iphone.html)
     * And Glauco Aquino (http://stackoverflow.com/users/2276639/glauco-aquino)
     * And Codeshaker (http://codeshaker.blogspot.com/2012/01/calling-original-overridden-method-from.html)
     */
    @interface UITapGestureRecognizer (SpecPrivate)
    
    @property (strong, nonatomic, readwrite) UIView *mockTappedView_;
    @property (assign, nonatomic, readwrite) CGPoint mockTappedPoint_;
    @property (strong, nonatomic, readwrite) id mockTarget_;
    @property (assign, nonatomic, readwrite) SEL mockAction_;
    
    @end
    
    NSString const *MockTappedViewKey = @"MockTappedViewKey";
    NSString const *MockTappedPointKey = @"MockTappedPointKey";
    NSString const *MockTargetKey = @"MockTargetKey";
    NSString const *MockActionKey = @"MockActionKey";
    
    @implementation UITapGestureRecognizer (Spec)
    
    // It is necessary to call the original init method; super does not set appropriate variables.
    // (eg, number of taps, number of touches, gods know what else)
    // Swizzle our own method into its place. Note that Apple misspells 'swizzle' as 'exchangeImplementation'.
    +(void)load {
        method_exchangeImplementations(class_getInstanceMethod(self, @selector(initWithTarget:action:)),
                                       class_getInstanceMethod(self, @selector(initWithMockTarget:mockAction:)));
    }
    
    -(id)initWithMockTarget:(id)target mockAction:(SEL)action {
        self = [self initWithMockTarget:target mockAction:action];
        self.mockTarget_ = target;
        self.mockAction_ = action;
        self.mockTappedView_ = nil;
        return self;
    }
    
    -(UIView *)view {
        return self.mockTappedView_;
    }
    
    -(CGPoint)locationInView:(UIView *)view {
        return [view convertPoint:self.mockTappedPoint_ fromView:self.mockTappedView_];
    }
    
    //-(UIGestureRecognizerState)state {
    //    return UIGestureRecognizerStateEnded;
    //}
    
    -(void)performTapWithView:(UIView *)view andPoint:(CGPoint)point {
        self.mockTappedView_ = view;
        self.mockTappedPoint_ = point;
    
    // warning because a leak is possible because the compiler can't tell whether this method
    // adheres to standard naming conventions and make the right behavioral decision. Suppress it.
    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [self.mockTarget_ performSelector:self.mockAction_];
    #pragma clang diagnostic pop
    
    }
    
    # pragma mark - Who says we can't add members in a category?
    
    - (void)setMockTappedView_:(UIView *)mockTappedView {
        objc_setAssociatedObject(self, &MockTappedViewKey, mockTappedView, OBJC_ASSOCIATION_ASSIGN);
    }
    
    -(UIView *)mockTappedView_ {
        return objc_getAssociatedObject(self, &MockTappedViewKey);
    }
    
    - (void)setMockTappedPoint_:(CGPoint)mockTappedPoint {
        objc_setAssociatedObject(self, &MockTappedPointKey, [NSValue value:&mockTappedPoint withObjCType:@encode(CGPoint)], OBJC_ASSOCIATION_COPY);
    }
    
    - (CGPoint)mockTappedPoint_ {
        NSValue *value = objc_getAssociatedObject(self, &MockTappedPointKey);
        CGPoint aPoint;
        [value getValue:&aPoint];
        return aPoint;
    }
    
    - (void)setMockTarget_:(id)mockTarget {
        objc_setAssociatedObject(self, &MockTargetKey, mockTarget, OBJC_ASSOCIATION_ASSIGN);
    }
    
    - (id)mockTarget_ {
        return objc_getAssociatedObject(self, &MockTargetKey);
    }
    
    - (void)setMockAction_:(SEL)mockAction {
        objc_setAssociatedObject(self, &MockActionKey, NSStringFromSelector(mockAction), OBJC_ASSOCIATION_COPY);
    }
    
    - (SEL)mockAction_ {
        NSString *selectorString = objc_getAssociatedObject(self, &MockActionKey);
        return NSSelectorFromString(selectorString);
    }
    
    @end
    
    0 讨论(0)
  • 2020-12-09 10:28

    I think you have multiple options here:

    1. May be the simplest would be to send a push event action to your view but i don't think that what you really want since you want to be able to choose where the tap action occurs.

      [yourView sendActionsForControlEvents: UIControlEventTouchUpInside];

    2. You could use UI automation tool that is provided with XCode instruments. This blog explains well how to automate your UI tests with script then.

    3. There is this solution too that explain how to synthesize touch events on the iPhone but make sure you only use those for unit tests. This sounds more like a hack to me and I will consider this solution as the last resort if the two previous points doesn't fulfill your need.

    0 讨论(0)
  • 2020-12-09 10:34

    Answer by @Ondrej updated to Swift 4:

    // Return type alias
    typealias TargetActionInfo = [(target: AnyObject, action: Selector)]
    
    // UIGestureRecognizer extension
    extension  UIGestureRecognizer {
    
        // MARK: Retrieving targets from gesture recognizers
    
        /// Returns all actions and selectors for a gesture recognizer
        /// This method uses private API's and will most likely cause your app to be rejected if used outside of your test target
        /// - Returns: [(target: AnyObject, action: Selector)] Array of action/selector tuples
        func getTargetInfo() -> TargetActionInfo {
            guard let targets = value(forKeyPath: "_targets") as? [NSObject] else {
                return []
            }
            var targetsInfo: TargetActionInfo = []
            for target in targets {
                // Getting selector by parsing the description string of a UIGestureRecognizerTarget
                let description = String(describing: target).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
                var selectorString = description.components(separatedBy: ", ").first ?? ""
                selectorString = selectorString.components(separatedBy: "=").last ?? ""
                let selector = NSSelectorFromString(selectorString)
    
                // Getting target from iVars
                if let targetActionPairClass = NSClassFromString("UIGestureRecognizerTarget"),
                    let targetIvar = class_getInstanceVariable(targetActionPairClass, "_target"),
                    let targetObject = object_getIvar(target, targetIvar) {
                    targetsInfo.append((target: targetObject as AnyObject, action: selector))
                }
            }
    
            return targetsInfo
        }
    
        /// Executes all targets on a gesture recognizer
        func sendActions() {
            let targetsInfo = getTargetInfo()
            for info in targetsInfo {
                info.target.performSelector(onMainThread: info.action, with: self, waitUntilDone: true)
            }
        }
    
    }
    

    Usage:

    struct Automator {
    
        static func tap(view: UIView) {
            let grs = view.gestureRecognizers?.compactMap { $0 as? UITapGestureRecognizer } ?? []
            grs.forEach { $0.sendActions() }
        }
    }
    
    
    let myView = ... // View under UI Logic Test
    Automator.tap(view: myView)
    
    0 讨论(0)
  • 2020-12-09 10:36

    What you attempt to do is very hard (but not entirely impossible) while staying on the (iTunes-)legal path.


    Let me first draft the right way;

    The proper way out for doing this is using UIAutomation. UIAutomation does exactly what you ask for, it simulates user behaviour for all kinds of tests.


    Now that hard way;

    The issue that your problems boils down to is to instantiate a new UIEvent. (Un)fortunately UIKit does not offer any constructors for such events due to obvious security reasons. There are however workarounds that did work in the past, not sure if they still do.

    Have a look at Matt Galagher's awesome blog drafting a solution on how to synthesise touch events.

    0 讨论(0)
提交回复
热议问题