Sources/Source/Components/ScrollingPaginationIndicator.swift (105 lines of code) (raw):

#if os(iOS) import SwiftUI /// A scrolling page indicator, inspired by Instagram's paging indicator. /// /// This uses a ScrollViewProxy to programmatically scroll to the selected index, anchoring it to the center of the scroll view. public struct ScrollingPaginationIndicator: View { private let pageCount: Int @Binding private var selectedIndex: Int private let numberOfVisibleDots: Int private let spacing: CGFloat private let indicatorWidth: CGFloat private let primaryColor: Color private let secondaryColor: Color private let maximumScale = 1.0 private let minimumScale = 0.375 /// This determines the visible area of the paging indicators based on the maximum number of visible dots private var scrollViewWidth: CGFloat { let visibleDots = min(numberOfVisibleDots, pageCount) return CGFloat(visibleDots * Int(indicatorWidth) + (visibleDots - 1) * Int(spacing)) } /// - Parameters: /// - pageCount: The total number of pages that will be scrolled through /// - numberOfVisibleDots: Number of dots that should be visible within the scrolling indicator. Note this is required to be odd in order for dot to be centered. /// - indicatorWidth: Width of dot indicator /// - spacing: Spacing between dot indicators /// - selectedIndex: Index of selected page /// - primaryColor: Primary colour, used for /// - secondaryColor: Color for the unselected state of dot indicator public init( pageCount: Int, numberOfVisibleDots: Int = 5, indicatorWidth: CGFloat, spacing: CGFloat = 4, selectedIndex: Binding<Int>, primaryColor: Color, secondaryColor: Color ) { self.pageCount = pageCount self.numberOfVisibleDots = numberOfVisibleDots.nearestOddNumberBelow() self.indicatorWidth = indicatorWidth self.spacing = spacing self.primaryColor = primaryColor self.secondaryColor = secondaryColor self._selectedIndex = selectedIndex } /// Calculates the scale factor for an item based on its distance from the selected index. /// /// The scaling effect is such that items closer to the selected index are /// scaled larger, while those farther away are scaled smaller. The scale factor is clamped between /// `minimumScale` and `maximumScale`. /// /// - Parameter index: The index of the item for which the scale factor is to be calculated. /// - Returns: A `CGFloat` value representing the scale factor for the item at the given index. private func scale(for index: Int) -> CGFloat { guard pageCount >= numberOfVisibleDots else { return 1.0 } let indexDifference = abs(index - selectedIndex) let scaleSpread = max((CGFloat(numberOfVisibleDots - 1) / 2 + 1), 1) let scaleFactor = CGFloat(indexDifference) / scaleSpread return max(1.0 + scaleFactor * (minimumScale - 1.0), minimumScale) } public var body: some View { ScrollViewReader { scrollViewProxy in ScrollView(.horizontal) { HStack(spacing: spacing) { ForEach(0..<pageCount, id: \.self) { index in Circle() .id(index) .foregroundStyle(selectedIndex == index ? primaryColor : secondaryColor) .frame(width: indicatorWidth) .scaleEffect(scale(for: index)) .animation(.smooth, value: selectedIndex) } } } .disabled(true) // The scroll view is only used for the scroll effect and shouldn't be interactable .frame(width: scrollViewWidth) .onChange(of: selectedIndex) { newValue in withAnimation { scrollViewProxy.scrollTo(newValue, anchor: .center) } } } } } struct ScrollingPageIndicator_Previews_Container: PreviewProvider { struct Container: View { @State var selectedIndex: Int = 0 let elementArray = [0, 1, 2, 3, 4, 5, 6, 7, 8] var body: some View { VStack { TabView(selection: $selectedIndex) { ForEach(elementArray, id: \.self) { index in Text("\(index)") .font(.largeTitle) } } .tabViewStyle(.page(indexDisplayMode: .never)) ScrollingPaginationIndicator( pageCount: elementArray.count, indicatorWidth: 16, selectedIndex: $selectedIndex, primaryColor: Color( uiColor: ColorPalette.neutral0 ), secondaryColor: Color( uiColor: ColorPalette.neutral73 ) ) .padding() } } } static var previews: some View { Container() } } fileprivate extension Int { /// Returns the nearest odd number, will just return itself if it is an odd number already. /// - Returns: An odd Integer func nearestOddNumberBelow() -> Int { if self % 2 != 0 { return self } return self - 1 } } #endif