使用 Jetpack Compose 实现一个计算器APP

16 篇文章 0 订阅
订阅专栏

前言

上一篇文章中,我们说到打算使用 compose 实现一个计算器 APP,最开始打算做一个经典的 LCD 基础计算器,后来觉得好像没啥特色,最终决定还是改成仿微软计算器。

不过,微软计算器的功能太多了,仿制的工程量不小,所以我打算只仿我认为最核心的两个模式:标准模式和程序员模式。

另外,这篇文章只说 UI 实现,具体的运算逻辑可以自行查看源码。

功能特性

是否支持功能
基础四则运算(标准、程序员)
无限输入(标准)
% , 1/x , x² , √x 扩展运算(标准)
运算过程历史记录(标准)
二进制、八进制、十进制、十六进制随意切换并实时换算(程序员)
位运算:左移、右移(程序员)
逻辑运算:AND、OR、NOT、XOR(程序员)
无限连续计算(标准、程序员)
支持悬浮窗计算器,可调整位置、大小、透明度(标准)
符合人体握持习惯的横屏键盘
旋转手机自动切换标准和程序员键盘
深色模式
酷炫的数字动效与振动反馈

注意:

  1. 标准模式使用 BigDecimal 计算,所以理论支持无限位数数字计算
  2. 程序员模式因为涉及到二进制计算,所以采用 64 位储存大小,故不支持无限位数计算
  3. 程序员模式不支持带小数运算,如果运算结果有小数,则会直接抛弃小数部分

截图

浅色深色


标准模式


标准模式


历史记录


历史记录


程序员模式


程序员模式


悬浮窗


悬浮窗

项目地址

github.com/equationl/c…

欢迎 star

界面布局

根据计划,我们需要实现的是微软计算器的标准模式和程序员模式。

标准模式界面方案确定

首先,确定一下微软计算器标准模式的界面:

可以看到,它的布局思路为最顶部是菜单,下方紧跟着显示区域,最下面是键盘,键盘又分为功能按键和数字按键,两种按键使用不同的背景颜色作为区分。并且等于按键使用特别的强调颜色。

先看一下我们简单模仿的布局:

看起来不错是吧?我也是这样觉得的,但是当我装到实机上时却发现好像不太对劲。

首先,使用灰色作为背景色,会显得特别晃眼,在观感上十分不理想。

其次,现在手机屏幕大多数不是 16:9 的比例了,所以会显得显示区域特别小,而按键被拉伸的特别长,用我一朋友的话来说就是,搞得跟个老年机似的。

这说明,虽然微软计算器的布局在 PC 上看起来不错,但是却不适合手机。

那么我们不如找一个手机上的计算器进行仿制,于是我将目光放到了手机自带的小米计算器上:

可以看到,小米计算器和微软计算器在布局上大差不差,但是在某些细节上有所区别。

例如,小米计算器所有按钮使用统一的背景颜色,依靠按钮文字区分不同按键类型;

小米计算器的显示区域和按键区域几乎是 1:1 均分,这样不会显得”头重脚轻“。

好,那么我们就按照这个思路来修改,修改后布局如下:

这下看起来顺眼多了,装到手机上一样的好看。

标准模式界面实现

确定了界面方案,下面就是研究怎么实现布局。

简单观察布局,无非就是一堆按钮堆叠在一起,但是如果我们真的一个一个按钮堆上去是不是显得有点傻?哈哈,所以这里我们定义一个列表用来存放按键信息,然后遍历这个列表渲染按键UI。

定义按键信息数据类:

data class KeyBoardData(
    /**
     * 按键文本
     * */
    val text: String,
    /**
     * 设置按钮颜色,设置范围取决于 [isFilled]
     * */
    val background: Color,
    /**
     * 按键索引
     * */
    val index: Int,
    /**
     * 是否填充该按钮,如果为 true 则 [background] 用于填充该按钮背景;否则,[background] 用于设置该按钮字体颜色
     * */
    val isFilled: Boolean = false,
    /**
     * 是否启用按键
     * */
    val isAvailable: Boolean = true
)
复制代码

定义按键信息列表:

@Composable
fun standardKeyBoardBtn(): List<List<KeyBoardData>> = listOf(
        listOf(
            KeyBoardData("%", functionColor(),  KeyIndex_Percentage),
            KeyBoardData("CE", functionColor(), KeyIndex_CE),
            KeyBoardData("C", functionColor(),  KeyIndex_Clear),
            KeyBoardData("⇦", functionColor(),  KeyIndex_Back),
        ),
        listOf(
            KeyBoardData("1/x", functionColor(), KeyIndex_Reciprocal),
            KeyBoardData("x²", functionColor(), KeyIndex_Pow2),
            KeyBoardData("√x", functionColor(), KeyIndex_Sqrt),
            KeyBoardData(Operator.Divide.showText, functionColor(), KeyIndex_Divide),
        ),
        listOf(
            KeyBoardData("7", numberColor(), KeyIndex_7),
            KeyBoardData("8", numberColor(), KeyIndex_8),
            KeyBoardData("9", numberColor(), KeyIndex_9),
            KeyBoardData(Operator.MULTIPLY.showText, functionColor(), KeyIndex_Multiply),
        ),
        listOf(
            KeyBoardData("4", numberColor(), KeyIndex_4),
            KeyBoardData("5", numberColor(), KeyIndex_5),
            KeyBoardData("6", numberColor(), KeyIndex_6),
            KeyBoardData(Operator.MINUS.showText, functionColor(), KeyIndex_Minus),
        ),
        listOf(
            KeyBoardData("1", numberColor(), KeyIndex_1),
            KeyBoardData("2", numberColor(), KeyIndex_2),
            KeyBoardData("3", numberColor(), KeyIndex_3),
            KeyBoardData(Operator.ADD.showText, functionColor(), KeyIndex_Add),
        ),
        listOf(
            KeyBoardData("+/-", numberColor(), KeyIndex_NegativeNumber),
            KeyBoardData("0", numberColor(), KeyIndex_0),
            KeyBoardData(".", numberColor(), KeyIndex_Point),
            KeyBoardData("=", equalColor(), KeyIndex_Equal, isFilled = true),
        )
    )
