March 9, 2024

57. Применяем маску с номером телефона

В этой статье покажу как можно применить маску с российским номером телефона к тексту внутри TextField без использования UIKit 🙂

Демо готовой вьюхи с тремя вариантами маски

Код для добавления маски

extension String {
  func makeMaskedNumber(_ mask: String) -> String {
    let cleanNumber = components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
    var result = ""
    var startIndex = cleanNumber.startIndex
    for char in mask where startIndex < cleanNumber.endIndex {
      if char == "X" {
        result.append(cleanNumber[startIndex])
        startIndex = cleanNumber.index(after: startIndex)
      } else {
        result.append(char)
      }
    }
    if let plusIndex = result.firstIndex(of: "+") {
      let nextIndex = result.index(after: plusIndex)
      let nextNumber = result[nextIndex]
      if nextNumber != "7" {
        var array = Array(result)
        array[1] = "7"
        array.insert(nextNumber, at: 2)
        result = array.map { String($0) }.joined()
      }
    }
    return result
  }
}

Код для вьюхи с текстфилдом

struct MaskedFieldExample: View {
  @State private var phoneNumber: String = ""
  let mask: String
   
  var body: some View {
    TextField("+7", text: $phoneNumber)
      .keyboardType(.phonePad)
      .textFieldStyle(.roundedBorder)
      .onReceive(Just(phoneNumber)) { newValue in
        if !newValue.isEmpty {
          phoneNumber = newValue.makeMaskedNumber(mask)
        }
      }
  }
}

#Preview

#Preview {
  VStack(spacing: 20) {
    MaskedFieldExample(mask: "(XXX) XXX-XX-XX")
    MaskedFieldExample(mask: "+X (XXX) XXX-XX-XX")
    MaskedFieldExample(mask: "+X XXX XXX-XX-XX")
  }
  .padding(.horizontal)
}

Нюансы

  1. Если использовать onChange, то в консоли выводится вот такой лог: onChange(of: String) action tried to update multiple times per frame, а с использованиeм onReceive такого нет
  2. В симуляторе и на девайсе маска применяется корректно и с onChange, и с onReceive, а в unit-тестах пробел после первой семерки не ставится, что приводит к падению теста (т.е. получается не +7 123, а +71 23). Судя по всему, SwiftUI-модификаторы делают дополнительную работу, которая в логике применения маски отсутствует
  3. Первая маска в превью подходит бОльшему количеству стран, чем остальные - они подходят только для телефонов России и Казахстана

Заключение

Мне понравилось, что можно без UITextFieldDelegate сделать нормальную маску в SwiftUI, и в целом она работает как ожидается (должна работать даже на iOS 13).

Код для этой статьи можно посмотреть тут, а другие статьи - тут.