Antes de terminar o trabalho no meu editor de código, entrei em uma varredura muitas vezes, provavelmente descompilei dezenas de aplicativos semelhantes e nesta série de artigos falarei sobre o que aprendi, que erros podem ser evitados e muitas outras coisas interessantes.
Introdução
Olá a todos! A julgar pelo título, é bastante claro o que será, mas ainda assim devo inserir algumas palavras minhas antes de passar para o código.
Decidi dividir o artigo em duas partes; na primeira, escreveremos o destaque otimizado da sintaxe e a numeração das linhas passo a passo; na segunda, adicionaremos a conclusão do código e o destaque dos erros.
Primeiro, vamos fazer uma lista do que nosso editor deve ser capaz de:
- Realce de sintaxe
- Mostrar numeração de linha
- Mostrar opções de preenchimento automático (mostrarei na segunda parte)
- Realçar erros de sintaxe (vou contar na segunda parte)
Esta não é a lista completa de quais propriedades um editor de código moderno deve ter, mas é exatamente sobre isso que quero falar nesta pequena série de artigos.
MVP - Editor de Texto Simples
Nesse estágio, não deve haver problemas - estenda
EditText
para a tela inteira, indique gravity
transparente background
para remover a faixa da parte inferior, tamanho da fonte, cor do texto etc. Eu gosto de começar com a parte visual, para que fique mais fácil entender o que está faltando no aplicativo e quais detalhes ainda precisam ser trabalhados.
Nesta fase, também carreguei / salvei arquivos na memória. Não vou dar o código, há uma superabundância de exemplos de trabalho com arquivos na Internet.
Realce de sintaxe
Assim que lemos os requisitos para o editor, é hora de passar para o mais interessante.
Obviamente, para controlar todo o processo - para responder à entrada, desenhar números de linha, teremos que escrever
CustomView
herdados EditText
. Jogamos TextWatcher
para ouvir as alterações no texto e substituímos o método afterTextChanged
pelo qual chamaremos o método responsável por destacar:
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() {
//
}
}
P: Por que a usamos
TextWatcher
como uma variável, porque você pode implementar a interface diretamente na classe?
R: Acontece que temos
TextWatcher
um método que entra em conflito com um método existente em TextView
:
// TextWatcher
fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)
// TextView
fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)
Ambos os métodos têm o mesmo nome e os mesmos argumentos, e parecem ter o mesmo significado, mas o problema é que o método
onTextChanged
y será TextView
chamado junto com onTextChanged
y TextWatcher
. Se colocarmos os logs no corpo do método, veremos o que é onTextChanged
chamado duas vezes:
Isso é muito crítico se planejarmos adicionar a funcionalidade Desfazer / Refazer. Além disso, podemos precisar de um momento em que os ouvintes não funcionem, em que possamos limpar a pilha com alterações de texto. Não queremos que, depois de abrir um novo arquivo, você possa clicar em Desfazer e obter um texto completamente diferente. Embora este artigo não fale sobre Desfazer / Refazer, é importante considerar esse ponto.
Assim, para evitar tal situação, você pode usar seu próprio método de instalação de texto em vez do padrão
setText
:
fun processText(newText: String) {
removeTextChangedListener(textWatcher)
// undoStack.clear()
// redoStack.clear()
setText(newText)
addTextChangedListener(textWatcher)
}
Mas voltando à luz de fundo.
Muitas linguagens de programação têm uma coisa maravilhosa como RegEx , esta é uma ferramenta que permite pesquisar correspondências de texto em uma string. Eu recomendo que você pelo menos se familiarize com seus recursos básicos, porque mais cedo ou mais tarde qualquer programador pode precisar "extrair" algumas informações do texto.
Agora é importante sabermos apenas duas coisas:
- Padrão determina o que exatamente precisamos encontrar no texto
- O Matcher percorrerá o texto tentando encontrar o que especificamos em Padrão
Talvez ele não tenha descrito corretamente, mas é assim que funciona.
Como estou escrevendo um editor para JavaScript, eis um pequeno padrão com palavras-chave de idioma:
private val KEYWORDS = Pattern.compile(
"\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b"
)
Obviamente, deve haver muito mais palavras aqui, e também precisamos de padrões para comentários, linhas, números etc. mas minha tarefa é demonstrar o princípio pelo qual você pode encontrar o conteúdo desejado no texto.
Em seguida, usando o Matcher, percorreremos todo o texto e definiremos os intervalos:
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
)
}
}
Deixe-me explicar: nós temos a Matcher objeto do padrão , e indicar a ele a área para procurar em símbolos (Assim, de 0 a
text.length
este é o texto inteiro). Além disso, a chamada matcher.find()
voltará true
se uma correspondência foi encontrada no texto, e com a ajuda de chamadas matcher.start()
e matcher.end()
vamos obter as posições de início e no final da partida no texto. Conhecendo esses dados, podemos usar o método setSpan
para colorir certas seções do texto.
Existem muitos tipos de extensões, mas geralmente é usado para repintar texto
ForegroundColorSpan
.
Então vamos começar!
O resultado atende às expectativas exatamente até começarmos a editar um arquivo grande (na captura de tela, o arquivo tem ~ 1000 linhas).
O fato é que o método
setSpan
funciona lentamente, carregando muito o Thread da interface do usuário e, como o método afterTextChanged
é chamado após cada caractere inserido, ele se torna um tormento.
Encontrando uma solução
A primeira coisa que vem à mente é mover uma operação pesada para um encadeamento em segundo plano. Mas a operação pesada aqui está em
setSpan
todo o texto, não na temporada regular. (Acho que não preciso explicar por que é impossível chamar setSpan
de um thread em segundo plano).
Depois de pesquisar um pouco por artigos temáticos, descobrimos que, se queremos obter suavidade, teremos que destacar apenas a parte visível do texto.
Certo! Vamos fazer isso! Quão?
Otimização
Embora eu tenha mencionado que estamos preocupados apenas com o desempenho do método
setSpan
, ainda recomendo colocar o RegEx no segmento de segundo plano para obter a máxima suavidade.
Precisamos de uma classe que processe todo o texto em segundo plano e retorne uma lista de extensões.
Não darei uma implementação específica, mas se alguém estiver interessado, então uso a que
AsyncTask
funciona ThreadPoolExecutor
. (Sim, sim, AsyncTask em 2020) O
principal para nós é que a seguinte lógica seja executada:
- A tarefa de
beforeTextChanged
parada que analisa o texto - Na tarefa
afterTextChanged
Executar, que analisa o texto - No final de seu trabalho, a Tarefa deve retornar a lista de vãos
TextProcessor
, que, por sua vez, destacará apenas a parte visível
E sim, também escreveremos nossos próprios períodos:
data class SyntaxHighlightSpan(
private val color: Int,
val start: Int,
val end: Int
) : CharacterStyle() {
// italic, ,
override fun updateDrawState(textPaint: TextPaint?) {
textPaint?.color = color
}
}
Assim, o código do editor se transforma em algo assim:
Muito código
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() {
//
}
}
Como eu não mostrei a implementação específica do processamento em segundo plano, vamos imaginar que escrevemos uma certa
JavaScriptStyler
que fará tudo em segundo plano que fizemos antes no thread da interface do usuário - percorra todo o texto em busca de correspondências e preencha a lista de extensões, e no final seu trabalho retornará o resultado para setSpansCallback
. Nesse momento, será lançado um método updateSyntaxHighlighting
que percorrerá a lista de extensões e exibirá apenas as que estão atualmente visíveis na tela.
Como você sabe qual texto cai na área visível?
Vou me referir a este artigo , onde o autor sugere usar algo como isto:
val topVisibleLine = scrollY / lineHeight
val bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height - View
val lineStart = layout.getLineStart(topVisibleLine)
val lineEnd = layout.getLineEnd(bottomVisibleLine)
E funciona! Agora, vamos colocá-
topVisibleLine
lo bottomVisibleLine
em métodos separados e adicionar algumas verificações adicionais, caso algo dê errado:
Novos métodos
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
}
A última coisa a fazer é percorrer a lista de extensões e colorir o texto:
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
)
}
}
Não tenha medo do terrível
if
, mas ele apenas verifica se o espaço da lista cai na área visível.
Bem, isso funciona?
Funciona, mas ao editar o texto, os períodos não são atualizados, você pode corrigir a situação limpando o texto de todos os períodos antes de sobrepor novos:
// : getSpans core-ktx
val textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)
for (span in textSpans) {
text.removeSpan(span)
}
Outro batente - depois de fechar o teclado, um pedaço de texto permanece apagado, corrija-o:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateSyntaxHighlighting()
}
O principal é não esquecer de indicar
adjustResize
no manifesto.
Rolagem
Falando sobre rolagem, vou me referir novamente a este artigo . O autor sugere esperar 500ms após o final da rolagem, o que contradiz meu senso de beleza. Não quero esperar a luz de fundo carregar, quero ver o resultado instantaneamente.
O autor também argumenta que executar o analisador após cada pixel "rolado" é caro, e eu concordo completamente com isso (em geral, recomendo que você leia o artigo na íntegra, é pequeno, mas há muitas coisas interessantes). Mas o fato é que já temos uma lista pronta de vãos e não precisamos iniciar o analisador.
Basta chamar o método responsável pela atualização do destaque:
override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {
super.onScrollChanged(horiz, vert, oldHoriz, oldVert)
updateSyntaxHighlighting()
}
Numeração de linha
Se adicionarmos mais uma marcação,
TextView
será problemático vinculá-los (por exemplo, atualizar o tamanho do texto de forma síncrona) e, mesmo se tivermos um arquivo grande, teremos que atualizar completamente o texto com números após cada letra inserida, o que não é muito legal. Portanto, vamos usar todos os meios normais CustomView
- desenho em Canvas
em onDraw
, é rápido e não é difícil.
Primeiro, vamos definir o que desenharemos:
- Números de linha
- A linha vertical que separa o campo de entrada dos números de linha
Você deve primeiro calcular e definir à
padding
esquerda do editor para que não haja conflitos com o texto impresso.
Para fazer isso, escreveremos uma função que atualizará o recuo antes de desenhar:
Atualizando recuo
private var gutterWidth = 0
private var gutterDigitCount = 0
private 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)
}
}
Explicação:
Primeiro, descobrimos o número de linhas inseridas
EditText
(não confundir com o número " \n
" no texto) e obtemos o número de caracteres desse número. Por exemplo, se tivermos 100 linhas, a variável gutterDigitCount
será igual a 3, porque existem exatamente 3 caracteres em 100. Mas suponha que tenhamos apenas 1 linha - o que significa que um recuo de 1 caractere aparecerá visualmente pequeno e, para isso, usamos a contagem de variáveis para definir o recuo mínimo exibido de 3 caracteres, mesmo se tivermos menos de 100 linhas de código.
Essa parte foi a mais confusa de todas, mas se você a ler cuidadosamente várias vezes (observando o código), tudo ficará claro.
Em seguida, definimos o recuo após calcular
widestNumber
e widestWidth
.
Vamos começar a desenhar
Infelizmente, se quisermos usar o empacotamento de texto padrão do Android em uma nova linha, teremos que invocar, o que levará muito tempo e ainda mais código, o que será suficiente para um artigo inteiro, portanto, para reduzir seu tempo (e o tempo do moderador habr), habilitaremos a horizontal rolagem para que todas as linhas sejam uma após a outra:
setHorizontallyScrolling(true)
Bem, agora você pode começar a desenhar, vamos declarar variáveis com o tipo
Paint
:
private val gutterTextPaint = Paint() //
private val gutterDividerPaint = Paint() //
init
Defina a cor do texto e a cor do separador
em algum lugar do bloco. É importante lembrar que, se você alterar a fonte do texto, ela Paint
deverá ser aplicada manualmente, por isso aconselho que você substitua o método setTypeface
. Da mesma forma com o tamanho do texto.
Redefina o método
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
)
}
Nós olhamos para o resultado
Isso parece legal.
No que fizemos
onDraw
? Antes de chamar o super
método, atualizamos o recuo, após o qual renderizamos os números apenas na área visível e, no final, desenhamos uma linha vertical que separa visualmente a numeração de linhas do editor de código.
Para beleza, você também pode repintar o recuo em uma cor diferente, destacar visualmente a linha na qual o cursor está localizado, mas deixarei isso a seu critério.
Conclusão
Neste artigo, escrevemos um editor de código responsivo com realce de sintaxe e numeração de linha e, na próxima parte, adicionaremos o conveniente preenchimento de código e o erro de sintaxe destacando durante a edição.
Também deixarei um link para as fontes do meu editor de código no GitHub , onde você encontrará não apenas os recursos que descrevi neste artigo, mas também muitos outros que foram deixados sem atenção.
UPD: A segunda parte já está disponível:
faça perguntas e sugira tópicos para discussão, porque eu poderia ter perdido alguma coisa.
Obrigado!