I\'m displaying a series of images in a UIScrollView. I pretty much want to replicate the Photos application.
My current architecture is:
I had to do a similar setup, but I basically custom wrote the whole thing. I'm not sure how you're going to get around the problem of 'handing off' touch events from the child UIScrollView to the parent UISscrollView when you reach the edge. You might try overriding UITouchesBegan:withEvent: in your parent UIScrollView, and dumping directly to the child. Good luck!
Yay! I tried to approach the problem with just one UIScrollView, and I think I found a solution.
Before the user starts zooming (in viewForZoomingInScrollView:
), I switch the scroll view into zooming mode (remove all additional pages, reset content size and offset). When the user zooms out to scale 1.00 (in scrollViewDidEndZooming:withView:atScale:
), I switch back to paging view (add all pages back, adjust content size and offset).
Here's the code of a simple view controller that does just that. This sample switches between, zooms and pans three large UIImageViews.
Note that a single view controller with a handful of functions is all it takes, no need to subclass UIScrollView or something.
typedef enum {
ScrollViewModeNotInitialized, // view has just been loaded
ScrollViewModePaging, // fully zoomed out, swiping enabled
ScrollViewModeZooming, // zoomed in, panning enabled
ScrollViewModeAnimatingFullZoomOut, // fully zoomed out, animations not yet finished
ScrollViewModeInTransition, // during the call to setPagingMode to ignore scrollViewDidScroll events
} ScrollViewMode;
@interface ScrollingMadnessViewController : UIViewController <UIScrollViewDelegate> {
UIScrollView *scrollView;
NSArray *pageViews;
NSUInteger currentPage;
ScrollViewMode scrollViewMode;
}
@end
@implementation ScrollingMadnessViewController
- (void)setPagingMode {
NSLog(@"setPagingMode");
if (scrollViewMode != ScrollViewModeAnimatingFullZoomOut && scrollViewMode != ScrollViewModeNotInitialized)
return; // setPagingMode is called after a delay, so something might have changed since it was scheduled
scrollViewMode = ScrollViewModeInTransition; // to ignore scrollViewDidScroll when setting contentOffset
// reposition pages side by side, add them back to the view
CGSize pageSize = scrollView.frame.size;
NSUInteger page = 0;
for (UIView *view in pageViews) {
if (!view.superview)
[scrollView addSubview:view];
view.frame = CGRectMake(pageSize.width * page++, 0, pageSize.width, pageSize.height);
}
scrollView.pagingEnabled = YES;
scrollView.showsVerticalScrollIndicator = scrollView.showsHorizontalScrollIndicator = NO;
scrollView.contentSize = CGSizeMake(pageSize.width * [pageViews count], pageSize.height);
scrollView.contentOffset = CGPointMake(pageSize.width * currentPage, 0);
scrollViewMode = ScrollViewModePaging;
}
- (void)setZoomingMode {
NSLog(@"setZoomingMode");
scrollViewMode = ScrollViewModeInTransition; // to ignore scrollViewDidScroll when setting contentOffset
CGSize pageSize = scrollView.frame.size;
// hide all pages besides the current one
NSUInteger page = 0;
for (UIView *view in pageViews)
if (currentPage != page++)
[view removeFromSuperview];
// move the current page to (0, 0), as if no other pages ever existed
[[pageViews objectAtIndex:currentPage] setFrame:CGRectMake(0, 0, pageSize.width, pageSize.height)];
scrollView.pagingEnabled = NO;
scrollView.showsVerticalScrollIndicator = scrollView.showsHorizontalScrollIndicator = YES;
scrollView.contentSize = pageSize;
scrollView.contentOffset = CGPointZero;
scrollViewMode = ScrollViewModeZooming;
}
- (void)loadView {
CGRect frame = [UIScreen mainScreen].applicationFrame;
scrollView = [[UIScrollView alloc] initWithFrame:frame];
scrollView.delegate = self;
scrollView.maximumZoomScale = 2.0f;
scrollView.minimumZoomScale = 1.0f;
UIImageView *imageView1 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"red.png"]];
UIImageView *imageView2 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"green.png"]];
UIImageView *imageView3 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"yellow-blue.png"]];
// in a real app, you most likely want to have an array of view controllers, not views;
// also should be instantiating those views and view controllers lazily
pageViews = [[NSArray alloc] initWithObjects:imageView1, imageView2, imageView3, nil];
self.view = scrollView;
}
- (void)setCurrentPage:(NSUInteger)page {
if (page == currentPage)
return;
currentPage = page;
// in a real app, this would be a good place to instantiate more view controllers -- see SDK examples
}
- (void)viewDidLoad {
scrollViewMode = ScrollViewModeNotInitialized;
[self setPagingMode];
}
- (void)viewDidUnload {
[pageViews release]; // need to release all page views here; our array is created in loadView, so just releasing it
pageViews = nil;
}
- (void)scrollViewDidScroll:(UIScrollView *)aScrollView {
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(setPagingMode) object:nil];
CGPoint offset = scrollView.contentOffset;
NSLog(@"scrollViewDidScroll: (%f, %f)", offset.x, offset.y);
if (scrollViewMode == ScrollViewModeAnimatingFullZoomOut && ABS(offset.x) < 1e-5 && ABS(offset.y) < 1e-5)
// bouncing is still possible (and actually happened for me), so wait a bit more to be sure
[self performSelector:@selector(setPagingMode) withObject:nil afterDelay:0.1];
else if (scrollViewMode == ScrollViewModePaging)
[self setCurrentPage:roundf(scrollView.contentOffset.x / scrollView.frame.size.width)];
}
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)aScrollView {
if (scrollViewMode != ScrollViewModeZooming)
[self setZoomingMode];
return [pageViews objectAtIndex:currentPage];
}
- (void)scrollViewDidEndZooming:(UIScrollView *)aScrollView withView:(UIView *)view atScale:(float)scale {
NSLog(@"scrollViewDidEndZooming: scale = %f", scale);
if (fabsf(scale - 1.0) < 1e-5) {
if (scrollView.zoomBouncing)
NSLog(@"scrollViewDidEndZooming, but zoomBouncing is still true!");
// cannot call setPagingMode now because scrollView will bounce after a call to this method, resetting contentOffset to (0, 0)
scrollViewMode = ScrollViewModeAnimatingFullZoomOut;
// however sometimes bouncing will not take place
[self performSelector:@selector(setPagingMode) withObject:nil afterDelay:0.2];
}
}
@end
Runnable sample project is available at http://github.com/andreyvit/ScrollingMadness/ (if you don't use Git, just click Download button there). A README is available there, explaining why the code was written the way it is.
(The sample project also illustrates how to zoom a scroll view programmatically, and has ZoomScrollView class that encapsulates a solution to that. It is a neat class, but is not required for this trick. If you want an example that does not use ZoomScrollView, go back a few commits in commit history.)
P.S. For the sake of completeness, there's TTScrollView — UIScrollView reimplemented from scratch. It's part of the great and famous Three20 library. I don't like how it feels to the user, but it does make implementing paging/scrolling/zooming dead simple.
P.P.S. The real Photo app by Apple has pre-SDK code and uses pre-SDK classes. One can spot two classes derived from pre-SDK variant of UIScrollView inside PhotoLibrary framework, however it is not immediately clear what they do (and they do quite a lot). I can easily believe this effect used to be harder to achieve in pre-SDK times.
This is the Andrey's code with c# translation for monotouch developers... First you need to edit your xib file.. Insert 3 view controllers and make outlets like _page1, _page2, _mainpage.. and link these outlets with views. note you must referance to view controller's view outlet with _mainpage view. (sorry for my english)
public partial class Test_Details_Controller : UIViewController
{
private UIPageControl _pageCont;
private UIScrollView _scView;
private Object[] _pageViews;
private int _currentPageIndex;
private bool _rotationInProgress;
void InitializeAfterLoad ()
{
this.Title = "Test";
this._pageCont = CreatePageControll();
}
private UIPageControl CreatePageControll()
{
UIPageControl pageControll = new UIPageControl( new RectangleF( 146,348, 38, 20 ) );
pageControll.BackgroundColor = UIColor.Red;
pageControll.Alpha = 0.7f;
return pageControll;
}
private void UpdatePageControll(UIPageControl cont, int current, int pages, UIView showed)
{
cont.CurrentPage = current;
cont.Pages = pages;
cont.UpdateCurrentPageDisplay();
UIPageControl.AnimationsEnabled = true;
UIPageControl.BeginAnimations(string.Empty, this.Handle);
cont.Frame = new RectangleF(showed.Frame.Location.X
, cont.Frame.Location.Y , pageSize().Width, cont.Frame.Height);
UIPageControl.CommitAnimations();
}
private UIView loadViewForPage(int pageIndex){
UIView _view = null;
switch ( pageIndex ) {
case 1:
_view = this._page1;
break;
case 2:
_view = this._page2;
break;
default:
_view = this._page1;
break;
}
return _view;
}
private int numberOfPages(){
return (int)this._pageViews.Count();
}
private UIView viewForPage( int pageIndex ){
UIView pageView;
if(this._pageViews.ElementAt( pageIndex ) == null)
{
pageView = loadViewForPage( pageIndex );
_pageViews[ pageIndex ] = pageView;
}
else{
pageView = (UIView)_pageViews[ pageIndex ];
}
_scView.AddSubview( pageView );
return pageView;
}
private SizeF pageSize(){
return this._scView.Frame.Size;
}
private bool isPageLoaded( int pageIndex ){
return this._pageViews.ElementAt( pageIndex ) != null;
}
private void layoutPage( int pageIndex ){
SizeF pageSize = this.pageSize();
((UIView)this._pageViews[pageIndex]).Frame = new RectangleF( pageIndex * pageSize.Width,0, pageSize.Width, pageSize.Height );
this.viewForPage( pageIndex );
}
private void loadView(){
this._scView = new UIScrollView();
this._scView.Delegate = new ScViewDelegate( this );
this._scView.PagingEnabled = true;
this._scView.ShowsHorizontalScrollIndicator = false;
this._scView.ShowsVerticalScrollIndicator = false;
this._scView.Layer.BorderWidth = 2;
this._scView.AddSubview( _pageCont );
this.View = this._scView;
}
public override void ViewDidLoad ()
{
base.ViewDidLoad ();
InitializeAfterLoad ();
this._pageViews = new Object[]{ _page1, _page2 };
this.loadView();
}
private void currentPageIndexDidChange(){
this.layoutPage( _currentPageIndex );
if(_currentPageIndex+1 < this.numberOfPages()){
this.layoutPage( _currentPageIndex + 1 );
}
if(_currentPageIndex >0){
this.layoutPage( _currentPageIndex - 1 );
}
this.UpdatePageControll( _pageCont, _currentPageIndex, this.numberOfPages(), ((UIView)this._pageViews[_currentPageIndex]) );
this._scView.BringSubviewToFront( _pageCont );
this.NavigationController.Title = string.Format( "{0} of {1}", _currentPageIndex + 1, this.numberOfPages() );
}
private void layoutPages(){
SizeF pageSize = this.pageSize();
this._scView.ContentSize = new SizeF( this.numberOfPages() * pageSize.Width, pageSize.Height );
// move all visible pages to their places, because otherwise they may overlap
for (int pageIndex = 0; pageIndex < this.numberOfPages(); pageIndex++) {
if(this.isPageLoaded( pageIndex ))
this.layoutPage( pageIndex );
}
}
public override void ViewWillAppear (bool animated)
{
this.layoutPages();
this.currentPageIndexDidChange();
this._scView.ContentOffset = new PointF( _currentPageIndex * this.pageSize().Width, 0 );
}
class ScViewDelegate : UIScrollViewDelegate
{
Test_Details_Controller id;
public ScViewDelegate ( Test_Details_Controller id )
{
this.id = id;
}
public override void Scrolled (UIScrollView scrollView)
{
if(id._rotationInProgress)
return;// UIScrollView layoutSubviews code adjusts contentOffset, breaking our logic
SizeF pageSize = id.pageSize();
int newPageIndex = ((int)id._scView.ContentOffset.X + (int)pageSize.Width / 2) / (int)pageSize.Width;
if( newPageIndex == id._currentPageIndex )
return;
id._currentPageIndex = newPageIndex;
id.currentPageIndexDidChange();
}
}
public override bool ShouldAutorotateToInterfaceOrientation (UIInterfaceOrientation toInterfaceOrientation)
{
return toInterfaceOrientation != UIInterfaceOrientation.PortraitUpsideDown;
}
public override void WillRotate (UIInterfaceOrientation toInterfaceOrientation, double duration)
{
_rotationInProgress = true;
// hide other page views because they may overlap the current page during animation
for (int pageIndex = 0; pageIndex < this.numberOfPages(); pageIndex++) {
if(this.isPageLoaded( pageIndex ))
this.viewForPage( pageIndex ).Hidden = ( pageIndex != _currentPageIndex );
}
}
public override void WillAnimateRotation (UIInterfaceOrientation toInterfaceOrientation, double duration)
{
// resize and reposition the page view, but use the current contentOffset as page origin
// (note that the scrollview has already been resized by the time this method is called)
SizeF pageSize = this.pageSize();
UIView pageView = this.viewForPage( _currentPageIndex );
this.viewForPage( _currentPageIndex ).Frame = new RectangleF( this._scView.ContentOffset.X, 0, pageSize.Width, pageSize.Height );
}
public override void DidRotate (UIInterfaceOrientation fromInterfaceOrientation)
{
base.DidRotate (fromInterfaceOrientation);
// adjust frames according to the new page size - this does not cause any visible changes
this.layoutPages();
this._scView.ContentOffset = new PointF( _currentPageIndex * this.pageSize().Width, 0 );
//unhide
for (int pageIndex = 0; pageIndex < this.numberOfPages(); pageIndex++) {
if( this.isPageLoaded( pageIndex ) )
this.viewForPage( pageIndex ).Hidden = false;
}
_rotationInProgress = false;
}
public override void DidReceiveMemoryWarning ()
{
//SuperHandle = DidReceiveMemoryWarning();
if(this._pageViews != null)
{
// unload non-visible pages in case the memory is scarse
for (int pageIndex = 0; pageIndex < this.numberOfPages(); pageIndex++) {
if( pageIndex < _currentPageIndex - 1 || pageIndex > _currentPageIndex + 1 )
if( this.isPageLoaded(pageIndex) ){
UIView pageview = (UIView)this._pageViews[ pageIndex ];
this._pageViews[ pageIndex ] = null;
pageview.RemoveFromSuperview();
}
}
}
}
public override void ViewDidUnload ()
{
this._pageViews = null;
this._scView = null;
}
}
I have a sample project and it works perfect.
https://code.google.com/p/uiscrollview-touch-events/