How to change SwiftUI TabView dot indicator shape?

63 Views Asked by At

I want to change the shape of the dot to a rounded rectangle which ever tab is selected.

First Image is the current dot style paging:

First Image

But I want it to change to 2nd Image kind of:

Second Image

1

There are 1 best solutions below

0
MatBuompy On BEST ANSWER

Thaks to YouTuber Kavsoft and to his video we know how to do it.

This View allows for some small customisation, you'll need to get your hand dirty if you want more, of course, and it is only compatible starting from iOS 17. Here's the code:

struct PagingIndicator: View {
    
    /// Customization properties
    var activeTint: Color = .primary
    var inactiveTint: Color = .primary.opacity(0.15)
    var opacityEffect: Bool = false
    var clipEdges: Bool = false
    
    var body: some View {
        let hstackSpacing: CGFloat = 10
        let dotSize: CGFloat = 8
        let spacingAndDotSize = hstackSpacing + dotSize
        GeometryReader {
            let width = $0.size.width
            /// ScrollView boounds
            if let scrollViewWidth = $0.bounds(of: .scrollView(axis: .horizontal))?.width,
               scrollViewWidth > 0 {
                
                let minX = $0.frame(in: .scrollView(axis: .horizontal)).minX
                let totalPages = Int(width / scrollViewWidth)
                
                /// Progress
                let freeProgress = -minX / scrollViewWidth
                let clippedProgress = min(max(freeProgress, 0), CGFloat(totalPages - 1))
                let progress = clipEdges ? clippedProgress : freeProgress
                
                /// Indexes
                let activeIndex = Int(progress)
                let nextIndex = Int(progress.rounded(.awayFromZero))
                let indicatorProgress = progress - CGFloat(activeIndex)
                
                /// Indicator width (Current & upcoming)
                let currentPageWidth = spacingAndDotSize - (indicatorProgress * spacingAndDotSize)
                let nextPageWidth = indicatorProgress * spacingAndDotSize
                
                HStack(spacing: hstackSpacing) {
                    ForEach(0..<totalPages, id: \.self) { index in
                        Capsule()
                            .fill(inactiveTint)
                            .frame(width: dotSize + ((activeIndex == index) ? currentPageWidth : (nextIndex == index) ? nextPageWidth : 0),
                                   height: dotSize)
                            .overlay {
                                ZStack {
                                    Capsule()
                                        .fill(inactiveTint)
                                    
                                    Capsule()
                                        .fill(activeTint)
                                        .opacity(opacityEffect ?
                                            (activeIndex == index) ? 1 - indicatorProgress : (nextIndex == index) ? indicatorProgress : 0
                                                 : 1
                                        )
                                }
                            }
                    } //: LOOP DOTS
                } //: HSTACK
                .frame(width: scrollViewWidth)
                
                .offset(x: -minX)
                
            }
        } //: GEOMETRY
        .frame(height: 30)
    }
}

The clipEdges Boolean property avoid the animation when reaching the end of the carousel when set to true in case you are wondering.

You can use it like this:

ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(colors, id: \.self) { color in
                    RoundedRectangle(cornerRadius: 25)
                        .fill(color.gradient)
                        .padding(.horizontal, 5)
                        .containerRelativeFrame(.horizontal)
                } //: LOOP COLORS
            } //: LAZY HSTACK
            .scrollTargetLayout() /// Comment to have standrd Paging behaviour
            .overlay(alignment: .bottom) {
                PagingIndicator(
                    activeTint: .white,
                    inactiveTint: .black.opacity(0.25),
                    opacityEffect: opacityEffect,
                    clipEdges: clipEdges
                )
            }
        } //: SCROLL
        .scrollIndicators(.hidden)
        .frame(height: 220)
        /// Uncomment these two lines to have the standard paging
        //.padding(.top, 15)
        //.scrollTargetBehavior(.paging)
        .safeAreaPadding(.vertical, 15)
        .safeAreaPadding(.horizontal, 25)
        .scrollTargetBehavior(.viewAligned)

Where colors is an array of colors:

@State private var colors: [Color] = [.red, .blue, .green, .yellow]

Here's the result:

Animated Dot Indicator

If you are looking to support older iOS versions you can check this other video of his.

Let me know your thoughts!