Сырые, но важные данные вроде номеров телефонов или кредиток — это именно то, что пользователи чаще всего вводят в наших приложениях. И с этим есть огромная проблема. Перепроверять 16 цифр своего Мастеркарда или 11 цифр номера телефона — это сущий ад для любого юзера. Решать эту проблему, естественно, приходится разработчикам, от лица которых я и пишу этот пост.

Поскольку современный Андроид не предоставляет инструментов для автоматического форматирования произвольного текста, эту задачу каждый решает своими костылями силами. Сначала в наших проектах эта задача решалась по месту: возникла необходимость — напиши свой TextWatcher и форматируй как надо. Но мы быстро поняли, что так делать не стоит — количество локальных костылей и специфических багов росло экспоненциально. Кроме того, задача весьма общая, так что и решать её надо системно.

Для начала хотелось следующего:

  1. Указал маску вроде +7 (___) ___-__-__
  2. Повесил её на EditText
  3. ...
  4. PROFIT

Со временем вкусы наши, как и требования к инструменту, возросли, а варианты с гитхаба не смогли в полной мере их удовлетворить. Так что мы решили со всей серьезностью создать свой уютненький модуль для решения поставленной задачи.

Начав работать над этим направлением, мы осознали, что создание полноценного языка описания формата — это сродни написанию своего RegEx-движка, что, честно говоря, в наши планы не входило. В итоге мы пришли к варианту, когда такой язык при необходимости можно добавить в любой момент (даже в клиентском коде) или пользоваться простеньким DSL, доступным из коробки (что в нашей практике решило 90% задач).

Посмотрев на то, что получилось, мы решили, что это круто, и надо бы поделиться с сообществом. Так у нас и родилась библиотека для Android-разработки Decoro. И сейчас я покажу пару фокусов из её арсенала.

Подключаем:

dependencies {
    compile "ru.tinkoff.decoro:decoro:1.1.1"
}

Допустим, нам надо попросить пользователя ввести серию и номер паспорта. Задача тривиальная — надо всего лишь добавить пробельчик и ограничить длину ввода:

Slot[] slots = new UnderscoreDigitSlotsParser().parseSlots("____ ______");
FormatWatcher formatWatcher = new MaskFormatWatcher( // форматировать текст будет вот он
    MaskImpl.createTerminated(slots)
); 
formatWatcher.installOn(passportEditText); // устанавливаем форматтер на любой TextView

В примере выше мы сделали три важных вещи:

  1. Описали маску ввода с помощью произвольной строки.
  2. Создали свой FormatWatcher и инициализировали его этой маской.
  3. Повесили FormatWatcher на EditText.


Вводим серию и номер паспорта.

Честно говоря, задачу про паспорт можно было решить чуть проще, для нее у нас есть уже есть заготовка:

FormatWatcher formatWatcher = new MaskFormatWatcher(
    MaskImpl.createTerminated(PredefinedSlots.RUS_PASSPORT) // маска для серии и номера
);
formatWatcher.installOn(passportEditText); // тут аргументом может быть любой TextView



Теперь, когда мы посмотрели на Decoro в действии, скажем пару слов о тех сущностях, которыми она оперирует.

  • Mask. Маска ввода — сердце нашей библиотеки. Именно она определяет, как украсить наши сырые данные. Маска оперирует слотами и может использоваться как самостоятельно, так и внутри FormatWatcher’а.
  • Slot. Внутри маски слот — это позиция, в которую можно вставить один единственный символ. Он определяет, какие именно символы можно вставить, и как это повлияет на соседние слоты. Подробнее о масках и слотах мы поговорим ниже.
  • PredefinedSlots содержит предустановленные наборы слотов (для номера телефона, паспорта и так далее)
  • FormatWatcher или форматтер — это абстрактная реализация TextWatcher’а. Он держит внутри себя маску и синхронизирует её содержимое с содержимым TextView. Именно этот парень используется для форматирования текста «на лету», пока пользователь его вводит. В коробке имеются реализации MaskFormatWatcher и DescriptorFormatWatcher, о различии между ними можно почитать в нашей вики. В этой же статье мы будем оперировать только MaskFormatWatcher, потому что он предоставляет более чистый и понятный API.
  • Иногда нам хочется создать маску на основе какого-нибудь DSL (вроде +1 (___) ___-__-__). SlotsParser как раз призван помочь нам это сделать. Обычный String он приводит к массиву слотов, которым умеет оперировать наша маска.

Что такое слот


