用BasicTextField其实就可以实现,compose的基础组件可扩展性还是很强大的
定义以下类和函数:
- Settings:编辑器相关设置
- Editor:文本内容和一些文本操作
- EditorView(model: Editor, modifier: Modifier)
设置类
class Settings {
// 对于修改后编辑器要实时更新的项,使用mutableStateOf
// 文本大小
var fontSize by mutableStateOf(13.sp)
// 显示行号
var showLines by mutableStateOf(false)
// 按tab后填入的空格数
var tabs = 4
}
编辑器类
class Editor (
val name: String, //名称,可以填入文件名或其他
val content: String, //初始化的内容
val settings: Settings = Settings() //设置
) {
// 将内容转为TextFieldValue
var filedValue by mutableStateOf(TextFieldValue(content))
// 当前内容
val text: String
get() = filedValue.text
// 当前行数
var lines: Int
get() = text.split('\n').size
// 文本操作指令
val actions = mutableMapOf<Int, ()->Unit>()
// 快捷键,Pair<Int, Key>用来确定是否按下了ctrl/shitf/alt
val shortKeys = mutableMapOf<Pair<Int, Key>, Int>()
// 选中的文本范围
val selection: TextRange
get() = fieldValue.selection
companion object {
// 特殊键是否按下
const val CTRL = 1
const val SHIFT = 2
const val ALT = 4
const val CTRL_SHIFT = 3
const val CTRL_ALT = 5
const val SHIFT_ALT = 6
const val CTRL_SHIFT_ALT = 7
}
}
编辑器类要实先文本的常规操作,以及回车键、tab键按下后的操作
事件注册
// 注册事件
fun register(i: Int, a: ()-> Unit) {
actions[i] = a
}
// 发送事件
fun sendAction(i: Int): Boolean {
if(actions.containsKey(i)) {
actions[i]!!()
return true
}
return false
}
// 注册快捷键
fun registerShortKey(keycode: Key, action: Int, ctrl: Boolean = true, shift: Boolean = false, alt: Boolean = false) { // 000
var tmp = 0
if(ctrl) tmp += 1
if(shift) tmp += 2
if(alt) tmp += 4
shortKeys[tmp to keycode] = action
}
// 同时注册事件和快捷键, keyPress可以参考companion object内常量
fun register(i: Int, keycode: Key, keyPress: Int = 1, a: ()->Unit) {
actions[i] = a
shortKeys[keyPress to keycode] = i
}
文本操作
当前行的范围
fun currentLineRange(): TextRange {
val st = selection.min
val en = selection.max
val st0 = (st - 1 downTo 0)
.firstOrNull{ text[it] == '\n' }
?.let { it + 1 }
?: 0
val en0 = (en until text.length)
.firstOrNull{ text[it] == '\n' }
?: text.length
return TextRange(st0, en0)
}
当前行的文本内容
fun currentLine(): String {
return text.substring(currentLineRange())
}
// 或者
val currentLine: String
get() = text.substring(currentLineRange())
在指定位置前插入文本
// str: 插入的内容, st: 位置
fun append(str: String, st: Int = selection.min) {
val en = selection.max
val ns = buildString {
append(text.substring(0, st))
append(str)
append(text.substring(st))
}
fieldValue = fieldValue.copy(text = ns, selection = TextRange(en + str.length))
}
在行首插入文本
// 行首直接插入
fun appendStart(str: String) {
val st = currentLineRange().min
append(str, st)
}
// 行首插入且跳过空格
fun appendStartTrim(str: String) {
val st = currentLineRange().min
val cl = currentLine()
append(str, st + cl.length - cl.trim().length)
}
在选择范围前后插入
fun surround(s1: String, s2: String = s1, st: Int = selection.min, en: Int = selection.max) {
val ns = buildString {
append(text.substring(0, st))
append(s1)
append(text.substring(st,en))
append(s2)
append(text.substring(en))
}
// 插入后自动选择
if(selection.collapsed)
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, st+s1.length))
else
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, en+s1.length+s2.length))
}
按键操作
键盘按键按下和松开都会调用一次,只要响应一次就行了,所以当松开的适合,直接返回true,告诉compose事件已经执行
这里简单的实现了
- 按下tab后自动填入空格
- 按下回车后,如果当前行前面有空格,下一行会插入相同数量的空格
- 删除时,若当前指针左边全是空格,删除所有的空格
fun defaultKeyEvent(event: KeyEvent): Boolean {
if(event.type == KeyEventType.KeyUp) return true
if(event.type == KeyEventType.KeyDown) {
if (!event.isCtrlPressed && !event.isShiftPressed && !event.isAltPressed) {
val st = selection.min
val en = selection.max
if (event.key == Key.Tab) {
val ns =
text.substring(0, st) + " ".repeat(settings.tabs) + text.substring(en)
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + settings.tabs))
return true
} else if (event.key == Key.Enter) {
val cl = currentLine()
val n = cl.length - cl.trimStart().length
val ns = text.substring(0, st) + "\n" + " ".repeat(n) + text.substring(en)
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + n + 1))
return true
} else if(event.key == Key.Backspace) {
if(selection.collapsed) {
val cl = currentLine()
if (cl.trim().isEmpty()) {
val range = currentLineRange()
val ns =
text.substring(0, if (range.min > 0) range.min - 1 else range.min) + text.substring(
range.max
)
fieldValue = fieldValue.copy(
text = ns,
selection = TextRange(if (range.min > 0) range.min - 1 else range.min)
)
return true
}
}
}
}
}
return false
}
响应快捷键
fun keyEvent(event: KeyEvent): Boolean {
if(event.type == KeyEventType.KeyDown) {
var tmp = 0
if (event.isCtrlPressed) tmp += 1
if (event.isShiftPressed) tmp += 2
if (event.isAltPressed) tmp += 4
if (shortKeys.containsKey(tmp to event.key)) {
return sendAction(shortKeys[tmp to event.key]!!)
}
}
return false
}
注册快捷键操作示例:
editor.apply {
register(1, Key.One) { // h1
appendStartTrim("# ")
}
register(10, Key.One, Editor.CTRL_SHIFT) { // 加粗
surround("** ", " **")
}
}
// 按下ctrl+1 或 editor.sendAction(1) 时,行首会插入#
// 按下ctrl+shift+1 或 editor.sendAction(10) 时,当前选择的内容前后会插入**
compose组合函数
@Composable
fun EditorView(
editor: Editor,
modifier: Modifier = Modifier.fillMaxSize()
) {
with(LocalDensity.current) {
val measurer = rememberTextMeasurer()
// 计算一个字符的高度
val heightDp = measurer.measure("1").size.height.toDp()
BasicTextField(
value = editor.fieldValue,
onValueChange = { newText ->
editor.fieldValue = newText
},
modifier = modifier
.verticalScroll(rememberScrollState())
.onKeyEvent { event->
// 快捷键响应
editor.keyEvent(event)
}
.onPreviewKeyEvent { event ->
// tab、enter、backspace等按键响应
editor.defaultKeyEvent(event)
},
decorationBox = @Composable { innerTextField ->
if (editor.settings.showLines) {
// 显示行号
Row {
val lines = editor.lines
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(start = 4.dp, end = 4.dp)
) {
for(i in 0 until lines) {
BasicText("${i+1}")
}
}
// 显示分割线
Spacer(
Modifier
.padding(start = 4.dp, end = 4.dp)
.width(1.dp)
.height(heightDp.times(lines))
.background(Color.Black)
)
// 文本编辑框内容
innerTextField()
}
} else {
innerTextField()
}
}
)
}
}
注意事项
由于compose框架限制,TextField在失去焦点后,selection会清空,这时候需要用Box替代Button,这里是个简易实现
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UnFocusButton(
text: String,
modifier: Modifier = Modifier.border(1.dp, Color.Gray, RoundedCornerShape(4.dp)),
onClick: ()->Unit
) {
Box(modifier = modifier.onClick {
onClick()
}) {
Text(text, modifier = Modifier.padding(6.dp, 3.dp))
}
}
最后来看看效果吧
fun main() {
application {
Window(
onCloseRequest = ::exitApplication,
title = "KotlinProject",
) {
App()
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun App() {
MaterialTheme {
Column(
modifier = Modifier.fillMaxSize()
) {
val setting by remember { mutableStateOf(Settings()) }
val editor by remember { mutableStateOf(Editor("", Path("test.md").readText(), setting)) }
var label1 by remember { mutableStateOf("123") }
editor.registerMarkdownKeys()
Row(Modifier.fillMaxWidth()) {
UnFocusButton("显示行号") {
setting.showLines = !setting.showLines
}
UnFocusButton("保存") {
Path("test.md").writeText(editor.text)
}
}
EditorView(
editor
)
}
}
}
// 注册一些markdown快捷键
fun Editor.registerMarkdownKeys() {
register(1, Key.One) { //h1
appendStartTrim("# ")
}
register(2, Key.Two) { //h2
appendStartTrim("## ")
}
register(3, Key.Three) { //h3
appendStartTrim("### ")
}
register(4, Key.Four) { //h4
appendStartTrim("#### ")
}
register(5, Key.Five) { //h5
appendStartTrim("##### ")
}
register(6, Key.Six) { //h6
appendStartTrim("###### ")
}
register(10, Key.One, 3) {
surround("* ", " *")
}
register(11, Key.Two, 3) {
surround("** ", " **")
}
register(12, Key.Three, 3) {
surround("*** ", " ***")
}
register(21, Key.L) {
appendStartTrim("* ")
}
}
待实现
- 文本高亮,这个理论上修改TextFieldValue的annotatedString就可以实现
- 当某行文本长度超出自动换行时,行号显示将会出错
附件
Editor.kt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.substring
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
class Editor (
val name: String,
val content: String,
val settings: Settings = Settings()
) {
var fieldValue by mutableStateOf(TextFieldValue(content))
val lines: Int
get() = text.split('\n').size
val lineLens: Int
get() = lines.toString().length
val text: String
get() = fieldValue.text
val actions = mutableMapOf<Int, ()->Unit>()
fun sendAction(i: Int): Boolean {
if(actions.containsKey(i)) {
actions[i]!!()
return true
}
return false
}
val shortKeys = mutableMapOf<Pair<Int, Key>, Int>()
fun registerShortKey(keycode: Key, action: Int, ctrl: Boolean = true, shift: Boolean = false, alt: Boolean = false) { // 000
var tmp = 0
if(ctrl) tmp += 1
if(shift) tmp += 2
if(alt) tmp += 4
shortKeys[tmp to keycode] = action
}
fun register(i: Int, a: ()-> Unit) {
actions[i] = a
}
// 0x000 alt,shift,ctrl
fun register(i: Int, keycode: Key, keyPress: Int = 1, a: ()->Unit) {
actions[i] = a
shortKeys[keyPress to keycode] = i
}
fun append(str: String, st: Int = selection.min) {
val en = selection.max
val ns = buildString {
append(text.substring(0, st))
append(str)
append(text.substring(st))
}
fieldValue = fieldValue.copy(text = ns, selection = TextRange(en + str.length))
}
fun appendStart(str: String) {
val st = currentLineRange().min
append(str, st)
}
fun appendStartTrim(str: String) {
val st = currentLineRange().min
val cl = currentLine()
append(str, st + cl.length - cl.trim().length)
}
fun surround(s1: String, s2: String = s1, st: Int = selection.min, en: Int = selection.max) {
val ns = buildString {
append(text.substring(0, st))
append(s1)
append(text.substring(st,en))
append(s2)
append(text.substring(en))
}
if(selection.collapsed)
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, st+s1.length))
else
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st, en+s1.length+s2.length))
}
val selection: TextRange
get() = fieldValue.selection
fun currentLineRange(): TextRange {
val st = selection.min
val en = selection.max
val st0 = (st - 1 downTo 0)
.firstOrNull{ text[it] == '\n' }
?.let { it + 1 }
?: 0
val en0 = (en until text.length)
.firstOrNull{ text[it] == '\n' }
?: text.length
return TextRange(st0, en0)
}
fun currentLine(): String {
return text.substring(currentLineRange())
}
fun defaultKeyEvent(event: KeyEvent): Boolean {
if(event.type == KeyEventType.KeyUp) return true
if(event.type == KeyEventType.KeyDown) {
if (!event.isCtrlPressed && !event.isShiftPressed && !event.isAltPressed) {
val st = selection.min
val en = selection.max
if (event.key == Key.Tab) {
val ns =
text.substring(0, st) + " ".repeat(settings.tabs) + text.substring(en)
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + settings.tabs))
return true
} else if (event.key == Key.Enter) {
val cl = currentLine()
val n = cl.length - cl.trimStart().length
val ns = text.substring(0, st) + "\n" + " ".repeat(n) + text.substring(en)
fieldValue = fieldValue.copy(text = ns, selection = TextRange(st + n + 1))
return true
} else if(event.key == Key.Backspace) {
if(selection.collapsed) {
val cl = currentLine()
if (cl.trim().isEmpty()) {
val range = currentLineRange()
val ns =
text.substring(0, if (range.min > 0) range.min - 1 else range.min) + text.substring(
range.max
)
fieldValue = fieldValue.copy(
text = ns,
selection = TextRange(if (range.min > 0) range.min - 1 else range.min)
)
return true
}
}
}
}
}
return false
}
fun keyEvent(event: KeyEvent): Boolean {
if(event.type == KeyEventType.KeyDown) {
var tmp = 0
if (event.isCtrlPressed) tmp += 1
if (event.isShiftPressed) tmp += 2
if (event.isAltPressed) tmp += 4
if (shortKeys.containsKey(tmp to event.key)) {
return sendAction(shortKeys[tmp to event.key]!!)
}
}
return false
}
companion object {
const val CTRL = 1
const val SHIFT = 2
const val ALT = 4
const val CTRL_SHIFT = 3
const val CTRL_ALT = 5
const val SHIFT_ALT = 6
const val CTRL_SHIFT_ALT = 7
}
}
class Settings {
var fontSize by mutableStateOf(13.sp)
var showLines by mutableStateOf(false)
var tabs = 4
// var autoTab = true
}
@Composable
fun EditorView(
editor: Editor,
// settings: Settings = Settings(),
modifier: Modifier = Modifier.fillMaxSize()
) {
with(LocalDensity.current) {
val measurer = rememberTextMeasurer()
val heightDp = measurer.measure("1").size.height.toDp()
BasicTextField(
value = editor.fieldValue,
onValueChange = { newText ->
editor.fieldValue = newText
},
modifier = modifier
.verticalScroll(rememberScrollState())
.onKeyEvent { event->
editor.keyEvent(event)
}
.onPreviewKeyEvent { event ->
editor.defaultKeyEvent(event)
},
decorationBox = @Composable { innerTextField ->
if (editor.settings.showLines) {
Row {
val lines = editor.lines
// val len = editor.lineLens
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(start = 4.dp, end = 4.dp)
) {
for(i in 0 until lines) {
BasicText("${i+1}")
}
}
Spacer(
Modifier
.padding(start = 4.dp, end = 4.dp)
.width(1.dp)
.height(heightDp.times(lines))
.background(Color.Black)
)
innerTextField()
}
} else {
innerTextField()
}
}
)
}
}