当前位置: 首页 > news >正文

Compose Multiplatform开发记录之文本输入框

此前发过一篇文章介绍了我开发的Desktop端端跨平台Android设备调试软件——DebugManager。

包含了基础设备信息,应用管理,文件管理,性能监测,主题切换等。

本次记录问题点

记录为开发AI大模型对话功能页面中,对TextField输入框回车键监听问题的解决。

页面如下:

普通用户在电脑程序中对于输入框的期望,就是按Enter键可以直接确认,按Alt+Enter可以输入换行符。

第一版——基础输入功能

对官方TextField进行简单封装:

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier
) {
    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.textFieldColors(
            textColor = MaterialTheme.colors.onPrimary,
            cursorColor = MaterialTheme.colors.onPrimary,
            focusedIndicatorColor = MaterialTheme.colors.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colors.onSecondary
        ),
        label = { Text(tipText, color = MaterialTheme.colors.onSecondary) },
        onValueChange = { onValueChange(it) },
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colors.secondary)
            .border(2.dp, MaterialTheme.colors.onSecondary, RoundedCornerShape(10.dp)),
    )
}

外部使用通过维护一个mutableStringState,和这里的onValueChange来进行TextField显示内容和实际字符串变量的更新。

@Composable
fun AiModelPage() {
    BasePage("AI大模型对话") {
        val mainStateHolder by remember { mutableStateOf(GlobalContext.get().get<MainStateHolder>()) }

        val toastState = rememberToastState()

        val userInputSting = remember { mutableStateOf("") }

        WrappedEditText(
            value = userInputSting.value,
            tipText = "输入对话文字",
            onValueChange = { userInputSting.value = it },
            modifier = Modifier.padding(start = 10.dp, end = 10.dp).weight(1f),
        )
        CommonButton(
            "发送", onClick = {
                if (userInputSting.value.isEmpty()) {
                    toastState.show("请先输入对话内容")
                } else {
                    mainStateHolder.chatWithAI(userInputSting.value)
                    userInputSting.value = ""
                }
            },
            modifier = Modifier.padding(10.dp)
        )
    }
}

只有点击来发送按钮后,才会将对话内容发给大模型。

第二版——加入Enter事件回调

这时候加入了KeyEvent的监听:

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier,
    onEnterPressed: () -> Unit = {}
) {
    val focusRequester = remember { FocusRequester() }
    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.colors(
            focusedTextColor = MaterialTheme.colorScheme.onPrimary,
            cursorColor = MaterialTheme.colorScheme.onPrimary,
            focusedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colorScheme.onPrimary
        ),
        label = { Text(tipText, color = MaterialTheme.colorScheme.onSecondary) },
        onValueChange = { onValueChange(it) },
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colorScheme.secondary)
            .border(2.dp, MaterialTheme.colorScheme.onSecondary, RoundedCornerShape(10.dp))
            .focusRequester(focusRequester)
            .onKeyEvent {
                if (it.key == Key.Enter) {
                    onEnterPressed()
                    return@onKeyEvent true
                }
                false
            },
    )
}

在监测到Enter键按下时,执行外部的onEnterPressed这个Lambda块,外部调用配置的时候,执行和点击发送按钮一样的逻辑。

问题就是,最后的这个换行符,连同输入的内容一起被添加到了输入框的UI,还有对话气泡中去了。

第三版——AI提供的传参数方案

查看官方文档,提供的几个api都会和上面那个按键监听策略一样的问题,换行符和内容混到了一起。

询问Gemini给出了一个方法,通过自定义`keyboardOptions`,`keyboardActions`两个参数,在onDone回调里调用onEnterPressed代码块。

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier,
    onEnterPressed: () -> Unit = {}
) {
    val focusRequester = remember { FocusRequester() }

    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.colors(
            focusedTextColor = MaterialTheme.colorScheme.onPrimary,
            cursorColor = MaterialTheme.colorScheme.onPrimary,
            focusedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colorScheme.onPrimary
        ),
        label = { Text(tipText, color = MaterialTheme.colorScheme.onSecondary) },
        onValueChange = { onValueChange(it) },
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(
            onDone = {
                onEnterPressed()
            }
        ),
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colorScheme.secondary)
            .border(2.dp, MaterialTheme.colorScheme.onSecondary, RoundedCornerShape(10.dp))
            .focusRequester(focusRequester),
    )
}

发现并没有监听到Enter键的事件。

