Русский
Русский
English
Статистика
Реклама

Edittext

Из песочницы Редактор кода на Android часть 1

02.07.2020 20:17:59 | Автор: admin

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

Вступление


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

Я решил разделить статью на 2 части, в первой мы поэтапно напишем оптимизированную подсветку синтаксиса и нумерацию строк, а во второй добавим автодополнение кода и подсветку ошибок.

Для начала составим список того, что наш редактор должен уметь:

  • Подсвечивать синтаксис
  • Отображать нумерацию строк
  • Показывать варианты автодополнения (расскажу во второй части)
  • Подсвечивать синтаксические ошибки (расскажу во второй части)

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

MVP простой текстовый редактор


На данном этапе проблем возникнуть не должно растягиваем EditText на весь экран, указываем gravity, прозрачный background чтобы убрать полосу снизу, размер шрифта, цвет текста и т.д. Я люблю начинать с визуальной части, так мне становится проще понять чего не хватает в приложении, и над какими деталями ещё стоит поработать.

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

Подсветка синтаксиса


Как только мы ознакомились с требованиями к редактору, пора переходить к самому интересному.

Очевидно, чтобы контролировать весь процесс реагировать на ввод, отрисовывать номера строк, нам придется писать CustomView наследуясь от EditText. Накидываем TextWatcher чтобы слушать изменения в тексте и переопределяем метод afterTextChanged, в котором и будем вызывать метод отвечающий за подсветку:

