Вводимо текст красиво

Сирі, але важливі дані на зразок номерів телефонів або кредиток — це саме те, що користувачі найчастіше вводять в наших програмах. І з цим є величезна проблема. Перевіряти 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 маска вводу = MaskImpl.createTerminated(PredefinedSlots.CARD_NUMBER_STANDART);
маска вводу.insertFront("5213100000000021");
Log.d("Card number", маска вводу.toString()); // Card number: 5213 1000 0000 0021
Log.d("RAW number", маска вводу.toUnformattedString()); // RAW number: 5213100000000021

І тепер для довільної маски:

Slot[] slots = new PhoneNumberUnderscoreSlotsParser().parseSlots("+86 (1__) ___-____");
Mask маска вводу = MaskImpl.createTerminated(slots);
маска вводу.insertFront("991112345");
Log.d("Phone number", маска вводу.toString()); // Phone number: +86 (199) 111-2345
Log.d("RAW phone", маска вводу.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
    Mask mask = MaskImpl.createTerminated(PredefinedSlots.RUS_PHONE_NUMBER);
    mask.setHideHardcodedHead(true);
    FormatWatcher formatWatcher = new MaskFormatWatcher(mask);
    formatWatcher.installOn(phoneEditText);
    




    Mask 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
    Mask mask = MaskImpl.createTerminated(PredefinedSlots.RUS_PHONE_NUMBER);
    mask.setForbidInputWhenFilled(true);
    FormatWatcher formatWatcher = new MaskFormatWatcher(mask);
    formatWatcher.installOn(phoneEditText);
    




    Mask 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) ***-**-**
    




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

Дякую за увагу!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.