Jetpack Compose学习(3)——图标(Icon) 按钮(Button) 输入框(TextField) 的使用

Stars-one 2021年09月08日 76次浏览 本篇字数为21,849字

本文为作者原创,转载请注明出处,谢谢配合
作者:Stars-one
链接:https://stars-one.site/2021/09/08/jetcompose-study-3


本篇分别对常用的组件:图标(Icon) 按钮(Button) 输入框(TextField)的使用方法及各参数使用进行讲解,参考了不少文章,且费了不少时间去时间去一一实践,希望对各位带来些帮助 😊

本系列以往文章请查看此分类链接Jetpack compose学习

图标Icon使用

Icon接收三种参数,如下图

//第一种就不多说,就是一个drawble对象


//获取图片资源,R.drawble.xx或R.mipmap.xx
Icon(painter = painterResource(id = R.drawable.head1_1024), null)

//自带的图标
Icon(Icons.Filled.Search, null)

Compose内置了几十个常用的图标,我们使用枚举类型即可使用

Icons里面定了5种类型Outlined Filled Sharp TwoTone Rounded,可以根据自己的需要选择不同的类型,如填充型(Filled)或者是轮廓型(Outlined)

Icon的构造方法参数简单说明下
contentDescription是给无障碍人使用的文本描述,考虑到一些视觉障碍的人使用,所以有个这个属性,会使用TTS语音播放将contentDescription属性读出来,告知用户此按钮的作用

tint则是图标颜色的设置

Row() {
    Icon(Icons.Outlined.Settings, contentDescription = null, tint = Color.Red)
    Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.Blue)
    Icon(Icons.Sharp.Settings, contentDescription = null, tint = Color.Green)
    Icon(Icons.TwoTone.Settings, contentDescription = null, tint = Color.Red)
    Icon(Icons.Rounded.Settings, contentDescription = null, tint = Color.Black)
}

效果如下图所示

PS:具体的图标名称写的时候会有代码提示

不过默认常用的就那40几个,其他的图标就没有包含在内,当然,如果你想用的话,也有方法实现,需要导入material-icons-extended依赖即可

dependencies {
  ...
  implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

但是全套图标会导致打包后的apk文件过大,所以官方推荐使用导入图标文件的方法,详情可参考官方文档

按钮 Button

Button这个组件,官方已经实现了Material Design的效果,一般来说我们直接使用这个即可

除此之外,官方也是给我们封装了不同类型的Button,分别为IconButton TextButton OutlinedButton IconToggleButton

上面我们刚讲了图标,下面就先讲些图标按钮IconButton的使用方式吧

基本使用

和以往我们使用的按钮不一样,这里的按钮可以看做是一个布局控件,我们需要设置文字也就是往里面添加一个Text组件,这就是compose和传统Android的xml的不同之处

由上面这点,所以我们在代码层面就十分灵活,可以实现各种效果(如带有图标的按钮),下面来个例子

Button(onClick = { println("点击了按钮")}){
    Icon(Icons.Default.Search,contentDescription = null)
    Text(text = "测试")
}

上面的代码实现的效果就是有个图标在左侧

参数讲解

我们先看下Button的定义,其实封装好的方法,代码如下所示

fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) 

Buttoncontent参数(也就是上面lambda),传入多个组件,Button会将其按照水平方式排列(即Button可视为Row布局)

由于kotlin的语法特性,所以我们可以在后面以花括号写个lambda函数

这里先讲下比较简单的参数:

  • onClick是点击事件.也是接收一个函数
  • modifier是修饰符,本章先不使用,之后出个篇文章,专门讲解下这个的用法
  • enabled按钮是否可用(不可用默认是灰色,可用默认是蓝色),当然这里的默认的禁用和可用的颜色可可以调整,详情请见下面的colors参数

接下来就是稍微有点复杂的参数说明了,因为用法与之前原生Button有所区别,这里特别分成一小节讲解,方便目录查阅

1.elevation 阴影

Button的阴影参数是有有默认值的,我们也可以使用下面的方法进行数值的修改

ButtonDefaults.elevation(defaultElevation,pressedElevation,disabledElevation)
  • defaultElevation表示默认的阴影
  • pressedElevation表示按下时的阴影
  • disabledElevation表示未启用时候的阴影
