Reputation: 13
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:
But I want it to change to 2nd Image kind of:
Upvotes: 0
Views: 2107
Reputation: 2893
Based of @NikolaVeljic's, I adding a variation that incorporates a time progress indicator, similar to the Apple TV banner showcase
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
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
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:
If you are looking to support older iOS versions you can check this other video of his.
Let me know your thoughts!
Upvotes: 6