前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >安卓软件开发:改进NimTwoTrackApp的无障碍功能

安卓软件开发:改进NimTwoTrackApp的无障碍功能

原创
作者头像
Nimyears
发布2024-09-24 18:46:51
1610
发布2024-09-24 18:46:51

2024年已经过半了,我作为聋人独立开发者,我经常反思自己在这半年中的成长,自己这半年到底进步了多少?在这篇文章里,我分享一个用Jetpack Compose、Material 3和Kotlin改进NimTwoTrackApp的无障碍功能的案例。如果你有一定开发经验,相信这篇文章对你会非常有所帮助。

一、项目背景

深知在数字世界中,不是每个人都能轻松地使用应用程序,有些人听力有障碍,有些人可能视力受限,还有些人可能有运动障碍。我觉得提高App的无障碍功能对于提升用户体验非常重要,这不只是技术进步的体现,更是我们作为开发者的社会责任。

我的个人经历让我更加关注那些可能被忽视的用户需求。本项目的核心目标是优化Android App,易于访问,特别是对于那些需要特殊辅助功能的用户。通过这篇文章,我分享一些实用的技巧和代码示例,帮助大家怎么开发具有包容性的App。


二、无障碍功能(Accessibility Features)定义

无障碍功能是为了目帮助有特殊需求的用户更好使用数字设备和软件。对应用程序进行无障碍优化,通常包括以下几方面:

  1. 屏幕阅读器支持:为视力障碍用户提供文本描述,使屏幕阅读器(如TalkBack)可以朗读界面元素。
  2. 可操作组件优化:提高按钮和输入控件的可操作性,确保用户可以轻松导航和交互。
  3. 视觉和听觉反馈:为听力或视力障碍用户提供更好的交互反馈,如振动、视觉动画或文字提示。

三、无障碍功能改进方案

3.1 屏幕阅读器支持

为了帮助使用屏幕阅读器的用户理解应用界面的内容,所有界面元素必须提供合适的contentDescription。在Jetpack Compose中,可以通过semantics修饰符为每个UI组件添加描述。

例如,在NimTwoTrackApp中,选手的进度条、按钮和文本等都需要添加语义描述:

代码语言:java
复制
@Composable
fun RaceTrackerScreen(
    playerOne: RaceParticipant,
    playerTwo: RaceParticipant,
    isRunning: Boolean,
    onRunStateChange: (Boolean) -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        //TODO 改进Title的无障碍
        Text(
            text = "NimTwoTrackApp",
            style = MaterialTheme.typography.h4,
            modifier = Modifier.semantics {
                contentDescription = "NimTwoTrackAppTitle"
            }
        )
        Spacer(modifier = Modifier.height(16.dp))

        //TODO 改进进度条的无障碍
        StatusIndicator(
            participantName = playerOne.name,
            currentProgress = playerOne.currentProgress,
            modifier = Modifier.semantics {
            
                contentDescription = "选手 ${playerOne.name} 的进度是 ${playerOne.currentProgress}%"
            }
        )
        Spacer(modifier = Modifier.height(8.dp))

        StatusIndicator(
            participantName = playerTwo.name,
            currentProgress = playerTwo.currentProgress,
            modifier = Modifier.semantics {
                contentDescription = "选手${playerTwo.name} 的进度是 ${playerTwo.currentProgress}%"
            }
        )
        //TODO 改进按钮的无障碍
        Spacer(modifier = Modifier.height(16.dp))
        RaceControls(
            isRunning = isRunning,
            onRunStateChange = onRunStateChange,
            modifier = Modifier.semantics {
                contentDescription = if (isRunning) "暂停比赛按钮" else "开始比赛按钮"
            }
        )
    }
}

在上述代码中,semantics修饰符为每个UI元素添加了描述文本,屏幕阅读器读取到界面关键信息,帮助用户理解界面布局内容。

3.2 优化可操作组件

无障碍设计中的一个重要部分是保证可操作组件(如按钮、输入框等)具备清楚的操作反馈,可以通过语义属性为按钮和控件增加无障碍提示,可以提供每个元素增加聚焦、可操作的动作。

例如,为了让按钮在聚焦时提供良好的反馈体验,可以为按钮添加触摸反馈:

