I've got an array of CALayers containing images which can be moved around by the user, and i'm trying to use containsPoint to detect if they have been touched - the code is as follows:
int num_objects = [pageImages count];
lastTouch = [touch locationInView:self];
CGRect objRect;
CALayer *objLayer;
for (int i = 0; i < num_objects; i++) {
objLayer = [pageImages objectAtIndex:i];
objRect = objLayer.bounds;
NSLog(#"layerPos:%#, layerBounds:%#", NSStringFromCGPoint(objLayer.position), NSStringFromCGRect(objRect));
NSLog(#"point:%#", NSStringFromCGPoint(lastTouch));
if ([objLayer containsPoint:lastTouch] == TRUE) {
NSLog(#"touched object %d", i);
return i;
}
}
The information i'm outputting puts the touch within the bounds of the layer (i've assumed position is the centre of the layer, i haven't altered the anchor point. The layer hasn't been rotated or anything like that either), but containsPoint: doesn't return true. Can anyone see what i'm doing wrong, or suggest a different/better way to achieve what i want?
So .. found the problem - the point needs to be converted from superlayer coordinates in order to work with the layer containsPoint:
replace
if ([objLayer containsPoint:lastTouch] == TRUE) {
with
if ([objLayer containsPoint:[objLayer convertPoint:lastTouch fromLayer:objLayer.superlayer]] == TRUE) {
You can mess about with the co-ordinates yourself and use CGRectContainsPoint: (see comments above), but this is a simpler solution so i get to answer my own question for the first time. big tick for me, yay!
Related
I am having a hard time trying to implement click handling in a simple isometric Sprite Kit game.
I have a Game Scene (SKScene) with a Map (SKNode) containing multiple Tile objects (SKSpriteNode).
Here is a screenshot of the map :
I want to be able to detect the tile the user clicked on, so I implemented mouseDown on the Tile object. Here is my mouseDown in Tile.m :
-(void)mouseDown:(NSEvent *)theEvent
{
[self setColorBlendFactor:0.5];
}
The code seems to work fine but there is a glitch : the nodes overlap and the click event is detected on the transparent part of the node. Example (the rects have been added to illustrate the problem only. They are not used in the logic) :
As you can see, if I click on the top left corner of the tile 7, the tile 8 becomes transparent.
I tried something like getting all the nodes at click location and checking if click is inside a CGPath without success (I think there was something wrong in the coordinates).
So my question here is how to detect the click only on the texture and not on the transparent part? Or maybe my approach of the problem is wrong?
Any advice would be appreciated.
Edit : for anyone interested in the solution I finally used, see my answer here
My solution for such a problem 'right now' is:
in your scene get all nodes which are in that position of your click, i.e.
[myScene nodesAtPoint:[theEvent lactionInNode:myScene]]
don't forget to check if your not clicking the root of your scene
something like that:
if (![[myScene nodeAtPoint:[theEvent locationInNode:myScene]].name isEqual: #"MyScene"])
then go through the Array of possible nodes and check the alpha of the Texture (NOT myNode.alpha)
if the alpha is 0.0f go to the next node of your Array
pick the first node which alpha is not 0.0f and return the nodes name
this way you can find your node (first) and save it, as the node you need, and kill the array, which you don't need anymore
than do what you want with your node
btw check if your new node you wish to use is nil after searching for its name. if thats true, just break out of your moveDown method
To get the alpha try this.
Mine looks something like that:
-(void)mouseDown:(NSEvent *)theEvent {
/* Called when a mouse click occurs */
if (![[self nodeAtPoint:[theEvent locationInNode:self]].name isEqual: self.name]]) {
/* find the node you clicked */
NSArray *clickedNodes = [self nodesAtPoint:[theEvent locationInNode:self]];
SKNode *clickedNode = [self childNodeWithName:[clickedNodes getClickedCellNode]];
clickedNodes = nil;
/* call the mouseDown method of your Node you clicked to to node specific actions */
if(clickedNode) {
[clickedNode mouseDown:theEvent];
}
/* kill the pointer to your clicked node */
clickedNode = nil;
}
}
For simple geometries like yours, a workaround would be to superimpose invisible SKShapeNodes on top of your diamonds, and watch for their touches (not for the skspritenode ones).
If this still does not work, make sure you create the SKPhysicsBody using the "fromPolygon: myNode.path!" option...
i have some problem with bugs [SKPhysicsWorld enumerateBodiesAtPoint].
But i found my solution and it will work for you too.
Create tile path (your path 4 points)
Catch touch and convert point to your tile node
if touch point inside shape node - win!
CODE:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
selectedBuilding = nil;
NSArray *nodes = [farmWorldNode nodesAtPoint:point];
if (!nodes || nodes.count == 0 || nodes.count == 1) {
return;
}
NSMutableArray *buildingsArray = [NSMutableArray array];
int count = (int)nodes.count;
for (int i = 0; i < count; i++) {
SKNode *findedBuilding = [nodes objectAtIndex:i];
if ([findedBuilding isKindOfClass:[FBuildingBaseNode class]]) {
FBuildingBaseNode *building = (FBuildingBaseNode *)findedBuilding;
CGPoint pointInsideBuilding = [building convertPoint:point fromNode:farmWorldNode];
if ([building.colisionBaseNode containsPoint:pointInsideBuilding]) {
NSLog(#"\n\n Building %# \n\n ARRAY: %# \n\n\n", building, findedBuilding);
[buildingsArray addObject:building];
}
}
}
selectedBuilding = (FBuildingBaseNode *)[buildingsArray lastObject];
buildingsArray = nil;
}
The same behavior of UICollectionView as described here has been led to this question. Even though I decided to post my own one, because I did further investigations, I didn't want to post in a comment or in edit of the question mentioned above.
What happens?:
When large cells being displayed in a UICollectionView with a UICollectionViewFlowLayout, after scrolling the collection view to a certain offset, the cells will disappear.
When scrolling further until another cell comes into visible area, the vanished/hidden cell becomes visible again.
I tested with a vertical scrolling collection view and full-width-cells, but I'm rather sure, that it would also happen with similar setups for horizontal scrolling.
What are large cells?:
The described behavior happens with cells higher than twice the display height (960.f + 1.f on 3,5 inch displays, 1136.f + 1.f on 4 inch).
What exactly happens?:
When the scrolling offset of the collection view exceeds cell.frame.origin.y + displayHeightOfHardware the cells hidden property is set to YES and -collectionView:didEndDisplayingCell:forItemAtIndexPath: gets called (e.g. the first cell changes to hidden when scrollingOffset.y reaches 481.f on 3,5-inch-iPhone).
As described above, when scrolling until next cell comes into view, the hidden cell gets displayed again (i.e. hidden property changes to NO) and furthermore, when scrolling far enough the cell will never vanish again, when it shouldn't, no matter where you scroll to.
This changes when working with cells larger than triple-display-height (1441.f/1705.f). Those show the same behavior, but it stays the same, no matter how far they're being scrolled up and down.
What else?:
The situation can not be fixed by overriding -(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds to return YES.
The cells cannot being forced to display with setting the hidden property to NO programmatically after they were hidden (in didEndDisplayingCell for example)
So, whats the question?:
I'm pretty sure, that this is a bug in UICollectionView/Controller/Cell/Layout and I'll submit a TSI at Apple. But for the meantime: Has anyone any ideas for a quick hack solution?
i have a VERY dirty and internal solution for this problem:
#interface UICollectionView ()
- (CGRect)_visibleBounds;
#end
#interface MyCollectionView : UICollectionView
#end
#implementation MyCollectionView
- (CGRect)_visibleBounds {
CGRect rect = [super _visibleBounds];
rect.size.height = [self heightOfLargestVisibleCell];
return rect;
}
- (float)heightOfLargestVisibleCell {
// do your calculations for current max cellHeight and return it
return 1234;
}
#end
I have a workaround that seems to be working for me and should not run amok of Apple's rules for iOS applications.
The key is the observation that the large cells bounds are the issue. I've worked around that by ensuring that one edge of the cell is within the viewable area of the scrollable content region. You'll obviously need to subclass the UICollectionViewFlowLayout class or UICollectionViewLayout depending on your needs and make use of the contentOffset value to track where you are in the UIScrollView.
I also had to ensure:
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
returns YES or face a runtime exception indicating the layout was invalid. I keep the edge of the larger cell bound to the left edge in my case. This way you can avoid the erroneous bounds intersection detection for these larger cells.
This does create more work depending on how you would like the contents of the cell to be rendered as the width/height of the cell is being updated as you scroll. In my case, the subviews within the cell are relatively simple and do not require a lot of fiddling with.
As requested here is an example of my layoutAttributesInRect
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSMutableArray* attributes = [NSMutableArray array];
NSArray *vertical = myVerticalCellsStore.cells;
NSInteger startRow = floor(rect.origin.y * (vertical.count)/ (vertical.count * verticalViewHeight + verticalViewSpacing * 2));
startRow = (startRow < 0) ? 0 : startRow;
for (NSInteger i = startRow; i < vertical.count && (rect.origin.y + rect.size.height >= i * verticalViewHeight); i++) {
NSArray *horizontals = myHorizontalStore.horizontalCells;
UICollectionViewLayoutAttributes *verticalAttr = [self layoutAttributesForSupplementaryViewOfKind:#"vertical" atIndexPath:[NSIndexPath indexPathForItem:0 inSection:i]];
if (CGRectIntersectsRect(verticalAttr.frame, rect)) {
[attributes addObject:verticalAttr];
}
BOOL foundAnElement = NO;
for (NSInteger j = 0 ; j < horizontals.count; j++) {
MYViewLayoutAttributes *attr = (MyViewLayoutAttributes *)[self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:j inSection:i]];
if (CGRectIntersectsRect(rect, attr.frame)) {
[attributes addObject: attr];
foundAnElement = YES;
}
else if (foundAnElement) {
break;
}
}
}
return attributes;
}
This is my sanitized code. Basically I calculate about were the first cell should be based on the cell height. In my case that is fixed, so the calculation is pretty easy. But my horizontal elements have various widths. So the inner loop is really about figuring out the right number of horizontal cells to include in the attributes array. There I'm using the CGRectIntersectsRect to determine if the cell intersects. Then the loop keeps going until the intersection fails. And if at least one horizontal cell has been found the loop will break. Hope that helps.
My solution is basically the same as Jonathan's but in a category, so you don't have to use your own subclass.
#implementation UICollectionView (MTDFixDisappearingCellBug)
+ (void)load {
NSError *error = nil;
NSString *visibleBoundsSelector = [NSString stringWithFormat:#"%#isib%#unds", #"_v",#"leBo"];
if (![[self class] swizzleMethod:NSSelectorFromString(visibleBoundsSelector) withMethod:#selector(mtd_visibleBounds) error:&error]) {
FKLogErrorVariables(error);
}
}
- (CGRect)mtd_visibleBounds {
CGRect bounds = [self mtd_visibleBounds]; // swizzled, no infinite loop
MTDDiscussCollectionViewLayout *layout = [MTDDiscussCollectionViewLayout castedObjectOrNil:self.collectionViewLayout];
// Don`t ask me why, but there's a visual glitch when the collection view is scrolled to the top and the max height is too big,
// this fixes it
if (bounds.origin.y <= 0.f) {
return bounds;
}
bounds.size.height = MAX(bounds.size.height, layout.maxColumnHeight);
return bounds;
}
#end
I found that this issue only occurred when using a subclassed UICollectionViewLayoutAttributes and when that attribute class did not have a correct isEqual: method.
So for example:
#implementation COGridCollectionViewLayoutAttributes
- (id)copyWithZone:(NSZone *)zone
{
COGridCollectionViewLayoutAttributes *attributes = [super copyWithZone:zone];
attributes.isInEditMode = _isInEditMode;
return attributes;
}
- (BOOL)isEqual:(id)other {
if (other == self) {
return YES;
}
if (!other || ![[other class] isEqual:[self class]]) {
return NO;
}
if ([((COGridCollectionViewLayoutAttributes *) other) isInEditMode] != [self isInEditMode]) {
return NO;
}
return [super isEqual:other];
}
#end
Worked but originally I had:
return YES;
This is on iOS 7.
Im have a subclass of UIView, called PinView, which contains an image. Basically PinView gets added multiple times to my app, and then I perform a transform on PinView objects. The issue is that when I add a lot of PinViews, my app gets sluggish because I am transforming each PinView.
Ideally, I want to have one 'static' PinView that gets added to a UIView multiple times but i only have to transform it once. Currently this doesn't seem to work. Every time I add the static PinView to my UIView, it will only ever appear in the UIView once (due to it only being able to have one superview I'm guessing).
Im struggle to find out the best way to go about solving this problem - how do I use a single pointer to a PinView, add it multiple times to a UIView and be able to perform a transform on the PinView which gets passed on to PinViews displayed in my UIView? (by the way, the transform is always the same for all the PinViews).
Im assuming this will be the best way to get a performance improvement, if this is not the case please let me know.
UPDATE:
- (void)layoutSubviews
{
CGAffineTransform transform = CGAffineTransformMakeScale(1.0/self.zoomValue, 1.0/self.zoomValue);
NSMutableArray *mut = nil;
PinView *pinView = nil;
CallOutView *callOut = nil;
//get all dictionary entries
for(NSString *identifier in self.annotationsDict.allKeys){
//get the array from dictionary
mut = [(NSArray *)([self.annotationsDict objectForKey:identifier]) mutableCopy];
//change layout if not nil
if([[mut objectAtIndex:PIN] isKindOfClass:[PinView class]]){
pinView = ((PinView *)[mut objectAtIndex:PIN]);
pinView.transform = transform;
[mut replaceObjectAtIndex:PIN withObject:pinView];
}
if([[mut objectAtIndex:CALL_OUT] isKindOfClass:[CallOutView class]]){
callOut = ((CallOutView *)[mut objectAtIndex:CALL_OUT]);
callOut.transform = transform;
[mut replaceObjectAtIndex:CALL_OUT withObject:callOut];
if(pinView !=nil)callOut.center = CGPointMake(pinView.center.x, pinView.center.y - pinView.frame.size.height);
}
[self updateAnnotationsKey:identifier forObject:[NSArray arrayWithArray:mut]];
mut = nil;
pinView = nil;
callOut = nil;
}
}
UPDATE:
Removed the above and now just have:
- (void)layoutSubviews
{
CGAffineTransform transform = CGAffineTransformMakeScale(1.0/self.zoomValue, 1.0/self.zoomValue);
for(UIView *view in self.subviews){
view.transform = transform;
}
}
This can't be done I'm afraid. Each UIView instance can only be added to the screen once.
If all your views have similar transforms, you might have more luck using something like CAReplicatorLayer, which is a system for automatically creating duplicates of CALayers with different transforms.
That will only works if your views are all arranged in a grid or circle or something though. If they are just dotted randomly, it won't help.
If you are trying to draw more than 100 views, you're probably just bumping up against the fundamental performance ceiling of Core Animation on iOS.
The next approach to try would be to use OpenGL to draw your pins, perhaps using a library like Sparrow or Cocos2D to simplify drawing multiple transformed images with OpenGL (I'd recommend Sparrow as it integrates better with other UIKit controls - Cocos is more appropriate for games).
UPDATE:
This code is unnecessary:
mut = [(NSArray *)([self.annotationsDict objectForKey:identifier]) mutableCopy];
if([[mut objectAtIndex:PIN] isKindOfClass:[PinView class]]){
pinView = ((PinView *)[mut objectAtIndex:PIN]);
pinView.transform = transform;
[mut replaceObjectAtIndex:PIN withObject:pinView];
}
The code below is sufficient, because setting the transform doesn't modify the pointer to the object, so it will update the object in the array even if the array isn't mutable, and array objects are declared as 'id' so they don't need to be cast if you assign them to a variable of a known type.
mut = [self.annotationsDict objectForKey:identifier];
if([[mut objectAtIndex:PIN] isKindOfClass:[PinView class]]){
pinView = [mut objectAtIndex:PIN];
pinView.transform = transform;
}
I would also think you can remove the isKindOfClass: check if you only ever use those array indices for those object types. It may seem like a good precaution, but it carries a performance penalty if you're doing it over and over in a loop.
But for 10 views, I just wouldn't expect this to be that slow at all. Have you tried it without moving the callout centres. Does that perform better? If so, can you limit that to just the callouts that are currently visible, or move them using CGAffineTransformTranslate instead of setting the centre (which may be a bit quicker).
I have some in a touch handler which responds to a tap on a view that I've drawn some attributed text in. through this, I've got to the point where I have a CTRunRef (and the associated line) as well as the number of glyphs in that run.
What I'm not able to figure out easily, is how I can take that run of glyphs and, given my attributed string, map it out to characters in the string.
Specifically the problem is I would like to know what word the user tapped on in the view, so I can process whether or not that word is a URL and fire off a custom delegate method so I can open a web view with it. I have all the possible substrings, I just don't know how to map where the user tapped to a particular substring.
Any help would be greatly appreciated.
UPDATE: I've actually gone and done it a different way, on the suggestion of another person off of stackoverflow. Basically what I've done is to set a custom attribute, #"MyAppLinkAddress" with the value of the URL I found when I was converting the string to an attributed string. This happens before I draw the string. Therefore, when a tap event occurs, I just check if that attribute exists, and if so, call my delegate method, if not, just ignore it. It is working how I'd like now, but I'm going to leave this question open for a few more days, if someone can come up with an answer, I'll happily accept it if its a working solution so that some others may be able to find this information useful at some point in the future.
So as I mentioned in the update, I elected to go a different route. Instead I got the idea to use a custom attribute in the attributed string to specify my link, since I had it at creation time anyway. So I did that. Then in my touch handler, when a run is tapped, I check if that run has that attribute, and if so, call my delegate with it. From there I'm happily loading a webview with that URL.
EDIT: Below are snippets of code explaining what I did in this answer. Enjoy.
// When creating the attribute on your text store. Assumes you have the URL already.
// Filled in for convenience
NSRange urlRange = [tmpString rangeOfString:#"http://www.foo.com/"];
[self.textStore addAttribute:(NSString*)kCTForegroundColorAttributeName value:(id)[UIColor blueColor].CGColor range:urlRange];
[self.textStore addAttribute:#"CustomLinkAddress" value:urlString range:urlRange];
then...
// Touch handling code — Uses gesture recognizers, not old school touch handling.
// This is just a dump of code actually in use, read through it, ask questions if you
// don't understand it. I'll do my best to put it in context.
- (void)receivedTap:(UITapGestureRecognizer*)tapRecognizer
{
CGPoint point = [tapRecognizer locationInView:self];
if(CGRectContainsPoint(textRect, point))
{
CGContextRef context = UIGraphicsGetCurrentContext();
point.y = CGRectGetHeight(self.contentView.bounds) - kCellNameLabelHeight - point.y;
CFArrayRef lines = CTFrameGetLines(ctframe);
CFIndex lineCount = CFArrayGetCount(lines);
CGPoint origins[lineCount];
CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), origins);
for(CFIndex idx = 0; idx < lineCount; idx++)
{
CTLineRef line = CFArrayGetValueAtIndex(lines, idx);
CGRect lineBounds = CTLineGetImageBounds(line, context);
lineBounds.origin.y += origins[idx].y;
if(CGRectContainsPoint(lineBounds, point))
{
CFArrayRef runs = CTLineGetGlyphRuns(line);
for(CFIndex j = 0; j < CFArrayGetCount(runs); j++)
{
CTRunRef run = CFArrayGetValueAtIndex(runs, j);
NSDictionary* attributes = (NSDictionary*)CTRunGetAttributes(run);
NSString* urlString = [attributes objectForKey:#"CustomLinkAddress"];
if(urlString && ![urlString isEqualToString:#""])
{
[self.delegate didReceiveURL:[NSURL URLWithString:urlString]];
UIGraphicsPopContext();
return;
}
}
}
}
UIGraphicsPopContext();
}
}
After you find the tapped line, you can ask for the index in string by calling CTLineGetStringIndexForPosition(). There's no need to access individual runs.
I have a list of several hundred locations and only want to display an MKPinAnnotation for those locations currently on the screen. The screen starts with the user's current location within a 2-mile radius. Of course, the user can scroll, and zoom on the screen. Right now, I wait for a map update event, then loop through my location list, and check the coordinates like this:
-(void)mapViewDidFinishLoadingMap:(MKMapView *)mapView {
CGPoint point;
CLLocationCoordinate2D coordinate;
. . .
/* in location loop */
coordinate.latitude = [nextLocation getLatitude];
coordinate.longitude = [nextLocation getLongitude];
/* Determine if point is in view. Is there a better way then this? */
point = [mapView convertCoordinate:coordinate toPointToView:nil];
if( (point.x > 0) && (point.y>0) ) {
/* Add coordinate to array that is later added to mapView */
}
So I am asking to convert the coordinate where the point would be on the screen(unless I misunderstand this method which is very possible). If the coordinate isn't on the screen, then I never add it to the mapView.
So my question is, is this the correct way to determine if a location's lat/long would appear in the current view and should be added to the mapView? Or should I be doing this in a different way?
In your code, you should pass a view for the toPointToView: option. I gave it my mapView. You have to specify an upper bound for the x and y too.
Here's some code which worked for me (told me the currently visible annotations on my map, while looping through the annotation):
for (Shop *shop in self.shops) {
ShopAnnotation *ann = [ShopAnnotation annotationWithShop:shop];
[self.mapView addAnnotation:ann];
CGPoint annPoint = [self.mapView convertCoordinate:ann.coordinate
toPointToView:self.mapView];
if (annPoint.x > 0.0 && annPoint.y > 0.0 &&
annPoint.x < self.mapView.frame.size.width &&
annPoint.y < self.mapView.frame.size.height) {
NSLog(#"%# Coordinate: %f %f", ann.title, annPoint.x, annPoint.y);
}
}
I know this is an old thread, not sure what was available back then... But you should rather do:
// -- Your previous code and CLLocationCoordinate2D init --
MKMapRect visibleRect = [mapView visibleMapRect];
if(MKMapRectContainsPoint(visibleRect, MKMapPointForCoordinate(coordinate))) {
// Do your stuff
}
No need to convert back to the screen space.
Also I am not sure the reason why you are trying to do this, I think this is strange to not add annotations when they are not on the screen... MapKit already optimizes this and only creates (and recycles) annotation views that are visible.
After a little bit of reading I can't find anything that says this is a bad idea. I've done a bit of testing in my app and I always get correct results. The app loads much quicker when I only add coordinates that will show up in the currently visible map region instead of all the 300+ coordinates at once.
What I was looking for was a method like [mapView isCoordinateInVisibleRegion:myCoordinate], but so far this method is quick and seems accurate.
I've also changed the title to read "in the visible map region" instead of the previous because I think the incorrect title may have confused my meaning.