UIScrollView
paging
iOS development
frame size
iOS programming

Paging UIScrollView in increments smaller than frame size

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

UIScrollView's built-in isPagingEnabled snaps to multiples of the scroll view's frame size. To page in smaller increments (such as showing a card carousel where adjacent cards peek from the edges), you must disable isPagingEnabled and implement custom snapping in the scroll view delegate's scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) method. Alternatively, you can use a UICollectionView with UICollectionViewFlowLayout paging behavior or the modern UICollectionViewCompositionalLayout with orthogonal scrolling.

The Problem with Default Paging

swift
let scrollView = UIScrollView()
scrollView.isPagingEnabled = true
scrollView.frame = CGRect(x: 0, y: 0, width: 375, height: 200)

With isPagingEnabled = true, the scroll view snaps in increments of 375 points (its frame width). There is no built-in property to change the paging increment.

Custom Paging with Delegate

Disable built-in paging and control snapping manually:

swift
1class CarouselViewController: UIViewController, UIScrollViewDelegate {
2
3    let scrollView = UIScrollView()
4    let pageWidth: CGFloat = 280  // Custom page increment
5    let pageSpacing: CGFloat = 16
6
7    override func viewDidLoad() {
8        super.viewDidLoad()
9
10        scrollView.frame = view.bounds
11        scrollView.isPagingEnabled = false  // Disable default paging
12        scrollView.decelerationRate = .fast
13        scrollView.delegate = self
14        scrollView.showsHorizontalScrollIndicator = false
15        view.addSubview(scrollView)
16
17        // Add content cards
18        let totalWidth = pageWidth + pageSpacing
19        for i in 0..<5 {
20            let card = UIView()
21            card.frame = CGRect(
22                x: CGFloat(i) * totalWidth,
23                y: 20,
24                width: pageWidth,
25                height: 160
26            )
27            card.backgroundColor = .systemBlue
28            card.layer.cornerRadius = 12
29            scrollView.addSubview(card)
30        }
31
32        scrollView.contentSize = CGSize(
33            width: totalWidth * 5,
34            height: 200
35        )
36    }
37
38    func scrollViewWillEndDragging(
39        _ scrollView: UIScrollView,
40        withVelocity velocity: CGPoint,
41        targetContentOffset: UnsafeMutablePointer<CGPoint>
42    ) {
43        let totalWidth = pageWidth + pageSpacing
44        let targetX = targetContentOffset.pointee.x
45
46        // Calculate the nearest page
47        var page = round(targetX / totalWidth)
48
49        // Account for velocity to allow flick-to-next-page
50        if velocity.x > 0.5 {
51            page = ceil(targetX / totalWidth)
52        } else if velocity.x < -0.5 {
53            page = floor(targetX / totalWidth)
54        }
55
56        // Clamp to valid range
57        let maxPage = CGFloat(4)  // 5 pages, 0-indexed
58        page = max(0, min(page, maxPage))
59
60        targetContentOffset.pointee.x = page * totalWidth
61    }
62}

The key is scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) — it lets you modify the target offset before the deceleration animation begins.

Clip-to-Bounds Trick for Peeking Cards

To show adjacent cards peeking from the edges while still using built-in paging:

swift
1class PeekingCarouselViewController: UIViewController {
2
3    override func viewDidLoad() {
4        super.viewDidLoad()
5
6        // The scroll view is smaller than the visible area
7        let scrollView = UIScrollView()
8        scrollView.frame = CGRect(x: 40, y: 100, width: 295, height: 200)
9        scrollView.isPagingEnabled = true
10        scrollView.clipsToBounds = false  // Allow content to be visible outside frame
11        view.addSubview(scrollView)
12
13        for i in 0..<5 {
14            let card = UIView()
15            card.frame = CGRect(x: CGFloat(i) * 295, y: 0, width: 280, height: 200)
16            card.backgroundColor = .systemGreen
17            card.layer.cornerRadius = 12
18            scrollView.addSubview(card)
19        }
20
21        scrollView.contentSize = CGSize(width: 295 * 5, height: 200)
22    }
23}

The scroll view's frame is 295pt wide, so paging snaps in 295pt increments. Setting clipsToBounds = false makes adjacent cards visible beyond the frame edges.

UICollectionView with Custom Paging

UICollectionView provides more structured paging control:

swift
1class CardCarouselLayout: UICollectionViewFlowLayout {
2
3    override func targetContentOffset(
4        forProposedContentOffset proposedContentOffset: CGPoint,
5        withScrollingVelocity velocity: CGPoint
6    ) -> CGPoint {
7        guard let collectionView = collectionView else {
8            return super.targetContentOffset(
9                forProposedContentOffset: proposedContentOffset,
10                withScrollingVelocity: velocity
11            )
12        }
13
14        let pageWidth = itemSize.width + minimumLineSpacing
15        let currentPage = collectionView.contentOffset.x / pageWidth
16        var nextPage: CGFloat
17
18        if velocity.x > 0.3 {
19            nextPage = ceil(currentPage)
20        } else if velocity.x < -0.3 {
21            nextPage = floor(currentPage)
22        } else {
23            nextPage = round(currentPage)
24        }
25
26        return CGPoint(x: nextPage * pageWidth, y: proposedContentOffset.y)
27    }
28}
29
30// Usage
31let layout = CardCarouselLayout()
32layout.scrollDirection = .horizontal
33layout.itemSize = CGSize(width: 280, height: 160)
34layout.minimumLineSpacing = 16
35
36let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
37collectionView.decelerationRate = .fast
38collectionView.isPagingEnabled = false

Override targetContentOffset(forProposedContentOffset:withScrollingVelocity:) on the layout to control snapping behavior.

Common Pitfalls

  • Leaving isPagingEnabled = true with custom snapping: If isPagingEnabled is true, the scroll view's built-in paging overrides the delegate's targetContentOffset. Always set isPagingEnabled = false when implementing custom paging logic.
  • Forgetting decelerationRate = .fast: Without fast deceleration, the scroll view glides too far after a flick, making custom snapping feel sluggish. Set .fast to match the feel of native paging.
  • Not handling velocity in the delegate: Without velocity checks, a fast flick may snap to the current page instead of advancing. Check velocity.x to determine whether the user intended to advance or retreat.
  • Setting clipsToBounds = false without handling touches: When clipsToBounds = false, content is visible outside the scroll view's frame but taps on that content are not registered. Override hitTest(_:with:) on the parent view to forward touches to the scroll view.
  • Incorrect contentSize calculation: If contentSize does not account for spacing between pages, the last page may not be reachable or may snap to the wrong position. Calculate as (pageWidth + spacing) * pageCount.

Summary

  • isPagingEnabled only supports paging at the scroll view's frame width — no built-in way to change the increment
  • Implement custom paging by disabling isPagingEnabled and using scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) to snap to custom increments
  • Use the clipsToBounds = false trick to show peeking adjacent cards with built-in paging on a smaller frame
  • For collection views, override targetContentOffset(forProposedContentOffset:withScrollingVelocity:) on a custom flow layout
  • Always set decelerationRate = .fast and handle velocity for natural-feeling paging
  • Forward touches to the scroll view when using clipsToBounds = false to keep off-frame content interactive

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.