因此,发布第二部分的时间到了,今天,我们将继续开发代码编辑器,并向其添加自动完成和错误突出显示功能,并讨论为什么任何代码编辑器
EditText
都不会落后。
在继续阅读之前,强烈建议您阅读第一部分。
介绍
首先,让我们记住最后一部分的内容。我们编写了一个优化的语法突出显示,该语法突出显示了背景中的文本并仅对其可见部分着色,并添加了行号(尽管没有android换行符,但仍然)。
在这一部分中,我们将添加代码完成和错误突出显示。
代码完成
首先,让我们想象一下它应该如何工作:
- 用户写一个字
- 输入前N个字符后,将出现一个提示窗口
- 当您单击提示时,单词将自动“打印”
- 带有提示的窗口将关闭,并且光标将移动到单词的末尾
- 如果用户自己输入了工具提示中显示的单词,则带有提示的窗口应自动关闭
看起来不像什么吗?Android已经具有一个具有完全相同逻辑的组件-
MultiAutoCompleteTextView
因此PopupWindow
我们不必与我们一起编写拐杖(他们已经为我们编写了)。
第一步是更改类的父级:
class TextProcessor @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.autoCompleteTextViewStyle
) : MultiAutoCompleteTextView(context, attrs, defStyleAttr)
现在我们需要编写
ArrayAdapter
将显示找到的结果的代码。完整的适配器代码将不可用,可以在Internet上找到实现示例。但是我现在将停止过滤。
为了
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
}
这里的过滤逻辑相当原始,我们遍历整个列表,并忽略大小写,比较字符串的开头。
安装适配器后,输入文字-不起作用。怎么了?在Google的第一个链接上,我们遇到一个回答,说我们忘记安装了
Tokenizer
。
Tokenizer的作用是什么?
简而言之,它
Tokenizer
有助于MultiAutoCompleteTextView
理解单词输入可以认为是完整的。它也有现成的实现形式,CommaTokenizer
将单词分隔成逗号,在这种情况下不适合我们。
好吧,由于
CommaTokenizer
我们不满意,因此我们将自己编写:
自订分词器
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
,那么选择高度将更加困难。
Google关于字体的属性,您可以偶然发现此帖子。作者所附的图片清楚地显示了我们可以用来计算垂直坐标的属性。
根据图片,我们需要基线。在此级别上,将出现一个带有自动完成选项的窗口。
现在让我们编写一个方法,当文本更改为时将调用该方法
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 + dropDownHeight
if (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
-所以它适合在光标没有一个巨大的凹痕,该控件本身增加。
PS:您可以在gif上看到键盘闪烁,并且说实话,我不知道如何解决它,因此,如果有解决办法,请写。
突出显示错误
突出显示错误,一切都比看起来简单得多,因为我们自己无法直接检测代码中的语法错误-我们将使用第三方解析器库。由于我正在编写JavaScript编辑器,因此我的选择落在Rhino上,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.Context
context.optimizationLevel = -1
context.maximumInterpreterStackDepth = 1
try {
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
文件的名称表示在-它会在错误显示,单位是行号开始计数,最后一个参数是安全域,但我们并不需要它,所以我们设置null
。
OptimizationLevel和maximumInterpreterStackDepth
optimizationLevel
值从1到9的
参数允许您启用某些代码“优化”(数据流分析,类型流分析等),这会将简单的语法错误检查变成非常耗时的操作,而我们不需要它。
如果您使用的值为0,那么将不会应用所有这些“优化”,但是,如果我理解正确,Rhino仍会使用一些简单的错误检查不需要的资源,这意味着它不适合我们。
仅存在一个负值-通过指定-1,我们激活了“解释器”模式,这正是我们所需要的。该文档说,这是运行Rhino最快,最经济的方式。
该参数
maximumInterpreterStackDepth
允许您限制递归调用的数量。
让我们想象一下如果不指定此参数会发生什么:
- 用户将编写以下代码:
function recurse() { recurse(); } recurse();
- Rhino将运行代码,一秒钟后,我们的应用程序将崩溃
OutOfMemoryError
。结束。
显示错误
就像我之前说的,一旦获得
ParseResult
包含RhinoException
的数据,我们将显示所有必要的数据集,包括行号-我们只需要调用method即可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
}
}
}
现在,您可以编写一种在问题行上安装span的方法:
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
,但是在处理大文件时不能夸耀性能。
如果为9k +行代码打开相同的TextView.java,则根据与我们相同的原理编写的任何文本编辑器都会滞后。问:为什么QuickEdit不落后?答:因为在后台,它既不使用,也不使用。 最近,CustomView上的代码编辑器越来越流行(在这里,那里,或者在这里或那里
EditText
TextView
,其中有很多)。从历史上看,TextView具有过多的逻辑,代码编辑器不需要这些逻辑。首先想到的是Autofill,Emoji表情,Composite Drawables,clickable链接等。
如果我理解正确的话,这些库的作者完全摆脱了所有这些,结果得到了一个文本编辑器,该编辑器能够处理一百万行的文件,而不会给UI线程带来很大的负担。 (虽然我可能是错误的部分,我不明白源多)
还有另一种选择,但在我看来不那么有吸引力-代码编辑器上的WebView(这里和那里,其中也有很多)。我不喜欢它们,因为WebView上的UI看起来比本机UI差,并且在性能方面,它们也输给了CustomView上的编辑器。
结论
如果您的任务是编写代码编辑器并到达Google Play的顶部,请不要浪费时间并在CustomView上使用现成的库。如果您想获得独特的体验,请使用本机窗口小部件自己编写所有内容。
我还将在GitHub上保留指向我的代码编辑器的源代码的链接,您不仅会发现我在这两篇文章中介绍的那些功能,而且还会发现许多其他未被关注的功能。
谢谢!