Tuesday 12 April 2011

Custom Scrollview


Multiple virtual pages in a UIScrollView with just 2 views

I'm presenting the following application which looks like this:

 

  The black region is a UIScrollView with pagingEnabled set to YES. The dots at the bottom are a UIPageControl indicating the number of pages in the UIScrollView.
Apple's sample application "PageControl" presents a similar user interface that loads a different UIViewController and UIView for each of the pages in the UIScrollView. I'll show you how you can create the same effect using exactly two child UIViewControllers and UIViews — a "current" and the "next" view which leap around out-of-view to fill in each new page as the scrolling reaches it.

Moving child views to maintain the illusion

The trick in this post is handling an arbitrary array of child pages using just two views. It will work as follows:
  1. Initially, the displayed view will be the currentPage view. The next view will be configured to display the cached data for Page 1
  2. As the user begins scrolling to the right, the nextPage is quickly moved into the location for the next page in the scrolling direction. At the same time as it is positioned, nextPage is configured with the data for Page 2. Neither of these configuration changes will be visible to the user.
  3. As the scroll operation ends, the pointers for currentPage and nextPage are swapped so that currentPage now points to the view displaying Page 2 and nextPage now points to Page 1
  4. If the next scroll is to the left, nextPage is already configured and in position. If the next scroll is to the right, nextPage will be moved and configured as it was during the first scroll.
The code that chooses how to move the views as the scroll view scrolls (step 2 in the above description) looks like this:



- (void)scrollViewDidScroll:(UIScrollView *)sender
{
    CGFloat pageWidth = scrollView.frame.size.width;
    float fractionalPage = scrollView.contentOffset.x / pageWidth;
    
    NSInteger lowerNumber = floor(fractionalPage);
    NSInteger upperNumber = lowerNumber + 1;
    
    if (lowerNumber == currentPage.pageIndex)
    {
        if (upperNumber != nextPage.pageIndex)
        {
            [self applyNewIndex:upperNumber pageController:nextPage];
        }
    }
    else if (upperNumber == currentPage.pageIndex)
    {
        if (lowerNumber != nextPage.pageIndex)
        {
            [self applyNewIndex:lowerNumber pageController:nextPage];
        }
    }
 

I've left off the final condition for size but it is a rarely invoked path for when very fast scrolling leaves the currentPage out of position and it needs to configure both pages.
The exchange of pointers at the end of scrolling (step 3 in the above description) is handled in the scrollViewDidEndScrollingAnimation: method:



- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)newScrollView
{
    CGFloat pageWidth = scrollView.frame.size.width;
    float fractionalPage = scrollView.contentOffset.x / pageWidth;
    NSInteger nearestNumber = lround(fractionalPage);

    if (currentPage.pageIndex != nearestNumber)
    {
        PageViewController *swapController = currentPage;
        currentPage = nextPage;
        nextPage = swapController;
    }

    pageControl.currentPage = currentPage.pageIndex;
}
 


The only remaining component to reveal is exactly how the pages are positioned and configured in applyNewIndex:pageController:


- (void)applyNewIndex:(NSInteger)newIndex pageController:
(PageViewController *)pageController
{
    NSInteger pageCount = 
[[DataSource sharedDataSource] numDataPages];
    BOOL outOfBounds = newIndex >= pageCount || newIndex < 0;

    if (!outOfBounds)
    {
        CGRect pageFrame = pageController.view.frame;
        pageFrame.origin.y = 0;
        pageFrame.origin.x = scrollView.frame.size.width * newIndex;
        pageController.view.frame = pageFrame;
    }
    else
    {
        CGRect pageFrame = pageController.view.frame;
        pageFrame.origin.y = scrollView.frame.size.height;
        pageController.view.frame = pageFrame;
    }

    pageController.pageIndex = newIndex;
}

You can see here that if a page is given an out-of-bounds index, it is placed below the bottom of the scroll view (invisible). I chose not to use setHidden: on the view because this resulted in "pop-in" when making the view visible again.
The actual configuration of the view for the new page index happens in the setter method for pageController.pageIndex. This method fetches the data for the page out of the DataSource and configures the view for displaying that page.



