If I'm being honest, it was quiet fun to build ! Don't forget to mark this answer as the right one if it solved your issue. ✅
PROBLEM
The jumping effect is due to SwiftUI updating all views positions based on available space calculated based on your content (passcode digits). The font, font weight, text size, etc… all has an effect on the available space left for other views.
SOLUTION
To avoid that, you need to a predefined frame that will let the parent view know that your digits will never take more space. Doing so, each update won't effect the position of any other view because the allocated top space would always be size you specified and not the digits sizes (or absence).
CODE
import SwiftUI
import Combine
// Using Combine to manage digits and future network calls…
class PasscodeManager: ObservableObject {
let codesQuantity = 4
@Published var codes = [Int]()
}
struct PasscodeView: View {
@StateObject private var manager = PasscodeManager()
var body: some View {
VStack {
Spacer()
// Dots placeholders and passcode digits
selectedCodes
Spacer()
// Numberpad
PasscodeLine(numbers: 1...3) { add(number: $0) }
PasscodeLine(numbers: 4...6) { add(number: $0) }
PasscodeLine(numbers: 7...9) { add(number: $0) }
PasscodeLine(numbers: 0...0) { add(number: $0) }
Spacer()
}
.padding()
}
var selectedCodes: some View {
let minDots = manager.codes.count == manager.codesQuantity ? 0:1
let maxDots = manager.codesQuantity - manager.codes.count
return HStack(spacing: 32) {
ForEach(manager.codes, id: \.self) { Text("\($0)") }
if maxDots != 0 {
ForEach(minDots...maxDots, id: \.self) { _ in
Circle().frame(width: 12)
}
}
}
.font(.title.bold())
// Setting a default height should fix your problem.
.frame(height: 70)
}
func add(number: Int) {
guard manager.codes.count < manager.codesQuantity else { return }
manager.codes.append(number)
}
}
struct PasscodeLine: View {
let numbers: ClosedRange<Int>
var select: (Int) -> Void
var body: some View {
HStack {
ForEach(numbers, id: \.self) { number in
Spacer()
Button(action: { select(number) },
label: {
Text("\(number)")
.font(.title)
.fontWeight(.medium)
.foregroundColor(Color(.label))
.padding(32)
.background(Color(.quaternarySystemFill))
.clipShape(Circle())
})
}
Spacer()
}
}
}
RESULT