代码语言:java
复制
@Composable
fun RaceControls(
    isRunning: Boolean,
    onRunStateChange: (Boolean) -> Unit
) {
    Column(
        modifier = Modifier.padding(top = 16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Button(
            onClick = { onRunStateChange(!isRunning) },
            modifier = Modifier
                .fillMaxWidth()
                //TODO 改进按钮的无障碍
                .semantics {
                    contentDescription = if (isRunning) "暂停比赛" else "开始比赛"
                    //TODO 增加按钮的聚焦反馈
                    focusable()
                },
        ) {
            Text(if (isRunning) "暂停" else "开始")
        }

        OutlinedButton(
            onClick = {  onReset()  },
            modifier = Modifier
                .fillMaxWidth()
                .semantics { contentDescription = "重置比赛" },
        ) {
            Text("重置")
        }
    }
}

3.3 视觉和听觉反馈的增强

对于聋人用户来说,无法通过声音接收到反馈,所以需要通过振动或视觉变化替代声音反馈。Compose允许使用Android系统的振动功能为聋人用户提供反馈:

代码语言:java
复制
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.ContextCompat

fun triggerVibration(context: Context) {
    //TODO 定义控制设备的振动效果
    val vibrator = ContextCompat.getSystemService(context, Vibrator::class.java)
    vibrator?.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
}

Button(
    onClick = {
         //TODO 按钮点击时振动反馈
        triggerVibration(context)
        onRunStateChange(!isRunning)
    },
    modifier = Modifier.fillMaxWidth(),
) {
    Text(if (isRunning) "暂停" else "开始")
}

3.4 适应低视力用户的字体和布局优化

低视力用户常常需要大字体和更清晰的对比度。为了优化NimTwoTrackApp的视觉体验,除了保证文本具有足够的对比度之外,还可以使用MaterialTheme中的字体缩放功能,根据系统设置自动调整字体大小:

代码语言:java
复制
Text(
    text = "NimTwoTrackApp",
    style = MaterialTheme.typography.h4.copy(fontSize = 24.sp),
    color = MaterialTheme.colorScheme.onBackground
)

3.5 其他无障碍优化建议

  • 适应手势控制:对于肢体(行动不便)用户,保证应用支持通过辅助技术(如语音输入、眼动控制)操作应用的各个控件。
  • 避免复杂的动画:虽然动画效果可以提高用户体验,对于部分用户来说,复杂的动画可能会引起不合适。可以提供关闭动画的选项。

3.6 完整代码如下

自行复制Code,用真机测试,祝成功 )

代码语言:java
复制
package com.nim.nimTwoTrack.ui

@Composable
fun RaceTrackerApp() {

    val playerOne = remember {
        RaceParticipant(name = "NimPlayer1", progressIncrement = 1)
    }
    val playerTwo = remember {
        RaceParticipant(name = "NimPlayer2", progressIncrement = 2)
    }
    var raceInProgress by remember { mutableStateOf(false) }

    if (raceInProgress) {
        LaunchedEffect(playerOne, playerTwo) {
            coroutineScope {
                launch { playerOne.run() }
                launch { playerTwo.run() }
            }
            raceInProgress = false
        }
    }
    RaceTrackerScreen(
        playerOne = playerOne,
        playerTwo = playerTwo,
        isRunning = raceInProgress,
        onRunStateChange = { raceInProgress = it },
        modifier = Modifier
            .statusBarsPadding()
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding()
            .padding(horizontal = dimensionResource(R.dimen.padding_medium)),
    )
}

@Composable
private fun RaceTrackerScreen(
    playerOne: RaceParticipant,
    playerTwo: RaceParticipant,
    isRunning: Boolean,
    onRunStateChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = stringResource(R.string.run_a_race),
            style = MaterialTheme.typography.headlineLarge,
        )
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium)),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {

            StatusIndicator(
                participantName = playerOne.name,
                currentProgress = playerOne.currentProgress,
                maxProgress = stringResource(
                    R.string.progress_percentage,
                    playerOne.maxProgress
                ),
                progressFactor = playerOne.progressFactor,
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.size(dimensionResource(R.dimen.padding_large)))
            StatusIndicator(
                participantName = playerTwo.name,
                currentProgress = playerTwo.currentProgress,
                maxProgress = stringResource(
                    R.string.progress_percentage,
                    playerTwo.maxProgress
                ),
                progressFactor = playerTwo.progressFactor,
                modifier = Modifier.fillMaxWidth(),
            )
            Spacer(modifier = Modifier.size(dimensionResource(R.dimen.padding_large)))
            RaceControls(
                isRunning = isRunning,
                onRunStateChange = onRunStateChange,
                onReset = {
                    playerOne.reset()
                    playerTwo.reset()
                    onRunStateChange(false)
                },
                modifier = Modifier.fillMaxWidth(),
            )
        }
    }
}

