Sambhav
Sambhav

Reputation: 13

How to change SwiftUI TabView dot indicator shape?

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

Upvotes: 0

Views: 2107

Answers (3)

Codelaby
Codelaby

Reputation: 2893

Based of @NikolaVeljic's, I adding a variation that incorporates a time progress indicator, similar to the Apple TV banner showcase

Watch Video

struct SlimeProgressDotPageIndicator: View {
    private let currentPage: Int
    private let numberOfPages: Int
    private let hidesForSinglePage: Bool
    private let config: Config
    private let progress: CGFloat
    
    private var adjustedIndex: Int {
        return currentPage < 0 ? numberOfPages : (currentPage > numberOfPages ? 0 : currentPage)
    }
    
    struct Config {
        var dotSize: CGFloat = 8
        var pageIndicatorHighlight: Color = .secondary
        var pageIndicatorNext: Color = .secondary
        var pageIndicatorLast: Color = .secondary
    }
    
    init(currentPage: Int, numberOfPages: Int, progress: CGFloat, hidesForSinglePage: Bool = true, config: Config = Config()) {
        self.currentPage = currentPage
        self.numberOfPages = numberOfPages - 1
        self.progress = min(max(progress, 0), 1)
        self.hidesForSinglePage = hidesForSinglePage
        self.config = config
    }
    
    var body: some View {
        HStack(spacing: 10) {
            ForEach(Array(0..<numberOfPages + 1), id: \.self) { index in
                Capsule()
                    .fill(index == adjustedIndex ? config.pageIndicatorHighlight : index < currentPage ? config.pageIndicatorLast : config.pageIndicatorNext)
                    .frame(width: index == currentPage ? config.dotSize * 2.5 : config.dotSize, height: config.dotSize)
                    .transition(.slide)
                    .animation(.easeInOut, value: currentPage)
                    .overlay(alignment: .leading) {
                        if index == currentPage && progress != 0 {
                            Capsule()
                                .frame(width: index == currentPage ? (config.dotSize * 2.5) * progress : config.dotSize, height: config.dotSize)
                                .animation(.linear(duration: 1), value: progress)
                        }
                            
                    }
            }
        }
        .padding(.vertical, 8)
        .padding(.horizontal, 12)
        .background(.thickMaterial, in: .capsule)
        .hidden(numberOfPages == 0 ? hidesForSinglePage : false)
        
    }
}

How to use

#Preview("Slime Progress Dot Indicator") {
    @Previewable @State var selectedTab = 0
    @Previewable @State var progress: CGFloat = 0

    let maxPages: Int = 5
    let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()

    VStack {
        TabView(selection: $selectedTab) {
            ForEach(0..<maxPages, id: \.self) { index in
                VStack {
                    Text("Page \(index + 1)")
                        .font(.largeTitle)
                        .padding()
                }
                .frame(maxWidth: .infinity, maxHeight: 280)
                .background(Color.blue.hueRotation(.degrees(Double(index) * 36)))
                .tag(index)
            }
            
        }
        .tabViewStyle(.page(indexDisplayMode: .never))
        
        SlimeProgressDotPageIndicator(currentPage: selectedTab, numberOfPages: maxPages, progress: progress)
        
    }
    .onChange(of: selectedTab, { oldValue, newValue in
        progress = 0
    })
    .onReceive(timer) { _ in
          progress += 0.1
          if progress >= 1.0 {
              withAnimation {
                  selectedTab = (selectedTab + 1) % maxPages
              }
              progress = 0
          }
      }
}

Upvotes: 0

Nikola Veljic
Nikola Veljic

Reputation: 73

The easiest way to do this is to use the current index. Based on the index, you can adjust the size of the views with the animation you showed us.

struct DotIndicatorView: View {
   
     @ObservedObject var sharedState: DotIndicatorViewState

      var body: some View {
        HStack {
            ForEach(0 ..< sharedState.numberOfDots, id: \.self) { index in
                Capsule()
                    .fill(blue(index == sharedState.currentIndex ? 1 : 0.5))
                    .frame(width: index == sharedState.currentIndex ? 20 : 10, height: 10)
                    .transition(.slide)
                    .animation(.easeInOut, value: sharedState.currentIndex)
            }
        }
        .padding()
    }
}

Upvotes: 2

MatBuompy
MatBuompy

Reputation: 2093

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!

Upvotes: 6

Related Questions