复制代码

这里之所以把按键信息列表定义为 Composable 是因为需要适配深色模式,而深色模式的颜色,只能在 Composable 中拿。

颜色代码定义如下:

@Composable
fun numberColor(): Color = Color.Unspecified // MaterialTheme.colors.secondary

@Composable
fun functionColor(): Color = MaterialTheme.colors.primary

@Composable
fun equalColor(): Color = MaterialTheme.colors.primaryVariant
复制代码

完成了按键信息定义,下面开始编写按键布局:

@Composable
private fun StandardKeyBoard(viewModel: StandardViewModel) {
    Column(modifier = Modifier.fillMaxSize()) {
        for (btnRow in standardKeyBoardBtn()) {
            Row(modifier = Modifier
                .fillMaxWidth()
                .weight(1f)) {
                for (btn in btnRow) {
                    Row(modifier = Modifier.weight(1f)) {
                        KeyBoardButton(
                            text = btn.text,
                            onClick = { viewModel.dispatch(StandardAction.ClickBtn(btn.index)) },
                            backGround = btn.background,
                            paddingValues = PaddingValues(0.5.dp),
                            isFilled = btn.isFilled
                        )
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun KeyBoardButton(
    text: String,
    onClick: () -> Unit,
    backGround: Color = Color.White,
    isFilled: Boolean = false,
    paddingValues: PaddingValues = PaddingValues(0.dp)
) {
    Card(
        onClick = { onClick() },
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues),
        backgroundColor = if (isFilled) backGround else MaterialTheme.colors.surface,
        shape = MaterialTheme.shapes.large,
        elevation = 0.dp,
        border = BorderStroke(0.dp, Color.Transparent)
    ) {
        Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
            Text(text, fontSize = 32.sp, color = if (isFilled) Color.Unspecified else backGround)
        }
    }
}
复制代码

然后是显示区域布局:

@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun ShowScreen(viewModel: StandardViewModel) {
    val viewState = viewModel.viewStates
    val inputScrollerState = rememberScrollState()
    val showTextScrollerState = rememberScrollState()

    Column(
        Modifier
            .fillMaxWidth()
            .fillMaxHeight(0.4f)
            .noRippleClickable { viewModel.dispatch(StandardAction.ToggleHistory(true)) }
        ,
        horizontalAlignment = Alignment.End,
        verticalArrangement = Arrangement.SpaceAround
    ) {
        // 上一个计算结果
        AnimatedContent(targetState = viewState.lastShowText) { targetState: String ->
            SelectionContainer {
                AutoSizeText(
                    text = targetState,
                    fontSize = ShowSmallFontSize,
                    fontWeight = FontWeight.Light,
                    color = if (MaterialTheme.colors.isLight) Color.Unspecified else MaterialTheme.colors.primary,
                    modifier = Modifier
                        .padding(horizontal = 12.dp)
                        .padding(bottom = 16.dp)
                        .alpha(0.5f),
                    minSize = 10.sp
                )
            }
        }

        Column(horizontalAlignment = Alignment.End) {
            // 计算公式
            AnimatedContent(targetState = viewState.showText) { targetState: String ->
                Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
                    if (showTextScrollerState.value != showTextScrollerState.maxValue) {
                        Icon(
                            imageVector = Icons.Outlined.ArrowLeft,
                            contentDescription = "scroll left",
                            modifier = Modifier.absoluteOffset(x = scrollToLeftAnimation(-10f).dp)
                        )
                    }
                    Row(
                        modifier = Modifier
                            .padding(vertical = 8.dp)
                            .padding(end = 8.dp)
                            .horizontalScroll(showTextScrollerState, reverseScrolling = true)
                    ) {
                        SelectionContainer {
                            Text(
                                text = if (targetState.length > 5000) "数字过长" else targetState,
                                fontSize = ShowNormalFontSize,
                                fontWeight = FontWeight.Light,
                                color = if (MaterialTheme.colors.isLight) Color.Unspecified else MaterialTheme.colors.primary
                            )
                        }
                    }
                }
            }

            // 输入值或计算结果
            AnimatedContent(
                targetState = viewState.inputValue,
                transitionSpec = {
                    if (targetState.length > initialState.length) {
                        slideInVertically { height -> height } + fadeIn() with
                                slideOutVertically { height -> -height } + fadeOut()
                    } else {
                        slideInVertically { height -> -height } + fadeIn() with
                                slideOutVertically { height -> height } + fadeOut()
                    }.using(
                        SizeTransform(clip = false)
                    )
                }
            ) { targetState: String ->
                Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
                    if (inputScrollerState.value != inputScrollerState.maxValue) {
                        Icon(
                            imageVector = Icons.Outlined.ArrowLeft,
                            contentDescription = "scroll left",
                            modifier = Modifier.absoluteOffset(x = scrollToLeftAnimation(-10f).dp)
                        )
                    }

                    Row(modifier = Modifier
                        .padding(vertical = 8.dp)
                        .padding(end = 8.dp)
                        .horizontalScroll(inputScrollerState, reverseScrolling = true)
                    ) {
                        SelectionContainer {
                            Text(
                                text = targetState.formatNumber(formatDecimal = viewState.isFinalResult),
                                fontSize = InputLargeFontSize,
                                fontWeight = FontWeight.Bold,
                                color = if (MaterialTheme.colors.isLight) Color.Unspecified else MaterialTheme.colors.primary
                            )
                        }
                        LaunchedEffect(Unit) {
                            inputScrollerState.scrollTo(0)
                        }
                    }
                }
            }
        }
    }
}
复制代码

由于标准模式使用 BigDecimal 进行计算,所以理论上可以输入无限长的数字,因此这里需要处理一下字符溢出。

原先我采用的是自适应缩放输入数字,即当输入文字即将超出屏幕时,自动缩小文字字号,确保文字能够完整显示,小米计算器和微软计算器都是这种处理方案。

但是这样处理的话,不用我上代码,相信各位读者已经发现问题了吧?

既然我说了文字是无限长度的,那么,即使我能一直缩放字体大小,那么到最后,字体也只是缩成一坨完全无法辨认的像素点。

所以这个方案不可行,我们应该换一种方案,那换成可以水平滚动或许要友好一点,

所以我给显示文本的地方增加了 .horizontalScroll(inputScrollerState, reverseScrolling = true),并且在每次重组时将其滚动到最后,确保最新输入的内容始终在屏幕可见区域:

LaunchedEffect(Unit) {
    inputScrollerState.scrollTo(0)
}
复制代码

这里使用 scrollTo(0) 是由于我们设置了 reverseScrolling = true,而之所以要这么设置是因为我们无法或者说不能方便的拿到这个水平滚动区域的最大值,索性直接反转它,然后滚动到 0 即为滚动到最后。

上面代码中还有一段:

if (inputScrollerState.value != inputScrollerState.maxValue) {
    Icon(
        imageVector = Icons.Outlined.ArrowLeft,
        contentDescription = "scroll left",
        modifier = Modifier.absoluteOffset(x = scrollToLeftAnimation(-10f).dp)
    )
}
复制代码

此处表示的是,当显示文本超出可视区域时且不在最开头,则显示一个指向左边的图标,提示用户此时有未显示完的文字,可以滚动查看。

对了,我还给这个图标加了个简单的位移动画:

@Composable
fun scrollToLeftAnimation(targetValue: Float = -5f): Float {
    val infiniteTransition = rememberInfiniteTransition()
    val slipUpYAnimation by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = targetValue,
        animationSpec = infiniteRepeatable(
            animation = tween(2000),
            repeatMode = RepeatMode.Restart
        )
    )
    return slipUpYAnimation
}
复制代码

最终效果如下:

程序员模式界面确定

老规矩,先看微软计算器的程序模式是什么样子的:

可以看到,程序员模式相比于标准模式多了几个数字(A、B、C……)用于表示十六进制,并且由于可以切换进制,还需要禁用某些数字按键,例如在八进制时需要禁用 8 和 9 。

其他就是一些功能按键的不同。

但是微软的程序员模式计算器支持的运算非常多,还支持很多扩展功能,这里我们就不仿这么多了,我们只实现它的多进制支持、位移、位逻辑运算和基础的四则运算即可,同时不再支持小数运算。

并且,为了让计算器显得更加人性化,我们决定把程序员模式做成横屏显示,大致显示效果如下:

从预览来看似乎挺不错的,但是,当装到实机上时出现了和标准计算器一样的问题:由于手机比例各不相同,按键会被拉伸成很奇怪的样子,而且显示区域将无法显示完所有文本。

那么或许程序员模式不适合使用现有的布局模式。

我想了想,想到了两种布局方案:

第一种采用两边放按键,中间放显示屏的布局方式;第二种使用显示区域和按键区域均分屏幕的方案。

最终,考虑到其实如果手机是横屏的话,一般都是双手握持手机的两端,这么一来似乎方案1更加合理,方案2可能会被左手遮挡住按键布局,并且右手在握持时不方便点击比较靠近中间的按键。

但是这里方案1也有一个问题,就是一般人的惯用手都是右手,所以应该把点击频率更高的数字按键放到右边,点击频率相对较低的功能按键放到左手边。

最终成品如下:

程序员模式界面实现

程序员模式实现过程与标准模式大差不差,同样是先定义按键信息列表,这里我们把功能按键和数字按键分开定义:

@Composable
fun programmerNumberKeyBoardBtn(): List<List<KeyBoardData>> = listOf(
    listOf(
        KeyBoardData("D", numberColor(),  KeyIndex_D),
        KeyBoardData("E", numberColor(),  KeyIndex_E),
        KeyBoardData("F", numberColor(),  KeyIndex_F)
    ),
    listOf(
        KeyBoardData("A", numberColor(),  KeyIndex_A),
        KeyBoardData("B", numberColor(),  KeyIndex_B),
        KeyBoardData("C", numberColor(),  KeyIndex_C)
    ),
    listOf(
        KeyBoardData("7", numberColor(), KeyIndex_7),
        KeyBoardData("8", numberColor(),  KeyIndex_8),
        KeyBoardData("9", numberColor(),  KeyIndex_9)
    ),
    listOf(
        KeyBoardData("4", numberColor(), KeyIndex_4),
        KeyBoardData("5", numberColor(),  KeyIndex_5),
        KeyBoardData("6", numberColor(),  KeyIndex_6)
    ),
    listOf(
        KeyBoardData("1", numberColor(), KeyIndex_1),
        KeyBoardData("2", numberColor(),  KeyIndex_2),
        KeyBoardData("3", numberColor(),  KeyIndex_3)
    ),
    listOf(
        KeyBoardData("<<", functionColor(), KeyIndex_Lsh),
        KeyBoardData("0", numberColor(),  KeyIndex_0),
        KeyBoardData(">>", functionColor(),  KeyIndex_Rsh)
    )
)

@Composable
fun programmerFunctionKeyBoardBtn(): List<List<KeyBoardData>> = listOf(
    listOf(
        KeyBoardData("C", functionColor(),  KeyIndex_Clear),
        KeyBoardData("⇦", functionColor(),  KeyIndex_Back)
    ),
    listOf(
        KeyBoardData("CE", functionColor(),  KeyIndex_CE),
        KeyBoardData(Operator.Divide.showText, functionColor(),  KeyIndex_Divide)
    ),
    listOf(
        KeyBoardData("NOT", functionColor(),  KeyIndex_Not),
        KeyBoardData(Operator.MULTIPLY.showText, functionColor(),  KeyIndex_Multiply)
    ),
    listOf(
        KeyBoardData("XOR", functionColor(),  KeyIndex_XOr),
        KeyBoardData(Operator.MINUS.showText, functionColor(),  KeyIndex_Minus)
    ),
    listOf(
        KeyBoardData("AND", functionColor(), KeyIndex_And),
        KeyBoardData(Operator.ADD.showText, functionColor(),  KeyIndex_Add)
    ),
    listOf(
        KeyBoardData("OR", functionColor(),  KeyIndex_Or),
        KeyBoardData("=", equalColor(),  KeyIndex_Equal, isFilled = true)
    )
)
复制代码

然后,在渲染界面时,需要判断一下当前数字按键是否启用,如果不启用则更改颜色,并且禁止点击:

@Composable
private fun FunctionKeyBoard(viewModel: ProgrammerViewModel) {
    val viewState = viewModel.viewStates

    Column(modifier = Modifier.fillMaxSize()) {
        for (btnRow in programmerFunctionKeyBoardBtn()) {
            Row(modifier = Modifier
                .fillMaxWidth()
                .weight(1f)) {
                for (btn in btnRow) {
                    // 判断该按键是否需要启用
                    val isAvailable = if (btn.isAvailable) {
                        btn.index !in viewState.inputBase.forbidBtn
                    }
                    else {
                        false
                    }

                    Row(modifier = Modifier.weight(1f)) {
                        KeyBoardButton(
                            text = btn.text,
                            onClick = { viewModel.dispatch(ProgrammerAction.ClickBtn(btn.index)) },
                            isAvailable = isAvailable,
                            backGround = btn.background,
                            isFilled = btn.isFilled,
                            paddingValues = PaddingValues(0.5.dp)
                        )
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun KeyBoardButton(
    text: String,
    onClick: () -> Unit,
    isAvailable: Boolean = true,
    backGround: Color = Color.White,
    isFilled: Boolean = false,
    paddingValues: PaddingValues = PaddingValues(0.dp)
) {
    Card(
        onClick = { onClick() },
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues),
        backgroundColor = if (isFilled) backGround else MaterialTheme.colors.surface,
        shape = MaterialTheme.shapes.large,
        elevation = 0.dp,
        border = BorderStroke(0.dp, Color.Transparent),
        enabled = isAvailable  // 是否可以点击
    ) {
        Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
            Text(
                text,
                fontSize = 24.sp,
                color = if (isAvailable) { // 根据是否启用设置按键文本颜色
                    if (isFilled) Color.Unspecified else backGround
                } else {
                    if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray
                }
            )
        }
    }
}
复制代码

其中的 inputBase.forbidBtn 是我定义的,当前使用进制需要禁用的按键索引:

enum class InputBase(val number: Int, val forbidBtn: List<Int>) {
    HEX(16, listOf()),
    DEC(
        10, listOf(
            KeyIndex_A,
            KeyIndex_B,
            KeyIndex_C,
            KeyIndex_D,
            KeyIndex_E,
            KeyIndex_F
        )
    )
    // ......
}
复制代码

例如当前使用的是十进制则禁用 A B C D E F 按键。

最后,说一下我是怎么分配不同组件的占用尺寸的:

@Composable
fun ProgrammerScreen(
    viewModel: ProgrammerViewModel = hiltViewModel()
) {
    Row(modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween) {
        // 左侧键盘
        Row(modifier = Modifier.weight(1.3f)) {
            FunctionKeyBoard(viewModel = viewModel)
        }

        Divider(modifier = Modifier
            .fillMaxHeight()
            .width(1.dp)
            .padding(vertical = 16.dp, horizontal = 0.dp))

        // 显示数据
        Row(modifier = Modifier.weight(2f)) {
            CenterScreen(viewModel = viewModel)
        }

        Divider(modifier = Modifier
            .fillMaxHeight()
            .width(1.dp)
            .padding(vertical = 16.dp, horizontal = 0.dp))

        // 右侧键盘
        Row(modifier = Modifier.weight(1.5f)) {
            NumberBoard(viewModel = viewModel)
        }
    }
}
复制代码

通过将三个不同的组件: FunctionKeyBoardCenterScreenNumberBoard 包裹在 Row 中,并设置不同的 weight 权重来实现三个组件按照比例完全占满屏幕宽度。

其他实现细节

自适应填充满屏幕

上面我们提到过,使用 AndroidStudio 预览的尺寸比例是 16:9,但是实际现在很多手机都不是 16:9 的比例了,导致预览时看起来布局很和谐,但是一旦安装到手机上就会被拉伸的很难看。

会出现这种情况是因为我在前期编写布局代码时,使用了硬编码尺寸,例如,标准计算器模式我将显示数字区域高度硬编码为 150 dp,这样虽然在 16:9 的设备上看起来很和谐,但是放到我手机上(比例 21:9)就显得显示区域非常小,而按键因为需要填充满屏幕则会被拉伸的特别大,特别难看。

最终我的解决方案是固定各个组件的占用比例,并且不硬编码他们的尺寸。

例如,对于标准模式的显示区域,我将其包裹在一个 Column 中,并设置 modifier 属性:

Column(
    Modifier
        .fillMaxWidth()
        .fillMaxHeight(0.4f)
    ,
    // ......
) {
    // ......
}
复制代码

因为显示区域的上级组件设置了 fillMaxSize ,所以此时显示区域会填充满屏幕宽度,并填充全屏幕的 40% 高度,也就是说,不管设备尺寸是多大,现在显示区域都会恒定填充设备的 40% 高度和所有宽度。

对了,这里插一句,其实使用 40% 宽度后会显得显示区域特别空旷,但是小米计算器不也是这么个比例吗?为什么它不会显得空旷呢?那是因为小米在显示区域上面加了个实时的历史记录显示,哈哈,所以我也给加上了,大概效果是这样的:

对了X2,这里只用更改显示区域,而按键区域会根据可用空间自适应调整大小,并且能确保所有按键尺寸一致。

因为按键布局在编写时给每一行都设置了权重为 1 ,用时每行中的每个按键也设置了权重为 1 :.weight(1f) 。所以最终所有按键会均分所有可用的尺寸。

历史记录遮罩层

微软计算器点击顶部菜单的历史记录图标后会从最底下缓慢上升一个遮罩层显示历史记录列表,并且这个遮罩层只会上升到按键的高度,不会超出按键区域,遮住显示区域。

这个效果其实也非常好实现:

Box(Modifier.fillMaxSize()) {
    val isShowHistory = viewState.historyList.isEmpty() // 通过当前状态中历史记录列表是否为空来判断是否应该显示历史记录遮罩层

    // 按键组件
    StandardKeyBoard(viewModel)

    AnimatedVisibility(
        visible = isShowHistory,
        enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
        exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
    ) {
        // 历史记录组件
        HistoryWidget(
            // ......
        )
    }
}
复制代码

将按键和历史记录包裹在同一个 Box 中。

其中按键始终可见,历史记录是否显示则却决于 isShowHistory 状态,并且将历史记录组件包裹在 AnimatedVisibility 中。

AnimatedVisibility 组件会为被它包裹的 content 添加由 enterexit 指定的动画。

例如这里就指定显示动画使用垂直滑入加淡入效果,退出则为垂直滑出加淡出效果。

滑入的起始 Y 坐标为最大高度(fullHeight)即屏幕底部,initialOffsetY 这个 lambda 的参数 it 表示的就是当前的最大高度。

滑出的目标 Y 坐标也为最大高度。

这样就能实现微软计算器的滑动效果了:

可滚动布局的一个BUG

在 实现标准模式界面 一节中,我们提到最终采用了可水平滚动来处理文本溢出显示区域。

但是经过我实际测试,当文本长度达到一定的长度时,会直接闪退:

Process: com.equationl.calculator_compose, PID: 2424
    java.lang.IllegalArgumentException: Can't represent a size of 507896 in Constraints
    at androidx.compose.ui.unit.Constraints$Companion.bitsNeedForSize(Constraints.kt:403)
    at androidx.compose.ui.unit.Constraints$Companion.createConstraints-Zbe2FdA$ui_unit_release(Constraints.kt:366)
    at androidx.compose.ui.unit.ConstraintsKt.Constraints(Constraints.kt:433)
    at androidx.compose.ui.unit.ConstraintsKt.Constraints$default(Constraints.kt:418)
    at androidx.compose.foundation.text.TextDelegate.layoutText-K40F9xA(TextDelegate.kt:198)
    at androidx.compose.foundation.text.TextDelegate.layout-NN6Ew-U(TextDelegate.kt:241)
    at androidx.compose.foundation.text.TextController$measurePolicy$1.measure-3p2s80s(CoreText.kt:314)
    at androidx.compose.ui.node.InnerPlaceable.measure-BRTryo0(InnerPlaceable.kt:44)
    at androidx.compose.ui.graphics.SimpleGraphicsLayerModifier.measure-3p2s80s(GraphicsLayerModifier.kt:405)
    at androidx.compose.ui.node.ModifiedLayoutNode.measure-BRTryo0(ModifiedLayoutNode.kt:53)
    at androidx.compose.ui.node.LayoutNode$performMeasure$1.invoke(LayoutNode.kt:1428)
    at androidx.compose.ui.node.LayoutNode$performMeasure$1.invoke(LayoutNode.kt:1427)
    at androidx.compose.runtime.snapshots.Snapshot$Companion.observe(Snapshot.kt:2116)
    at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:110)
    at androidx.compose.ui.node.OwnerSnapshotObserver.observeReads$ui_release(OwnerSnapshotObserver.kt:78)
    at androidx.compose.ui.node.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui_release(OwnerSnapshotObserver.kt:66)
    at androidx.compose.ui.node.LayoutNode.performMeasure-BRTryo0$ui_release(LayoutNode.kt:1427)
    at androidx.compose.ui.node.OuterMeasurablePlaceable.remeasure-BRTryo0(OuterMeasurablePlaceable.kt:94)
    at androidx.compose.ui.node.OuterMeasurablePlaceable.measure-BRTryo0(OuterMeasurablePlaceable.kt:75)
    at androidx.compose.ui.node.LayoutNode.measure-BRTryo0(LayoutNode.kt:1366)
    at androidx.compose.foundation.text.selection.SimpleLayoutKt$SimpleLayout$1.measure-3p2s80s(SimpleLayout.kt:35)
    
    ......
    
复制代码

通过这个错误堆栈,大致可以猜测出闪退是由于文本太大,导致水平滚动也无法测量尺寸导致闪退。

错误的具体原因目前我还不知道,但是我们可以通过简单的限制最大文本数量避免触发这个问题:

Text(
    text = if (targetState.length > 5000) "数字过长" else targetState,
    fontSize = ShowNormalFontSize,
    fontWeight = FontWeight.Light,
    color = if (MaterialTheme.colors.isLight) Color.Unspecified else MaterialTheme.colors.primary
)
复制代码

虽然这样就违背了我们设计的可以无限输入数字的特性了。

总结

使用 compose 仿其他 APP 的界面相比较于使用传统 xml 可以说是方便的多了,现在 compose 基本也可以完美使用了,就是总还是会有一些奇奇怪怪的小 BUG 让人很烦,就比如我上面说到的这个尺寸溢出闪退的问题。

程序员计算器
06-12
程序员计算器
JetpackComposeCalculator:Jetpack撰写的Android 10计算器UI克隆
03-19
JetpackComposeCalculator Jetpack撰写的Android 10计算器UI克隆 中篇文章: : 使用解析算术表达式。
Android计算器(仿小米计算器)
孤夜的博客
04-07 1361
前言:这个项目是还存在很多bug,基本上可以实现16位以下的加减乘除求余,后面有空再进行一下修改吧,最近有点懒… 整个项目的完整代码链接:点击下载吧 实现思路: 设置一个标识用来判断用户输入字符或非字符 再定义三个值,左值,右值,运算结果 左值只有在字符模式下才能赋值,右值只有在用户点击了运算符后进入非字符模式,才能进行赋值 用户点击"="后,通过对左值右值进行相应运算得出最终结果,并进行...
Android布局之线性布局LinearLayout(二) ----简单模仿ios端小米计算器主界面UI
akx6795的博客
04-22 465
Android布局之线性布局LinearLayout(二) ----简单模仿ios端小米计算器主界面UI   今天老师的要求是让用LinearLayout布局做自己手机自带的计算器的UI设计,因为ios端的计算器是圆形按钮的,当时做的时候还不清楚Button控件如何调整成圆形,故我下载了小米计算器,方形按钮比较容易用Button控件实现。   图片如下:   思路:计算器界面可以用竖直...
仿小米计算器
Mis_wenwen的博客
06-19 4085
模仿小米计算器: 基本的功能:大写转换,科学计算器(打开自带Calculator),长度转换,面积转换,体积转换,温度转换,速度转换,时间转换,重量转换。 最终效果图: 实现中遇到的问题: GridView高度自适应全屏。 自定义PickerView。 单位转换的算法设计。 AlertDialog位置可控。 因为写这个比较早了,现在想想可以优化的点:
Kotlin Compose 计算器程序
丿灬安之若死
10-10 872
枚举的升级版本 一下子就列举出来所有情况。枚举只能列举相同的状态。而他不一样可以扩展。所有按钮都用到了这个。根据他们三个的变化。然后计算逻辑在viewModel当中。新建最基础的 button。这里用到了status。扩展的另外一个密封类。
Jetpack Compose实现的厨房计时器-Android开发
05-26
厨房定时器的动机和环境为了纪念Jetpack Compose的第一个beta版本,Google推出了#AndroidDevChallenge。 在第二轮中,为了纪念Jetpack Compose的第一个beta版本,Google推出了#AndroidDevChallenge。 在第二轮中,要求参赛者创建一个简单的倒计时应用程序,而创建厨房计时器的想法突然出现在我的脑海中。 到那时,我没有时间在一个星期内完成它,所以我提交了一个简单的书,但是现在有了它,这就是结果:smiling_face_with_smiling_eyes:我写了一篇中篇文章来解释该过程,您可以阅读它在这里组成,相机和C
用十种编程语言开发秒表应用-第一篇-安卓
蓝不蓝编程
12-22 1928
用十种编程语言开发秒表应用 安卓Kotlin (安卓App) 安卓Kotlin+JetPack Compose(安卓App) Swift (iOS应用,采用SwiftUI) Dart(Flutter应用,跨平台,适用安卓、ios、mac、windows、web) 微信小程序 抖音小程序 鸿蒙 (Java版) 鸿蒙 (ArkUI版) Js+Html+Vue(H5应用) Js+Html+Vue+ElementUI(H5应用) 安卓Kotlin 开发工具 Android Studio 如何下载 工程截图
Jetpack Compose - Button
乐翁龙
12-08 5426
Jetpack Compose - Button1、Button1.1、属性一览1.2、使用示例2、IconButton2.1、属性一览:2.2、使用示例3、IconToggleButton3.1、属性一览3.2、使用示例待补充未解决问题 关于Button官方给出了很多种样式,有Button、IconButton、IconToggleButton、TextButton、OutlinedButton等,还有一个RadioButton我们会单独开文章去讲解。 1、Button 1.1、属性一览 我们先来看下 B
Jetpack-Compose-WhatsApp-Clone:一个示例项目,演示如何使用Jetpack Compose构建WhatsApp
03-19
Jetpack-Compose-WhatsApp-Clone-通过构建WhatsApp克隆来学习Jetpack Compose关于此项目(Jetpack Compose WhatsApp克隆):如果您想开始使用Jetpack Compose,那么这个项目适合您。 Jetpack Compose的常见用例已在...
Retrogamer-Compose:使用Jetpack Compose实现的复古游戏
03-08
Retrogamer-撰写 :brick: 俄罗斯方块
Jetpack Compose入门到精通
01-29
Jetpack Compose入门到精通
Doom-Compose使用Jetpack Compose实现DOOM射击效果
02-04
Doom-Compose使用Jetpack Compose实现DOOM射击效果
Jetpack Compose 入门到精通.pdf
07-07
Jetpack Compose 是第一个使用 Kotlin 正在开发中的大型项目,因此 Android 团队正在探索 Kotlin API 指南的新世界,以创建一组特定于 Compose API 的指南,该工作仍在进行中,仍然有很长的路要走。 4. Compose API...
Android小项目——仿iPhone计算器
LQwraith的博客
08-01 3829
计算器前言 前言
Android计算器源码分享
weixin_36527282的博客
12-16 1163
今天给大家带来自己用Android开发的计算器小程序,话不多说先把代码奉上。
高仿小米计算器界面UI 适合新手学习 [附源码]
localhost
10-08 1728
       初学Android尝试着做一些布局,看到手机上的小米计算器界面简洁,适合新手尝试,于是做了一下,但是未实现逻辑,只是高仿界面。 小米计算器UI 高仿小米计算器UI    源码:https://download.csdn.net/download/qq_41113081/10707135...
WIN10自带程序员计算器
最新发布
weixin_49380162的博客
09-15 474
WIN10自带程序员计算器
使用jetpack compose实现一个类似抖音上下滑动切换视频的功能
04-27
实现类似抖音上下滑动切换视频的功能,可以使用Jetpack Compose中的`LazyColumn`和`Pager`组件。 首先,我们需要准备一些视频数据,可以使用一个包含视频URL的列表: ```kotlin val videos = listOf( "https://example.com/video1.mp4", "https://example.com/video2.mp4", "https://example.com/video3.mp4", // ... ) ``` 然后,我们可以使用`LazyColumn`和`Pager`组件来实现上下滑动和视频切换的功能: ```kotlin LazyColumn { val pagerState = rememberPagerState(pageCount = videos.size) items(count = videos.size) { index -> val videoUrl = videos[index] Pager( state = pagerState, modifier = Modifier.fillMaxWidth(), ) { VideoPlayer(videoUrl) } } } ``` 在上面的代码中,我们首先创建了一个`PagerState`,用于管理视频的切换。然后,我们使用`LazyColumn`组件创建一个垂直滚动列表,每个列表项都是一个`Pager`组件,用于显示一个视频。`Pager`组件的`state`属性绑定了`PagerState`,`modifier`属性设置了组件的宽度为最大,这样视频就可以充满屏幕了。`Pager`组件的内容是一个自定义的`VideoPlayer`组件,用于播放视频。 最后,我们需要实现`VideoPlayer`组件,可以使用`VideoView`和`ExoPlayer`来播放视频。下面是一个简单的`VideoPlayer`组件的实现: ```kotlin @Composable fun VideoPlayer(url: String) { val context = LocalContext.current val videoView = remember { VideoView(context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) setMediaController(MediaController(context)) setOnPreparedListener(MediaPlayer.OnPreparedListener { mp -> mp.isLooping = true }) } } AndroidView( factory = { videoView }, update = { it.setVideoURI(Uri.parse(url)) it.start() } ) } ``` 在上面的代码中,我们首先使用`remember`来创建一个`VideoView`实例,然后使用`AndroidView`组件将其包装成一个Compose组件。在`AndroidView`组件的`update`块中,我们设置`VideoView`的视频URL并开始播放。这样就可以在`Pager`组件中显示一个视频并自动播放了。 以上就是使用Jetpack Compose实现类似抖音上下滑动切换视频的简单示例。当然,这只是一个基础实现,你还可以根据自己的需求进行扩展和优化。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
写文章

热门文章

  • 新手向:Python常见错误及解决方法 13065
  • java.lang.IllegalArgumentException Unknown URI: content://downloads/public_downloads/ 解决方案 12449
  • androidstudio gradle错误:Error:Unrecognized SSL message, plaintext connection? 解决方案 9070
  • Output file is empty, nothing was encoded (check -ss / -t / -frames parameters if used) 解决方法 4922
  • 在macOS上安装使用基于 ESP32C3 的 Arduino“ 4383

分类专栏

  • Arduino 4篇
  • MicroPython 1篇
  • 安卓开发踩坑记 16篇
  • Python日常 2篇

最新评论

  • 少年,不知道怎么在安卓中使用 PaddleOCR ?看我怎么把它二次封装成只需要两行代码即可使用

    nuaaZeng: 只需要cp其中一部分就可以; 但是有个小小缺陷, libpaddle_light_api_shared.so在频繁加载情况下会产生OOM

  • 少年,不知道怎么在安卓中使用 PaddleOCR ?看我怎么把它二次封装成只需要两行代码即可使用

    Scoffin: 有没有人写出来啊,我用java写的,勉强看懂,移植不动

  • 初探 Compose for Wear OS:实现一个简易选择APP

    有只猫吃很多: 博主你好 请问我的Android Studio版本创建wear os 没有第三个选项是为什么呢? 可以分享下你用的的版本吗

  • 少年,不知道怎么在安卓中使用 PaddleOCR ?看我怎么把它二次封装成只需要两行代码即可使用

    nuaaZeng: 非常感谢分享!有个问题请教一下: paddleocr4android 这个是已经封装好的?还是我需要我再次封装? 如果我在我的开发模块中使用,可以直接导入 paddleocr4android修改后的模块? 谢谢

  • 少年,不知道怎么在安卓中使用 PaddleOCR ?看我怎么把它二次封装成只需要两行代码即可使用

    huangwei_er: 如果需要替换成pp-ocrv4如何操作

大家在看

  • 短信测压APP
  • 使用Ventoy 替代Win_To_Go更好的随身系统 478
  • OpenGL系列(六)变换
  • 穿越火线启动错误:应对未找到bugsplat.dll的解决方案
  • 使用ChatGPT来撰写和润色学术论文的教程 498

最新文章

  • Compose for iOS:kotlin 与 swift 互操作
  • 为 Compose MultiPlatform 添加 C/C++ 支持(3):实战 Desktop、Android、iOS 调用同一个 C/C++ 代码
  • 为 Compose MultiPlatform 添加 C/C++ 支持(2):在 jvm 平台使用 jni 实现桌面端与 C/C++ 互操作
2023年46篇
2022年16篇
2021年1篇
2020年1篇
2019年1篇
2018年5篇

目录

目录

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值

哆哆女性网不可思议的生物起名 公司装台电视剧免费观看霜降生的孩子起啥名好锐度主张硕大无朋周易风水起名五行与八字起名优秀党员个人主要事迹手机小说txt如何给孩子起名字一键ghost优盘版猫咪起什么名字好按古诗词语起名字独家披露电视剧飞扬影院赵 姓 起名官场之权色2018狗婴儿起名大全cctv4直播英语起名网布林肯被追问“美国是否保卫台湾”党员民主评议个人总结天真无邪是什么意思酷狗在线音乐诗经论语史记唐诗宋词起名字模型姐妹房产中介公司起名字大全免费免费起名大全2018起名字免费八字起名淀粉肠小王子日销售额涨超10倍罗斯否认插足凯特王妃婚姻不负春光新的一天从800个哈欠开始有个姐真把千机伞做出来了国产伟哥去年销售近13亿充个话费竟沦为间接洗钱工具重庆警方辟谣“男子杀人焚尸”男子给前妻转账 现任妻子起诉要回春分繁花正当时呼北高速交通事故已致14人死亡杨洋拄拐现身医院月嫂回应掌掴婴儿是在赶虫子男孩疑遭霸凌 家长讨说法被踢出群因自嘲式简历走红的教授更新简介网友建议重庆地铁不准乘客携带菜筐清明节放假3天调休1天郑州一火锅店爆改成麻辣烫店19岁小伙救下5人后溺亡 多方发声两大学生合买彩票中奖一人不认账张家界的山上“长”满了韩国人?单亲妈妈陷入热恋 14岁儿子报警#春分立蛋大挑战#青海通报栏杆断裂小学生跌落住进ICU代拍被何赛飞拿着魔杖追着打315晚会后胖东来又人满为患了当地回应沈阳致3死车祸车主疑毒驾武汉大学樱花即将进入盛花期张立群任西安交通大学校长为江西彩礼“减负”的“试婚人”网友洛杉矶偶遇贾玲倪萍分享减重40斤方法男孩8年未见母亲被告知被遗忘小米汽车超级工厂正式揭幕周杰伦一审败诉网易特朗普谈“凯特王妃P图照”考生莫言也上北大硕士复试名单了妈妈回应孩子在校撞护栏坠楼恒大被罚41.75亿到底怎么缴男子持台球杆殴打2名女店员被抓校方回应护栏损坏小学生课间坠楼外国人感慨凌晨的中国很安全火箭最近9战8胜1负王树国3次鞠躬告别西交大师生房客欠租失踪 房东直发愁萧美琴窜访捷克 外交部回应山西省委原副书记商黎光被逮捕阿根廷将发行1万与2万面值的纸币英国王室又一合照被质疑P图男子被猫抓伤后确诊“猫抓病”

哆哆女性网 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化