UIScrollView horizontal paging like Mobile Safari tabs
Asked Answered
Y

12

88

Mobile Safari allows you to switch pages by entering a sort of UIScrollView horizontal paging view with a page control at the bottom.

I am trying to replicate this particular behavior where a horizontally scrollable UIScrollView shows some of the next view's content.

The Apple provided example: PageControl shows how to use a UIScrollView for horizontal paging, but all views take up the whole screen width.

How do I get a UIScrollView to show some content of the next view like mobile Safari does?

Yolk answered 3/8, 2009 at 1:10 Comment(3)
Just a thought... try making the scroll view's bounds smaller than the screen, and fiddle around with getting the views to display properly. (and set scroll view's clipsToBounds to NO)Pronate
I wanted to have pages bigger the the uiscrollview's width (horizontal scroll). And mjhoy's thought actually helped me out!Monmouthshire
Related is Paging UIScrollView in increments smaller than content size.Cooe
M
265

A UIScrollView with paging enabled will stop at multiples of its frame width (or height). So the first step is to figure out how wide you want your pages to be. Make that the width of the UIScrollView. Then, set your subview's sizes however big you need them to be, and set their centers based on multiples of the UIScrollView's width.

Then, since you want to see the other pages, of course, set clipsToBounds to NO as mhjoy stated. The trick part now is getting it to scroll when the user starts the drag outside the range of the UIScrollView's frame. My solution (when I had to do this very recently) was as follows:

Create a UIView subclass (i.e. ClipView) that will contain the UIScrollView and it's subviews. Essentially, it should have the frame of what you would assume the UIScrollView would have under normal circumstances. Place the UIScrollView in the center of the ClipView. Make sure the ClipView's clipsToBounds is set to YES if its width is less than that of its parent view. Also, the ClipView needs a reference to the UIScrollView.

The final step is to override - (UIView *)hitTest:withEvent: inside the ClipView.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
  return [self pointInside:point withEvent:event] ? scrollView : nil;
}

This basically expands the touch area of the UIScrollView to the frame of its parent's view, exactly what you need.

Another option would be to subclass UIScrollView and override its - (BOOL)pointInside:(CGPoint) point withEvent:(UIEvent *) event method, however you will still need a container view to do the clipping, and it may be difficult to determine when to return YES based only on the UIScrollView's frame.

NOTE: You should also take a look at Juri Pakaste's hitTest:withEvent: modification if you are having issues with subview user interaction.

Missal answered 3/8, 2009 at 3:48 Comment(9)
Thanks Ed. I went with a UIScrollView subclass for lazy loading of views (in layoutSubviews), and used a clippingRect to do the pointInside:withEvent: testing. Works really well, and no additional container view required.Few
Great answer. I used a UIScrollView subclass like @Few but with a container view for clipping and return [super pointInside:CGPointMake(self.contentOffset.x + self.frame.size.width/2, self.contentOffset.y + self.frame.size.height/2) withEvent:event]; in pointInside:withEvent:. This works very well and there are no issues with user interaction mentioned in @JuriPakaste's answer.Module
Wow, this is a really good solution. Just one thing though, on my setup, the pages that I'm adding have UIButtons. When I override the hitTest method it does not let me tap on those buttons. I can scroll, but no action can be selected inside any page.Pluralism
For those coming across this more recently be aware that - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset added to UIScrollViewDelegate in iOS5 means you don't have to go through this palaver any more.Decrescent
@MikePollard can you explain why this isn't necessary anymore in light of scrollViewWillEndDragging:withVelocity:targetContentOffset: being added to iOS5?Deutsch
@JeremyWiebe sure. As the apple docs say, you can "change the value of the 'targetContentOffset' parameter to adjust where the scrollview finishes its scrolling animation." So instead of setting the pagingEnabled property of the scrollView to YES, implement this method in your delegate and set targetContentOffsetto whatever offset you want the scrolling to 'snap' to...Decrescent
@MikePollard the effect is not quiet the same, targetContentOffset doesn't fit well with this situation.Bocanegra
Thanks @MikePollard , For Latest Solution Please go through this link #9368100Ravishing
What if the UICollectionView? The unvisible cells will not show on the view.Jaquez
L
70

The ClipView solution above worked for me, but I had to do a different -[UIView hitTest:withEvent:] implementation. Ed Marty's version didn't get user interaction working with vertical scrollviews I have inside the horizontal one.

The following version worked for me:

-(UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event
{
    UIView* child = nil;
    if ((child = [super hitTest:point withEvent:event]) == self)
        return self.scrollView;     
    return child;
}
Leyes answered 3/9, 2009 at 12:25 Comment(6)
This also helps get buttons and other subviews with user interaction working. :) thanksCyclograph
Thank you for this code, how can I use this modification to get events from subviews (buttons) not contained in the scrollview boundaries? It works if a button for example is within the boundaries of the central scrollview but not outside.Criner
This really helped. It should be included as a modification of the selected answer.Debouchment
Yes! This also makes user interaction within the scrollview work again! I did have to set userInteractionEnabled to YES on the ClipView in order for this to work.Buber
I had to iterate through self.scrollView.subviews and perform hitTest first.Toreador
What does this look like in Swift? I'm unfamiliar with this obj-c control flow + setting a variable at the same time thing.Yettayetti
B
8