- (void)setPageIndex:(NSInteger)newPageIndex
{
    pageIndex = newPageIndex;
    
    if (pageIndex >= 0 &&
        pageIndex < [[DataSource sharedDataSource] numDataPages])
    {
        NSDictionary *pageData =
            [[DataSource sharedDataSource] dataForPage:pageIndex];
        label.text = [pageData objectForKey:@"pageName"];
        textView.text = [pageData objectForKey:@"pageText"];
    }
}


Notice that I've made this method tolerant of out-of-bounds indices. This is because while out-of-bounds indices are invalid for data from the DataSource, they can be valid locations for views in the scroll view (representing an offscreen position) so these positions must be permitted.




Building The Custom UIScrollView Menu 

Building The Main Slider


The requirements were simple: can be swiped left or right and each item in the menu can be selected. This led me to make the main component a UIScrollView subclass. I subclassed it because I needed to do my own custom drawing in its drawRect method to execute the design. Let's take a look at the drawing code, it's very simple:
- (void)drawRect:(CGRect)rect {
    UIImage *bg = [[UIImage imageNamed:@"slider.png"]
      stretchableImageWithLeftCapWidth:15 topCapHeight:0];
    [bg drawInRect:self.bounds];
}
 
Here we're taking a PNG, stretching it horizontally, and drawing it in
the precise location that this scrollview is located. The left cap of
15px means that the first 15px of the image are kept pixel-precise, the
16th pixel is used to stretch across the wide area, then the final
right pixels are kept pixel precise also. This is a common technique to
execute custom designs, I wish I could do this in CSS! 
  
Adding The Tappable Topics 

To make a UIScrollView actually scroll you need to know the total width (or height) of the content it contains. For my scrollview, I programmatically add the tappable topics and then calculate the total width of them once I'm finished. I first thought to make each topic a custom UIButton but for some reason, if the buttons are one-after-another with no pixels in between, the touch events they intercepted stopped the slider from scrolling. I couldn't quite figure out the issue but fortunately there are numerous ways to accomplish the same design. Instead of UIButtons I decided to use UILabel subclasses and add the tap events myself using UIGestureRecognizers, one of the new APIs available on the iPad. Here's the code that calculates the total width of this scrollview's content:

CGFloat contentWidth = 20;
for( NSString *string in topics ) {
    contentWidth += [string sizeWithFont:[UIFont boldSystemFontOfSize:18]].width + 30;
}
self.contentSize = CGSizeMake(contentWidth, self.bounds.size.height);

 
Here I'm using NSString's method sizeWithFont to calculate the exact size of a string rendered using a given font. The 30px extra is to account for 15px padding on the left and right of each one. Once I've iterated across my entire array of topics, I now have an exact pixel amount to assign to the contentSize property.
After calculating the total width, I add each topic one at a time to the scrollview. (DPTopicLabel is my UILabel subclass.)


CGPoint startingPoint = CGPointMake(10, 0);
for( NSString *string in topics ) {
    CGRect labelRect = CGRectMake(startingPoint.x, startingPoint.y,
      [string sizeWithFont:[UIFont boldSystemFontOfSize:18]].width + 30,
      self.bounds.size.height);
            
    DPTopicLabel *label = [[DPTopicLabel alloc] initWithFrame:labelRect andText:string];
    if( [string isEqualToString:@"Top Stories"] ) {
        [label setSelected:YES];
    }
            
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]
      initWithTarget:self action:@selector(handleTap:)];
    [label addGestureRecognizer:tap];
    [tap release];
            
    startingPoint.x += label.bounds.size.width;
    [self addSubview:label];
    [label release];
}

 
First, I create the CGRect that will be the exact position of my tappable topic. The CGPoint startingPoint is updated at the end of each iteration to push it ahead to where the next topic will go. Next, I create my new DPTopicLabel and use my custom initWithFrame:andText: method to pass in what the text should be. If the string is "Top Stories" then I call my setSelected method which draws the custom background for a selected topic.
After I create the topic I need to make it respond to touch events. There are a few ways to do this but I like how the new gesture recognizers work so I used that. Once you add a gesture recognizer to a view, you tell it what type of gesture you want it to recognize (complicated, eh?) and then what method you want it to call when it happens. In this case I'm catching tap events and passing in my handleTap method which will toggle the selected state of my label.
All that's left to do at the end is change my startingPoint variable and add the label to the overall scrollview. Done!


No comments:

Post a Comment