2024年已经过半了,我作为聋人独立开发者,我经常反思自己在这半年中的成长,自己这半年到底进步了多少?在这篇文章里,我分享一个用Jetpack Compose、Material 3和Kotlin改进NimTwoTrackApp的无障碍功能的案例。如果你有一定开发经验,相信这篇文章对你会非常有所帮助。
深知在数字世界中,不是每个人都能轻松地使用应用程序,有些人听力有障碍,有些人可能视力受限,还有些人可能有运动障碍。我觉得提高App的无障碍功能对于提升用户体验非常重要,这不只是技术进步的体现,更是我们作为开发者的社会责任。
我的个人经历让我更加关注那些可能被忽视的用户需求。本项目的核心目标是优化Android App,易于访问,特别是对于那些需要特殊辅助功能的用户。通过这篇文章,我分享一些实用的技巧和代码示例,帮助大家怎么开发具有包容性的App。
无障碍功能是为了目帮助有特殊需求的用户更好使用数字设备和软件。对应用程序进行无障碍优化,通常包括以下几方面:
为了帮助使用屏幕阅读器的用户理解应用界面的内容,所有界面元素必须提供合适的contentDescription
。在Jetpack Compose中,可以通过semantics
修饰符为每个UI组件添加描述。
例如,在NimTwoTrackApp
中,选手的进度条、按钮和文本等都需要添加语义描述:
@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元素添加了描述文本,屏幕阅读器读取到界面关键信息,帮助用户理解界面布局内容。
无障碍设计中的一个重要部分是保证可操作组件(如按钮、输入框等)具备清楚的操作反馈,可以通过语义属性为按钮和控件增加无障碍提示,可以提供每个元素增加聚焦、可操作的动作。
例如,为了让按钮在聚焦时提供良好的反馈体验,可以为按钮添加触摸反馈:
@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("重置")
}
}
}
对于聋人用户来说,无法通过声音接收到反馈,所以需要通过振动或视觉变化替代声音反馈。Compose允许使用Android系统的振动功能为聋人用户提供反馈:
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 "开始")
}
低视力用户常常需要大字体和更清晰的对比度。为了优化NimTwoTrackApp的视觉体验,除了保证文本具有足够的对比度之外,还可以使用MaterialTheme
中的字体缩放功能,根据系统设置自动调整字体大小:
Text(
text = "NimTwoTrackApp",
style = MaterialTheme.typography.h4.copy(fontSize = 24.sp),
color = MaterialTheme.colorScheme.onBackground
)
自行复制Code,用真机测试,祝成功 )
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
注释标记已修改的地方
PS:不能模拟器进行测试,因为TalkBack无障碍功能之所以无法正常测试;必须用真机上进行测试,安卓的屏幕阅读器(例如TalkBack)会读取通过semantics或contentDescription添加的无障碍描述。真机环境模拟用户的实际使用情况,帮助开发者验证无障碍功能的效果。 在使用TalkBack时,用户可以通过手指在屏幕上滑动,设备会通过语音播报屏幕上元素的描述。如果你为按钮、文本或其他UI元素设置了contentDescription,TalkBack读取这些描述,告诉用户这些元素的功能。
测试无障碍功能的步骤: 在安卓设备上启用TalkBack: 设置 -> 辅助功能 -> TalkBack -> 启用
检查每个组件的无障碍功能是否达到预期结果。
通过本项目的改进,我开发了这个App增强了NimTwoTrackApp的无障碍功能,具体包括:
contentDescription
为屏幕阅读器提供详细描述。我一直对开发无障碍功能充满热情,无障碍功能帮助有特殊需求的用户,提升应用的整体体验和用户满意度。在未来的开发中,开发者应该持续关注无障碍功能的改进,融入App设计的各个环节。
有任何问题欢迎提问,感谢大家阅读 :)
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。