Button(
    enabled = true,
    onClick = { /*TODO*/ },
    elevation = ButtonDefaults.elevation(4.dp, 10.dp, 0.dp)
) {
    Text(text = "阴影按钮")
}

Button(
    enabled = false,
    onClick = { /*TODO*/ },
    elevation = ButtonDefaults.elevation(4.dp, 10.dp, 0.dp)
) {
    Text(text = "禁用状态的阴影按钮")
}

PS:使用的时候,发现导包会失败,给了些奇怪的东西...建议复制下ButtonDefaults.elevation(),再输入参数

导包错误

2.shape 形状

Android官方给我们提供了以下四种形状,我从代码提示里只看到有这四种

  • RoundedCornerShape 圆角形状
  • CutCornerShape 切角形状
  • AbsoluteRoundedCornerShape 绝对圆角形状
  • AbsoluteCutCornerShape 绝对切角形状

这里从字面翻译知道其的意思,但是具体圆角形状和绝对圆角形状有什么区别,实际测试也有,但是没法看出来有什么区别,官方的文档也是解释的有点模糊

后来者如果知道,可以在评论区回复下,感谢~

上面四种类的接收参数其实是一样的,这里就截个图给大家看看

我们常用就是使用dp定位进行设置,如

RoundedCornerShape(10.dp) //设置10dp的圆角
RoundedCornerShape(topStart = 5.dp,topEnd = 6.dp,bottomEnd = 10.dp,bottomStart = 10.dp)
  • topStart 左上角
  • topEnd 右上角
  • bottomStart 左下角
  • bottomEnd 右下角

PS: 记住start是左,end是右,上面就比较好记了

Button(
    onClick = { /*TODO*/ },
    elevation = ButtonDefaults.elevation(4.dp, 10.dp, 0.dp),
    shape = RoundedCornerShape(topStart = 5.dp,topEnd = 6.dp,bottomEnd = 10.dp,bottomStart = 10.dp)

) {
    Text(text = "按钮")
}

我们可以实现如下图的效果

代码如下:

Modifier.size(50.dp,50.dp)是用来设置宽高的

Row() {
	//固定长宽一样,圆角设置为50%即为圆形
    Button(
        modifier = Modifier.size(50.dp,50.dp),
        onClick = { /*TODO*/ },
        shape = RoundedCornerShape(50),
    ) {
        Text(text = "")
    }

	//固定长宽一样,切角设置为50%即为菱形
    Button(
        modifier = Modifier.size(50.dp,50.dp),
        onClick = { /*TODO*/ },
        shape = CutCornerShape(50.dp),
    ) {
        Text(text = "")
    }

	//左上角设置圆角
    Button(
        onClick = { /*TODO*/ },
        shape = RoundedCornerShape(topStart = 20.dp),
    ) {
        Text(text = "按钮")
    }

	//圆角设置为50%
    Button(
        onClick = { /*TODO*/ },
        shape = RoundedCornerShape(50),
        border = BorderStroke(1.dp, Color.Green),
        colors = ButtonDefaults.buttonColors(),
    ) {
        Text(text = "按钮111")
    }

    Button(
        modifier = Modifier.size(50.dp,50.dp),
        onClick = { /*TODO*/ },
        shape = CutCornerShape(25),
        border = BorderStroke(1.dp, Color.Green),
        colors = ButtonDefaults.buttonColors(),
    ) {
        Text(text = "按钮111")
    }

}

3.border 边框

边框就简单了,使用BorderStroke,接收两个参数,一个是边框的宽度,另外一个则是边框的颜色

BorderStroke(1.dp,color = Color.Black)
Button(
    onClick = { /*TODO*/ },
    elevation = ButtonDefaults.elevation(4.dp, 10.dp, 0.dp),
    shape = RoundedCornerShape(topStart = 5.dp,topEnd = 6.dp,bottomEnd = 10.dp,bottomStart = 10.dp),
    border = BorderStroke(1.dp, Color.Green)
) {
    Text(text = "边框按钮")
}

4.colors 颜色

可以通过下面的方法进行颜色的参数的设置

