Zoom with a drawing view - objective-c

I'm looking for a solution in order to have a beautiful zoom on a drawing view.
In my app, I have a view with an other UIView (which is used like a drawing view) and when I draw a stroke on it, the stroke is perfect. But when I zoom the view, I have this really ugly effect (a pixelised stroke) :
(source: imagup.com)
url image
Is there a solution in order to have a proper stroke ?
My UIViewController has a hierarchy like that :
UIViewController
ScrollView
View zoomable (defined with the viewForZoomingInScrollView method)
Image view
Drawing view
Thanks a lot !
Regards,
Sébastien ;)

I'm in the process of making a vector drawing application and let me tell you, this is NOT a trivial task to do correctly and requires quite a bit of work.
Some issues to keep in mind:
If you are not using vector graphics (CGPaths, for example, are
vectors) you will NOT be able to remove the pixelation. A UIImage,
for example, only has so much resolution.
In order to get your drawing to not look pixelated, you are going to
have to redraw everything. If you have a lot of drawing, this can be
an expensive task to perform.
Having good resolution WHILE zooming is nearly impossible because it would require an excessively large context and your drawing would likely exceed the capabilities of the device
I use core graphics to do my drawing, so the way I solved this issue was by allocating and managing multiple CGContexts and using them as buffers. I have one context that is ALWAYS kept at my least zoomed level (scale factor of 1). That context is drawn into at all times and makes it so that when unzooming completely, no time is spent redrawing since it is already done. Another context is used soley for drawing when zoomed. When not zoomed, that context is ignored (since it will have to be redrawn based on the new zoom level anyway). A high level algorithm for how I perform my zooming is as follows:
- (IBAction)handlePinchGesture:(UIGestureRecognizer *)sender
{
if(sender.state == UIGestureRecognizerStateBegan)
{
//draw an image from the unzoomedContext into my current view
//set the scale transformation of my current view to be equal to "currentZoom", a property of the view that keeps track of the actual zoom level
}
else if(sender.state == UIGestureRecognizerStateChanged)
{
//determine the new zoom level and transform the current view, keeping track in the currentZoom property
//zooming will be pixelated.
}
else if(sender.state == UIGestureRecognizerStateEnded || sender.state == UIGestureRecognizerStateCancelled)
{
if(currentZoom == 1.0)
{
//you are done because the unzoomedContext image is already drawn into the view!
}
else
{
//you are zoomed in and will have to do special drawing
//perform drawing into your zoomedContext
//scale the zoomedContext
//set the scale of your current view to be equal to 1.0
//draw the zoomedContext into the current view. It will not be pixelated!
//any drawing done while zoomed needs to be "scaled" based on your current zoom and translation amounts and drawn into both contexts
}
}
}
This gets even more complicated for me because I have additional buffers for the buffers because drawing images of my paths is much faster than drawing paths when there is lots of drawing.
Between managing multiple contexts, tweaking your code to draw efficiently into multiple contexts, following proper OOD, scaling new drawing based on your current zoom and translation, etc, this is a mountain of a task. Hopefully this either motivates you and puts you on the right track, or you decide that getting rid of that pixelation isn't worth the effort :)

I had the same problem and found a solution: tell the view to use a CATiledLayer as backing layer, then tell the view how many levels of zoom it supports. This worked for me, my drawing methods get automatically called when the (parent) view is zoomed.
A short explanation of levelsOfDetail and levelsOfDetailBias:
levelsOfDetail determine how many zooming levels there are in total
levelsOfDetailBias determine how many of those are zooming in.
So in my example I have 4 zooming levels, 3 are zoomed in and 1 is the non-zoomed level, meaning my view only redraws when zooming in.
#imprementation MyZoomableView
+ (Class)layerClass
{
return [CATiledLayer class];
}
- (id)initWithFrame:(CGRect)frame
{
if ((self = [super initWithFrame:frame])) {
((CATiledLayer *)self.layer).levelsOfDetail = 4;
((CATiledLayer *)self.layer).levelsOfDetailBias = 3;
}
return self;
}
#end

Use [self setContentScaleFactor:scale]; in your scrollViewDidEndZooming: delegate method.

Related

Overriding setTransform for UIView breaks drawing on the UIView

