I am going to preface my answer by pointing you to a wonderful post on creating an animating shapes: Blob, the Builder - A Step by Step Guide to SwiftUI Animation that does a great job of creating a shape and animating it. However, in the end, what he created was a Capsule() into which he could send inputs to change the size. That seemed a bit of overkill when we can do essentially the same thing with a Capsule() and a .frame().
The other thing I wanted to avoid was a GeometryReader. I like them and use them, but used incorrectly things can go sideways pretty quickly. This answer is all computed with just one spacer and one capsule each in its own frame underneath of the buttons. The code is commented below.
struct FluidSegmentedControl: View { // I converted your position variable to an array of RotationCase that represents the total range covered by the `Capsule`. At rest, it contains one element, the position. @State var range: [RotationCase] = [.P1] // The HStack below has no spacing in it. That was trick #1 to be able to line things up, so we need to add our padding into the width of the button. let textWidth: CGFloat = 50 let textHeight: CGFloat = 44 // This is the animation for the movement. You can play around with it. // Amos Gyamfi has an excellent resource at https://medium.com/@amosgyamfi/learning-swiftui-spring-animations-the-basics-and-beyond-4fb032212487 let animation: Animation = .spring(response: 0.7, dampingFraction: 0.7, blendDuration: 1.5) // This is how long the range contains the full array from start to finish, until it reverts to the single position element. let movementDuration = 0.4 // This allows us to line up the capsule properly. var offset: CGFloat { (textWidth - textHeight) / 2 } var body: some View { ZStack { // It is very important that the spcaing is set to 0 for both HStacks. We need to control it. HStack(spacing: 0) { ForEach(RotationCase.allCases) { rcase in Text(rcase.rawValue) .foregroundColor(.white) // Rather than change the color based on the selection to get the right contrast, a .blendMode(.difference) will cause the contrast to be pixel perfect .blendMode(.difference) .clipShape(Circle()) .onTapGesture { updateRange(rcase: rcase) } .frame(width: textWidth, height: textHeight) } } // There is only one capsule, and it is contained in an HStack in the background. .background( HStack(spacing: 0) { // By controlling the size of this Spacer(), we control where the capsule starts Spacer() .frame(width: leadingSpacerWidth()) Capsule() .fill(.white) .frame(width: capsuleWidth()) .offset(x: offset) // the capsule always needs to be offset a bit to be centered. Spacer() } ) } .frame(height: textHeight) .padding(10) .background(.black) .clipShape(Capsule()) .shadow(radius: 8) } // The capuleWidth is the number of elements in the array times the textWidth. This makes it stretch while there are multiple elements. private func capsuleWidth() -> CGFloat { let spaces = CGFloat(range.count) if spaces > 1 { return (spaces * textWidth) - (2 * offset) } else { return textHeight } } // Whatever the first element of the array is determines how many spaces we push the capsule over. The index is 0 based, so we don't push it at all for the first. private func leadingSpacerWidth() -> CGFloat { guard let first = range.first else { return 0 } return CGFloat(first.index) * textWidth } func updateRange(rcase: RotationCase) { // It may be possible for the user to click to fast before the animation is complete. This prevents that with an immediate return. if range.count > 1 { return } guard let position = range.first else { return } // We are moving to the right, so the rcase is the upper end in the array if rcase > position { withAnimation(animation) { // We sent the range to be an array slice of the total array of RotationCase range = Array(RotationCase.allCases[position.index...rcase.index]) } // We are moving to the left, so the rcase is the lower end in the array } else { withAnimation(animation) { range = Array(RotationCase.allCases[rcase.index...position.index]) } } // after some time, we tell the array to include only the position as an element. DispatchQueue.main.asyncAfter(deadline: .now() + movementDuration) { withAnimation(animation) { range = [rcase] } } } } enum RotationCase: String, CaseIterable, Identifiable, Comparable { case P1, P2, P3, P4, P5, P6 // This makes it Identifiable so we can remove the var id: String { self.rawValue } // This allows us to obtain the index for the enum var index: Int { switch self { case .P1: return 0 case .P2: return 1 case .P3: return 2 case .P4: return 3 case .P5: return 4 case .P6: return 5 } } static var total: Int { allCases.count } static func < (lhs: RotationCase, rhs: RotationCase) -> Bool { lhs.index < rhs.index } }