Cocoa in the Shell

Progressive image download with ImageIO

Ever get tired of the spinners or progress indicators to display the loading of an image ? Well, this post is for you, I will show how to display progressively an image while it’s downloading, like we can see in our web browsers.

Your browser does not support the video tag.

To do this we need to use the ImageIO framework which is available for Mac OS since 10.4 and for iOS since 4.0.
For iOS we need to link with QuartzCore.framework and ImageIO.framework, for Mac OS we will need ApplicationsServices.framework.

To illustrate this post, I’ll create a simple iPhone application with just an UIButton and an UIImageView.
Mac OS developers can easily adapt the code for their needs, in fact it will be easier on Mac OS because there is a little caveat on iOS that I’ll explain later.

So what really interests us is the CGImageSource object, in particular, the CGImageSourceCreateIncremental() function. As the name suggests, this function creates an empty CGImageSource on which we can add data by calling CGImageSourceUpdateData().
Once the source contains enough data, we can create a CGImage object by simply calling CGImageSourceCreateImageAtIndex().

First let’s create a NSOperation subclass to handle the image download with a delegate.

The init method looks like this :

-(id)initWithURL:(NSURL*)url
{
    if ((self = [super init]))
    {
        _imageSource = CGImageSourceCreateIncremental(NULL);

        _connection = nil;
        _dataTemp = nil;
        _delegate = nil;

        _url = [url copy];

        _fullWidth = -1.0f;
        _fullHeight = -1.0f;
    }
    return self;
}

__fullWidth and __fullHeight are the expected width and height of the downloaded image. Once we set the delegate (via a property), we add the operation to the queue and the start method is called.
In this method we simply start the asynchronous NSURLConnection. So the most important part is implementing the callbacks methods.

Let’s begin by implementing the first one :

-(void)connection:(NSURLConnection*)connection didReceiveResponse:(NSURLResponse*)response
{
    _expectedSize = [response expectedContentLength];
    _dataTemp = [[NSMutableData alloc] initWithCapacity:(_expectedSize != NSUIntegerMax) ? _expectedSize : 0];
}

Nothing complicated here, we look if we know the size of the data we will download, and init the NSMutableData object. It’s not a big deal if we don’t know the size of the image.

Then we can implement the fail and finish delegate methods that are pretty straightforward too :

-(void)connection:(NSURLConnection*)connection didFailWithError:(NSError*)error
{
    [_delegate downloadOperationFailedWithBytesCount:[_dataTemp length]];
    [self stopOperation];
}

The connection has failed, so we have nothing to do.

-(void)connectionDidFinishLoading:(NSURLConnection*)connection
{
    UIImage* img = [UIImage imageWithData:_dataTemp];
    [_delegate downloadOperationCompleted:img];
    [self stopOperation];
}

Here all the data have been downloaded, so we can create the image object and send it to the delegate.

The last delegate method is the most complicated to write, so let’s decompose it :

[_dataTemp appendData:data];
const NSUInteger totalSize = [_dataTemp length];
CGImageSourceUpdateData(_imageSource, (CFDataRef)_dataTemp, (totalSize == _expectedSize) ? true : false);

We append the data like we usually do, get the size of the data we have downloaded so far, and finally we update the image source, but we have to be careful because we must pass all the downloaded data so far, not just the small chunk we just added.

if (_fullHeight > 0 && _fullWidth > 0)
{
    CGImageRef image = CGImageSourceCreateImageAtIndex(_imageSource, 0, NULL);
    if (image)
    {
#ifdef __IPHONE_4_0 // iOS
            CGImageRef imgTmp = [self createTransitoryImage:image];
            if (imgTmp)
            {
                [_delegate downloadedImageUpdated:imgTmp];
                CGImageRelease(imgTmp);
            }
#else // Mac OS
            [_delegate downloadedImageUpdated:image];
#endif
            CGImageRelease(image);
    }
}
  • Then we check if we know the width and height of the image we are downloading.

  • If we know them we create a CGImage object.

  • The next step depends on the platform you are targeting. Like I said at the beginning it’s simpler on Mac OS, because we just tell the delegate that we have a new image object and that’s all.

On iOS it’s a bit more complicated, it appears that if the image we are downloading isn’t a PNG, when we will display it in the image view, it will be malformed, like on the picture below. I don’t know if it’s a bug of iOS yet, I intend to look further later.

So to fix this behaviour, we render the image in a bitmap context and send this one to the delegate, here is the method to do that :

-(CGImageRef)createTransitoryImage:(CGImageRef)partialImg
{
    const size_t height = CGImageGetHeight(partialImg);
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    CGContextRef bmContext = CGBitmapContextCreate(NULL, _fullWidth, _fullHeight, 8, _fullWidth * 4, colorSpace, kCGBitmapByteOrderDefault | kCGImageAlphaPremultipliedFirst);
    CGColorSpaceRelease(colorSpace);
    if (!bmContext)
    {
        NSLog(@"fail creating context");
        return NULL;
    }
    CGContextDrawImage(bmContext, (CGRect){.origin.x = 0.0f, .origin.y = 0.0f, .size.width = _fullWidth, .size.height = height}, partialImg);
    CGImageRef goodImageRef = CGBitmapContextCreateImage(bmContext);
    CGContextRelease(bmContext);
    return goodImageRef;
}

Well, that was probably the most difficult part ! The last piece of code is used to retrieve the size of the image :

else
{
    CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(_imageSource, 0, NULL);
    if (properties)
    {
        CFTypeRef val = CFDictionaryGetValue(properties, kCGImagePropertyPixelHeight);
        if (val)
            CFNumberGetValue(val, kCFNumberLongType, &_fullHeight);
        val = CFDictionaryGetValue(properties, kCGImagePropertyPixelWidth);
        if (val)
            CFNumberGetValue(val, kCFNumberLongType, &_fullWidth);
        CFRelease(properties);
    }
}

We simply copy the properties of the image and check if we can get the width and height, and that’s all, we are done ;)

Here is the sample project

Tags: ,