Set frame size of scrollView as your pages size would be:

[self.view addSubview:scrollView];
[self.view addGestureRecognizer:mainScrollView.panGestureRecognizer];

Now you can pan on self.view, and content on scrollView will be scrolled.
Also use scrollView.clipsToBounds = NO; to prevent clipping the content.

Bekelja answered 9/12, 2014 at 13:35 Comment(0)
T
5

I wound up going with the custom UIScrollView myself as it was the quickest and simpler method it seemed to me. However, I didn't see any exact code so figured I would share. My needs were for a UIScrollView that had small content and therefore the UIScrollView itself was small to achieve the paging affect. As the post states you can't swipe across. But now you can.

Create a class CustomScrollView and subclass UIScrollView. Then all you need to do is add this into the .m file:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
  return (point.y >= 0 && point.y <= self.frame.size.height);
}

This allows you to scroll from side to side (horizontal). Adjust the bounds accordingly to set your swipe/scrolling touch area. Enjoy!

Telmatelo answered 19/11, 2013 at 3:6 Comment(0)
T
4

I have made another implementation which can return the scrollview automatically. So it don't need to have an IBOutlet which will limit reusage in project.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if ([self pointInside:point withEvent:event]) {
        for (id view in [self subviews]) {
            if ([view isKindOfClass:[UIScrollView class]]) {
                return view;
            }
        }
    }
    return nil;
}
Tiffanytiffi answered 17/8, 2012 at 10:28 Comment(1)
This is the one to use if you already have a xib/storyboard. Otherwise I'm not sure how you would get the reference to the Paging/Scrollview. Any ideas?Woodchopper
A
3

I have another potentially useful modification for the ClipView hitTest implementation. I didn't like having to provide a UIScrollView reference to the ClipView. My implementation below allows you to re-use the ClipView class to expand the hit-test area of anything, and not have to supply it with a reference.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (([self pointInside:point withEvent:event]) && (self.subviews.count >= 1))
    {
        // An extended-hit view should only have one sub-view, or make sure the
        // first subview is the one you want to expand the hit test for.
        return [self.subviews objectAtIndex:0];
    }

    return nil;
}
Amye answered 2/3, 2012 at 21:27 Comment(0)
A
3

I implemented the upvoted suggestion above, but the UICollectionView I was using considered anything out of the frame to be off the screen. This caused nearby cells to only render out of bounds when the user was scrolling toward them, which wasn't ideal.

What I ended up doing was emulating the behavior of a scrollview by adding the method below to the delegate (or UICollectionViewLayout).

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
{    
  if (velocity.x > 0) {
    proposedContentOffset.x = ceilf(self.collectionView.contentOffset.x / pageSize) * pageSize;
  }
  else {
    proposedContentOffset.x = floorf(self.collectionView.contentOffset.x / pageSize) * pageSize;
  }

  return proposedContentOffset;
}

This avoids the delegation of the the swipe action entirely, which was also a bonus. The UIScrollViewDelegate has a similar method called scrollViewWillEndDragging:withVelocity:targetContentOffset: which could be used to page UITableViews and UIScrollViews.

Akeyla answered 29/10, 2012 at 17:1 Comment(1)
Dave, I was struggling to achieve such a behaviour using UICollectionView as well and I ended up using same approach that you describe here. However, scrolling experience is not as good as it was a scrollview with paging enabled. When I swiped with bigger velocity, several pages were scrolled and it stopped on proposed page. What I want to achieve is save behaviour as normal scroll view with paging enabled - whatever velocity I use, I will get only 1 page more. Do you have any idea how to do it?Sheetfed
A
3

Enable firing tap events on child views of the scroll view while supporting the technique of this SO question. Uses a reference to the scroll view (self.scrollView) for readability.

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    UIView *hitView = nil;
    NSArray *childViews = [self.scrollView subviews];
    for (NSUInteger i = 0; i < childViews.count; i++) {
        CGRect childFrame = [[childViews objectAtIndex:i] frame];
        CGRect scrollFrame = self.scrollView.frame;
        CGPoint contentOffset = self.scrollView.contentOffset;
        if (childFrame.origin.x + scrollFrame.origin.x < point.x + contentOffset.x &&
            point.x + contentOffset.x < childFrame.origin.x + scrollFrame.origin.x + childFrame.size.width &&
            childFrame.origin.y + scrollFrame.origin.y < point.y + contentOffset.y &&
            point.y + contentOffset.y < childFrame.origin.y + scrollFrame.origin.y + childFrame.size.height
        ){
            hitView = [childViews objectAtIndex:i];
            return hitView;
        }
    }
    hitView = [super hitTest:point withEvent:event];
    if (hitView == self)
        return self.scrollView;
    return hitView;
}

