Multiple virtual pages in a UIScrollView with just 2 views
I'm presenting the following application which looks like this:
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 UIViewController
s and UIView
s — 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:- Initially, the displayed view will be the
currentPage
view. The next view will be configured to display the cached data for Page 1 - 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. - As the scroll operation ends, the pointers for
currentPage
andnextPage
are swapped so thatcurrentPage
now points to the view displaying Page 2 andnextPage
now points to Page 1 - 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.
- (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