Теперь чуть подробнее о том, как работает Decoro. Наша маска ввода определяет, как именно будет отформатирован пользовательский текст. И главный атрибут этой маски — связный список слотов, каждый из которых отвечает за один символ. Так что в примере с паспортом у нас после ввода получилась такая структура:


Каждый слот держит один символ и указатели на соседей. Красным я обозначил hardcoded слот, его значение изменить нельзя.

Для создания маски нам нужен массив слотов. Его можно создать вручную, можно взять готовый из класса PredefinedSlots, а можно использовать какую-нибудь реализацию интерфейса SlotsParser (например, упомянутый выше UnderscoreDigitSlotsParser) и получить этот массив из простой строки. UnderscoreDigitSlotsParser работает просто — для каждого символа _ он создаст слот, в который можно записывать только цифры (ведь для каждого слота можно еще и ограничить множество допустимых символов). А для всех остальных символов создаст hardcoded слоты, и в маску они войдут как есть (это и произошло с нашим пробелом). Подобным образом можно написать свой уникальный SlotsParser и получить возможность описывать маски на своем собственном DSL.

Когда мы только начинали работать над библиотекой, мы думали, что для слота будет достаточно двух поведений hardcoded/non-hardcoded. Казалось, что в красненькие символы складывать будет нельзя, а в беленькие можно. Но это была иллюзия.

Сначала выяснилось, что всё-таки надо позволить вставлять символ в hardcoded-слот. Но только тот символ, который там уже лежит. Иначе не работает функционал копировать-вставить. Допустим, в маску про российский номер телефона я пытаюсь вставить +79991112233 (в смысле, сделать paste), а у меня получается +7 (+799) 911-12-23. Добавили такую возможность. Однако, вскоре выяснилось, что и это поведение не всегда корректно. В итоге мы пришли к так называемым правилам вставки, которые накладываются на каждый слот отдельно.

