Lab 111 – Progressive Page Indicator
Building a timer progress capsule in SwiftUI
There was a discussion in our Discord server recently about the animated progress indicator that Apple uses in the Apple TV app. I went down a full rabbit hole trying to figure out whether there was a native way to achieve this, what the API was actually called and how close I could get it using pure SwiftUI. Here is what I was able to find and come up with so others can use it and modify it to fit their own projects.
UIPageControlTimerProgress is the UIKit API that you’ve seen in several apps which show a row of dots at the bottom of featured content, where the active dot expands into a pill shape and fills from left to right before the next item loads automatically. It’s a subtle way to show there is more and how long until the next one will show.
The thing is UIPageControlTimerProgress is UIKit only. SwiftUI’s built in TabView with .page style gives us the static dots, but no timer, no pill expansion, none of the progress behavior. There’s currently no direct SwiftUI equivalent. But there’s a simple way to add a progressive timer to the familiar pagination dots. This will indicate there is more content, but also how much time is left on the current one before the next begins.
How it Works
The active page dot expands from a small circle into a pill shape. A white fill begins from the left and progresses to the right as time progresses. When it completes, the pill collapses back into a circle slightly brighter than the others signifying that this page has already been seen. Upcoming dots stay dim and recede into the background keeping the focus on the content.
Building the Pill
The active pill is two Capsule shapes stacked in a ZStack. The bottom capsule is the track and the top capsule is the filler. Its width is pillWidth * progress so as progress goes from 0.0 to 1.0 the fill takes over the pill width.Â
ZStack(alignment: .leading) {
Capsule()
.fill(.white.opacity(0.35))
.frame(width: pillWidth, height: height)
Capsule()
.fill(.white)
.frame(width: pillWidth * progress, height: height)
.animation(.linear(duration: 0.05), value: progress)
}The Dot State
Here we want to handle the active/ inactive states by dimming the opacity if it has not been seen yet and brightening it if it has.
Circle()
.fill(.white.opacity(i < current ? 0.7 : 0.35))
.frame(width: dotSize, height: dotSize)Expansion Animation
When the active page changes, the new dot expands into a pill and the previous pill will collapse back into a circle. The transition will be a modifier on the HStack itself. We do this so SwiftUI interpolates the width change automatically when current updates.
HStack(spacing: 6) {
// the dots and the pill
}
.animation(.spring(response: 0.3, dampingFraction: 0.75), value: current)
This is how the active page dot changes and expands into a pill and the previous pill collapses back into a dot. Let’s see it all in action.
Video demo of the progressive progress indicator in the simulator.
Full Lab Code
We provided two examples for using this control with Tab View. The key to either option us to use this style:
.tabViewStyle(.page(indexDisplayMode: .never))
struct Lab111: View {
var body: some View {
TabView {
Tab("Lab Pages", systemImage: "rectangle.stack") {
LabPageIndicatorExample()
}
Tab("Tab Values", systemImage: "number.square") {
DirectValueIndicatorExample()
}
}
}
}
fileprivate struct LabPageIndicatorExample: View {
@State private var currentPage = LabPage.red
@State private var progress = 0.0
private let pageDuration = 4.0
private let timerStep: UInt64 = 50_000_000
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentPage) {
Tab(value: LabPage.red) {
RoundedRectangle(cornerRadius: 12)
.foregroundStyle(.stepRed)
}
Tab(value: LabPage.green) {
RoundedRectangle(cornerRadius: 12)
.foregroundStyle(.stepGreen)
}
Tab(value: LabPage.blue) {
RoundedRectangle(cornerRadius: 12)
.foregroundStyle(.stepBlue)
}
}
// use page style, but hide the page indicators
.tabViewStyle(.page(indexDisplayMode: .never))
PageControlIndicator(
count: LabPage.allCases.count,
current: currentPage.index,
progress: progress
)
.padding(.bottom, 28)
}
.task(id: currentPage) {
await runPageTimer()
}
}
private func runPageTimer() async {
progress = 0
let startDate = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startDate)
progress = min(elapsed / pageDuration, 1)
if progress >= 1 {
currentPage = currentPage.next
return
}
try? await Task.sleep(nanoseconds: timerStep)
}
}
}
fileprivate struct DirectValueIndicatorExample: View {
@State private var currentPage = 0
@State private var progress = 0.0
private let pageCount = 3
private let pageDuration = 4.0
private let timerStep: UInt64 = 50_000_000
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentPage) {
Tab(value: 0) {
RoundedRectangle(cornerRadius: 12)
.foregroundStyle(.stepRed)
}
Tab(value: 1) {
RoundedRectangle(cornerRadius: 12)
.foregroundStyle(.stepGreen)
}
Tab(value: 2) {
RoundedRectangle(cornerRadius: 12)
.foregroundStyle(.stepBlue)
}
}
// use page style, but hide the page indicators
.tabViewStyle(.page(indexDisplayMode: .never))
PageControlIndicator(
count: pageCount,
current: currentPage,
progress: progress
)
.padding(.bottom, 28)
}
.task(id: currentPage) {
await runPageTimer()
}
}
private func runPageTimer() async {
progress = 0
let startDate = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startDate)
progress = min(elapsed / pageDuration, 1)
if progress >= 1 {
currentPage = (currentPage + 1) % pageCount
return
}
try? await Task.sleep(nanoseconds: timerStep)
}
}
}
fileprivate enum LabPage: Int, CaseIterable, Hashable {
case red
case green
case blue
var index: Int {
rawValue
}
var next: LabPage {
let nextIndex = (index + 1) % Self.allCases.count
return Self.allCases[nextIndex]
}
}
fileprivate struct PageControlIndicator: View {
let count: Int
let current: Int
let progress: Double // 0.0 -> 1.0
private let dotSize: CGFloat = 8
private let pillWidth: CGFloat = 36
private let height: CGFloat = 8
var body: some View {
HStack(spacing: 6) {
ForEach(0..<count, id: \.self) { i in
if i == current {
ZStack(alignment: .leading) {
Capsule()
.fill(.white.opacity(0.35))
.frame(width: pillWidth, height: height)
Capsule()
.fill(.white)
.frame(width: pillWidth * progress, height: height)
.animation(.linear(duration: 0.05), value: progress)
}
} else {
Circle()
.fill(.white.opacity(i < current ? 0.7 : 0.35))
.frame(width: dotSize, height: dotSize)
}
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.75), value: current)
}
}Support our work so we can continue to bring you new examples and articles.
Download the Xcode project with this and many more labs from Step Into Vision.

Follow Step Into Vision