class TextProcessor @JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = R.attr.editTextStyle) : EditText(context, attrs, defStyleAttr) {    private val textWatcher = object : TextWatcher {        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}        override fun afterTextChanged(s: Editable?) {            syntaxHighlight()        }    }    private fun syntaxHighlight() {        // Тут будем подсвечивать текст    }}

Вопрос: Почему мы используем TextWatcher как переменную, ведь можно реализовать интерфейс прямо в классе?

Ответ: Так уж получилось, что у TextWatcher есть метод который конфликтует c уже существующим методом у TextView:

// Метод TextWatcherfun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)// Метод TextViewfun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)

Оба этих метода имеют одинаковое название и одинаковые аргументы, да и смысл вроде у них тот же, но проблема в том что метод onTextChanged у TextView вызовется вместе с onTextChanged у TextWatcher. Если проставить логи в тело метода, то увидим что onTextChanged вызовется дважды:


Это очень критично если мы планируем добавлять функционал Undo/Redo. Также нам может понадобится момент, в котором не будут работать слушатели, в котором мы сможем очищать стэк с изменениями текста. Мы ведь не хотим, чтобы после открытия нового файла можно было нажать Undo и получить совершенно другой текст. Хоть об Undo/Redo в этой статье говориться не будет, важно учитывать этот момент.

Соответственно, чтобы избежать такой ситуации можно использовать свой метод установки текста вместо стандартного setText:

fun processText(newText: String) {    removeTextChangedListener(textWatcher)    // undoStack.clear()    // redoStack.clear()    setText(newText)    addTextChangedListener(textWatcher)}

Но вернёмся к подсветке.

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

Сейчас нам важно знать только две вещи:

  1. Pattern определяет что конкретно нам нужно найти в тексте
  2. Matcher будет пробегать по всему тексту в попытках найти то, что мы указали в Pattern

Может не совсем корректно описал, но принцип работы такой.

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

private val KEYWORDS = Pattern.compile(    "\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b")

Конечно, слов тут должно быть гораздо больше, а ещё нужны паттерны для комментариев, строк, чисел и т.д. но моя задача заключается в демонстрации принципа, по которому можно найти нужный контент в тексте.

Далее с помощью Matcher мы пройдёмся по всему тексту и установим спаны:

private fun syntaxHighlight() {    val matcher = KEYWORDS.matcher(text)    matcher.region(0, text.length)    while (matcher.find()) {        text.setSpan(            ForegroundColorSpan(Color.parseColor("#7F0055")),            matcher.start(),            matcher.end(),            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE        )    }}

Поясню: мы получаем объект Matcher у Pattern, и указываем ему область для поиска в символах (Соответственно с 0 по text.length это весь текст). Далее вызов matcher.find() вернёт true если в тексте было найдено совпадение, а с помощью вызовов matcher.start() и matcher.end() мы получим позиции начала и конца совпадения в тексте. Зная эти данные, мы можем использовать метод setSpan для раскраски определённых участков текста.

Существует много видов спанов, но для перекраски текста обычно используется ForegroundColorSpan.

Итак, запускаем!


Результат соответствует ожиданиям ровно до того момента, пока мы не начнём редактировать большой файл (на скриншоте файл в ~1000 строк)

Дело в том что метод setSpan работает медленно, сильно нагружая UI Thread, а учитывая что метод afterTextChanged вызывается после каждого введенного символа, писать код становится одним мучением.

Поиск решения


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

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

Точно! Так и сделаем! Вот только как?

Оптимизация


Хоть я и упомянул что нас заботит только производительность метода setSpan, всё же рекомендую выносить работу RegEx в фоновой поток чтобы добиться максимальной плавности.

Нам нужен класс, который будет в фоне обрабатывать весь текст и возвращать список спанов.
Конкретной реализации приводить не буду, но если кому интересно то я использую AsyncTask работающий на ThreadPoolExecutor. (Да-да, AsyncTask в 2020)

Нам главное, чтобы выполнялась такая логика:

  1. В beforeTextChanged останавливаем Task который парсит текст
  2. В afterTextChanged запускаем Task который парсит текст
  3. По окончанию своей работы, Task должен вернуть список спанов в TextProcessor, который в свою очередь подсветит только видимую часть

И да, спаны тоже будем писать свои собственные:

data class SyntaxHighlightSpan(    private val color: Int,    val start: Int,    val end: Int) : CharacterStyle() {    // можно заморочиться и добавить italic, например, только для комментариев    override fun updateDrawState(textPaint: TextPaint?) {        textPaint?.color = color    }}

Таким образом, код редактора превращается в нечто подобное:

Много кода
class TextProcessor @JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = R.attr.editTextStyle) : EditText(context, attrs, defStyleAttr) {    private val textWatcher = object : TextWatcher {        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {            cancelSyntaxHighlighting()        }        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}        override fun afterTextChanged(s: Editable?) {            syntaxHighlight()        }    }    private var syntaxHighlightSpans: List<SyntaxHighlightSpan> = emptyList()    private var javaScriptStyler: JavaScriptStyler? = null    fun processText(newText: String) {        removeTextChangedListener(textWatcher)        // undoStack.clear()        // redoStack.clear()        setText(newText)        addTextChangedListener(textWatcher)        // syntaxHighlight()    }    private fun syntaxHighlight() {        javaScriptStyler = JavaScriptStyler()        javaScriptStyler?.setSpansCallback { spans ->            syntaxHighlightSpans = spans            updateSyntaxHighlighting()        }        javaScriptStyler?.runTask(text.toString())    }    private fun cancelSyntaxHighlighting() {        javaScriptStyler?.cancelTask()    }    private fun updateSyntaxHighlighting() {        // подсветка видимой части будет тут    }}


Т.к конкретной реализации обработки в фоне я не показал, представим что мы написали некий JavaScriptStyler, который в фоне будет делать всё тоже самое что мы делали до этого в UI Thread пробегать по всему тексту в поисках совпадений и заполнять список спанов, а в конце своей работы вернёт результат в setSpansCallback. В этот момент запустится метод updateSyntaxHighlighting, который пройдётся по списку спанов и отобразит только те, что видны в данный момент на экране.

Как понять, какой текст попадает в видимую область?


Буду ссылаться на эту статью, там автор предлагает использовать примерно такой способ:

val topVisibleLine = scrollY / lineHeightval bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height - высота Viewval lineStart = layout.getLineStart(topVisibleLine)val lineEnd = layout.getLineEnd(bottomVisibleLine)

И он работает! Теперь вынесем topVisibleLine и bottomVisibleLine в отдельные методы и добавим пару дополнительных проверок, на случай если что-то пойдёт не так:

Новые методы
private fun getTopVisibleLine(): Int {    if (lineHeight == 0) {        return 0    }    val line = scrollY / lineHeight    if (line < 0) {        return 0    }    return if (line >= lineCount) {        lineCount - 1    } else line}private fun getBottomVisibleLine(): Int {    if (lineHeight == 0) {        return 0    }    val line = getTopVisibleLine() + height / lineHeight + 1    if (line < 0) {        return 0    }    return if (line >= lineCount) {        lineCount - 1    } else line}


Последнее что остаётся сделать пройтись по полученному списку спанов и раскрасить текст:

for (span in syntaxHighlightSpans) {    val isInText = span.start >= 0 && span.end <= text.length    val isValid = span.start <= span.end    val isVisible = span.start in lineStart..lineEnd            || span.start <= lineEnd && span.end >= lineStart    if (isInText && isValid && isVisible)) {        text.setSpan(            span,            if (span.start < lineStart) lineStart else span.start,            if (span.end > lineEnd) lineEnd else span.end,            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE        )    }}

Не пугайтесь страшного if'а, он всего лишь проверяет попадает ли спан из списка в видимую область.

Ну что, работает?


Работает, вот только при редактировании текста спаны не обновляются, исправить ситуацию можно очистив текст от всех спанов перед наложением новых:

// Примечание: метод getSpans из библиотеки core-ktxval textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)for (span in textSpans) {    text.removeSpan(span)}

Ещё один косяк после закрытия клавиатуры кусок текста остаётся неподсвеченным, исправляем:

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {    super.onSizeChanged(w, h, oldw, oldh)    updateSyntaxHighlighting()}

Главное не забыть указать adjustResize в манифесте.

Скроллинг


Говоря про скроллинг снова буду ссылаться на эту статью. Автор предлагает ждать 500 мс после окончания скроллинга, что противоречит моему чувству прекрасного. Я не хочу дожидаться пока прогрузится подсветка, я хочу видеть результат моментально.

Так же автор приводит аргумент что запускать парсер после каждого проскроленного пикселя затратно, и я полностью с этим согласен (вообще рекомендую полностью ознакомится с его статьей, она небольшая, но там много интересного). Но дело в том, что у нас уже есть готовый список спанов, и нам не нужно запускать парсер.

Достаточно вызывать метод отвечающий за обновление подсветки:

override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {    super.onScrollChanged(horiz, vert, oldHoriz, oldVert)    updateSyntaxHighlighting()}

Нумерация строк


Если мы добавим в разметку ещё один TextView то будет проблематично их между собой связать (например, синхронно обновлять размер текста), да и если у нас большой файл то придется полностью обновлять текст с номерами после каждой введенной буквы, что не очень круто. Поэтому будем использовать стандартные средства любой CustomView рисование на Canvas в onDraw, это и быстро, и не сложно.

Для начала определим что будем рисовать:

  • Номера строк
  • Вертикальную линию, отделяющую поле ввода от номеров строк

Предварительно необходимо вычислить и установить padding слева от редактора, чтобы не рисовать поверх написанного кода.

Для этого напишем функцию, которая будет обновлять отступ перед отрисовкой:

Обновление отступа
private var gutterWidth = 0private var gutterDigitCount = 0private var gutterMargin = 4.dpToPx() // отступ от разделителя в пикселях...private fun updateGutter() {    var count = 3    var widestNumber = 0    var widestWidth = 0f    gutterDigitCount = lineCount.toString().length    for (i in 0..9) {        val width = paint.measureText(i.toString())        if (width > widestWidth) {            widestNumber = i            widestWidth = width        }    }    if (gutterDigitCount >= count) {        count = gutterDigitCount    }    val builder = StringBuilder()    for (i in 0 until count) {        builder.append(widestNumber.toString())    }    gutterWidth = paint.measureText(builder.toString()).toInt()    gutterWidth += gutterMargin    if (paddingLeft != gutterWidth + gutterMargin) {        setPadding(gutterWidth + gutterMargin, gutterMargin, paddingRight, 0)    }}


Пояснение:

Для начала мы узнаем кол-во строк в EditText (не путать с кол-вом "\n" в тексте), и берем кол-во символов от этого числа. Например, если у нас 100 строк, то переменная gutterDigitCount будет равна 3, потому что в числе 100 ровно 3 символа. Но допустим, у нас всего 1 строка а значит отступ в 1 символ будет визуально казаться маленьким, и для этого мы используем переменную count, чтобы задать минимально отображаемый отступ в 3 символа, даже если у нас меньше 100 строк кода.

Эта часть была самая запутанная из всех, но если вдумчиво прочитать несколько раз (поглядывая на код), то всё станет понятно.

Далее вычисляем и устанавливаем отступ на основе имеющихся widestNumber и widestWidth.

Приступим к рисованию


К сожалению, если мы хотим использовать стандартный андройдовский перенос текста на новую строку то придется поколдовать, что займет у нас много времени и ещё больше кода, которого хватит на целую статью, поэтому дабы сократить ваше время (и время модератора хабра), мы включим горизонтальный скроллинг, чтобы все строки шли одна за другой:

setHorizontallyScrolling(true)

Ну а теперь можно приступать к рисованию, объявим переменные с типом Paint:

private val gutterTextPaint = Paint() // Нумерация строкprivate val gutterDividerPaint = Paint() // Отделяющая линия

Где-нибудь в init блоке установим цвет текста и цвет разделителя. Важно помнить, что если вы поменяйте шрифт текста, то шрифт Paint'а придется применять вручную, для этого советую переопределить метод setTypeface. Аналогично и с размером текста.

После чего переопределяем метод onDraw:

override fun onDraw(canvas: Canvas?) {    updateGutter()    super.onDraw(canvas)    var topVisibleLine = getTopVisibleLine()    val bottomVisibleLine = getBottomVisibleLine()    val textRight = (gutterWidth - gutterMargin / 2) + scrollX    while (topVisibleLine <= bottomVisibleLine) {        canvas?.drawText(            (topVisibleLine + 1).toString(),            textRight.toFloat(),            (layout.getLineBaseline(topVisibleLine) + paddingTop).toFloat(),            gutterTextPaint        )        topVisibleLine++    }    canvas?.drawLine(        (gutterWidth + scrollX).toFloat(),        scrollY.toFloat(),        (gutterWidth + scrollX).toFloat(),        (scrollY + height).toFloat(),        gutterDividerPaint    )}

Смотрим на результат




Выглядит круто.

Что же мы сделали в методе onDraw? Перед вызовом super-метода мы обновили отступ, после чего отрисовали номера только в видимой области, ну и под конец провели вертикальную линию, визуально отделяющую нумерацию строк от редактора кода.

Для красоты можно ещё перекрасить отступ в другой цвет, визуально выделить строку на которой находится курсор, но это я уже оставлю на ваше усмотрение.

Заключение


В этой статье мы написали отзывчивый редактор кода с подсветкой синтаксиса и нумерацией строк, а в следующей части добавим удобное автодополнение кода и подсветку синтаксических ошибок прямо во время редактирования.

Также оставлю ссылку на исходники моего редактора кода на GitHub, там вы найдёте не только те фичи о которых я рассказал в этой статье, но и много других которые остались без внимания.

Задавайте вопросы и предлагайте темы для обсуждения, ведь я вполне мог что-то упустить.

Спасибо!
Подробнее..

Редактор кода на Android часть 2

13.07.2020 20:06:08 | Автор: admin

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

Перед дальнейшим прочтением настоятельно рекомендую ознакомиться с первой частью.

Вступление


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

В этой части мы добавим автодополнение кода и подсветку ошибок.

Автодополнение кода


Для начала представим как это должно работать:

  1. Пользователь пишет слово
  2. После ввода N первых символов появляется окошко с подсказками
  3. При нажатии на подсказку слово автоматически допечатывается
  4. Окошко с подсказками закрывается, и курсор переносится в конец слова
  5. Если пользователь ввел слово отображаемое в подсказке сам, то окошко с подсказками должно автоматически закрыться

Ничего не напоминает? В андройде уже есть компонент с точно такой же логикой MultiAutoCompleteTextView, поэтому писать костыли с PopupWindow нам не придется (их уже написали за нас).

Первым шагом поменяем родителя у нашего класса:

class TextProcessor @JvmOverloads constructor(    context: Context,    attrs: AttributeSet? = null,    defStyleAttr: Int = R.attr.autoCompleteTextViewStyle) : MultiAutoCompleteTextView(context, attrs, defStyleAttr)

Теперь нам нужно написать ArrayAdapter который будет отображать найденные результаты. Полного кода адаптера не будет, примеры реализации можно найти в интернете. Но на моменте с фильтрацией я всё таки остановлюсь.

Чтобы ArrayAdapter мог понимать какие подсказки нужно отобразить, нам нужно переопределить метод getFilter:

override fun getFilter(): Filter {    return object : Filter() {        private val suggestions = mutableListOf<String>()        override fun performFiltering(constraint: CharSequence?): FilterResults {            // ...        }        override fun publishResults(constraint: CharSequence?, results: FilterResults) {            clear() // необходимо очистить старый список            addAll(suggestions)            notifyDataSetChanged()        }    }}

И в методе performFiltering наполнить список suggestions из слов, основываясь на слове которое начал вводить пользователь (содержится в переменной constraint).

Откуда взять данные перед фильтрацией?


Тут всё зависит от вас можно использовать какой-нибудь интерпретатор для подбора только валидных вариантов, либо сканировать весь текст при открытии файла. Для простоты примера я буду использовать уже готовый список вариантов автодополнения:

private val staticSuggestions = mutableListOf(    "function",    "return",    "var",    "const",    "let",    "null"    ...)...override fun performFiltering(constraint: CharSequence?): FilterResults {    val filterResults = FilterResults()    val input = constraint.toString()    suggestions.clear() // очищаем старый список    for (suggestion in staticSuggestions) {        if (suggestion.startsWith(input, ignoreCase = true) &&             !suggestion.equals(input, ignoreCase = true)) {            suggestions.add(suggestion)        }    }    filterResults.values = suggestions    filterResults.count = suggestions.size    return filterResults}

Логика фильтрации тут довольно примитивная, проходимся по всему списку и игнорируя регистр сравниваем начало строки.

Установили адаптер, пишем текст не работает. Что не так? По первой ссылке в гугле натыкаемся на ответ, в котором говорится что мы забыли установить Tokenizer.

Для чего нужен Tokenizer?


Говоря простым языком, Tokenizer помогает MultiAutoCompleteTextView понять, после какого введенного символа можно считать ввод слова завершенным. Также у него есть готовая реализация в виде CommaTokenizer с разделением слов на запятые, что в данном случае нам не подходит.

Что ж, раз CommaTokenizer нас не устраивает, тогда напишем свой:

Кастомный Tokenizer
class SymbolsTokenizer : MultiAutoCompleteTextView.Tokenizer {    companion object {        private const val TOKEN = "!@#$%^&*()_+-={}|[]:;'<>/<.? \r\n\t"    }    override fun findTokenStart(text: CharSequence, cursor: Int): Int {        var i = cursor        while (i > 0 && !TOKEN.contains(text[i - 1])) {            i--        }        while (i < cursor && text[i] == ' ') {            i++        }        return i    }    override fun findTokenEnd(text: CharSequence, cursor: Int): Int {        var i = cursor        while (i < text.length) {            if (TOKEN.contains(text[i - 1])) {                return i            } else {                i++            }        }        return text.length    }    override fun terminateToken(text: CharSequence): CharSequence = text}


Разбираемся:
TOKEN строка с символами которые отделяют одно слово от другого. В методах findTokenStart и findTokenEnd мы проходимся по тексту в поисках этих самых отделяющих символов. Метод terminateToken позволяет вернуть измененный результат, но нам он не нужен, поэтому просто возвращаем текст без изменений.

Ещё я предпочитаю добавлять задержку на ввод в 2 символа перед отображением списка:

textProcessor.threshold = 2

Устанавливаем, запускаем, пишем текст работает! Вот только почему-то окошко с подсказками странно себя ведет отображается во всю ширину, высота у него маленькая, да и по идее оно ведь должно появляться под курсором, как будем фиксить?

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


Вот тут и начинается самое интересное, ведь API позволяет нам изменять не только размеры окна, но и его положение.

Для начала определимся с размерами. На мой взгляд, наиболее удобным вариантом будет окошко размером с половину от высоты и ширины экрана, но т.к размер нашей View изменяется в зависимости от состояния клавиатуры, подбирать размеры будем в методе onSizeChanged:

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {    super.onSizeChanged(w, h, oldw, oldh)    updateSyntaxHighlighting()    dropDownWidth = w * 1 / 2    dropDownHeight = h * 1 / 2}

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

Если с перемещением по X всё довольно просто берем координату начала буквы и устанавливаем это значение в dropDownHorizontalOffset, то с подбором высоты будет сложнее.

Гугля про свойства шрифтов можно наткнуться на вот такой пост. Картинка которую прикрепил автор наглядно показывает, какими свойствами мы можем воспользоваться для вычисления вертикальной координаты.

Судя по картинке, Baseline это то что нам нужно. Именно на этом уровне и должно появляться окошко с вариантами автодополнения.

Теперь напишем функцию, которую будем вызывать при изменении текста в onTextChanged:

private fun onPopupChangePosition() {    val line = layout.getLineForOffset(selectionStart) // строка с курсором    val x = layout.getPrimaryHorizontal(selectionStart) // координата курсора    val y = layout.getLineBaseline(line) // тот самый baseline    val offsetHorizontal = x + gutterWidth // нумерация строк тоже часть отступа    dropDownHorizontalOffset = offsetHorizontal.toInt()    val offsetVertical = y - scrollY // -scrollY чтобы не "заезжать" за экран    dropDownVerticalOffset = offsetVertical}

Вроде ничего не забыли смещение по X работает, но смещение по Y рассчитывается неправильно. Это потому что мы не указали dropDownAnchor в разметке:

android:dropDownAnchor="@id/toolbar"

Указав Toolbar в качестве dropDownAnchor мы даём виджету понять, что выпадающий список будет отображаться под ним.

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

val offset = offsetVertical + dropDownHeightif (offset < getVisibleHeight()) {    dropDownVerticalOffset = offsetVertical} else {    dropDownVerticalOffset = offsetVertical - dropDownHeight}...private fun getVisibleHeight(): Int {    val rect = Rect()    getWindowVisibleDisplayFrame(rect)    return rect.bottom - rect.top}

Нам не нужно изменять отступ если сумма offsetVertical + dropDownHeight меньше видимой высоты экрана, ведь в таком случае окошко помещается под курсором. Но если всё таки больше, то вычитаем из отступа dropDownHeight так оно поместится над курсором без огромного отступа который добавляет сам виджет.

P.S На гифке можно заметить промаргивания клавиатуры, и честно говоря я не знаю как это исправить, поэтому если у вас есть решение пишите.

Подсветка ошибок


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

Как парсить будем?


Запуск Rhino довольно тяжелая операция, поэтому запускать парсер после каждого введенного символа (как мы делали с подсветкой) вообще не вариант. Для решения этой проблемы я буду использовать библиотеку RxBinding, а для тех кто не хочет тащить в проект RxJava можно попробовать подобные варианты.

Оператор debounce поможет нам добиться желаемого, а если вы с ним не знакомы то советую почитать вот эту статью.

textProcessor.textChangeEvents()    .skipInitialValue()    .debounce(1500, TimeUnit.MILLISECONDS)    .filter { it.text.isNotEmpty() }    .distinctUntilChanged()    .observeOn(AndroidSchedulers.mainThread())    .subscribeBy {        // Запуск парсера будет тут    }    .disposeOnFragmentDestroyView()

Теперь напишем модель которую нам будет возвращать парсер:

data class ParseResult(val exception: RhinoException?)

Предлагаю использовать такую логику: если ошибок не найдено, то exception будет null. В противном случае мы получим объект RhinoException который содержит в себе всю необходимую информацию номер строки, сообщение об ошибке, StackTrace и т.д.

Ну и собственно, сам парсинг:

// Это должно выполняться в фоне !val context = Context.enter() // org.mozilla.javascript.Contextcontext.optimizationLevel = -1context.maximumInterpreterStackDepth = 1try {    val scope = context.initStandardObjects()    context.evaluateString(scope, sourceCode, fileName, 1, null)    return ParseResult(null)} catch (e: RhinoException) {    return ParseResult(e)} finally {    Context.exit()}

Разбираемся:
Самое главное тут это метод evaluateString он позволяет запустить код, который мы передали в качестве строки sourceCode. В fileName указывается имя файла оно будет отображаться в ошибках, единица номер строки для начала отсчета, последний аргумент это security domain, но он нам не нужен, поэтому ставим null.

optimizationLevel и maximumInterpreterStackDepth


Параметр optimizationLevel со значением от 1 до 9 позволяет включить определенные оптимизации кода (data flow analysis, type flow analysis и т.д), что превратит простую проверку синтаксических ошибок в очень длительную операцию, а нам это не к чему.

Если же использовать его со значением 0, то все эти оптимизации применяться не будут, однако, если я правильно понял, Rhino по-прежнему будет использовать часть ресурсов не нужных для простой проверки ошибок, а значит нам это не подходит.

Остаётся только отрицательное значение указав -1 мы активируем режим интерпретатора, а это именно то что нам нужно. В документации сказано что это самый быстрый и экономичный вариант работы Rhino.

Параметр maximumInterpreterStackDepth позволяет ограничить количество рекурсивных вызовов.

Представим что будет если не указать этот параметр:

  1. Пользователь напишет следующий код:

    function recurse() {    recurse();}recurse();
    
  2. Rhino запустит код, и через секунду наше приложение вылетит с OutOfMemoryError. Конец.

Отображение ошибок


Как я говорил ранее, как только мы получим ParseResult содержащий RhinoException, у нас появится весь необходимый набор данных для отображения, в том числе и номер строки нужно лишь вызвать метод lineNumber().

Теперь напишем спан с красной волнистой линией, который я скопировал на StackOverflow. Кода много, но логика простая рисуем две короткие красные линии под разным углом.

ErrorSpan.kt
class ErrorSpan(    private val lineWidth: Float = 1 * Resources.getSystem().displayMetrics.density + 0.5f,    private val waveSize: Float = 3 * Resources.getSystem().displayMetrics.density + 0.5f,    private val color: Int = Color.RED) : LineBackgroundSpan {    override fun drawBackground(        canvas: Canvas,        paint: Paint,        left: Int,        right: Int,        top: Int,        baseline: Int,        bottom: Int,        text: CharSequence,        start: Int,        end: Int,        lineNumber: Int    ) {        val width = paint.measureText(text, start, end)        val linePaint = Paint(paint)        linePaint.color = color        linePaint.strokeWidth = lineWidth        val doubleWaveSize = waveSize * 2        var i = left.toFloat()        while (i < left + width) {            canvas.drawLine(i, bottom.toFloat(), i + waveSize, bottom - waveSize, linePaint)            canvas.drawLine(i + waveSize, bottom - waveSize, i + doubleWaveSize, bottom.toFloat(), linePaint)            i += doubleWaveSize        }    }}


Теперь можно написать метод установки спана на проблемную строку:

fun setErrorLine(lineNumber: Int) {    if (lineNumber in 0 until lineCount) {        val lineStart = layout.getLineStart(lineNumber)        val lineEnd = layout.getLineEnd(lineNumber)        text.setSpan(ErrorSpan(), lineStart, lineEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)    }}

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

Поэтому чтобы не получить IndexOutOfBoundsException мы добавляем проверку в самом начале. Ну а дальше по знакомой схеме вычисляем первый и последний символ строки, после чего устанавливаем спан.

Главное не забыть очистить текст от уже установленных спанов в afterTextChanged:

fun clearErrorSpans() {    val spans = text.getSpans<ErrorSpan>(0, text.length)    for (span in spans) {        text.removeSpan(span)    }}

Почему редакторы кода лагают?


За две статьи мы написали неплохой редактор кода наследуясь от EditText и MultiAutoCompleteTextView, но производительностью при работе с большими файлами похвастаться не можем.

Если открыть тот же TextView.java на 9к+ строк кода то любой текстовый редактор написанный по такому же принципу как наш будет лагать.

Q: А почему QuickEdit тогда не лагает?
A: Потому что под капотом он не использует ни EditText, ни TextView.

В последнее время набирают популярность редакторы кода на CustomView (вот и вот, ну или вот и вот, их очень много). Исторически так сложилось, что TextView имеет слишком много лишней логики, которая не нужна редакторам кода. Первое что приходит на ум Autofill, Emoji, Compound Drawables, кликабельные ссылки и т.д.

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

Есть ещё один вариант, но на мой взгляд менее привлекательный редакторы кода на WebView (вот и вот, их тоже очень много). Мне они не нравятся потому что UI на WebView выглядит хуже чем нативный, да и редакторам на CustomView они так же проигрывают по производительности.

Заключение


Если ваша задача написать редактор кода и выйти в топ Google Play не тратьте время и возьмите готовую библиотеку на CustomView. Если же вы хотите получить уникальный опыт пишите всё сами, используя нативные виджеты.

Также оставлю ссылку на исходники моего редактора кода на GitHub, там вы найдёте не только те фичи о которых я рассказал за эти две статьи, но и много других которые остались без внимания.

Спасибо!
Подробнее..

Превращаем EditText в SearchEditText

12.09.2020 22:20:08 | Автор: admin
image

Пробовали ли Вы когда-нибудь настроить внешний вид или поведение стандартного компонента SearchView? Полагаю, да. В этом случае, я думаю что вы согласитесь, что далеко не все его настройки являются достаточно гибкими, для того, чтобы удовлетворить всем бизнес-требованиям отдельно взятой задачи. Одним из способов решения этой проблемы является написание собственного кастомного SearchView, чем мы сегодня и займемся. Поехали!

Примечание: создаваемое view (далее SearchEditText), не будет обладать всеми свойствами стандартного SearchView. В случае необходимости, вы можете без труда добавить дополнительные опции под конкретные нужды.

План действий


Есть несколько вещей, которые нам нужно сделать, для превращения EditText в SearchEditText. Если кратко, то нам нужно:

  • Унаследовать SearchEditText от AppCompatEditText
  • Добавить иконку Поиск в левом (или правом) углу SearchEditText, при нажатии на которую введённый поисковый запрос будет передаваться зарегистрированному слушателю
  • Добавить иконку Очистка в правом (или левом) углу SearchEditText, при нажатии на которую введённый текст в поисковой строке будет очищаться
  • Установить в параметре imeOptions SearchEditText-а значение IME_ACTION_SEARCH, для того, чтобы при появлении клавиатуры кнопка ввода текста выполняла роль кнопки Поиск

SearchEditText во всей красе!


import android.content.Contextimport android.util.AttributeSetimport android.view.MotionEventimport android.view.View.OnTouchListenerimport android.view.inputmethod.EditorInfoimport androidx.appcompat.widget.AppCompatEditTextimport androidx.core.widget.doAfterTextChangedclass SearchEditText@JvmOverloads constructor(    context: Context,    attributeSet: AttributeSet? = null,    defStyle: Int = androidx.appcompat.R.attr.editTextStyle) : AppCompatEditText(context, attributeSet, defStyle) {    init {        setLeftDrawable(android.R.drawable.ic_menu_search)        setTextChangeListener()        setOnEditorActionListener()        setDrawablesListener()        imeOptions = EditorInfo.IME_ACTION_SEARCH    }    companion object {        private const val DRAWABLE_LEFT_INDEX = 0        private const val DRAWABLE_RIGHT_INDEX = 2    }    private var queryTextListener: QueryTextListener? = null    private fun setTextChangeListener() {        doAfterTextChanged {            if (it.isNullOrBlank()) {                setRightDrawable(0)            } else {                setRightDrawable(android.R.drawable.ic_menu_close_clear_cancel)            }            queryTextListener?.onQueryTextChange(it.toString())        }    }        private fun setOnEditorActionListener() {        setOnEditorActionListener { _, actionId, _ ->            if (actionId == EditorInfo.IME_ACTION_SEARCH) {                queryTextListener?.onQueryTextSubmit(text.toString())                true            } else {                false            }        }    }        private fun setDrawablesListener() {        setOnTouchListener(OnTouchListener { view, event ->            view.performClick()            if (event.action == MotionEvent.ACTION_UP) {                when {                    rightDrawableClicked(event) -> {                        setText("")                        return@OnTouchListener true                    }                    leftDrawableClicked(event) -> {                        queryTextListener?.onQueryTextSubmit(text.toString())                        return@OnTouchListener true                    }                    else -> {                        return@OnTouchListener false                    }                }            }            false        })    }    private fun rightDrawableClicked(event: MotionEvent): Boolean {        val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]        return if (rightDrawable == null) {            false        } else {            val startOfDrawable = width - rightDrawable.bounds.width() - paddingRight            val endOfDrawable = startOfDrawable + rightDrawable.bounds.width()            startOfDrawable <= event.x && event.x <= endOfDrawable        }    }    private fun leftDrawableClicked(event: MotionEvent): Boolean {        val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]        return if (leftDrawable == null) {            false        } else {            val startOfDrawable = paddingLeft            val endOfDrawable = startOfDrawable + leftDrawable.bounds.width()            startOfDrawable <= event.x && event.x <= endOfDrawable        }    }    fun setQueryTextChangeListener(queryTextListener: QueryTextListener) {        this.queryTextListener = queryTextListener    }    interface QueryTextListener {        fun onQueryTextSubmit(query: String?)        fun onQueryTextChange(newText: String?)    }}

В приведенном выше коде были использованы две extension-функции для установки правого и левого изображения EditText-а. Эти две функции выглядят следующим образом:

import android.widget.TextViewimport androidx.annotation.DrawableResimport androidx.core.content.ContextCompatprivate const val DRAWABLE_LEFT_INDEX = 0private const val DRAWABLE_TOP_INDEX = 1private const val DRAWABLE_RIGHT_INDEX = 2private const val DRAWABLE_BOTTOM_INDEX = 3fun TextView.setLeftDrawable(@DrawableRes drawableResId: Int) {    val leftDrawable = if (drawableResId != 0) {        ContextCompat.getDrawable(context, drawableResId)    } else {        null    }    val topDrawable = compoundDrawables[DRAWABLE_TOP_INDEX]    val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]    val bottomDrawable = compoundDrawables[DRAWABLE_BOTTOM_INDEX]    setCompoundDrawablesWithIntrinsicBounds(        leftDrawable,        topDrawable,        rightDrawable,        bottomDrawable    )}fun TextView.setRightDrawable(@DrawableRes drawableResId: Int) {    val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]    val topDrawable = compoundDrawables[DRAWABLE_TOP_INDEX]    val rightDrawable = if (drawableResId != 0) {        ContextCompat.getDrawable(context, drawableResId)    } else {        null    }    val bottomDrawable = compoundDrawables[DRAWABLE_BOTTOM_INDEX]    setCompoundDrawablesWithIntrinsicBounds(        leftDrawable,        topDrawable,        rightDrawable,        bottomDrawable    )}

Наследование от AppCompatEditText


class SearchEditText@JvmOverloads constructor(    context: Context,    attributeSet: AttributeSet? = null,    defStyle: Int = androidx.appcompat.R.attr.editTextStyle) : AppCompatEditText(context, attributeSet, defStyle)

Как видите, из написанного конструктора мы передаём все необходимые параметры в конструктор AppCompatEditText. Важным моментом тут является то, что значением defStyle по-умолчанию является android.appcompat.R.attr.editTextStyle. Наследуясь от LinearLayout, FrameLayout и некоторых других view, мы, как правило, используем 0 в качестве значения по-умолчанию для defStyle. Однако в нашем случае это не подходит, иначе наш SearchEditText будет вести себя как TextView, а не как EditText.

Обработка изменения текста


Следующее, что нам нужно сделать, это научиться реагировать на события изменения текста нашего SearchEditText. Нам это необходимо по двум причинам:

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

Посмотрим на код слушателя:

private fun setTextChangeListener() {    doAfterTextChanged {        if (it.isNullOrBlank()) {            setRightDrawable(0)        } else {            setRightDrawable(android.R.drawable.ic_menu_close_clear_cancel)        }        queryTextListener?.onQueryTextChange(it.toString())    }}

Для обработки событий изменения текста использовалась extension-функция doAfterTextChanged из androidx.core:core-ktx.

Обработка нажатия кнопки ввода на клавиатуре


Когда пользователь нажимает клавишу ввода на клавиатуре, происходит проверка на то, является ли это действие IME_ACTION_SEARCH. Если это так, то мы сообщаем слушателю об этом действии и передаем ему текст из SearchEditText. Посмотрим как это происходит.

private fun setOnEditorActionListener() {    setOnEditorActionListener { _, actionId, _ ->        if (actionId == EditorInfo.IME_ACTION_SEARCH) {            queryTextListener?.onQueryTextSubmit(text.toString())            true        } else {            false        }    }}

Обработка нажатий на иконки


Ну и наконец, последний по счету, но не по значению вопрос обработка нажатия на иконки поиска и очистки текста. Здесь загвоздка заключается в том, что по-умолчанию drawables стандартного EditText-а не реагируют на события нажатия, а значит и официального слушателя, который мог бы их обрабатывать, нет.

Для решения этой проблемы был зарегистрирован OnTouchListener в SearchEditText. При касании, с помощью функций leftDrawableClicked и rightDrawableClicked мы теперь можем обрабатывать клик по иконкам. Взглянем на код:

private fun setDrawablesListener() {    setOnTouchListener(OnTouchListener { view, event ->        view.performClick()        if (event.action == MotionEvent.ACTION_UP) {            when {                rightDrawableClicked(event) -> {                    setText("")                    return@OnTouchListener true                }                leftDrawableClicked(event) -> {                    queryTextListener?.onQueryTextSubmit(text.toString())                    return@OnTouchListener true                }                else -> {                    return@OnTouchListener false                }            }        }        false    })}private fun rightDrawableClicked(event: MotionEvent): Boolean {    val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]    return if (rightDrawable == null) {        false    } else {        val startOfDrawable = width - rightDrawable.bounds.width() - paddingRight        val endOfDrawable = startOfDrawable + rightDrawable.bounds.width()        startOfDrawable <= event.x && event.x <= endOfDrawable    }}private fun leftDrawableClicked(event: MotionEvent): Boolean {    val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]    return if (leftDrawable == null) {        false    } else {        val startOfDrawable = paddingLeft        val endOfDrawable = startOfDrawable + leftDrawable.bounds.width()        startOfDrawable <= event.x && event.x <= endOfDrawable    }}

В функциях leftDrawableClicked и RightDrawableClicked нет ничего сложного. Возьмём, к примеру, первую из них. Для левой иконки мы сначала рассчитываем startOfDrawable и endOfDrawable, а затем проверяем, находится ли x-координата точки касания в диапазоне [startofDrawable, endOfDrawable]. Если да, то это означает, что левая иконка была нажата. Функция rightDrawableClicked работает аналогичным образом.

В зависимости от того, нажата ли левая или правая иконка, мы осуществляем те или иные действия. При нажатии на левую иконку (значок поиска) мы сообщаем об этом слушателю, вызывая его функцию onQueryTextSubmit. При нажатии на правую очищаем текст SearchEditText.

Вывод


В этой статье мы рассмотрели вариант превращения стандартного EditText в более продвинутый SearchEditText. Как уже упоминалось ранее, готовое решение не поддерживает все параметры, предоставляемые SearchView, однако вы в любой момент можете его усовершенствовать, добавив дополнительные опции на свое усмотрение. Дерзайте!

P.S:

Доступ к исходному коду SearchEditText вы можете получить из этого репозитория GitHub.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru