Extending the PageControl sample to UN-load pages

There is a good (altough somewhat dated) Apple sample application which demonstrates lazy-loading pages in a paginated scroll view, called PageControl.

As it turns out, this sample is a good starting point, but I think it definitely wants some work before it's ready to use in any significant way.

Some problems with its current state:

The Basics

So how does it work? Basically, PageControl instantiates an array of empty pages, using [NSNull null] as a placeholder for each page, to be later replaced with a page's contents. The scroll view is then loaded up with a particular page from this array, using [scrollView addSubview:].

On awakeFromNib, after setting everything up, it loads the first two pages. The idea is that the page a user is on and the two pages around it will always be loaded, so that scrolling left or right reveals an already-loaded page which does not appear blank.

Implement the Changes

First things first: make sure we're not nesting views owned by different ViewControllers.

Pages get lazy-loaded with the method - (void)loadScrollViewWithPage:(int)page.

Here is the relevant piece of loadScrollViewWithPage code:

// replace the placeholder if necessary
MyViewController *controller = [viewControllers objectAtIndex:page];
if ((NSNull *)controller == [NSNull null])
{
    controller = [[MyViewController alloc] initWithPageNumber:page];
    [viewControllers replaceObjectAtIndex:page withObject:controller];
    [controller release];
}

As you can see from the code, viewControllers is an array holding two types of pages: instances of MyViewController and instances of [NSNull null].

To see if a page is null, we first create a new MyViewController object, returning whatever is the result of calling [viewControllers objectAtIndex:page] -- which will give us either the NSNull or the MyViewController for the page we want.

To check if the page needs to be loaded, we cast our new object as an (NSNull*) and check it against [NSNull null]. If this returns YES, we go ahead and create (lazy-load) the new page.

Here's how I decided to change this:

The following assumes you've got a root ViewController that holds a UIScrollView you want to load pages into.

This part will differ slightly depending on what you need. But instead of adding views belonging to ViewControllers to the scroll view as in the sample, you're going to want to somehow construct a UIView in whatever custom way you want.

I've done this two different ways, one using a XIB and one without. In both cases, I created a new Objective-C class inheriting from NSObject. Most importantly, this class should have the following in its interface:

@interface MyObj : NSObject {
    UIView *view;
}
@property (strong, nonatomic) UIView *imageView;
@end

If you plan to use a XIB file, the @property should be an IBOutlet.

The .m file should contain the method:

- (UIView *)view {
    return view;
}

Now to create the XIB and connect it to your new class. Cmd+N > UserInterface > View > Next, select iPhone or iPad, and give it a name. Now click on the .xib file, click on 'File's Owner,' and go to the Identity inspector. Open the 'Class' dropdown and change it to "MyObj", or whatever you called the class you just made.

Now connect up the XIB to its outlets in the .h file. With the XIB file still open, click the Assistant Editor button (the one at the top right above Editor - it looks like a suit & bowtie.) You should now see your XIB file and your MyObj class side-by-side. While holding the CTRL key, click on the XIB's View and drag to the UIView @property in your MyObj class.

Here's the idea: you now have a UIView that you can manipulate and add things to, without all the UIViewController overhead, without methods like viewDidLoad and viewWillAppear and all of that, which you won't need, since all you're interested is adding the UIView itself to your scroll view.

So you can go ahead and build up the XIB like you want, adding subviews to the View, and any other elements and logic you think you might need.

With these changes, your - (void)loadScrollViewWithPage:(int)page method should now look something like this (notice that I've changed the name of the viewControllers array to 'pages', and 'controller' to 'thisPage'):

// replace the placeholder if necessary
MyObj *thisPage = [pages objectAtIndex:page];
if ((NSNull *)page == [NSNull null])
{
    page = [[MyPage alloc] initWithPageNumber:page];
    [pages replaceObjectAtIndex:page withObject:thisPage];
}

// add the controller's view to the scroll view
if (thisPage.view.superview == nil)
{
    CGRect frame = scrollView.frame;
    frame.origin.x = frame.size.width * page;
    frame.origin.y = 0;
    thisPage.view.frame = frame;
    [scrollView addSubview:thisPage.view];
}

I've also taken out the call to release, since of course we're using ARC.

NOTE: This assumes that you have an init method on your MyObj class, as Apple's example has on its UIViewController subclass, named initWithPageNumber:(int)page. How you choose to fill in your pages is largely up to you, but you should create a method which takes an integer of the page number as its parameter, and fills out MyObj based on that. You can look at PageControl's initWithPageNumber implementation, their solution should still work well.

In PageControl, after they add the new page's view to the scrollView, they use the setters on their instance of MyPageControl to set the page's specific content right in loadScrollViewWithPage.

This is the bit where they do it:

    NSDictionary *numberItem = [self.contentList objectAtIndex:page];
    controller.numberImage.image = [UIImage imageNamed:[numberItem valueForKey:ImageKey]];
    controller.numberTitle.text = [numberItem valueForKey:NameKey];

And it could look the same in whatever your implementation is. All you need to do is use the generated setters for whatever properties you've added to MyObj.

That should be pretty much it for cleaning up the implementation when you're loading views into a scroll view that is owned by a ViewController. You'll still use the lazy-loading method - (void)loadScrollViewWithPage:(int)page for any specific page you want to lazy-load.

Tripping A Lazy-Load

But when do you call the method loadScrollViewWithPage?

The way the PageControl sample does it is a good start: right in awakeFromNib (or for us, since we're using a ViewController, in viewDidLoad or viewWillAppear), they explicitly call:

[self loadScrollViewWithPage:0];
[self loadScrollViewWithPage:1];

This loads the first visible pages, and is a good way to go.

From here on out, loadScrollViewWithPage only gets called in one other place: in scrollViewDidScroll.

Which brings us to our second problem: loading pages halfway through a scroll.

As mentioned earlier, this is bad because with a page of any significant size and complexity, a noticable mid-scroll hang-up will occur; the page simply freezes halfway through its scroll. It is definitely an unwanted glitch in the user experience.

I found that I could solve this very simply: by triggering the lazy-load at full width increments of the page, rather than half-width increments.

My original thought had been to do the lazy-load in scrollViewDidEndDecelerating, but this is not ideal because, of course, a user can scroll past multiple pages without that method getting called, since it requires the scroll view to come to a complete rest.

So the following code will trip a lazy-load only at whole page increments:

CGFloat pageWidth = scrollView.frame.size.width;
if (page != floor((scrollView.contentOffset.x - pageWidth) / pageWidth) + 1) {
    page = floor((scrollView.contentOffset.x - pageWidth) / pageWidth) + 1;
    [self loadScrollViewWithPage:page - 1];
    [self loadScrollViewWithPage:page];
    [self loadScrollViewWithPage:page + 1];
}

In my experiece this simple change is enough to get smooth performance even while loading large images into a page, targeted to an iPhone 3Gs and iPad 1 performance benchmark.

Lazy Un-Loading

Finally, what I promised from the beginning, the un-loading.

My original and failed attempt was to create a method called unloadScrollViewWithPage:, and pass in page - 2 and page + 2 in order to unload the pages around the three active ones.

This worked okay until I implemented device rotation. When the device is rotatedand the scrollView's contentOffset is changed to fill the new width, scrollViewDidSroll gets called, so you need to make sure you account for that. With unloadScrollViewWithPage:page - 2...:page+2, I was getting really whacky results after rotations; pages were randomly appearing and dissappearing and re-ordering themselves.

Rather than fix this in the rotation event, I figured out a way to solve it in the page-unloading method, which was to change it to:

unloadIrrelevantPages,

which, rather than specify the pages to unload, specifies the pages not to unload, and unloads all the others. It takes no parameters and blasts any pages EXCEPT for the current page and the two pages on either side of it.

First, you need a currentPage ivar with getters and setters on your ViewController. In scrollViewDidScroll, set this variable instead of creating a local variable like they do in PageControl (they call it int page = floor...).

Your unloadIrrelevantPages method could look like this:

-(void) unloadIrrelevantPages {

    //Loop through all the pages in the pages array:
    for (int i = 0; i < pages.count; i++) {

        //Check to see if it's a page that should stay alive:
        if (i != currentPage && i!=currentPage+1 && i!= currentPage-1) {

            //if not, remove it:
            MyObj *pageToRemove = [pages objectAtIndex:i];
            if ((NSNull *)pageToRemove != [NSNull null]) {
                NSLog(@"removing page %d", i);
                [pageToRemove removeFromSuperview];
                NSNull *emptyPage = [NSNull null];
                [pages replaceObjectAtIndex:i withObject:emptyPage];
            }
        }
    }    
}

Now it doesn't matter if scrollViewDidScroll is called on a rotate, because it will always cause currentPage to be freshly calculated, and it will only unload pages that are not currentPage +/- 1!

I hope this helps someone who is trying to solve the same problem I was. This'll do as a first draft of this article, I hope to come back and clean it up later.

Comments:

rss

blog powered by Pelican