ButtonDefaults.buttonColors(backgroundColor,contentColor,disabledBackgroundColor,disabledContentColor)
  • backgroundColor表示设置背景颜色
  • contentColor表示设置内容颜色这里比如说是登录文本的颜色
  • disabledBackgroundColor表示enable等于false的时候的背景颜色
  • disabledContentColor表示enable等于false时候的内容的颜色

PS:这个和之前的一样,直接导包会报错,使用复制大法ButtonDefaults.buttonColors()解决

5.contentPadding 内容内边距

contentPadding参数接收一个PaddingValues对象,这个对象的构造方法如下:

  • PaddingValues(all)
  • PaddingValues(horizontal: Dp, vertical: Dp)
  • PaddingValues(start: Dp = 0.dp,top: Dp = 0.dp,end: Dp = 0.dp,bottom: Dp = 0.dp)
PaddingValues(10.dp) //所有内边距为10dp

PaddingValues(10.dp,20.dp) //左右内边距ge10dp,上下内边距各20dp

PaddingValues(10.dp,15.dp,20.dp,25.dp) //左内边距10dp,上内边距15dp,右内边距20dp,下内边距25dp

6.interactionSource 状态变化

这个主要是用来按钮的状态说明,我们可以使用这个来达到动态切换按钮样式的效果(如按下按钮的样式效果,松开后按钮的样式),类似我们之前常用selector的xml文件给按钮设置样式

可以处理状态的,比如按下的时候什么效果,正常时候什么效果。类似之前再布局文件里写Selector

interactionSource是一个接口,我们需要使用其的实现类MutableInteractionSource

MutableInteractionSource中提供了三个属性用来获取状态

  • collectIsPressedAsState 按压状态
  • collectIsDraggedAsState 拖动状态
  • collectIsFocusedAsState 焦点状态

我们可以可以此状态来动态更改按钮的样式,如下面的代码

@Preview(showBackground = true)
@Composable
fun DefaultPreview2() {

    val myInteractionSource = remember {
        MutableInteractionSource()
    }

    val pressState = myInteractionSource.collectIsPressedAsState()
    //如果是按压状态则是切角形状,否则则是圆角形状
    val myShape = if(pressState.value) CutCornerShape(10.dp) else RoundedCornerShape(10.dp)

    Column(
        Modifier.padding(20.dp)
    ) {
        Button(
            onClick = { /*TODO*/ },
            //设置我们定义的shape
            shape = myShape,
            //设置创建的MutableInteractionSource对象
            interactionSource = myInteractionSource
        ) {
            Text("你好")
        }
    }
}

效果如下(要按住才会变化):

补充:

构造一个可观察的状态对象可以使用下面的三种方法,唯一有所区别的是,返回值不一样

val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }

如下面有个例子:

val mutableState = remember { mutableStateOf("") } //mutableState是State<String>对象

var value by remember { mutableStateOf("") } //value是String对象
val (value, setValue) = remember { mutableStateOf("") } 

一般选用by关键字的那种,代码就比较方便,如果是第一种的话,需要通过mutableState.value才能拿到其保存的数值

这里强烈建议看下官方的文档状态和Jetpack Compose

图标按钮IconButton

IconButton 可以帮助我们生成一个可点击的图标按钮,点击按钮默认会有水波涟漪的点击效果

IconButton(onClick = { /*TODO*/ }) {
    Icon(Icons.Filled.Search, null)
}

其实这里里面也可以传多个组件,但是效果可能会变得怪怪的,所以我们就是按照规范来使用吧

TextButton

这个其实是扁平按钮,之前有个FlatButton,然后改名成这个了,用法和Button一样,就是样式有所调整

TextButton(onClick = { /*TODO*/ }) {
    Icon(Icons.Default.Search,contentDescription = null)
    Text(text = "测试")
}

OutlinedButton

这个的话,看效果觉得应该是带有边框的按钮,我们也可以根据实际需求改造

OutlinedButton(onClick = { /*TODO*/ }) {
    Text(text = "测试")
}

TextField

TextField在第一篇登录页面也是有提及到,这里再深入了解下各个属性

TextField 实现分为两个级别:

1.TextField 是 Material Design 实现。我们建议您选择此实现,因为它遵循的是 Material Design 指南:
> - 默认样式为填充
> - OutlinedTextField 是轮廓样式版本
2.BasicTextField 允许用户通过硬件或软件键盘编辑文字,但没有提供提示或占位符等装饰

