@@ -44,6 +44,7 @@ struct VerificationCodeInputField: View {
4444 digit: $digitFields [ index] ,
4545 isError: isError,
4646 isFocused: focusedIndex == index,
47+ maxDigits: codeLength - index,
4748 onDigitChanged: { newDigit in
4849 handleDigitChanged ( at: index, newDigit: newDigit)
4950 } ,
@@ -53,7 +54,9 @@ struct VerificationCodeInputField: View {
5354 onFocusChanged: { isFocused in
5455 DispatchQueue . main. async {
5556 if isFocused {
56- focusedIndex = index
57+ if focusedIndex != index {
58+ focusedIndex = index
59+ }
5760 } else if focusedIndex == index {
5861 focusedIndex = nil
5962 }
@@ -88,39 +91,61 @@ struct VerificationCodeInputField: View {
8891 }
8992
9093 private func handleDigitChanged( at index: Int , newDigit: String ) {
91- // Update the digit field only if it actually changed to avoid redundant state writes
92- if digitFields [ index] != newDigit {
93- digitFields [ index] = newDigit
94+ let sanitized = newDigit. filter { $0. isNumber }
95+
96+ guard !sanitized. isEmpty else {
97+ processSingleDigitInput ( at: index, digit: " " )
98+ return
99+ }
100+
101+ let firstDigit = String ( sanitized. prefix ( 1 ) )
102+ processSingleDigitInput ( at: index, digit: firstDigit)
103+
104+ let remainder = String ( sanitized. dropFirst ( ) )
105+ let availableSlots = max ( codeLength - ( index + 1 ) , 0 )
106+ if availableSlots > 0 {
107+ let trimmedRemainder = String ( remainder. prefix ( availableSlots) )
108+ if !trimmedRemainder. isEmpty {
109+ applyBulkInput ( startingAt: index + 1 , digits: trimmedRemainder)
110+ }
111+ }
112+ }
113+
114+ private func processSingleDigitInput( at index: Int , digit: String ) {
115+ if digitFields [ index] != digit {
116+ digitFields [ index] = digit
94117 }
95118
96- // Update the main code string
97119 let newCode = digitFields. joined ( )
98120 code = newCode
99121 onCodeChange ( newCode)
100-
101- // Move to next empty field if digit was entered (only when adding, not removing)
102- if !newDigit . isEmpty {
103- if let nextIndex = findNextEmptyField ( startingFrom : index ) {
104- DispatchQueue . main . async {
122+
123+ if ! digit. isEmpty ,
124+ let nextIndex = findNextEmptyField ( startingFrom : index ) {
125+ DispatchQueue . main . async {
126+ if focusedIndex != nextIndex {
105127 focusedIndex = nextIndex
106128 }
107129 }
108130 }
109131
110- // Check if code is complete
111132 if newCode. count == codeLength {
112133 DispatchQueue . main. async {
113134 onCodeComplete ( newCode)
114135 }
115136 }
116137 }
138+
117139
118140 private func handleBackspace( at index: Int ) {
119141 // If current field is empty, move to previous field and clear it
120142 if digitFields [ index] . isEmpty && index > 0 {
121143 digitFields [ index - 1 ] = " "
122144 DispatchQueue . main. async {
123- focusedIndex = index - 1
145+ let previousIndex = index - 1
146+ if focusedIndex != previousIndex {
147+ focusedIndex = previousIndex
148+ }
124149 }
125150 } else {
126151 // Clear current field
@@ -133,6 +158,41 @@ struct VerificationCodeInputField: View {
133158 onCodeChange ( newCode)
134159 }
135160
161+ private func applyBulkInput( startingAt index: Int , digits: String ) {
162+ guard !digits. isEmpty, index < codeLength else { return }
163+
164+ var updatedFields = digitFields
165+ var currentIndex = index
166+
167+ for digit in digits where currentIndex < codeLength {
168+ updatedFields [ currentIndex] = String ( digit)
169+ currentIndex += 1
170+ }
171+
172+ if digitFields != updatedFields {
173+ digitFields = updatedFields
174+ }
175+
176+ let newCode = updatedFields. joined ( )
177+ code = newCode
178+ onCodeChange ( newCode)
179+
180+ if newCode. count == codeLength {
181+ DispatchQueue . main. async {
182+ onCodeComplete ( newCode)
183+ }
184+ } else {
185+ let clampedIndex = max ( min ( currentIndex - 1 , codeLength - 1 ) , 0 )
186+ if let nextIndex = findNextEmptyField ( startingFrom: clampedIndex) {
187+ DispatchQueue . main. async {
188+ if focusedIndex != nextIndex {
189+ focusedIndex = nextIndex
190+ }
191+ }
192+ }
193+ }
194+ }
195+
136196 private func findNextEmptyField( startingFrom index: Int ) -> Int ? {
137197 // Look for the next empty field after the current index
138198 for i in ( index + 1 ) ..< codeLength {
@@ -154,13 +214,23 @@ private struct SingleDigitField: View {
154214 @Binding var digit : String
155215 let isError : Bool
156216 let isFocused : Bool
217+ let maxDigits : Int
157218 let onDigitChanged : ( String ) -> Void
158219 let onBackspace : ( ) -> Void
159220 let onFocusChanged : ( Bool ) -> Void
160-
161- @State private var borderWidth : CGFloat = 1
162- @State private var borderColor : Color = Color ( . systemFill)
163-
221+
222+ private var borderWidth : CGFloat {
223+ if isError { return 2 }
224+ if isFocused || !digit. isEmpty { return 3 }
225+ return 1
226+ }
227+
228+ private var borderColor : Color {
229+ if isError { return . red }
230+ if isFocused || !digit. isEmpty { return . accentColor }
231+ return Color ( . systemFill)
232+ }
233+
164234 var body : some View {
165235 BackspaceAwareTextField (
166236 text: $digit,
@@ -175,6 +245,7 @@ private struct SingleDigitField: View {
175245 onFocusChanged: { isFocused in
176246 onFocusChanged ( isFocused)
177247 } ,
248+ maxCharacters: maxDigits,
178249 configuration: { textField in
179250 textField. font = . systemFont( ofSize: 24 , weight: . medium)
180251 textField. textAlignment = . center
@@ -197,33 +268,6 @@ private struct SingleDigitField: View {
197268 )
198269 )
199270 . frame ( maxWidth: . infinity)
200- . onChange ( of: digit) { _, _ in
201- updateBorderAppearance ( )
202- }
203- . onChange ( of: isFocused) { oldValue, newValue in
204- updateBorderAppearance ( )
205- }
206- . onChange ( of: isError) { oldValue, newValue in
207- updateBorderAppearance ( )
208- }
209- . onAppear {
210- updateBorderAppearance ( )
211- }
212- }
213-
214- private func updateBorderAppearance( ) {
215- withAnimation ( . easeInOut( duration: 0.15 ) ) {
216- if isError {
217- borderWidth = 2
218- borderColor = . red
219- } else if isFocused || !digit. isEmpty {
220- borderWidth = 3
221- borderColor = . accentColor
222- } else {
223- borderWidth = 1
224- borderColor = Color ( . systemFill)
225- }
226- }
227271 }
228272}
229273
@@ -232,6 +276,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable {
232276 var isFirstResponder : Bool
233277 let onDeleteBackwardWhenEmpty : ( ) -> Void
234278 let onFocusChanged : ( Bool ) -> Void
279+ let maxCharacters : Int
235280 let configuration : ( UITextField ) -> Void
236281 let onTextChange : ( String ) -> Void
237282
@@ -267,10 +312,16 @@ private struct BackspaceAwareTextField: UIViewRepresentable {
267312 }
268313 }
269314
270- if isFirstResponder && !uiView. isFirstResponder {
271- uiView. becomeFirstResponder ( )
272- } else if !isFirstResponder && uiView. isFirstResponder {
273- uiView. resignFirstResponder ( )
315+ if isFirstResponder {
316+ if !context. coordinator. isFirstResponder {
317+ context. coordinator. isFirstResponder = true
318+ DispatchQueue . main. async { [ weak uiView] in
319+ guard let uiView, !uiView. isFirstResponder else { return }
320+ uiView. becomeFirstResponder ( )
321+ }
322+ }
323+ } else if context. coordinator. isFirstResponder {
324+ context. coordinator. isFirstResponder = false
274325 }
275326 }
276327
@@ -280,6 +331,7 @@ private struct BackspaceAwareTextField: UIViewRepresentable {
280331
281332 final class Coordinator : NSObject , UITextFieldDelegate {
282333 var parent : BackspaceAwareTextField
334+ var isFirstResponder = false
283335
284336 init ( parent: BackspaceAwareTextField ) {
285337 self . parent = parent
@@ -292,10 +344,12 @@ private struct BackspaceAwareTextField: UIViewRepresentable {
292344 }
293345
294346 func textFieldDidBeginEditing( _ textField: UITextField ) {
347+ isFirstResponder = true
295348 parent. onFocusChanged ( true )
296349 }
297350
298351 func textFieldDidEndEditing( _ textField: UITextField ) {
352+ isFirstResponder = false
299353 parent. onFocusChanged ( false )
300354 }
301355
@@ -308,13 +362,23 @@ private struct BackspaceAwareTextField: UIViewRepresentable {
308362 return true
309363 }
310364
311- guard string. allSatisfy ( { $0. isNumber } ) else {
365+ let digitsOnly = string. filter { $0. isNumber }
366+ guard !digitsOnly. isEmpty else {
312367 return false
313368 }
314369
315370 let currentText = textField. text ?? " "
316371 let nsCurrent = currentText as NSString
317- let updated = nsCurrent. replacingCharacters ( in: range, with: string)
372+
373+ if digitsOnly. count > 1 || string. count > 1 {
374+ let limit = max ( parent. maxCharacters, 1 )
375+ let truncated = String ( digitsOnly. prefix ( limit) )
376+ let proposed = nsCurrent. replacingCharacters ( in: range, with: truncated)
377+ parent. onTextChange ( String ( proposed. prefix ( limit) ) )
378+ return false
379+ }
380+
381+ let updated = nsCurrent. replacingCharacters ( in: range, with: digitsOnly)
318382 return updated. count <= 1
319383 }
320384 }
0 commit comments