C4: Add panning to an object other than "self" - panning

I watched the C4 tutorial on adding a pan gesture to an object and animating it to return to its original position when the panning is finished. I'm trying to add this to three individual objects. I have it working with one object so far to move it and reset it to a CGPoint, but for it to work, I have to add the pan gesture to "self", not the object. For reference, I'm pretty much using the code from here:
http://www.c4ios.com/tutorials/interactionPanning
If I add the gesture to the object itself, sure, it pans around, but then it just leaves itself at the last touch location. However, I'm assuming that leaving the gesture on "self" will affect more than just the object I want to move, and I want to be able to move the three objects individually.
I'm using roughly the same modification to the "move" method that's used in the example:
-(void)move:(UIPanGestureRecognizer *)recognizer {
[character move:recognizer];
if (recognizer.state == UIGestureRecognizerStateEnded) {
[character setCenter: charStartOrigin];
}
}
And then a new method to spawn the object:
-(void)createCharacters {
character = [C4Shape ellipse:charStart];
[character addGesture:PAN name:#"pan" action:#"move:"];
[self.canvas addShape:character];
}

The example link you are working from is sneaky. Since I knew that there was only going to be one object on the canvas I knew I could make it look like I was panning the label. This won't work for multiple objects, as you have already figured out.
To get different objects to move independently, and recognize when they are done being dragged, you need to subclass the objects and give them their own "abilities".
To do this I:
Subclass C4Shape
Add custom behaviour to the new class
Create subclassed objects on the canvas
The code for each step looks like the following:
subclassing
You have to create a subclass that gives itself some behaviour. Since you're working with shapes I have done it this way as well. I call my subclass Character, its files look like this:
Character.h
#import "C4Shape.h"
#interface Character : C4Shape
#property (readwrite, atomic) CGPoint startOrigin;
#end
I have added a property to the shape so that I can set its start origin (i.e. the point to which it will return).
Character.m
#import "Character.h"
#implementation Character
-(void)setup {
[self addGesture:PAN name:#"pan" action:#"move:"];
}
-(void)move:(UIGestureRecognizer *)sender {
if(sender.state == UIGestureRecognizerStateEnded) {
self.center = self.startOrigin;
} else {
[super move:sender];
}
}
#end
In a subclass of a C4 object, setup gets called in the same way as it does for the canvas... So, this is where I add the gesture for this object. Setup gets run after new or alloc/init are called.
The move: method is where I want to override with custom behaviour. In this method I catch the gesture recognizer, and if it's state is UIGestureRecognizerStateEnded then I want to animate back to the start origin. Otherwise, I want it to move: like it should so I simply call [super move:sender] which runs the default move: method.
That's it for the subclass.
Creating Subclassed Objects
My workspace then looks like the following:
#import "C4WorkSpace.h"
//1
#import "Character.h"
#implementation C4WorkSpace {
//2
Character *charA, *charB, *charC;
}
-(void)setup {
//3
CGRect frame = CGRectMake(0, 0, 100, 100);
//4
frame.origin = CGPointMake(self.canvas.width / 4 - 50, self.canvas.center.y - 50);
charA = [self createCharacter:frame];
frame.origin.x += self.canvas.width / 4.0f;
charB = [self createCharacter:frame];
frame.origin.x += self.canvas.width / 4.0f;
charC = [self createCharacter:frame];
//5
[self.canvas addObjects:#[charA,charB,charC]];
}
-(Character *)createCharacter:(CGRect)frame {
Character *c = [Character new];
[c ellipse:frame];
c.startOrigin = c.center;
c.animationDuration = 0.25f;
return c;
}
#end
I have added a method to my workspace that creates a Character object and adds it to the screen. This method creates a Character object by calling its new method (I have to do it this way because it is a subclass of C4Shape), turns it into an ellipse with the frame I gave it, sets its startOrigin, changes its animationDuration.
What's going on with the rest of the workspace is this (NOTE: the steps are marked in the code above):
I #import the subclass so that I can create objects with it
I create 3 references to Character objects.
I create a frame that I will use to build each of the new objects
For each object, I reposition frameby changing its origin and then use it to create a new object with the createCharacter: method I wrote.
I add all of my new objects to the canvas.
NOTE: Because I created my subclass with a startOrigin property, I am able within that class to always animate back to that point. I am also able to set that point from the canvas whenever I want.

Related

Swallowing a ccTouch on ccTouchMoved

I am attempting to let players drag objects in my game from one part of the screen to another. The problem is, the objects which are to be dragged have layers beneath them that need to receive touches, too. Normally I'd just swallow the touch, but as far as I can tell, that can only be done during ccTouchBegan. I can't tell if the user is attempting to drag an object until after ccTouchMoved is called, so I need a way to explicitly swallow (or otherwise prevent lower layers) from receiving the touch after I've determined that it is a touch I'm interested in (within ccTouchMoved).
I got almost the same problem, but I don't know if my solution would fit here. The main idea was that objects which should be dragged were children on the same CCNode hierarchy with beneath items. The solution consists in the fact that parent disabled children's touch events, then intercepts these events. In case some object was dragged parent sends all event to it, in the other case parent handles the event itself.
Let me try to show what I mean. Create protocol for items which can swallow touches ccTouchMoved:
#protocol SwallowingOnTouchMovedNode
{
-(BOOL) ccTouchMoved:(UITouch*)touch; // not full signature for simpleness (UIEvent should be also here)
}
Then create layer, which will manually handle the touches of its children:
#interface TouchHandler : CCLayer
{
NSMutableArray *draggableChildren_, nonDraggableChildren_, *claimedChildren_;
BOOL isDragging_;
}
#implementation TouchHandler
-(id) init
{
...
self.isTouchEnabled = YES;
draggableChildren_ = [[NSMutableArray alloc] init];
nonDraggableChildren_ = [[NSMutableArray alloc] init];
claimedChildren = [[NSMutableArray alloc] init];
...
}
Create two methods for TouchHandler for adding two types of children - the ones which can be dragged and the others. That methods will disable touches on children so the parent will manually handle them.
-(void) addChild:(CCNode*)child shouldBeDragged:(BOOL)shouldBeDragged
{
NSMutableArray *arrayToAddChild = shouldBeDragged ? draggableChildren_ : nonDraggableChildren_;
[arrayToAddChild addObject:child];
// note, that if the child has its own children, you will need to
// set isTouchEnabled on all of them, as adding all children to array recursively
if ([child respondsToSelector:#selector(setIsTouchEnabled:)]) ((CCLayer*)child).isTouchEnabled = NO;
[self addChild:child]; // maybe you need to call -addChild:zOrder:tag: here
}
Then override touch handles like that:
-(BOOL) ccTouchBegan:(UITouch*)touch
{
for (CCNode *child in draggableChildren)
{
if ([child ccTouchBegin:touch])
{
// this behavior looks like the one in CCTouchDispatcher -
// we claim children which return YES for touchBegin
[claimedChildren addObject:child];
}
}
}
-(void) ccTouchMoved:(UITouch*)touch
{
for (CCNode *child in claimedChildren)
{
if ([(id<SwallowingOnTouchMovedNode>)child ccTouchMoved:touch])
{
isDragging_ = YES;
}
}
// if no one swallowed touches
if (!isDragging_)
{
for (CCNode *child in nonDraggableChildren)
{
// we did not called ccTouchBegan earlier for these items,
// so for consistency we need to call it now
if ([child ccTouchBegin:touch])
{
[child ccTouchMoved:touch];
[claimedChildren addObject:child];
}
}
}
}
-(void) ccTouchEnded:(UITouch*)touch
{
isDragging_ = NO;
for (CCNode *child in claimedChildren)
{
[child ccTouchEnded];
}
}
Do not forget to implement -ccTouchCancelled. This code is pretty concrete, so you may need to make some changes, but I hope I have explained my idea. In general, the TouchHandler may not even be the CCLayer to work like this, just add it as targeted delegate for receiving touches.
There is another way, which seems to be more consistent and correct from OOP point of view, but I am not sure about it. The behavior in ccTouchBegin, ccTouchMoved and ccTouchEnded almost duplicates the one in the CCTouchDispatcher. You can subclass it and override some methods for receiving touch events and implement -(BOOL)ccTouchMoved, as I've done. Also, I don't know if we can replace default CCTouchDispatcher. Hope this will help!

How can I set a maximum on the number of pages in a TTLauncherView?

I'm using TTLauncherView as a sort of home screen for my app and I only have one page's worth of icons. How can I make it so the TTLauncherView won't let you drag icons to "the next page"? I want to set a maximum number of pages (in this case one.)
(EDIT: long story short, I subclassed beginEditing, see the answer below.)
I see where why it adds an extra page when beginEditing is called, but I don't want to edit the framework code. (That makes it hard to update to newer versions.) I'd also prefer not to subclass and override that one method, if I have to rely on how it's implemented. (I'm not against subclassing or adding a category if it's clean.)
I tried setting scrollView.scrollEnabled to NO in the callback method launcherViewDidBeginEditing in my TTLauncherViewDelegate, but that doesn't work while it's in editing mode and I don't know why.
I tried adding a blocker UIView to the scrollview to intercept the touch events by setting userInteractionEnabled=NO, which works OK. I still have to disable the dragging of TTLauncherItems to the next page somehow.
I also tried setting the contentSize of the scrollview to it's bounds in launcherViewDidBeginEditing, but that didn't seem to work either.
Is there a better way?
Tried to block gestures:
- (void)setLauncherViewScrollEnabled:(BOOL)scrollEnabled {
if (scrollEnabled) {
[self.scrollViewTouchInterceptor removeFromSuperview];
self.scrollViewTouchInterceptor = nil;
} else {
// iter through the kids to get the scrollview, put a gesturerecognizer view in front of it
UIScrollView *scrollView = [launcherView scrollViewSubview];
self.scrollViewTouchInterceptor = [UIView viewWithFrame:scrollView.bounds]; // property retains it
UIView *blocker = self.scrollViewTouchInterceptor;
[scrollView addSubview:scrollViewTouchInterceptor];
[scrollView sendSubviewToBack:scrollViewTouchInterceptor];
scrollViewTouchInterceptor.userInteractionEnabled = NO;
}
}
For reference: TTLauncherView.m:
- (void)beginEditing {
_editing = YES;
_scrollView.delaysContentTouches = YES;
UIView* prompt = [self viewWithTag:kPromptTag];
[prompt removeFromSuperview];
for (NSArray* buttonPage in _buttons) {
for (TTLauncherButton* button in buttonPage) {
button.editing = YES;
[button.closeButton addTarget:self action:#selector(closeButtonTouchedUpInside:)
forControlEvents:UIControlEventTouchUpInside];
}
}
// Add a page at the end
[_pages addObject:[NSMutableArray array]];
[_buttons addObject:[NSMutableArray array]];
[self updateContentSize:_pages.count];
[self wobble];
if ([_delegate respondsToSelector:#selector(launcherViewDidBeginEditing:)]) {
[_delegate launcherViewDidBeginEditing:self];
}
}
I think overriding beginEditing in TTLauncherView is your best bet. Since you'd only really be touching one method (and only a few lines in that method), upgrading it when the time comes shouldn't be too bad. Since that method explicitly adds the extra page, I'm not sure how you'd get around it w/o editing that specific piece of code
As Andrew Flynn suggested in his answer, I was able to make it work by subclassing and overriding the beginEditing method to remove the extra page TTLauncherView adds when it goes into editing mode.
One problem I'm having is I can't figure out how to remove the warning I get for calling the (private) method updateContentSize on my subclass. I tried casting it to id, but that didn't remove the warning. Is it possible?
edit: I was able to remove the warning by using performSelector to send the message to the private class. (I had previously create a category method performSelector:withInt that wraps NSInvocation so that I can pass primitives via performSelector methods, which makes this very convenient.)
// MyLauncherView.h
#interface MyLauncherView : TTLauncherView {
NSInteger _maxPages;
}
#property (nonatomic) NSInteger maxPages;
#end
// MyLauncherView.m
#implementation MyLauncherView
#synthesize maxPages = _maxPages;
- (void)beginEditing {
[super beginEditing];
// ignore unset or negative number of pages
if (self.maxPages <= 0) {
return;
}
// if we've got the max number of pages, remove the extra "new page" that is added in [super beginEditing]
if ([_pages count] >= self.maxPages ) {
[_pages removeLastObject];
[self updateContentSize:_pages.count]; // this has a compiler warning
// I added this method to NSObject via a category so I can pass primitives with performSelector
// [self performSelector:#selector(updateContentSize:) withInt:_pages.count waitUntilDone:NO];
}
}

CALayer -hitTest: not respecting containsPoint: overload

Back again and have a different question with the same function that I posted before:
- (AIEnemyUnit *) hitTestForEnemyUnit:(CGPoint)where {
CALayer * layer = [self hitTest:where];
while (layer) {
if ([layer isKindOfClass:[AIEnemyUnit class]]) {
return (AIEnemyUnit *)layer;
} else {
layer = layer.superlayer;
}
}
return nil;
}
I have a bomb that the user drags on top of the enemy so that it is displayed directly above the AIEnemyUnit. For this bomb I implemented the CALayer -containsPoint: to return NO during a drag to allow -hitTest: to pass through the layer. Basically this type of hit testing was working fine with these "pass-through" layers as long as I only used CGImageRef contexts. However once I started implementing sublayers for additional effects on the bomb, -hitTest: immediately broke. It was obvious, the new layers were capturing the -hitTest:. I tried implementing the same technique by overloading -containsPoint: for these layers, but it was still returning the bomb's generic CALayer subclass instead of passing through.
Is there a better way?
Maybe the "where" point is not relative to your "self" layer. You need to convert these points between the layers coordinate systems using:
– convertPoint:fromLayer: or
– convertPoint:toLayer:
See http://developer.apple.com/library/ios/documentation/GraphicsImaging/Reference/CALayer_class/Introduction/Introduction.html#//apple_ref/occ/instm/CALayer/convertPoint:fromLayer:
I resolved this by putting everything on a second "root" layer (called "gameLayer") the same size as the original. Then during the UIPanGestureRecognizer, I move the bomb element from "gameLayer" into my UIView.layer. Then while I am testing for a AIEnemyUnit, I only run the hitTest on the "gameLayer".
- UIView.layer --------- gameLayer
| |
dragObj(bomb) gameElements
|
bomb

Disabling implicit animations in -[CALayer setNeedsDisplayInRect:]

I've got a layer with some complex drawing code in its -drawInContext: method. I'm trying to minimize the amount of drawing I need to do, so I'm using -setNeedsDisplayInRect: to update just the changed parts. This is working splendidly. However, when the graphics system updates my layer, it's transitioning from the old to the new image using a cross-fade. I'd like it to switch over instantly.
I've tried using CATransaction to turn off actions and set the duration to zero, and neither work. Here's the code I'm using:
[CATransaction begin];
[CATransaction setDisableActions: YES];
[self setNeedsDisplayInRect: rect];
[CATransaction commit];
Is there a different method on CATransaction I should use instead (I also tried -setValue:forKey: with kCATransactionDisableActions, same result).
You can do this by setting the actions dictionary on the layer to return [NSNull null] as an animation for the appropriate key. For example, I use
NSDictionary *newActions = #{
#"onOrderIn": [NSNull null],
#"onOrderOut": [NSNull null],
#"sublayers": [NSNull null],
#"contents": [NSNull null],
#"bounds": [NSNull null]
};
layer.actions = newActions;
to disable fade in / out animations on insertion or change of sublayers within one of my layers, as well as changes in the size and contents of the layer. I believe the contents key is the one you're looking for in order to prevent the crossfade on updated drawing.
Swift version:
let newActions = [
"onOrderIn": NSNull(),
"onOrderOut": NSNull(),
"sublayers": NSNull(),
"contents": NSNull(),
"bounds": NSNull(),
]
Also:
[CATransaction begin];
[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
//foo
[CATransaction commit];
When you change the property of a layer, CA usually creates an implicit transaction object to animate the change. If you do not want to animate the change, you can disable implicit animations by creating an explicit transaction and setting its kCATransactionDisableActions property to true.
Objective-C
[CATransaction begin];
[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
// change properties here without animation
[CATransaction commit];
Swift
CATransaction.begin()
CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)
// change properties here without animation
CATransaction.commit()
In addition to Brad Larson's answer: for custom layers (that are created by you) you can use delegation instead of modifying layer's actions dictionary. This approach is more dynamic and may be more performant. And it allows disabling all implicit animations without having to list all animatable keys.
Unfortunately, it's impossible to use UIViews as custom layer delegates, because each UIView is already a delegate of its own layer. But you can use a simple helper class like this:
#interface MyLayerDelegate : NSObject
#property (nonatomic, assign) BOOL disableImplicitAnimations;
#end
#implementation MyLayerDelegate
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event
{
if (self.disableImplicitAnimations)
return (id)[NSNull null]; // disable all implicit animations
else return nil; // allow implicit animations
// you can also test specific key names; for example, to disable bounds animation:
// if ([event isEqualToString:#"bounds"]) return (id)[NSNull null];
}
#end
Usage (inside the view):
MyLayerDelegate *delegate = [[MyLayerDelegate alloc] init];
// assign to a strong property, because CALayer's "delegate" property is weak
self.myLayerDelegate = delegate;
self.myLayer = [CALayer layer];
self.myLayer.delegate = delegate;
// ...
self.myLayerDelegate.disableImplicitAnimations = YES;
self.myLayer.position = (CGPoint){.x = 10, .y = 42}; // will not animate
// ...
self.myLayerDelegate.disableImplicitAnimations = NO;
self.myLayer.position = (CGPoint){.x = 0, .y = 0}; // will animate
Sometimes it's convenient to have view's controller as a delegate for view's custom sublayers; in this case there is no need for a helper class, you can implement actionForLayer:forKey: method right inside the controller.
Important note: don't try to modify the delegate of UIView's underlying layer (e.g. to enable implicit animations) — bad things will happen :)
Note: if you want to animate (not disable animation for) layer redraws, it is useless to put [CALayer setNeedsDisplayInRect:] call inside a CATransaction, because actual redrawing may (and probably will) happen sometimes later. The good approach is to use custom properties, as described in this answer.
Here's a more efficient solution, similar to accepted answer but for Swift. For some cases it will be better than creating a transaction every time you modify the value which is a performance concern as others have mentioned e.g. common use-case of dragging the layer position around at 60fps.
// Disable implicit position animation.
layer.actions = ["position": NSNull()]
See apple's docs for how layer actions are resolved. Implementing the delegate would skip one more level in the cascade but in my case that was too messy due to the caveat about the delegate needing to be set to the associated UIView.
Edit: Updated thanks to the commenter pointing out that NSNull conforms to CAAction.
Actually, I didn't find any of the answers to be the right one. The method that solves the problem for me was this:
- (id<CAAction>)actionForKey:(NSString *)event {
return nil;
}
Then you can whatever logic in it, to disable a specific animation, but since I wanted to removed them all, I returned nil.
Based on Sam's answer, and Simon's difficulties... add the delegate reference after creating the CSShapeLayer:
CAShapeLayer *myLayer = [CAShapeLayer layer];
myLayer.delegate = self; // <- set delegate here, it's magic.
... elsewhere in the "m" file...
Essentially the same as Sam's without the ability to toggle via the custom "disableImplicitAnimations" variable arrangement. More of a "hard-wire" approach.
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
// disable all implicit animations
return (id)[NSNull null];
// allow implicit animations
// return nil;
// you can also test specific key names; for example, to disable bounds animation:
// if ([event isEqualToString:#"bounds"]) return (id)[NSNull null];
}
To disable implicit layer animations in Swift
CATransaction.setDisableActions(true)
Found out a simpler method to disable action inside a CATransaction that internally calls setValue:forKey: for the kCATransactionDisableActions key:
[CATransaction setDisableActions:YES];
Swift:
CATransaction.setDisableActions(true)
Updated for swift and disabling only one implicit property animation in iOS not MacOS
// Disable the implicit animation for changes to position
override open class func defaultAction(forKey event: String) -> CAAction? {
if event == #keyPath(position) {
return NSNull()
}
return super.defaultAction(forKey: event)
}
Another example, in this case eliminating two implicit animations.
class RepairedGradientLayer: CAGradientLayer {
// Totally ELIMINATE idiotic implicit animations, in this example when
// we hide or move the gradient layer
override open class func defaultAction(forKey event: String) -> CAAction? {
if event == #keyPath(position) {
return NSNull()
}
if event == #keyPath(isHidden) {
return NSNull()
}
return super.defaultAction(forKey: event)
}
}
Add this to your custom class where you are implementing -drawRect() method. Make changes to code to suite your needs, for me 'opacity' did the trick to stop cross-fade animation.
-(id<CAAction>) actionForLayer:(CALayer *)layer forKey:(NSString *)key
{
NSLog(#"key: %#", key);
if([key isEqualToString:#"opacity"])
{
return (id<CAAction>)[NSNull null];
}
return [super actionForLayer:layer forKey:key];
}
If you ever need a very quick (but admittedly hacky) fix it might be worth just doing (Swift):
let layer = CALayer()
// set other properties
// ...
layer.speed = 999
As of iOS 7 there's a convenience method that does just this:
[UIView performWithoutAnimation:^{
// apply changes
}];
To disable the annoying (blurry) animation when changing the string property of a CATextLayer, you can do this:
class CANullAction: CAAction {
private static let CA_ANIMATION_CONTENTS = "contents"
#objc
func runActionForKey(event: String, object anObject: AnyObject, arguments dict: [NSObject : AnyObject]?) {
// Do nothing.
}
}
and then use it like so (don't forget to set up your CATextLayer properly, e.g. the correct font, etc.):
caTextLayer.actions = [CANullAction.CA_ANIMATION_CONTENTS: CANullAction()]
You can see my complete setup of CATextLayer here:
private let systemFont16 = UIFont.systemFontOfSize(16.0)
caTextLayer = CATextLayer()
caTextLayer.foregroundColor = UIColor.blackColor().CGColor
caTextLayer.font = CGFontCreateWithFontName(systemFont16.fontName)
caTextLayer.fontSize = systemFont16.pointSize
caTextLayer.alignmentMode = kCAAlignmentCenter
caTextLayer.drawsAsynchronously = false
caTextLayer.actions = [CANullAction.CA_ANIMATION_CONTENTS: CANullAction()]
caTextLayer.contentsScale = UIScreen.mainScreen().scale
caTextLayer.frame = CGRectMake(playbackTimeImage.layer.bounds.origin.x, ((playbackTimeImage.layer.bounds.height - playbackTimeLayer.fontSize) / 2), playbackTimeImage.layer.bounds.width, playbackTimeLayer.fontSize * 1.2)
uiImageTarget.layer.addSublayer(caTextLayer)
caTextLayer.string = "The text you want to display"
Now you can update caTextLayer.string as much as you want =)
Inspired by this, and this answer.
Try this.
let layer = CALayer()
layer.delegate = hoo // Same lifecycle UIView instance.
Warning
If you set delegate of UITableView instance, sometimes happen crash.(Probably scrollview's hittest called recursively.)

UIDatePicker inside UIScrollView with pages

I have a UIScrollView with 2 pages, and I can scroll horizontally between them. However, on one of my pages, I have a UIDatePicker, and the scroll view is intercepting the vertical touch events so I can no longer manipulate the date picker (except by clicking or tapping). Is there some way to tell the ScrollView to send the vertical touch events to the date picker, but send the horizontal touch events to the scroll view to switch pages?
Actually, there is a much simpler implementation than what Bob suggested. This works perfectly for me. You will need to subclass your UIScrollview if you haven't already, and include this method:-
- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
UIView* result = [super hitTest:point withEvent:event];
if ([result.superview isKindOfClass:[UIPickerView class]])
{
self.canCancelContentTouches = NO;
self.delaysContentTouches = NO;
}
else
{
self.canCancelContentTouches = YES; // (or restore bool from prev value if needed)
self.delaysContentTouches = YES; // (same as above)
}
return result;
}
The reason I use result.superview is that the view which gets the touches will actually be a UIPickerTable, which is a private API.
Cheers
I think there's two parts to this problem. The first is determining the user's intent, and the second is getting the correct control to respond to that intent.
Determining Intent
I think it's important to be clear about what the user intends. Imagine this scenario: The user starts touching the screen and moves his finger far to the left, but also up a little. The user probably intended to scroll the view, and didn't intend to change the date at all. It would be bad to both scroll the view and change the date, especially just as it moves off-screen. So to determine what the user intends I suggest the following algorithm:
When the user starts touching the screen, record the starting position. As the user's finger starts to move away from that position, the controls should not react at all. Once the touch moves past a certain threshold distance from the starting position, determine whether it moved more horizontally or vertically.
If it moved vertically, the user intends to change the date, so ignore the horizontal portion of the movement and only change the date.
If it moved more horizontally, the user intends to scroll the view, so ignore the vertical portion of the movement and only scroll the view.
Implementation
In order to implement this, you need to handle the events before the UIScrollView or date picker do. There's probably a few ways to do this, but one in particular comes to mind: Make a custom UIView called ScrollingDateMediatorView. Set the UIScrollView as a child of this view. Override the ScrollingDateMediatorView's hitTest:withEvent: and pointInside:withEvent: methods. These methods need to perform the same kind of hit testing that would normally occur, but if the result is the date picker, return self instead. This effectively hijacks any touch events that were destined for the date picker, allowing the ScrollingDateMediatorView to handle them first. Then you implement the algorithm described above in the various touches* methods. Specifically:
In the touchesBegan:withEvent method, save the starting position.
In touchesMoved:withEvent, if the user's intent isn't known yet, determine whether the touched has moved far enough away from the starting position. If it has, determine whether the user intends to scroll or change the date, and save that intent.
If the user's intent is already known and it's to change the date, send the date picker the touchedMoved:withEvent message, otherwise send the UIScrollView the touchesMoved:withEvent message.
You'll have to do some simliar work within touchesEnded:withEvent and touchesCancelled:withEvent to make sure the other views get the appropriate messages. Both of these methods should reset the saved values.
Once you have it properly propagating events, you'll probably have to try some user testing to tune the movement threshold.
Awesome help Sam! I used that to create a simple category that swizzles the method (because I was doing this in a UITableViewController and thus would have had to do some really messy stuff to subclass the scroll view).
#import <UIKit/UIKit.h>
#interface UIScrollView (withControls)
+ (void) swizzle;
#end
And the main code:
#import </usr/include/objc/objc-class.h>
#import "UIScrollView+withControls.h"
#define kUIViewBackgroundImageTag 6183746
static BOOL swizzled = NO;
#implementation UIScrollView (withControls)
+ (void)swizzleSelector:(SEL)orig ofClass:(Class)c withSelector:(SEL)new;
{
Method origMethod = class_getInstanceMethod(c, orig);
Method newMethod = class_getInstanceMethod(c, new);
if (class_addMethod(c, orig, method_getImplementation(newMethod),
method_getTypeEncoding(newMethod))) {
class_replaceMethod(c, new, method_getImplementation(origMethod),
method_getTypeEncoding(origMethod));
} else {
method_exchangeImplementations(origMethod, newMethod);
}
}
+ (void) swizzle {
#synchronized(self) {
if (!swizzled) {
[UIScrollView swizzleSelector:#selector(hitTest:withEvent:)
ofClass:[UIScrollView class]
withSelector:#selector(swizzledHitTest:withEvent:)];
swizzled = YES;
}
}
}
- (UIView*)swizzledHitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView* result = [self swizzledHitTest:point withEvent:event]; // actually calling the original hitTest method
if ([result.superview isKindOfClass:[UIPickerView class]]) {
self.canCancelContentTouches = NO;
self.delaysContentTouches = NO;
} else {
self.canCancelContentTouches = YES; // (or restore bool from prev value if needed)
self.delaysContentTouches = YES; // (same as above)
}
return result;
}
#end
Then, in my viewDidLoad method, I just called
[UIScrollView swizzle];