TextField(value = "", onValueChange = {},label = {Text("用户名")})

OutlinedTextField(value = "", onValueChange = {},label = {Text("用户名")})

BasicTextField(value = "", onValueChange = {})

简单来说,就是**BasicTextField是超级原生的输入框,其什么样式都没有,可以让我们进行高度的自定义**,而TextFieldOutlinedTextField则是Android官方给我们封装好Material Design样式的控件

1.label

获得输入焦点,顶头的文字提示,接收一个组件的lambda表达式,一般传Text,示例代码如下

TextField(value = "", onValueChange = {},label = {Text("用户名")})

效果如下图所示

2.leadingIcon

输入框左边显示内容,leadingIcon接收来自一个组件的lambda表达式,可以是图标、文本或者其他组件

TextField(
    value = text,
    onValueChange = {
        text = it
    },
    leadingIcon = {
        Icon(Icons.Filled.Search, null)
    },
)

3.trailingIcon

输入框右边的内容,和上面的leadingIcon一样的使用,这里不再赘述

PS:可以在右边放个x的图标,点击删除输入全部文本功能哦 😉

4.singleLine

设置是否单行,接收一个boolean值

注: 此参数不能和maxLines参数联用

TextField(
    value = text,
    onValueChange = {
        text = it
    },
    singleLine =true,
)

5.color

设置各种颜色,参数如下(参数真的多,应该够灵活了吧🤣)

@Composable
fun textFieldColors(
    // 输入的文字颜色
    textColor: Color = LocalContentColor.current.copy(LocalContentAlpha.current),

    // 禁用 TextField 时,已有的文字颜色
    disabledTextColor: Color = textColor.copy(ContentAlpha.disabled),

    // 输入框的背景颜色,当设置为 Color.Transparent 时,将透明
    backgroundColor: Color = MaterialTheme.colors.onSurface.copy(alpha = BackgroundOpacity),

    // 输入框的光标颜色
    cursorColor: Color = MaterialTheme.colors.primary,

    // 当 TextField 的 isError 参数为 true 时,光标的颜色
    errorCursorColor: Color = MaterialTheme.colors.error,

    // 当输入框处于焦点时,底部指示器的颜色
    focusedIndicatorColor: Color = MaterialTheme.colors.primary.copy(alpha = ContentAlpha.high),

    // 当输入框不处于焦点时,底部指示器的颜色
    unfocusedIndicatorColor: Color = MaterialTheme.colors.onSurface.copy(alpha = UnfocusedIndicatorLineOpacity),

    // 禁用 TextField 时,底部指示器的颜色
    disabledIndicatorColor: Color = unfocusedIndicatorColor.copy(alpha = ContentAlpha.disabled),

    // 当 TextField 的 isError 参数为 true 时,底部指示器的颜色
    errorIndicatorColor: Color = MaterialTheme.colors.error,

    // TextField 输入框前头的颜色
    leadingIconColor: Color = MaterialTheme.colors.onSurface.copy(alpha = IconOpacity),

    // 禁用 TextField 时 TextField 输入框前头的颜色
    disabledLeadingIconColor: Color = leadingIconColor.copy(alpha = ContentAlpha.disabled),

    // 当 TextField 的 isError 参数为 true 时 TextField 输入框前头的颜色
    errorLeadingIconColor: Color = leadingIconColor,

    // TextField 输入框尾部的颜色
    trailingIconColor: Color = MaterialTheme.colors.onSurface.copy(alpha = IconOpacity),

    // 禁用 TextField 时 TextField 输入框尾部的颜色
    disabledTrailingIconColor: Color = trailingIconColor.copy(alpha = ContentAlpha.disabled),

    // 当 TextField 的 isError 参数为 true 时 TextField 输入框尾部的颜色
    errorTrailingIconColor: Color = MaterialTheme.colors.error,

    // 当输入框处于焦点时,Label 的颜色
    focusedLabelColor: Color = MaterialTheme.colors.primary.copy(alpha = ContentAlpha.high),

    // 当输入框不处于焦点时,Label 的颜色
    unfocusedLabelColor: Color = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium),

    // 禁用 TextField 时,Label 的颜色
    disabledLabelColor: Color = unfocusedLabelColor.copy(ContentAlpha.disabled),

    // 当 TextField 的 isError 参数为 true 时,Label 的颜色
    errorLabelColor: Color = MaterialTheme.colors.error,

    // Placeholder 的颜色
    placeholderColor: Color = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium),

    // 禁用 TextField 时,placeholder 的颜色
    disabledPlaceholderColor: Color = placeholderColor.copy(ContentAlpha.disabled)
)