Слоты организованы в двусвязный список, и каждый из них знает про своих соседей. Вставка или удаление символа в одном из слотов может привести к модификации его соседей. Приведет или нет — зависит от правил этого слота. Варианты правил такие:

  1. Режим вставки. Если не указывать специфичного правила, слот ведет себя как символ в вашем текстовом редакторе в обычном режиме. Попытаемся вставить на его место другой символ, и текущий уедет на следующую позицию и сдвинет весь текст. Новый же символ займет его место. По умолчанию слоты ведут себя точно также.


    Все слоты в режиме вставки.

  2. Режим замены. Это то же самое, что вводить текст с нажатой кнопкой INSERT на клавиатуре. Новое значение слота заменяет текущее, но не влияет на соседей.


    Все слоты в режиме замены.

  3. Hardcoded-режим. Новый символ «проталкивается» в следующий слот, а текущее значение не изменяется. Этот режим удобно комбинировать с режимом замены. В этом случае в hardcoded-слот можно вставить то же самое значение, которое в нем уже записано, и это не повлияет на соседей.


    При попытке вставки в начало «телефонной» маски символы проталкиваются через цепочку hardcoded-слотов +43 (.

Как выяснилось, эти простые правила позволяют описать маски практически для любых целей. Мы таким образом описываем телефонные номера (с произвольными кодами страны), даты и номера документов.

Интересный факт
Изначально мы описали только 2 правила: «вставка» и «hardcoded». А когда потребовалось правило про «замену», выяснилось, что оно реализовалось само собой — достаточно было не указывать ни первое, ни второе. Мы радовались как дети и мечтали, что все законы Вселенной можно описать набором вот таких примитивных правил.

Форматируем в коде


Но забудем на время про красоту ввода в EditText. Бывает и такое, что надо всего лишь разово отформатировать строку. Создавать для этого целый TextWatcher было бы излишним. Воспользуемся маской напрямую, без посредников.

Mask inputMask = MaskImpl.createTerminated(PredefinedSlots.CARD_NUMBER_STANDART);
inputMask.insertFront("5213100000000021");
Log.d("Card number", inputMask.toString()); // Card number: 5213 1000 0000 0021
Log.d("RAW number", inputMask.toUnformattedString()); // RAW number: 5213100000000021

И теперь для произвольной маски:

Slot[] slots = new PhoneNumberUnderscoreSlotsParser().parseSlots("+86 (1__) ___-____");
Mask inputMask = MaskImpl.createTerminated(slots);
inputMask.insertFront("991112345");
Log.d("Phone number", inputMask.toString()); // Phone number: +86 (199) 111-2345
Log.d("RAW phone", inputMask.toUnformattedString()); // RAW phone: +861991112345

Декоративные слоты


В примерах выше вы могли обратить внимание на метод Mask#toUnformattedString(). Он волшебным образом позволяет нам получить строку без лишней мишуры, с одними только данными. Сейчас расскажу, как это работает.

Каждый слот, помимо правил вставки и, собственно, значения, содержит еще и набор тэгов. Тэг — это просто Integer, и слот содержит их Set. Сам слот ничего с этими тэгами делать не умеет, может только хранить. Нужны они для внешнего мира (прямо как View#mKeyedTags только в плоской структуре). Тэгами можно пользоваться по своему усмотрению. Из коробки же доступен тэг Slot#TAG_DECORATION, который позволяет помечать слоты как декоративные.

Когда мы дергаем Mask#toString(), маска собирает значения со всех слотов и формирует из них единую строку. Вызов же Mask#toUnformattedString() пропускает декоративные слоты, что позволяет исключить из финальной строки незначимые символы (вроде пробелов и скобок).

Остается только пометить нужные слоты как декоративные. Если вы используете доступные из коробки наборы слотов (из класса PredefinedSlots), там декоративные уже помечены, так что вы просто берете и пользуетесь. Если же слоты создаются из строки, то эта работа ложится на SlotsParser. Из коробки создавать декоративные слоты умеет PhoneNumberUnderscoreSlotsParser. Декоративными он сделает все позиции, кроме цифр и плюса. Если же вы пишете свой SlotsParser, то пометить слот как декоративный помогут методы Slot#getTags() и Slot#withTags(Integer...).



И несколько слов о том, что еще умеет Decoro:

  • Бесконечные маски с помощью MaskImpl#createNonTerminated(). В них последний слот бесконечно копируется, и в маску можно вставить сколько угодно текста.

    Non-terminated mask
    FormatWatcher formatWatcher = new MaskFormatWatcher(
        MaskImpl.createNonTerminated(PredefinedSlots.RUS_PHONE_NUMBER)
    );
    formatWatcher.installOn(phoneEditText);
    



  • Скрытие/показ цепочки hardcoded-слотов в начале маски в зависимости от заполненности маски (Mask#setHideHardcodedHead()). Это полезно для полей ввода номера телефона.

    Hide hardcoded head

    MaskImpl mask = MaskImpl.createTerminated(PredefinedSlots.RUS_PHONE_NUMBER);
    mask.setHideHardcodedHead(true);
    FormatWatcher formatWatcher = new MaskFormatWatcher(mask);
    formatWatcher.installOn(phoneEditText);
    




    MaskImpl mask = MaskImpl.createTerminated(PredefinedSlots.RUS_PHONE_NUMBER);
    mask.setHideHardcodedHead(false); // default value
    FormatWatcher formatWatcher = new MaskFormatWatcher(mask);
    formatWatcher.installOn(phoneEditText);
    


  • Запрет ввода в заполненную маску. Mask#setForbidInputWhenFilled() позволяет запретить вводить новые символы, если все свободные места уже заняты.

    Forbid input when filled

    MaskImpl mask = MaskImpl.createTerminated(PredefinedSlots.RUS_PHONE_NUMBER);
    mask.setForbidInputWhenFilled(true);
    FormatWatcher formatWatcher = new MaskFormatWatcher(mask);
    formatWatcher.installOn(phoneEditText);
    




    MaskImpl mask = MaskImpl.createTerminated(PredefinedSlots.RUS_PHONE_NUMBER);
    mask.setForbidInputWhenFilled(false); // default value
    FormatWatcher formatWatcher = new MaskFormatWatcher(mask);
    formatWatcher.installOn(phoneEditText);
    


  • Отображение всей маски вне зависимости от заполненности (по умолчанию Mask#toString() вернет строку только до первого незаполненного символа). Mask#setShowingEmptySlots() позволяет включить отображение пустых слотов. На их месте будет отображаться placeholder (по умолчанию _), свой placeholder можно задать с помощью Mask#setPlaceholder(). Данная функция работает только при работе с маской напрямую и недоступна для использования внутри FormatWatcher’а.

    Set showing empty slots
    final Mask mask = MaskImpl.createTerminated(PredefinedSlots.RUS_PHONE_NUMBER);
    mask.setPlaceholder('*');
    mask.setShowingEmptySlots(true);
    Log.d("Mask", mask.toString()); // Mask: +7 (***) ***-**-**
    
    
    mask.insertFront("999");
    Log.d("Mask", mask.toString()); // Mask: +7 (999) ***-**-**
    



Найти исходники библиотеки, задать вопрос и сообщить о багах можно на гитхабе. Замечания, пожелания и предложения приветствуются.

Благодарю за внимание!
Поделиться с друзьями
-->

Комментарии (24)


  1. dmdev
    07.11.2016 13:41

    В приведенных примерах код страны захардкожен в маске, но что если я хочу форматировать номер телефона динамически, в зависимости от набранного кода страны? Аналогично с номером банковской карточки – он может быть произвольным от 12 до 19 цифр.


    1. meandnano
      07.11.2016 14:06

      Проблема пока открытая. Сейчас мы как раз работаем над безболезненной сменой маски во время ввода. Это не так просто сделать, как нам казалось, но есть перспективные идеи.


      Сейчас в собственном клиентском коде мы отслеживаем ввод через FormattedTextChangeListener и при необходимости меняем маску в форматтере. Для номеров карт работает хорошо.


      По поводу переменной длины маски можно сделать так: если мы поняли что вводим номер, например, маэстро (у них как раз длина переменная), создаем соответствующую нетерминированную маску из PredefinedSlots#CARD_NUMBER_MAESTRO. А чтобы ограничить 19-ю символами — можно такую максимальную длину выставить у EditText. Для других типов карт, соответственно, создаем терминированные маски.


    1. Ashot
      07.11.2016 15:36

      Для парсинга, форматирования и валидации номеров есть libphonenumber от google. Мощная штука (правда я пользовался только js версией)


  1. foxyrus
    07.11.2016 13:57
    +2

    Здесь тоже используется input mask?


    1. meandnano
      07.11.2016 14:52
      +2

      Непонятные значения у вас получаются потому, что данное поле ожидает ввода месяца и года в формате ММ.ГГГГ.


      33.6666 -> 09.6668 — это логичное преобразование. Парсер даты (java.text.SimpleDateFormat) догадался, что 33 месяца, это 2 года и 9 месяцев и прибавил нужные года.


      1. foxyrus
        07.11.2016 15:01

        Я то это понимаю, но никаких подсказок и контроля нет. Наверно туда нужен DateTimePicker — просил добавить несколько месяцев назад или хотя бы подсказку в Label.


  1. artemlight
    07.11.2016 14:10
    +7

    Года три назад в Сбербанке не могли решить какой-то элементарный вопрос с моей картой в отделении — система требовала паспорт гражданина РФ с серией и номеров из цифр, а у меня был паспорт гражданина Беларуси с серией из букв. В итоге через три месяца пришлось сменить банк — обслуживающая софтварь не смогла принять тот факт, что в серии бывают не только цифры, но и буквы.

    Похожая ситуация была и при поступлении в ВУЗ — десятибалльный сертификат РБ против пятибалльного российского. Невероятно умная и, наверное, очень дорогая система упорно не давала ввести больше одной цифры в поля ввода, а после нажатия кнопки «отправить» сказала, что наверное что-то здесь не так и стёрла все отметки.

    Я часто отправляю корреспонденцию в Европу — и некоторым российским роботам сложно принять тот факт, что в Польше, например, в индексе пять цифр и дефис. А польские роботы упорно пытаются вставить дефис туда, где он совсем-совсем не нужен. Причем так себя ведут даже те роботы, которым надо бы такие вещи знать — например, польская система электронного декларирования товаров на границе.

    А с номерами телефонными вообще всё сложно. Е.164 с плюсиком вообще мало кто использует для внутренних номеров — в Беларуси, например, принято звонить на мобильные через «восьмерку», в Украине — через ноль. Причем ведущий 0 префикса не нужно включать в формат е.164, а в Украине — нужно.

    Поэтому на всех подобных формах, имхо, просто необходима кнопка «мне не нужна твоя помощь, робот. Уходи».
    Иначе будут потерянные клиенты, недоходящая почта и единички вместо настоящего номера телефона клиентов.


    1. Spunreal
      07.11.2016 14:33
      +2

      У меня в паспорте серия и номер как I-AS XXXXXX
      В сбербанке обслуживаюсь уже лет 7 (ещё со студенческих лет), к небольшому сожалению. Не было проблем с вводом серии и номера.
      Возмоно ключевое слово здесь:
      система требовала паспорт гражданина РФ с серией и номеров цифр
      Тут уж вопрос к тому, кто требовал паспорт РФ, а не иностранного гражданина.

      Во многих сервисах, где есть возможность указать иное гражданство обычно для РФ точный шаблон из цифр, а для всего остального просто текстовое поле, куда можно вписать что угодно.


    1. Fen1kz
      07.11.2016 15:02

      Сначала я подумал, что делать альтернативный, минимальный plain-form, без клиентской валидации и наворотов это хорошая идея, а потом понял, что ведь робот на клиенте не пускает к серверу, который точно так же не поймет других индексов/телефонов/паспортов


  1. sinneren
    07.11.2016 15:04

    Помню, были у вас проблемы в приложении с вставкой телефона из буфера — чёрти как обрезал, мешал предустановленный код города вроде. Ну и в мобильной версии сайта(с андроида) тоже шляпа полная с инпутами материализа — пришла смс на вход и ввести код почти нереально было, т.к. сбрасывался вечно фокус на плейсхолдер


    1. meandnano
      07.11.2016 15:05

      Проверьте на актуальной версии приложения.


  1. Aquahawk
    07.11.2016 16:20

    Вот на прошлой неделе у меня знакомый где-то в европах не мог привязать интернет банк(банк неизвестен мне) к местной симке именно из за +7 захардкоженой. Он утратил РФ симку, и остался в очень неудобном положении из за этого. Хоть и имея евро симку какую-то.


  1. HotIceCream
    07.11.2016 16:29

    Выглядит не плохо. Если решите вопрос с различным форматированием телефонных номеров в зависимости от кода страны, то будет очень полезной либой.


    1. meandnano
      07.11.2016 16:34

      Если выбор страны (вместе с кодом) у вас реализован через отдельный контрол, то никаких проблем нет: юзер выбрал страну, в EditText подставилась соответствующая маска. Мы такое делали, работает.


    1. Ashot
      07.11.2016 19:33

      Я немного выше писал про готовое решение


  1. printercu
    07.11.2016 16:43

    ИМО, лучше как можно раньше перейти с ____-__-__ на \d{3}-\d{2}-\d{2} (или хотя бы {3}-{2}-{2}), чтобы потом меньше переделывать и не считать курсором подчеркивания. При том что парсер можно сразу не делать навороченым, а поддерживать только небольшую грамматику.


    1. vintage
      08.11.2016 09:11

      Лучше так: +0 (123) 456-78-90
      Любая буква/цифра — плейсхолдер для соответственно буквы/цифры.


  1. dmitryredkin
    07.11.2016 18:27
    +2

    Вот убил бы всех этих горе-писателей масок в браузерах. Каждый раз, когда на сайте очередного магазина нужно ввести телефон из-под Андроидного хрома, и я вижу маску — я мысленно чертыхаюсь.
    Это каждый раз новые глюки. Иногда так и не удается их победить и приходится идти до большого брата.
    Ну НАХРЕНА ради сомнительной красивости жертвовать практически целой платформой?


  1. megatron
    08.11.2016 11:58

    В мобильном тинькове с этими масками без ругани ни один платеж не могу сделать.
    Например, пополнить телефон: из буфера номера с 8 вставляются после +7. Если вводить руками, то на поле ввода не смотришь обычно, привычно набирая +7, но у вас после плюса 7 подставляется сразу.
    С другими полями еще большие проблемы.
    Например, поле даты платежки ЖКУ: привычно ввожу 10.16 нажимаю Окей — в поле вставляется 10.0016.


  1. amarao
    08.11.2016 12:30

    А почему только +7? У меня вот, номер +357, и я ваш клиент.


    1. meandnano
      08.11.2016 14:48

      Decoro не ограничивает разработчика каким-то одним кодом для страны (просто из коробки маска с +7 доступна как одна из самых часто используемых).


      1. vintage
        08.11.2016 15:00

        Не стоит потакать плохим практикам использования. Многие разработчики подумают, что "так и надо" и так и будут лепить её везде. А страдают от этого прежде всего пользователи. Надо ответственней относиться к тому, что вы публикуете от имени известной компании.


        1. willykolepniy
          10.11.2016 13:04

          Не стоит потакать плохим практикам использования. — какими плохими практиками?
          Многие разработчики подумают, что «так и надо» и так и будут лепить её везде. — Ты откуда знаешь как ведут себя многие разработчики?


          1. vintage
            10.11.2016 13:16
            +1

            Практика хардкодить код страны, экономя пользователю целое одно нажатие.


            Я вижу как одни и те же антипаттерны бездумно копипастятся из приложения в приложение.