恢复到第二版的方案后,通过在 `onValueChange` 和 `onKeyEvent` 里打印log看到,在普通按键按下时,KeyEvent可以拦截,先手回调。然而按下Enter键时,KeyEvent却在onValueChange后面回调,这样就无法提前对onValueChange回调之前进行操作。

第四版——使用内部状态来多重判断

先上代码:

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier,
    onEnterPressed: () -> Unit = {}
) {
    val focusRequester = remember { FocusRequester() }

    var ctrlPressed by remember { mutableStateOf(false) }

    var altPressed by remember { mutableStateOf(false) }

    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.colors(
            focusedTextColor = MaterialTheme.colorScheme.onPrimary,
            cursorColor = MaterialTheme.colorScheme.onPrimary,
            focusedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colorScheme.onPrimary
        ),
        label = { Text(tipText, color = MaterialTheme.colorScheme.onSecondary) },
        onValueChange = {
            // 如果此时使用了ctrl或者alt键,那么就不做处理
            // 否则就处理,丢弃掉最后一个换行符
            onValueChange(if (!ctrlPressed && !altPressed) it.processText() else it)
        },
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colorScheme.secondary)
            .border(2.dp, MaterialTheme.colorScheme.onSecondary, RoundedCornerShape(10.dp))
            .focusRequester(focusRequester)
            .onKeyEvent {
                // 只有单独按下enter键才触发,其余组合键只换行
                if (it.isCtrlPressed) {
                    ctrlPressed = true
                    return@onKeyEvent false
                } else {
                    ctrlPressed = false
                }
                if (it.isAltPressed) {
                    altPressed = true
                    return@onKeyEvent false
                } else {
                    altPressed = false
                }
                if (it.key == Key.Enter) {
                    onEnterPressed()
                    return@onKeyEvent true
                }
                false
            },
    )
}

/**
 * 用来兜底TextField的bug,暂时没有找到更好的解决方案
 * 手动丢弃掉最后一个换行符
 */
private fun String.processText(): String {
    return if (this.endsWith("\n")) {
        // 如果是单一个换行符,直接置空
        // 如果非单换行符,就丢弃最后一个字符
        if (this.length == 1) ""
        else this.dropLast(1)
    } else this
}

KeyEvent里面提供了几个重要按键按下的状态回调,我使用内部State来记录Ctrl和Alt这两个按键的按键状态,isPressed时置为true,没有按下时置为false,这样就可以在onValueChange时对回调过来的字符串进行加工处理。即,在Ctrl按键和Alt按键按下时,如实地回调键盘事件给输入框,这两个按键都没有按时,对字符串的最后一个字符进行检查。

处理方法如 `String.processText()`,如果以换行符结尾,判断这个zfc是不是就只有一个换行符,这种情况就直接置为空字符串,如果有多个字符,就把最后一个换行符给去掉,再传递给外部的调用方,保证了输入框的UI和实际的字符串里都不会显示异常。

最终实现组合按键正常换行,单独换行键直接发送对话。后续计划持续跟进,看看这里是不是跨平台库中的一个BUG,还有就是有没有官方封装完善的方案来直接使用。
 

相关文章:

  • window下的docker内使用gpu
  • 从零开始:使用 Python 实现机器学习的基础与实践
  • 2025年天梯赛第1场选拔赛
  • 软考高级信息系统项目管理师笔记-第10章项目进度管理
  • python实现的可爱卸载动画
  • 电路基础:【1】PN结二极管制作电桥点亮LED灯
  • django各种mixin用法
  • NodeJS学习笔记
  • HCIA—IP路由静态
  • 代码随想录算法训练营第22天 | 组合 组合总和 电话号码的字母组合
  • react中NavLink和a标签区别
  • 最新的前端场景面试题
  • wxWidgets GUI 跨平台 入门学习笔记
  • gmm_08.pkl 解析 读取
  • wordpress分类名称调用的几种情况
  • Manus邀请码获取方法 + 使用指南(直接领取pdf)
  • TOB企业发展前期,在获客方面容易碰到哪些问题?
  • MyBatis 配置文件核心
  • c++实现在同一台主机两个程序实现实时通信
  • 阿里推出全新推理模型(因果语言模型),仅1/20参数媲美DeepSeek R1
  • 宁波seo服务推广平台/山东网络优化公司排名
  • 如何利用问答类网站做推广/培训网站设计
  • 重庆网站建设 微客巴巴/关键词排名优化工具
  • 建网站靠什么赚钱/萧山seo
  • 专业简历制作网站模板/网络营销网站分析
  • 中企动力制作的网站/如何发布自己的网站