Avant de terminer le travail sur mon éditeur de code, je suis monté plusieurs fois sur un râteau, décompilé probablement des dizaines d'applications similaires, et dans cette série d'articles, je parlerai de ce que j'ai appris, des erreurs qui peuvent être évitées et de nombreuses autres choses intéressantes.
introduction
Bonjour à tous! À en juger par le titre, il est assez clair de quoi il s'agira, mais je dois quand même insérer quelques mots avant de passer au code.
J'ai décidé de diviser l'article en 2 parties, dans la première, nous écrivons étape par étape la mise en évidence de la syntaxe optimisée et la numérotation des lignes, et dans la seconde, nous ajoutons la complétion de code et la mise en évidence des erreurs.
Pour commencer, nous allons faire une liste de ce que notre éditeur devrait être capable de:
- Mise en évidence de la syntaxe
- Afficher la numérotation des lignes
- Afficher les options de saisie semi-automatique (je vous le dirai dans la deuxième partie)
- Souligner les erreurs de syntaxe (je le dirai dans la deuxième partie)
Ce n'est pas la liste complète des propriétés qu'un éditeur de code moderne devrait avoir, mais c'est exactement ce dont je veux parler dans cette petite série d'articles.
MVP - Éditeur de texte simple
À ce stade, il ne devrait y avoir aucun problème - nous l'étirons
EditText
en plein écran, indiquons gravity
transparent background
pour supprimer la barre du bas, la taille de la police, la couleur du texte, etc. J'aime commencer par la partie visuelle, il est donc plus facile pour moi de comprendre ce qui manque dans l'application et sur quels détails méritent encore d'être travaillés.
À ce stade, j'ai également téléchargé / enregistré des fichiers en mémoire. Je ne donnerai pas le code, il existe une multitude d'exemples de travail avec des fichiers sur Internet.
Mise en évidence de la syntaxe
Dès que nous lisons les exigences de l'éditeur, il est temps de passer au plus intéressant.
De toute évidence, afin de contrôler l'ensemble du processus - pour répondre à l'entrée, tracer des numéros de ligne, nous devrons écrire
CustomView
héritant de EditText
. Nous jetons TextWatcher
pour écouter les changements dans le texte et remplacer la méthode afterTextChanged
dans laquelle nous appellerons la méthode responsable de la mise en évidence:
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() {
//
}
}
Q: Pourquoi utilisons-nous
TextWatcher
comme variable, car vous pouvez implémenter l'interface directement dans la classe?
R: Il se trouve que nous
TextWatcher
avons une méthode qui entre en conflit avec une méthode existante dans TextView
:
// TextWatcher
fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)
// TextView
fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)
Ces deux méthodes ont le même nom et les mêmes arguments, et elles semblent avoir la même signification, mais le problème est que la méthode
onTextChanged
y sera TextView
appelée avec onTextChanged
y TextWatcher
. Si nous mettons les journaux dans le corps de la méthode, nous verrons ce qu'on onTextChanged
appelle deux fois:
Ceci est très critique si nous prévoyons d'ajouter la fonctionnalité Annuler / Rétablir. De plus, nous pouvons avoir besoin d'un moment pendant lequel les écouteurs ne fonctionneront pas, dans lequel nous pouvons effacer la pile avec des modifications de texte. Nous ne voulons pas, après avoir ouvert un nouveau fichier, vous pouvez cliquer sur Annuler et obtenir un texte complètement différent. Bien que cet article ne parle pas d'Annuler / Rétablir, il est important de considérer ce point.
Par conséquent, afin d'éviter une telle situation, vous pouvez utiliser votre propre méthode d'installation de texte au lieu de la méthode standard
setText
:
fun processText(newText: String) {
removeTextChangedListener(textWatcher)
// undoStack.clear()
// redoStack.clear()
setText(newText)
addTextChangedListener(textWatcher)
}
Mais revenons au point culminant.
De nombreux langages de programmation ont une chose aussi merveilleuse que RegEx , c'est un outil qui vous permet de rechercher des correspondances de texte dans une chaîne. Je recommande au moins de vous familiariser avec ses fonctionnalités de base, car tôt ou tard, tout programmeur peut avoir besoin de «retirer» une information du texte.
Maintenant, il est important pour nous de ne connaître que deux choses:
- Le modèle définit exactement ce que nous devons trouver dans le texte
- Matcher parcourra le texte pour essayer de trouver ce que nous avons indiqué dans le modèle
Il ne l’a peut-être pas tout à fait correctement décrit, mais c’est ainsi que cela fonctionne.
Depuis que j'écris un éditeur pour JavaScript, voici un petit modèle avec des mots-clés de langue:
private val KEYWORDS = Pattern.compile(
"\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b"
)
Bien sûr, il devrait y avoir beaucoup plus de mots ici, et nous avons également besoin de modèles pour les commentaires, les lignes, les chiffres, etc. mais ma tâche est de démontrer le principe par lequel vous pouvez trouver le contenu souhaité dans le texte.
Ensuite, en utilisant Matcher, nous allons parcourir tout le texte et définir les étendues:
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
)
}
}
Laissez - moi vous expliquer: nous obtenons le matcher objet de la modèle , et lui indiquer la zone à rechercher dans les symboles ( En conséquence, de 0 à
text.length
ceci est le texte intégral). Ensuite, l'appel matcher.find()
sera renvoyé true
si une correspondance a été trouvée dans le texte, et à l'aide de l'appel matcher.start()
, matcher.end()
nous obtiendrons la position du début et de la fin de la correspondance dans le texte. Connaissant ces données, nous pouvons utiliser la méthode setSpan
pour colorer certaines sections du texte.
Il existe de nombreux types de plages, mais il est généralement utilisé pour repeindre du texte
ForegroundColorSpan
.
Alors commençons!
Le résultat correspond exactement aux attentes jusqu'au moment où nous commençons à éditer un gros fichier (dans la capture d'écran un fichier de ~ 1000 lignes)
Le fait est que la méthode
setSpan
fonctionne lentement, en chargeant fortement le thread d'interface utilisateur, et étant donné que la méthode afterTextChanged
est appelée après chaque caractère entré, elle devient un tourment.
Trouver une solution
La première chose qui me vient à l'esprit est d'effectuer une opération lourde en arrière-plan. Mais ici, c'est une opération difficile
setSpan
dans tout le texte, pas des lignes régulières. (Je pense que je n'ai pas besoin d'expliquer pourquoi il est impossible d'appeler à setSpan
partir d'un fil d'arrière-plan).
Après un peu de recherche d'articles thématiques, nous découvrons que si nous voulons obtenir de la douceur, nous devrons mettre en évidence uniquement la partie visible du texte.
Exactement! Alors faisons-le! Juste comment?
Optimisation
Bien que j'aie mentionné que nous ne sommes préoccupés que par les performances de la méthode
setSpan
, je recommande toujours de placer RegEx dans le thread d'arrière-plan pour obtenir une fluidité maximale.
Nous avons besoin d'une classe qui traitera tout le texte en arrière-plan et retournera une liste d'étendues.
Je ne donnerai pas d'implémentation spécifique, mais si quelqu'un est intéressé, j'utilise celui qui
AsyncTask
fonctionne ThreadPoolExecutor
. (Oui, oui, AsyncTask en 2020) L'
essentiel pour nous est que la logique suivante soit exécutée:
- La tâche d'
beforeTextChanged
arrêt qui analyse le texte - En cours d'
afterTextChanged
exécution Tâche qui analyse le texte - À la fin de son travail, Task doit renvoyer la liste des travées dans
TextProcessor
, qui, à son tour, ne mettra en évidence que la partie visible
Et oui, les travées écriront également les leurs:
data class SyntaxHighlightSpan(
private val color: Int,
val start: Int,
val end: Int
) : CharacterStyle() {
// italic, ,
override fun updateDrawState(textPaint: TextPaint?) {
textPaint?.color = color
}
}
Ainsi, le code de l'éditeur se transforme en quelque chose comme ceci:
Beaucoup de code
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() {
//
}
}
Comme je n'ai pas montré l'implémentation spécifique du traitement en arrière-plan, imaginons que nous en ayons écrit un
JavaScriptStyler
qui fera tout en arrière-plan que nous faisions auparavant dans le fil UI - parcourez tout le texte à la recherche de correspondances et remplissez la liste des plages, et à la fin son travail rendra le résultat setSpansCallback
. A ce moment, une méthode sera lancée updateSyntaxHighlighting
qui parcourra la liste des travées et n'affichera que celles qui sont actuellement visibles à l'écran.
Comment comprendre quel texte tombe dans la zone visible?
Je ferai référence à cet article , où l'auteur suggère d'utiliser quelque chose comme ceci:
val topVisibleLine = scrollY / lineHeight
val bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height - View
val lineStart = layout.getLineStart(topVisibleLine)
val lineEnd = layout.getLineEnd(bottomVisibleLine)
Et il fonctionne! Maintenant, déplaçons-
topVisibleLine
le bottomVisibleLine
vers des méthodes séparées et ajoutons quelques vérifications supplémentaires, au cas où quelque chose ne va pas:
De nouvelles méthodes
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
}
La dernière chose à faire est de parcourir la liste des travées et de colorer le texte:
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
)
}
}
Ne soyez pas alarmé par l'effrayant
if
'mais, il vérifie simplement si la portée de la liste tombe dans la zone visible.
Eh bien, ça marche?
Cela fonctionne, uniquement lorsque l'édition des plages de texte ne sont pas mises à jour, vous pouvez corriger la situation en effaçant le texte de toutes les plages avant d'en appliquer de nouvelles:
// : getSpans core-ktx
val textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)
for (span in textSpans) {
text.removeSpan(span)
}
Un autre montant - après avoir fermé le clavier, un morceau de texte reste éteint, corrigez-le:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateSyntaxHighlighting()
}
L'essentiel est de ne pas oublier d'indiquer
adjustResize
dans le manifeste.
Défilement
En parlant de défilement, je reviendrai sur cet article . L'auteur propose d'attendre 500ms après la fin du défilement, ce qui contredit mon sens de la beauté. Je ne veux pas attendre que le rétroéclairage se charge, je veux voir le résultat instantanément.
De plus, l'auteur fait valoir que lancer l'analyseur après chaque pixel «défilé» coûte cher, et je suis tout à fait d'accord avec cela (généralement je vous recommande de lire entièrement son article, il est petit, mais il y a beaucoup de choses intéressantes). Mais le fait est que nous avons déjà une liste d’étapes prédéfinies et que nous n’avons pas besoin de lancer l’analyseur.
Il suffit d'appeler la méthode responsable de la mise à jour du rétroéclairage:
override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {
super.onScrollChanged(horiz, vert, oldHoriz, oldVert)
updateSyntaxHighlighting()
}
Numérotation des lignes
Si nous en ajoutons un de plus au balisage,
TextView
il sera problématique de les lier (par exemple, mettre à jour de manière synchrone la taille du texte), et même si nous avons un gros fichier, vous devrez mettre à jour complètement le texte avec des chiffres après chaque lettre saisie, ce qui n'est pas très cool. Par conséquent, nous utiliserons tous les moyens standards CustomView
- dessin sur Canvas
dans onDraw
, il est rapide et pas difficile.
Tout d'abord, définissons ce que nous allons dessiner:
- Numéros de ligne
- La ligne verticale séparant le champ de saisie des numéros de ligne
Vous devez d'abord calculer et régler à
padding
gauche de l'éditeur afin qu'il n'y ait pas de conflits avec le texte imprimé.
Pour ce faire, nous allons écrire une fonction qui mettra à jour le retrait avant de dessiner:
Mise à jour de l'indentation
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)
}
}
Explication:
Pour commencer, nous trouvons le nombre de lignes dans
EditText
(à ne pas confondre avec le nombre de " \n
" dans le texte), et prenons le nombre de caractères de ce nombre. Par exemple, si nous avons 100 lignes, la variable gutterDigitCount
sera égale à 3, car il y a exactement 3 caractères sur 100. Mais disons que nous n'avons qu'une seule ligne - ce qui signifie qu'un retrait de 1 caractère apparaîtra visuellement petit, et pour cela, nous utilisons la variable count pour définir le retrait minimum affiché de 3 caractères, même si nous avons moins de 100 lignes de code.
Cette partie était la plus déroutante de toutes, mais si vous la lisez attentivement plusieurs fois (en regardant le code), tout deviendra clair.
Ensuite, nous définissons l'indentation après avoir calculé
widestNumber
et widestWidth
.
Commençons à dessiner
Malheureusement, si nous voulons utiliser l'habillage de texte Android standard sur une nouvelle ligne, nous devrons évoquer, ce qui nous prendra beaucoup de temps et encore plus de code, ce qui sera suffisant pour un article entier, donc, afin de réduire votre temps (et le temps du modérateur habr), nous activerons l'horizontale défilement pour que toutes les lignes se succèdent:
setHorizontallyScrolling(true)
Eh bien, maintenant vous pouvez commencer à dessiner, déclarer des variables de type
Paint
:
private val gutterTextPaint = Paint() //
private val gutterDividerPaint = Paint() //
Quelque part dans le
init
bloc, définissez la couleur du texte et la couleur du séparateur. Il est important de se rappeler que si vous changez la police du texte, alors la police Paint
devra être appliquée manuellement, pour cela je vous conseille de remplacer la méthode setTypeface
. De même avec la taille du texte.
Redéfinissez ensuite la méthode
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
)
}
Nous regardons le résultat
Ça a l'air cool.
Qu'avons-nous fait
onDraw
? Avant d'appeler la super
méthode, nous avons mis à jour l'indentation, après quoi nous avons dessiné les numéros uniquement dans la zone visible, et à la fin nous avons dessiné une ligne verticale séparant visuellement la numérotation des lignes de l'éditeur de code.
Pour la beauté, vous pouvez également repeindre le retrait dans une couleur différente, mettre en évidence visuellement la ligne sur laquelle se trouve le curseur, mais je laisserai cela à votre discrétion.
Conclusion
Dans cet article, nous avons écrit un éditeur de code réactif avec coloration syntaxique et numérotation des lignes, et dans la partie suivante, nous ajouterons une complétion de code pratique et une coloration syntaxique juste pendant l'édition.
Je laisserai également un lien vers les sources de mon éditeur de code sur GitHub , où vous trouverez non seulement les fonctionnalités que j'ai décrites dans cet article, mais aussi bien d'autres qui ont été laissées sans attention.
UPD: La deuxième partie est déjà sortie:
posez des questions et proposez des sujets de discussion, car j'aurais bien pu manquer quelque chose.
Remercier!