@Composable
private fun StatusIndicator(
    participantName: String,
    currentProgress: Int,
    maxProgress: String,
    progressFactor: Float,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
    ) {
        Text(
            text = participantName,
            modifier = Modifier
                .padding(end = dimensionResource(R.dimen.padding_small))
                //TODO 文本添加无障碍描述
                .semantics { contentDescription = "Player: $participantName" }

        )
        Column(
            verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small))
        ) {
            LinearProgressIndicator(
                progress = progressFactor,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(dimensionResource(R.dimen.progress_indicator_height))
                    .clip(RoundedCornerShape(dimensionResource(R.dimen.progress_indicator_corner_radius)))
                    //TODO 进度条添加无障碍描述
                    .semantics {
                        contentDescription = "$participantName progress bar: $currentProgress%"
                    }

            )
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(
                    text = stringResource(R.string.progress_percentage, currentProgress),
                    textAlign = TextAlign.Start,
                    modifier = Modifier
                        .weight(1f)
                        //TODO 文本添加无障碍描述
                        .semantics { contentDescription = "Current progress: $currentProgress%" }

                )
                Text(
                    text = maxProgress,
                    textAlign = TextAlign.End,
                    modifier = Modifier
                        .weight(1f)
                        //TODO 文本添加无障碍描述
                        .semantics { contentDescription = "Maximum progress: 100%" }

                )
            }
        }
    }
}

@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun RaceControls(
    onRunStateChange: (Boolean) -> Unit,
    onReset: () -> Unit,
    modifier: Modifier = Modifier,
    isRunning: Boolean = true,
) {
    //TODO 定义控制设备的振动效果
    val context = LocalContext.current
    val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
    Column(
        modifier = modifier.padding(top = dimensionResource(R.dimen.padding_medium)),
        verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium))
    ) {
        Button(
            onClick = {
                onRunStateChange(!isRunning)
                //TODO 添加振动反馈
                vibrator.vibrate(
                    VibrationEffect.createOneShot(
                        200,
                        VibrationEffect.DEFAULT_AMPLITUDE
                    )
                )

            },
            modifier = Modifier
                //TODO 调整按钮大小
                .fillMaxWidth()
                .size(dimensionResource(R.dimen.button_height_large))
                //TODO 按钮添加无障碍描述
                .semantics { contentDescription = if (isRunning) "Pause race" else "Start race" }


        ) {
            Text(if (isRunning) stringResource(R.string.pause) else stringResource(R.string.start))
        }
        OutlinedButton(
            onClick = {
                //TODO 点击重置按钮会震动
                onReset()
                vibrator.vibrate(
                    VibrationEffect.createOneShot(
                        200,
                        VibrationEffect.DEFAULT_AMPLITUDE
                    )
                )
            },
            modifier = Modifier
                .fillMaxWidth()
                //TODO 调整按钮大小
                .size(dimensionResource(R.dimen.button_height_large))
                //TODO 为按钮添加无障碍描述
                .semantics { contentDescription = "Reset race" }

        ) {
            Text(stringResource(R.string.reset))
        }
    }
}

@Preview(showBackground = true)
@Composable
fun RaceTrackerAppPreview() {
    RaceTrackerTheme {
        RaceTrackerApp()
    }
}

AndroidStudio组左侧点击更多图标,可看到TODO工具 ,用 //TODO 注释标记已修改的地方

3.7 效果图

PS:不能模拟器进行测试,因为TalkBack无障碍功能之所以无法正常测试必须用真机上进行测试,安卓的屏幕阅读器(例如TalkBack)会读取通过semantics或contentDescription添加的无障碍描述。真机环境模拟用户的实际使用情况,帮助开发者验证无障碍功能的效果。 在使用TalkBack时,用户可以通过手指在屏幕上滑动,设备会通过语音播报屏幕上元素的描述。如果你为按钮、文本或其他UI元素设置了contentDescription,TalkBack读取这些描述,告诉用户这些元素的功能。

测试无障碍功能的步骤: 在安卓设备上启用TalkBack: 设置 -> 辅助功能 -> TalkBack -> 启用

检查每个组件的无障碍功能是否达到预期结果。


四、总结

通过本项目的改进,我开发了这个App增强了NimTwoTrackApp的无障碍功能,具体包括:

  • 使用contentDescription为屏幕阅读器提供详细描述。
  • 调整控件大小和间距,提高可操作性。
  • 添加振动反馈,满足听力障碍用户的需求。
  • 提升颜色对比度,使视觉障碍用户能轻松使用App。

我一直对开发无障碍功能充满热情,无障碍功能帮助有特殊需求的用户,提升应用的整体体验和用户满意度。在未来的开发中,开发者应该持续关注无障碍功能的改进,融入App设计的各个环节。

有任何问题欢迎提问,感谢大家阅读 :)

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、项目背景
  • 二、无障碍功能(Accessibility Features)定义
  • 三、无障碍功能改进方案
    • 3.1 屏幕阅读器支持
      • 3.2 优化可操作组件
        • 3.3 视觉和听觉反馈的增强
          • 3.4 适应低视力用户的字体和布局优化
            • 3.5 其他无障碍优化建议
              • 3.6 完整代码如下
                • 3.7 效果图
                • 四、总结
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档