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.

Questions or feedback?