Add this to your child view to capture the touch event:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event

(This is a variation on user1856273's solution. Cleaned up for readability and incorporated Bartserk's bug fix. I thought of editing user1856273's answer but it was too big a change to make.)

Astarte answered 25/7, 2013 at 17:49 Comment(1)
To get tapping to work on a UIButton embedded in the subview, I replaced the code hitView = [childViews objectAtIndex:i]; with //find the UIButton for (UIView *paneView in [[childViews objectAtIndex:i] subviews]) { if ([paneView isKindOfClass:[UIButton class]]) return paneView; }Loblolly
G
2

my version Pressing the button lying on the scroll - work =)

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView* child = nil;
    for (int i=0; i<[[[[self subviews] objectAtIndex:0] subviews] count];i++) {

        CGRect myframe =[[[[[self subviews] objectAtIndex:0] subviews]objectAtIndex:i] frame];
        CGRect myframeS =[[[self subviews] objectAtIndex:0] frame];
        CGPoint myscroll =[[[self subviews] objectAtIndex:0] contentOffset];
        if (myframe.origin.x < point.x && point.x < myframe.origin.x+myframe.size.width &&
            myframe.origin.y+myframeS.origin.y < point.y+myscroll.y && point.y+myscroll.y < myframe.origin.y+myframeS.origin.y +myframe.size.height){
            child = [[[[self subviews] objectAtIndex:0] subviews]objectAtIndex:i];
            return child;
        }


    }

    child = [super hitTest:point withEvent:event];
    if (child == self)
        return [[self subviews] objectAtIndex:0];
    return child;
    }

but only [[self subviews] objectAtIndex: 0] must be a scroll

Geophilous answered 15/3, 2013 at 16:57 Comment(1)
This solved my problem :) Just note that you're not adding the scroll offset to point.x when you're checking the bounds, and this makes the code doesn't work well on horizontal scrolls. After adding that, it worked just fine!Attentive
D
2

Here is a Swift answer based on Ed Marty's answer but also including the modification by Juri Pakaste to allow button taps etc inside the scrollview.

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    let view = super.hitTest(point, with: event)
    return view == self ? scrollView : view
}
Determiner answered 19/8, 2019 at 17:21 Comment(1)
Thanks for updating this :)Inequitable
Y
0

Swift 5 / iOS 15

TLDR - Do what the accepted answer says, but use my version of hitTest(...) below

I wanted the user to be able to tap a page off to the left or right & scroll it to the center, but existing solutions didn't work (I added gesture recognizers to each page for this, and the gestures were never triggered for pages outside the scrollView frame).

The accepted answer almost worked great for me, but the hitTest override was no good - the elements on each page weren't responding to touches (I had UISwitches that wouldn't react to taps). The suggested modifications of hitTest(...) to allow interactivity on each page weren't complete either, since taps on any page outside the scrollView bounds weren't sent to the page (suggested answers just pass the touch to the scrollView, which doesn't pass the touch down to its content lying outside its frame).

I used the following code instead, and it's working perfectly. I don't like accessing the scrollView's content view like this, but the code won't crash & safely falls back to the original suggested code. If anyone knows of a better way to pass touches to the scrollView's content view, I would appreciate it.

public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    guard let scrollViewContentView = scrollView.subviews.first else {
        // fallback behavior - user can scroll carousel if touch starts outside the scrollView frame, but taps on elements outside the scrollView frame will be ignored
        let view = super.hitTest(point, with: event)
        return view == self ? scrollView : view
    }
    
    let convertedPoint = scrollViewContentView.convert(point, from: self)
    return self.point(inside: point, with: event) ? scrollViewContentView.hitTest(convertedPoint, with: event) : nil
}
Yettayetti answered 29/9, 2021 at 0:37 Comment(0)
M
0

In case you want to be able to touch views in the UIScrollView that are out of bounds of the UIScrollView but inside the container view.

  1. Assign the touchable views to the container view
  2. override hitTest

The view hierarchy is

- ContainerView
-- UIScrollView
--- ContentViews

Take the class below as example for the container view.

class PassthroughView: UIView {
    
    var touchableSubviews: [UIView] = []
    
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        for view in touchableSubviews {
            if let v = view.hitTest(view.convert(point, from: self), with: event) {
                return v
            }
        }
        return super.hitTest(point, with: event)
    }
}

You could iterate through the subview hierarchy of the container but I like it more explicit.

You now have a UIScrollView with paging enabled where the content views can be smaller than the full screen width. You can touch and scroll the content as if it is a normal UIScrollView.

Don't forget to set the properties from the UIScrollView

scrollView.isPagingEnabled = true
scrollView.clipsToBounds = false
Microelectronics answered 11/7, 2023 at 9:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.