代码使用:

TextField(
    value = text,
    onValueChange = {
        text = it
    },
    leadingIcon = {
        Icon(Icons.Filled.Search, null)
    },
    colors = TextFieldDefaults.textFieldColors(
        textColor = Color(0xFF0079D3),
        backgroundColor = Color.Transparent
    )
)

效果:

6.visualTransformation 视图变化

视图变化是我自己翻译出来的,也不知道准不准确,个人更倾向于理解成输入类型(inputType) 😃

这个有点类似之前原生的inputType,可以改变输入的字符串(如密码或者是输入手机号时候多个-),不过官方目前只实现了PasswordVisualTransformation,其他的需要我们自定义

使用的话也很简单

var inputText by remember { mutableStateOf("") }
	
TextField(value = inputText, onValueChange = {value-> inputText= value},visualTransformation = PasswordVisualTransformation())

我们如果想实现Android那种带有个图标,点击可以显示密码的输入框,该怎么实现呢?

其实也很简单,设置个可观察的boolean值,点击图标改变数值即可,具体可参考下面代码


//密码内容
var inputText by remember { mutableStateOf("") }
//是否展示密码(默认是false)
var isShowPwd by remember { mutableStateOf(false) }

//显示效果(true:显示内偶然你 false:显示密码的"*"好
val myVisualTransformation =
    if (isShowPwd) VisualTransformation.None else PasswordVisualTransformation()

TextField(

    value = inputText,
    colors=TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
    onValueChange = { value -> inputText = value },
    visualTransformation = myVisualTransformation,
    trailingIcon = {
        //根据表示不同,显示不同的图标
        if (isShowPwd) {
            //当前是显示密码,则图标为眼睛
            IconButton(onClick = {
                //更改标志
                isShowPwd = !isShowPwd
            }) {
                Icon(painter = painterResource(id = R.drawable.eye_show), null)
            }
        } else {
            //当前是隐藏密码,则图标为眼睛禁止
            IconButton(onClick = {
                //更改标志
                isShowPwd = !isShowPwd
            }) {
                Icon(painter = painterResource(id = R.drawable.eye_hide), null)
            }
        }
    })

上面的两个图标是我自己去iconfont-阿里巴巴矢量图标库上找,效果如下:

补充(自定义VisualTransformation)

注意: 经过实践发现,这个只是改变了显示的数值而已😅,实际上你输入什么,保存的数值还是那个,单纯只是TextField没显示而已,不是很清楚这个操作,那这样是不能实现限制长度的功能,密码显示星号这种效果应该没啥问题

此功能有待讨论,或者是可能官方后面会更新长度限制等功能?

上面说到官方只实现了一个简单的密码输入类型,那如果我们想自定义该如何实现呢?

好在官方也是在API文档中给了个例子,可以实现输入信用卡号,以-隔开的效果

我们先看下官方的代码及效果(有点坑,官方只给出了一部分代码,稍微琢磨了一番才知道它是实现了VisualTransformation接口,并重写了filter()方法)

