Perfectly correct approach for 2020:
Simply add a UIPageControl
in storyboard.
Put it below (i.e., visible on top of) your collection view.

Link to it...
class YourVC: UIViewController, UICollectionViewDelegate,
UICollectionViewDataSource,
UICollectionViewDelegateFlowLayout {
@IBOutlet var collectionView: UICollectionView!
@IBOutlet var dots: UIPageControl!
Simply add a constraint centering it horizontally to the collection view, and add a constraint to align the bottoms.
That will give the standard positioning / spacing.
(Of course, you can place the dots anywhere you want, but that is the standard.)

Tip 1 - colors
Bizarrely the default colors for the dots are .. clear!
So set them to gray/black or whatever you wish:

Or you can do that in code:
override func viewDidLoad() {
super.viewDidLoad()
dots.pageIndicatorTintColor = .systemGray5
dots.currentPageIndicatorTintColor = .yourCorporateColor
}
Next. In numberOfItemsInSection
, add ...
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
let k = ... yourData.count, or whatever your count is
dots.numberOfPages = k
return k
}
Tip 2 - in fact, do NOT use the deceleration calls
Add this code:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
dots.currentPage = Int(
(collectionView.contentOffset.x / collectionView.frame.width)
.rounded(.toNearestOrAwayFromZero)
)
)
}
You simply set the page in "scrollViewDidScroll".
In fact
• do not use scrollViewWillBeginDecelerating
• do not use scrollViewDidEndDecelerating
.
To see why: try it using either of those calls. Now skim quickly through many pages. Notice it does not work properly.
Simply use scrollViewDidScroll
for the correct, perfect result, including initialization.
Tip 3 - do NOT use Int division - it completely changes the behavior and is totally wrong.
You will often see this example code:
// wrong, do not do this
dots.currentPage = Int(collectionView.contentOffset.x) /
Int(collectionView.frame.width)
// wrong, do not do this
That often-seen example code is completely wrong.
If you try that, it will result in the dots "jumping" in a non-standard way, as you skim through pages.
Best explanation is to try it and see.
For the usual, correct, Apple-style behavior as you scroll through or skim through the pages, the code is:
dots.currentPage = Int(
(collectionView.contentOffset.x / collectionView.frame.width)
.rounded(.toNearestOrAwayFromZero)
)
Final example...