I have a view (it's the view that is zoomed in a scrollview) that I am trying to make only stretch in the x direction when the user pinches or double-taps to zoom. This view is being constantly drawn on: up to 10 times per second, using Core Graphics to draw a graph.
So I override setTransform like so:
- (void)setTransform:(CGAffineTransform)newValue;
{
CGAffineTransform constrainedTransform = CGAffineTransformIdentity;
constrainedTransform.a = newValue.a;
[super setTransform:constrainedTransform];
}
And this generally gives me the behavior I want, except for one big problem. When the view is being drawn on very often and the user double taps to zoom in, the whole view will often just go black. This happens very rarely if I don't override the above method (although it still does happen once in a while). Also interesting is that when the user zooms using a pinch gesture, this does not happen. This is the function triggered by the double tap:
- (void)handleDoubleTap:(UIGestureRecognizer *)gestureRecognizer
{
CGRect frame = [[self tiledScrollView] frame];
float newScale = [[self tiledScrollView] zoomScale] * kZoomStep;
CGRect zoomRect = [self zoomRectForFrame:frame withScale:newScale withCenter:[gestureRecognizer locationInView:[[self tiledScrollView] tileContainerView]]];
[[self tiledScrollView] zoomToRect:zoomRect animated:YES];
}
And the pinch gestures are just handled by UIScrollView's stock pinch recognizer. One thing that does bother me about the above function is that zoomRect is scaled in both the x and y directions even though my view only scales in one direction. I have tried scaling zoomRect in only the x direction and then calling zoomToRect, but then the graph won't zoom.
So it seems that UIScrollView's pinch recognizer and my double tap recognizer scale the view in two different ways, and only the pinch recognizer's way works with my code... Also, this problem becomes very rare when the drawing rate is slowed, and nonexistent when there is no drawing going on. I've tried using queues to make drawing and zooming sequential but I haven't had much luck with that. I suspect that zoomToRect and other zooming methods may dispatch tasks off to other threads or whatever so I don't think they can be sequentialized that way. But is this something I should look into more?
Any help would be greatly appreciated. I've wasted days trying to fix this already -_- Thanks

UIScrollView - Custom Map - Prevent marker subview on map from scaling with map

I have a custom map of a limited area, and have it set up to correctly show the users' location. The map is a 1600px square image within a UIScrollView.
I have a crosshair image to show the current location of the user, which at zoomScale 1.0 is the desired size. When I pinch and zoom the scrollView, the crosshair scales with it. I would like to have the subview remain the same size on screen.
I haven't been able to find any information on this, what would be the best way to go about this?
If there is anything I can provide you with to help the answer, please let me know.
Many thanks!
EDIT -
Having looked in to this further, there is a UIScrollViewDelegate method - (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(float)scale which I tried using to take the marker's current center and size, then adjust, but this only scales at the end of the zoom. I would prefer to have the marker remain the same size while the user is zooming.
EDIT 2-
Cake has provided a great answer below, but I haven't been able to implement this in the way I imagined it would be.
I have the UIImageView as a placeholder, with alpha set to 0. This placeholder moves around relative to the map to show the user location. This operates as I expect it to. Unfortunately, this resizes with the map, as it is a subview of the map (so it stays in place).
Taking Cake's below answer, I have created the non-scaling crosshair image, and added it as a sibling subview to the scrollview. The maths, once Cake had pointed them out, were quite simple to get the new frame for the crosshair:
CGPoint ULPC = userLocationPlaceholder.center;
float zs = scrollView.zoomScale;
CGRect newFrame = CGRectMake(((ULPC.x * zs) - scrollView.contentOffset.x) - 20, ((ULPC.y * zs) - scrollView.contentOffset.y) - 20, 40, 40);
Where the image is 40points wide. This matches the centers perfectly.
The problem I now have is that I cannot get the crosshair image to stay locked to the placeholder.
I have tried using a self calling animation as such:
-(void)animeUserLocationAttachment
{
[UIView animateWithDuration:0.05
delay:0
options:(UIViewAnimationOptionAllowUserInteraction | UIViewAnimationOptionCurveLinear )
animations:^{
userLocationDotContainer.frame = newFrame;
} completion:^(BOOL finished){
// Call self
[self animateUserLocationAttachment];
}];
}
As soon as I start scrolling/zooming, this locks the animation so that the crosshair just sits in place until I release the scrolling/zooming, then it correctly updates it's location.
Is there any way I can get around this, or an alternative method I can apply?
Many thanks
EDIT 3 -
I've re-accepted Cake's answer as it covers 90% of the issue. Further to his answer I have implemented the ScrollViewDelegate methods scrollViewWillBeginDragging: andscrollViewWillBeginDecelerating: to scale the placeholder to match the current size of the crosshair relative to the map, show the placeholder (that is a subview of the map image) and hide the crosshair image. The delegate method scrollviewWillBeginZooming:withView: does not show the placeholder because it scales with the map. As Cake recommends, I'll make a new question for this issue.
The counterpart methods (scrollViewDidEndZooming:withView:atScale:, scrollViewDidEndDragging:willDecelerate: and -scrollViewDidEndDecelerating:`) all hide the placeholder, and re-show the crosshair.
The question is old but for the future similar questions I've recently resolved a similar problem applying the hint of Andrew Madsen of another post.
I'had a UIScrollView, with an UIImageView in it. Attached to the UIImageView I had many MKAnnotationView (those are my subviews that I didn't want scaling with the superview).
I did subclass UIImageView and implement setTransform: method like here:
#import "SLImageView.h"
#implementation SLImageView
- (void)setTransform:(CGAffineTransform)transform
{
[super setTransform:transform];
CGAffineTransform invertedTransform = CGAffineTransformInvert(transform);
for (id obj in self.subviews)
{
if ([obj isKindOfClass:[MKAnnotationView class]])
{
[((UIView *)obj) setTransform:invertedTransform];
}
}
}
#end
This works perfectly!
Mick.
Create another crosshair image that's associated with the view or view controller that contains the scrollview. Then have this one always snap to the center of the crosshair image you already have. Then, hide your original crosshair image. Then you can avoid having the scrollview scale the disassociated crosshair, and it should stay the same size.
Relative coordinate systems
Each view in cocoa touch has a frame property that has an origin. In order to position an object owned by one view properly relative to another view, all you have to do is figure out the differences in their origins. If one view is a subview of another, then this isn't too difficult.
Get the origin of the container view
Get the location of the subview inside of the container view
Get the origin of the subview
Calculate the difference in the positions of the origins
Get the location of the object you want to overlap (relative to the subview)
Calculate the location of the object you want to overlap relative to the container view
Move your crosshair to this position
Swift equivalent for Mick's answer:
class MapContainerView:UIView {
#IBOutlet var nonScalingViews: [UIView]!
override var transform: CGAffineTransform {
didSet {
guard let nonScalingViews = nonScalingViews else {
return
}
let invertedTransform = CGAffineTransformInvert(transform)
for view in nonScalingViews {
view.transform = invertedTransform
}
}
}
}

CATiledLayer being removed and refreshed while zoomed in iPad 3rd gen

I'm experiencing a redraw problem on a CATiledLayer when the parent UIScrollView is zoomed in.
I'm rendering a PDF page in a CATiledLayer backed UIView. It has another UIImageView behind it, which contains a low-res image of the page that the CATiledLayer will draw. When I zoom in, it works as expected. The CATiledLayer will render a higher resolution image according to the zoom level.
The problem occurs after zooming. If I zoom in then just leave the iPad alone, the displayed image blurs then resharpens. It looks like the CATiledLayer is being removed, since I see the blurry low resolution image in the backing view, then the CATiledLayer gets redrawn, i.e. I see the tiling effect and the resharpening of the image. This happens if I just leave the app alone and wait about 30 to 40 seconds. I've only observed it on the iPad 3rd gen (New iPad, iPad3, whatever). I'm also testing on iPad2s and I have yet to encounter the issue.
Has anyone else encountered this problem? Any known cause and, possibly, solutions?
Edit:
My UIScrollViewDelegate methods are as follows:
// currentPage, previousPage, and nextPage are the pdf page views
// that are having the refresh problem
- (void)positionBufferedPages {
// performs math {code omitted}
// then sets the center of the views
[previousPage.view setCenter:...];
[nextPage.view setCenter:...];
}
- (void)hideBufferedPages {
if ([previousPage.view isDescendantOfView:scrollView]) {
[previousPage.view removeFromSuperview];
}
if ([nextPage.view isDescendantOfView:scrollView]) {
[nextPage.view removeFromSuperview];
}
}
- (void)showBufferedPages {
if (![previousPage.view isDescendantOfView:scrollView]) {
[scrollView addSubview:previousPage.view];
}
if (![nextPage.view isDescendantOfView:scrollView]) {
[scrollView addSubview:nextPage.view];
}
if (![currentPage.view isDescendantOfView:scrollView]) {
[scrollView addSubview:currentPage.view];
}
}
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
return currentPage.view;
}
- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view {
[self hideBufferedPages];
}
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollViewParam withView:(UIView *)view atScale:(float)scale {
[self positionBufferedPages];
[self showBufferedPages];
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView {
// nothing relating to the pdf page view
// but does set the center of some other subviews on top of the pdf page views
}
Not sure how helpful this will be though, as the scrollview is not receiving input while the problem happens. As mentioned above, the pdf page is loaded to the CATiledLayer, then I leave the iPad alone (no input is received by the device), and the CATiledLayer will redraw by itself.
I've also tried catching calls to setNeedsDisplay, setNeedsDisplayInRect:, setNeedsLayout, and setNeedsDisplayOnBoundsChange: on both the view and the tiled layer, but the redraw happens without any of those functions getting called. drawLayer:inContext: gets called, of course, but the trace only shows some Quartz calls being started in a background thread (as expected, as tiled layers prepare the content in the background), so it is of no help either.
Thanks in advance!
How is your app's memory usage looking? CATiledLayer will discard its cache and redraw if a memory warning occurs. I've seen it do this even without memory warnings being sent to the app (just a higher than usual memory load). Use Instruments to see memory usage. You may need to use the OpenGL ES Driver instrument to see what's going on with graphics memory.
I spoke with an Apple engineer about this and the short answer is that iOS only has X amount of memory available for caching a CATiledLayer and on the Retina display of the iPad, there are just too many pixels to use more than one layer.
I had been using two CATileLayers to display a map view and a drawing view on top. I removed the second CATiledLayer and the problem went away.
I've had the exact same problem. In my case it was caused by using the UIGraphicsBeginImageContext() function; this function does not take scale into account, which gives problems on a retina display. The solution was to replace the call with UIGraphicsBeginImageContextWithOptions(), with the scale (=third) parameter set to 0.0.
If you based your code on the Zooming PDF Viewer sample code from Apple, like I did, chances are this will solve your problem too.
Longshot, any chance you are calling any methods on the views from the non-main thread? All sorts of unexpected funky stuff can happen if you do.

iOS OpenGL layer and UIScrollView

I'm creating a drawing app on the iPad where the user can draw and scroll through the drawing. (Think of a canvas 4000 pixels wide with a view port width of 1024) At the moment, I'm using OpenGL for the drawing, and with a width of 1024, it works great. When I change the frame size of the UIView to 4000, I get "failed to make complete framebuffer object 8cd6". When I reduced it to a width of 2000, I ended up getting "wacky" results. I know I can manipulate the frame correctly, as having a frame width of 500 creates the correct result.
I was also thinking of leaving the width 1024 and moving the camera of the OpenGL layer, but how would that work with the UIScrollView that I setup? So I'm unsure on what to do at the moment. Any advice?
Thanks in advance
P.S. The code is largely based of GLPaint sample Apple provides here
I think you'd be best off with the scheme you suggest towards the end — keeping the OpenGL view static and outside of the scroll view, and moving its contents so as to match the movement of the scroll view.
Assuming you're using a GLKView, implement your glkView:drawInRect: so that it gets the contentOffset (and, probably, the bounds) properties from your scroll view and draws appropriately. E.g. (pretending you're using GLES 1.0 just because the matrix manipulation methods are so well known):
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// displayArea will be the area the scroll view is
// currently displaying; taking just the bounds would
// likely be fine too
CGRect displayArea;
displayArea.origin = scrollView.contentOffset;
displayArea.size = scrollView.bounds.size;
// assuming (0, 0) in the GL view is in the centre,
// we'll adjust things so that it's in the corner ala
// UIKit
CGPoint centre = CGPointMake(
displayArea.origin.x + displayArea.size.width*0.5f,
displayArea.origin.y + displayArea.size.height*0.5f);
glPushMatrix();
// apply the scroll as per the scroll view
// so that its centre is aligned with our centre
glTranslatef(-centre.x, -centre.y, -1);
/* rest of drawing here */
glPopMatrix();
}
Then connect yourself as a delegate to the scrollview and just perform:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
[glView setNeedsDisplay];
}
To ensure the GL redraws whenever the scroll view is scrolled.
I would strongly recommend to do your zooming in panning with your OpenGL camera instead of trying to use it in a UIScrollView. It will be a little more work to set up, but ultimately the way to go IMHO.

Using NSImageView to display multiple images in quick sucession

I have an application where, in one window, there is an NSImageView. The user should be able to drag and drop ANY FILE/FOLDER (not only images) into the image view, so I subclassed NSImageView class to add support for those types.
The reason why I chose an NSImageView instead of a normal view is because I also wanted to display an animation (say an arrow pointing downwards and going up and down) when the user hovers over with files ready to drop. My question is this: what would be the best way (most efficient, quickest, least CPU usage, etc) to do this?
In fact, I have already done it, but what made me ask this question is the fact that when I set the images to change at a rate below 0.02 sec it starts to lag. Here is how I did it:
In the NSImageView subclass:
have an ivar: NSTimer* animTimer;
override awakeFromNib, calling [super awakeFromNib] and loading the images into an array (about 45 images) using NSImage
whenever user enters with files, start animTimer with frequency = 0.025 (less and it lags), and a selector that sets the next image in the array (called drawNextImage)
whenever the user exits or ends the drag and drop, call [animTimer invalidate] to stop updating images
Here is how I set the image in the subclass:
- (void)drawNextImage
{
currentImageIndex++; // ivar / kNumberDNDImages is a constant defined as 46
if (currentImageIndex >= kNumberDNDImages) { currentImageIndex = 0;}
[super setImage: [imagesArray objectAtIndex: currentImageIndex]]; // imagesArray is ivar
}
So, how would I do this quick enough? I'd like the frequency to be about 0.01 secs, but less than 0.025 lags, so that is what I have set for the moment. Oh, and my images are the correct size (+ or - one pixel or something) and they are in .png (I need the transparency - jpegs, for example, won't do it).
EDIT:
I have tried to follow NSResponder's suggestion, and have updated my method to this:
- (void)drawNextImage
{
currentImageIndex++;
if (currentImageIndex >= kNumberDNDImages) { currentImageIndex = 0;}
NSRect smallImgRect;
smallImgRect.origin = NSMakePoint(kSmallImageWidth * currentImageIndex, [self.bigDNDImage size].height); // Up left corner - ??
smallImgRect.size = NSMakeSize(kSmallImageWidth, [self.bigDNDImage size].height);
// Bottom left corner - ??
NSPoint imgPoint = NSMakePoint(([self bounds].size.width - kSmallImageWidth) / 2, 0);
[bigDNDImage drawAtPoint: imgPoint fromRect: smallImgRect operation: NSCompositeCopy fraction: 1];
}
I have also moved this method and the other drag and drop methods from the NSImageView subclass to an NSView subclass I already had. Everything is exactly the same, except for the superclass and this method. I also modified some constants.
In my early testing of this, I got some error/warning messages that didn't stop execution talking abou NSGraphicsContext or something. These have disappeared now, but just so you know. I have absolutely no idea why they were appearing and what they mean. If they ever appear again I'll worry about them, not now :)
EDIT 2:
This is what I'm doing now:
- (void)drawNextImage
{
currentImageIndex++;
if (currentImageIndex >= kNumberDNDImages) { currentImageIndex = 0;}
[self drawCurrentImage];
}
- (void)drawCurrentImage
{
NSRect smallImgRect;
smallImgRect.origin = NSMakePoint(kSmallImageWidth * currentImageIndex, 0); // Bottom left, for sure
smallImgRect.size = NSMakeSize(kSmallImageWidth, [self.bigDNDImage size].height);
// Bottom left as well
NSPoint imgPoint = NSMakePoint(([self bounds].size.width - kSmallImageWidth) / 2, 0);
[bigDNDImage drawAtPoint: imgPoint fromRect: smallImgRect operation: NSCompositeCopy fraction: 1];
}
And the catch here is to call drawCurrentImage when drawRect is called (see, it actually was easier to solve than I thought).
Now, I must say I haven't tried this with the composite image, because I couldn't find a good and quick way to merge 40+ images the way I wanted (one next to the other). But for the ones ineterested, I modified this to do the same thing as my NSImageView subclass (reading 40+ images from an array and displaying them) and I found no speed bump: NSView is as laggy below 0.025 as NSImageView. Also I found some problems when using core animation (the image is drawn in weird places instead of the place I tell her to) and some warnings talking about NSGraphicsContext, which I don't know how to solve at all (I'm a complete noob when it comes to drawing and such with Objective-C's tools). So for the time being I'm using NSImageView, unless I find a way to merge all those images and try it with NSView.
Core Animation would probably be quickest, since it'll do everything on the GPU. Create a layer for each image, setting each layer's contents to the CGImage you can make from each image, add them all as sublayers of a single top-level layer, host the top-level layer in a plain NSView, and then just toggle each image layer's hidden property in turn.
I'd probably draw all of the component images into one long image, and draw segments into a view using -drawAtPoint:fromRect:operation:fraction:. I'm sure you could make it faster than that by resorting to OpenGL, though.