class CardVisualTransformation : VisualTransformation{
    override fun filter(text: AnnotatedString): TransformedText {
        // Making XXXX-XXXX-XXXX-XXXX string.
        val trimmed = if (text.text.length >= 16) text.text.substring(0..15) else text.text
        var out = ""
        for (i in trimmed.indices) {
            out += trimmed[i]
            if (i % 4 == 3 && i != 15) out += "-"
        }

        /**
         * The offset translator should ignore the hyphen characters, so conversion from
         *  original offset to transformed text works like
         *  - The 4th char of the original text is 5th char in the transformed text.
         *  - The 13th char of the original text is 15th char in the transformed text.
         *  Similarly, the reverse conversion works like
         *  - The 5th char of the transformed text is 4th char in the original text.
         *  - The 12th char of the transformed text is 10th char in the original text.
         */
        val creditCardOffsetTranslator = object : OffsetMapping {
            override fun originalToTransformed(offset: Int): Int {
                if (offset <= 3) return offset
                if (offset <= 7) return offset + 1
                if (offset <= 11) return offset + 2
                if (offset <= 16) return offset + 3
                return 19
            }

            override fun transformedToOriginal(offset: Int): Int {
                if (offset <= 4) return offset
                if (offset <= 9) return offset - 1
                if (offset <= 14) return offset - 2
                if (offset <= 19) return offset - 3
                return 16
            }
        }

        return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
    }
}

之后我们将TextField设置为上面的对象,代码如下

//密码内容
var inputText by remember { mutableStateOf("") }

//我们定义的卡号VisualTransformation
val myVisualTransformation = CardVisualTransformation()

TextField(
    value = inputText,
    label={
          Text(text = "卡号")
    },
    colors=TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
    onValueChange = { value -> inputText = value },
    visualTransformation = myVisualTransformation
)

效果如下:

可以看见,每输入4个字符,后面会自动加上-,且输入了16个字符后,就无法继续输入了,删除的时候,也会自动将-删除,我们分析下代码

//这里的text是之前提到的AnnotatedString类型
//最大程度过滤,只要16个字符,大于16个字符,后面的字符就忽略掉
val trimmed = if (text.text.length >= 16) text.text.substring(0..15) else text.text
//TextFiel显示的数据,满足条件即追加"-"
var out = ""
for (i in trimmed.indices) {
    out += trimmed[i]
    //最后个字符不需要加"-"(即中间每隔四个字符追加"-")
    if (i % 4 == 3 && i != 15) out += "-"
}

接下来是实现了一个接口OffsetMapping

官方文档关于此类说明: 提供原始文本和转换文本(transformed text)之间的双向偏移映射

看到这里,相信各位对原理已经有了一定的了解,VisualTransformation这个类其实就是将原始文本转为转换文本,所以我们看到filter(text: AnnotatedString)最后是返回的一个TransformedText对象

val creditCardOffsetTranslator = object : OffsetMapping {
    //原始文本对应的转换文本的下标映射
    override fun originalToTransformed(offset: Int): Int {
        if (offset <= 3) return offset
        if (offset <= 7) return offset + 1
        if (offset <= 11) return offset + 2
        if (offset <= 16) return offset + 3
        //转换文本最大长度为19
        return 19
    }
    
    //转换文本对应的原始文本下标映射
    override fun transformedToOriginal(offset: Int): Int {
        //4 9 14都是"-"的下标位置
        if (offset <= 4) return offset
        if (offset <= 9) return offset - 1
        if (offset <= 14) return offset - 2
        if (offset <= 19) return offset - 3
        //原始文本的最大长度为16
        return 16
    }
}

映射这里稍微想下就明白了,如有个abcdefgh,其对应的转换文本就为abcd-efgh,其中,a-d是下标没变,都对应得上,但从e开始,由于多了个-,所以原始文本中e的下标为4,而在转换文本中,e的下标变为了5,后面的以此类推,反过来也是同理

我们根据官方的,改下手机号的,代码如下

class PhoneVisualTransformation : VisualTransformation{
    override fun filter(text: AnnotatedString): TransformedText {

        val trimmed = if (text.text.length >= 11) text.text.substring(0,11) else text.text
        var out = ""
        for (i in trimmed.indices) {
            out += trimmed[i]
            if (i==2 || i==6 ) out += "-"
        }

        // 147-9611-2406
        // 14796112406
        val creditCardOffsetTranslator = object : OffsetMapping {
            override fun originalToTransformed(offset: Int): Int {
                if (offset <= 2) return offset
                if(offset<=6) return offset + 1
                if (offset <= 11) return offset + 2
                return 13
            }

            override fun transformedToOriginal(offset: Int): Int {
                if (offset <= 3) return offset
                if (offset <= 8) return offset - 1
                if (offset <= 14) return offset - 2
                return 11
            }
        }

        return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
    }
}

效果如下所示:

参考