How to use android accessibility for global texts? - android

I have some metric units like "kg" and "ml". Talkback reads the letters, and I implemented the contentDescription on every case:
when (someText) {
"kg" -> someText.contentDescription = "kilograms"
"ml" -> someText.contentDescription = "milliliters"
}
This works, but I need to make this global: every kg or ml in the app, needs to be read by Talkback as "kilograms" or "milliliters".
Which is the best approach for this?

It cannot be set for Talkback on an app level. You could create a utils function and use it throughout the codebase.
Something like:
fun TextView.setUnit(unit: String) {
text = unit
when (unit) {
"kg" -> contentDescription = "kilograms"
"ml" -> contentDescription = "milliliters"
}
}
And then to use it:
textView.setUnit("kg")
Another option could be to make a custom view for rendering units that sets content description.

Related

Jetpack Compose: Provide initial value for TextField

I want to achieve the following use case: A payment flow where you start with a screen to enter the amount (AmountScreen) to pay and some other screens to enter other values for the payment. At the end of the flow, a summary screen (SummaryScreen) is shown where you can modify the values inline. For the sake of simplicity we will assume there is only AmountScreen followed by SummaryScreen.
Now the following requirements should be realized:
on AmountScreen you don't loose your input on configuration change
when changing a value in SummaryScreen and go back to AmountScreen (using system back), the input is set to the changed value
AmountScreen and SummaryScreen must not know about the viewModel of the payment flow (PaymentFlowViewModel, see below)
So the general problem is: we have a screen with an initial value for an input field. The initial value can be changed on another (later) screen and when navigating back to the first screen, the initial value should be set to the changed value.
I tried various approaches to achieve this without reverting to Kotlin flows (or LiveData). Is there an approach without flows to achieve this (I am quite new to compose so I might be overlooking something obvious). If flows is the correct approach, would I keep a MutableStateFlow inside the PaymentFlowViewModel for amount instead of a simple string?
Here is the approach I tried (stripped and simplified from the real world example).
General setup:
internal class PaymentFlowViewModel : ViewModel() {
var amount: String = ""
}
#Composable
internal fun NavigationGraph(viewModel: PaymentFlowViewModel = viewModel()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "AMOUNT_INPUT_SCREEN"
) {
composable("AMOUNT_INPUT_SCREEN") {
AmountInputRoute(
// called when the Continue button is clicked
onAmountConfirmed = {
viewModel.amount = it
navController.navigate("SUMMARY_SCREEN")
},
// apply the entered amount as the initial value for the input text
initialAmount = viewModel.amount
)
}
composable("SUMMARY_SCREEN") {
SummaryRoute(
// called when the amount is changed inline
onAmountChanged = {
viewModel.amount = it
},
// apply the entered amount as the initial value for the input text
amount = viewModel.amount
)
}
}
}
The classes of the AmountScreen look like this:
#Composable
internal fun AmountInputRoute(
initialAmount: String,
onAmountConfirmed: (String) -> Unit
) {
// without the "LaunchedEffect" statement below this fulfils all requirements
// except that the changed value from the SummaryScreen is not applied
val amountInputState: MutableState<String> = rememberSaveable { mutableStateOf(initialAmount) }
// inserting this fulfils the req. that the changed value from SummaryScreen is
// applied, but breaks keeping the entered value on configuration change
LaunchedEffect(Unit) {
amountInputState.value = initialAmount
}
Column {
AmountInputView(
amountInput = amountInputState.value,
onAmountChange = { amountInput ->
amountInputState.value = amountInput
}
)
Button(onClick = { onAmountConfirmed(amountInputState.value) }) {
Text(text = "Continue")
}
}
}
```
I achieved the goal with a quite complicated approach - I would think there are better alternatives out there.
What I tried that did not work: using rememberSaveable passing initialAmount as parameter for inputs. Theoretically rememberSaveable would reinitialize its value when inputs changes, but apparently this does not happen when the composable is only on the back stack and also is not executed when it gets restored from the back stack.
What I implemented that did work:
#Composable
internal fun AmountInputRoute(
initialAmount:String,
onAmountConfirmed: (String) -> Unit
) {
var changedAmount by rememberSaveable {
mutableStateOf<String?>(null)
}
val amountInput by derivedStateOf {
if (changedAmount != null)
changedAmount
else
initialAmount
}
AmountInputView(
amountInput = amountInput,
onContinueClicked = {
onAmountConfirmed(amountInput)
changedAmount = null
},
validAmountChanged = {
changedAmount = it
}
)
}
Any better ideas?

Jetpack Compose: Mimicking spinner.setSelection() inside of a DropDownMenu

The use case is that you have 10s or 100s of items inside of a dropdown menu, the dropdown options have some ordering - as with number values or alphabetical listing of words - and selections are made in succession.
When the user reopens the menu, you'd like for it to open in the same region as their last selection, so that for instance you don't jump from "car" to "apple" but rather from "car" to "cat". Or if they just opted to view order number 358, they can quickly view order number 359.
Using views, you could create a Spinner and put all of your items in an ArrayAdapter and then call spinner.setSelection() to scroll directly to the index you want.
DropdownMenu doesn't have anything like HorizontalPager's scrollToPage(). So what solutions might exist to achieve this?
So far, I've tried adding verticalScroll() to the DropdownMenu's modifier and trying to do arithmetic with the scrollState. But it crashes at runtime with an error saying the component has infinite height, the same error you get if you try to nest scrollable components like a LazyColumn inside of a Column with verticalScroll.
It's a known issue.
DropdownMenu has its own vertical scroll modifier inside, and there is no API to work with it.
Until this problem is fixed by providing a suitable API, the only workaround I can think of is to create your own view - you can take the source code of DropdownMenu as reference.
I'll post a more detailed answer here because I don't want to mislead anyone with my comment above.
If you're in Android Studio, click the three dots on the mouse-hover quick documentation box and select "edit source" to open the source for DropdownMenu in AndroidMenu.android.kt. Then observe that it uses a composable called DropdownMenuItemContent. Edit source again and you're in Menu.kt.
You'll see this:
#Composable
internal fun DropdownMenuContent(
...
...
...
{
Column(
modifier = modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),//<-We want this
content = content
)
}
So in your custom composable just replace that rememberScrollState() with your favorite variable name for a ScrollState.
And then chain that reference all the way back up to your original view.
Getting Access to the ScrollState
#Composable
fun MyCustomDropdownMenu(
expanded:Boolean,
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
#Composable
fun MyCustomDropdownMenuContent(
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
//And now for your actual content
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded}))//<-More about this line in the sequel
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false })
{//your spinner content}
}
}
}
The above only specifies how to access the ScrollState of the DropdownMenu. But once you have the ScrollState, you'll have to do some arithmetic to get the scroll position right when it opens. Here's one way that seems alright.
Calculating the scroll distance
Even after setting the contents of the menu items explicitly, the distance was never quite right if I relied on those values. So I used an onTextLayout callback inside the Text of my menu items in order to get the true Text height at the time of rendering. Then I use that value for the arithmetic. It looks like this:
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
val chosenText:String by remember{mutableStateOf(myListOfSpinnerOptions[0])
val height by remember{mutableStateOf(0)}
val heightHasBeenChecked by remember{mutableStateOf(false)}
val coroutineScope=rememberCoroutineScope()
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded
coroutineScope.launch{scrollStateProvidedByTopParent.scrollTo(height*myListOfSpinnerOptions.indexOf[chosenText])}}))//<-This gets some arithmetic for scrolling distance
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false }) {
myListOfSpinnerOptions.forEach{option->
DropdownMenuItem(onClick={
chosenText=option
spinnerExpanded=false
}){
Text(option,onTextLayout={layoutResult->
if (!heightHasBeenChecked){
height=layoutResults.size.height
heightHasBeenChecked=true
}
}
)
}
}
}
}
}

how to assert that text not contains specific characters in android jetpack compose testing?

I'm trying to write some test cases for my compose functions.
I have an outlined Text field with a maximum value of 16 characters.
So I want to test this feature. Here is the test:
#Test
fun checkMaxTaxCodeLength_16Character() {
val taxCode = composeRule.onNodeWithTag(testTag = AUTHENTICATION_SCREEN_TAX_CODE_EDIT_TEXT)
for (i in 'A'..'Z')
taxCode.performTextInput(i.toString())
taxCode.assertTextEquals("ABCDEFGHIJKLMNOP")
}
But although I can see the input is correct, the test fails, and it seems assertTextEquals doesn't work correctly. So:
first of all, what am I doing wrong?
Second, is there any way to, instead of checking the equality, check the text does not contain specific characters?
here is the code of text field:
OutlinedTextField(
value = state.taxCode,
maxLines = 1,
onValueChange = { string ->
viewModel.onEvent(
AuthenticationEvent.TaxCodeChanged(string)
)
},
label = {
Text(text = stringResource(id = R.string.tax_code))
},
modifier = Modifier
.fillMaxWidth()
.testTag(TestingConstant.AUTHENTICATION_SCREEN_TAX_CODE_EDIT_TEXT)
)
The maximum length is handled in the view model. If the user adds more characters than 16, the view model won't update the state and keep the old value.
first of all, what am I doing wrong?
assertTextEquals() takes the value of Text and EditableText in your semantics node combines them and then does a check against the values you pass in. The order does not matter, just make sure to pass in the value of the Text as one of the arguments.
val mNode = composeTestRule.onNodeWithText("Email"))
mNode.performTextInput("test#mail.com")
mNode.assertTextEquals("Email", "test#mail.com")
Please note the text Email is the label for the textfield composable.
To get the semantic information about your nodes you can have
#Test
fun print_semantics_tree() {
composeTestRule.onRoot(useUnmergedTree = true).printToLog(TAG)
}
For the TAG you can use any string. After running the above test you can search the logcat with the specified TAG. You should see something like
|-Node #3 at (l=155.0, t=105.0, r=925.0, b=259.0)px
| Focused = 'false'
| ImeAction = 'Default'
| EditableText = 'test#mail.com'
| TextSelectionRange = 'TextRange(0, 0)'
| Text = '[Email]'
| Actions = [RequestFocus, GetTextLayoutResult, SetText, SetSelection,
OnClick, OnLongClick, PasteText]
Please note you can also obtain the semantics node object with an index operation rather than iterating through all the values.
val value = fetchSemanticsNode().config[EditableText]
assertEquals("test#mail.com", value.toString())
Ok, still, the problem is open, but I achieved what I wanted another way. I used semantic nodes to get what is in edit text and compared it with what it should be:
#Test
fun checkMaxTaxCodeLength_16Character() {
val taxCode = composeRule.onNodeWithTag(testTag = AUTHENTICATION_SCREEN_TAX_CODE_EDIT_TEXT)
for (i in 'A'..'Z')
taxCode.performTextInput(i.toString())
for ((key,value) in taxCode.fetchSemanticsNode().config)
if (key.name =="EditableText")
assertEquals("ABCDEFGHIJKLMNOP",value.toString())
}

reveal parts of text without causing a recomposition?

I'm basically writing a small quiz game with android jetpack compose in which you've got a question displayed and a text field below, I wanted to implement a "hint" (which appears under the text field and shows more and more of the correct answer for every bad answer). After implementing it, it causes a recomposition which selects a new question. Thus a question arrives, is there a way to update just the "hint" part of the screen or is that impossible ? (this might sound stupid but maybe I've missed something and there is a way)
Thanks in advance for every comment :)
You can recompose any part of a UI (without recomposing the entire screen or unrelated parts of the screen) as long as you isolate that section and only apply state changes to those parts that you want updated. The value you apply to your hint text should come from a viewmodel that contains a mutable state variable containing the text you want to update the hint with:
class MyViewModel : ViewModel() {
var hintText = mutableStateOf("")
fun onTextFieldChange(answer: String) {
// Retrieve your hint text from whatever api handles the user's response.
hintText.value = processAnswer(answer)
}
}
#Composable
fun Question() {
val vm = MyViewModel()
var text by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth()) {
TextField(
value = text,
onValueChange = {
text = it
vm.onTextFieldChange(it)
},
label = { Text("Label") },
singleLine = true
)
HintText(vm = vm)
}
}
#Composable
fun HintText(
vm: MyViewModel
) {
Text(text = vm.hintText.value)
}

Jetpack Compose State Hoisting, Previews, and ViewModels best practices

So it seems like the recommended thing in Jetpack Compose is to hoist state out of your composables, to make them stateless, reusable, and testable, and allow using them in previews easily.
So instead of having something like
#Composable
fun MyInputField() {
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
}
You'd hoist the state, like this
#Composable
fun MyInputField(text: String, onTextChange: (String) -> Unit) {
TextField(value = text, onValueChange = onTextChange)
}
This is fine, however what of some more complex uses?
Let's pretend I have a screen represented by a composable, with multiple interactions between the View and the ViewModel. This screen is split into multiple inner composable (think for instance one for a header, one for the body, which in turn is split into several smaller composables)
You can't create a ViewModel (with viewModel() at least, you can instantiate one manually) inside a composable and use this composable in a Preview (previews don't support creating viewmodel like this)
Using a ViewModel inside the inner composables would make them stateful, wouldn't it ?
So the "cleanest" solution I see, would be to instantiate my viewmodel only at the highest composable level, and then pass to the children composables only vals representing the state, and callbacks to the ViewModel functions.
But that's wild, I'm not passing down all my ViewModel state and functions through individual parameters to all composables needing them.
Grouping them in a data class for example could be a solution
data class UiState(
val textInput: String,
val numberPicked: Int,
……
and maybe create another one for callbacks ?
But that's still creating a whole new class just to mimic what the viewmodel already has.
I don't actually see what the best way of doing this could be, and I find nothing about that anywhere
A good way to manage complex states is to encapsulate required complex behavior into a class and use remember function while having stateless widgets as most as you can and change any properties of state whenever it's required.
SearchTextField is a component that uses only state hoisting, SearchBar has back arrow and SearchTextField and also itself is a stateless composable. Communication between these two and parent of Searchbar is handled via callback functions only which makes both SearchTextField re-suable and easy to preview with a default state in preview. HomeScreen contains this state and where you manage changes.
Full implementation is posted here.
#Composable
fun <R, S> rememberSearchState(
query: TextFieldValue = TextFieldValue(""),
focused: Boolean = false,
searching: Boolean = false,
suggestions: List<S> = emptyList(),
searchResults: List<R> = emptyList()
): SearchState<R, S> {
return remember {
SearchState(
query = query,
focused = focused,
searching = searching,
suggestions = suggestions,
searchResults = searchResults
)
}
}
remember function to keep state for this only to be evaluated during the composition.
class SearchState<R, S>(
query: TextFieldValue,
focused: Boolean,
searching: Boolean,
suggestions: List<S>,
searchResults: List<R>
) {
var query by mutableStateOf(query)
var focused by mutableStateOf(focused)
var searching by mutableStateOf(searching)
var suggestions by mutableStateOf(suggestions)
var searchResults by mutableStateOf(searchResults)
val searchDisplay: SearchDisplay
get() = when {
!focused && query.text.isEmpty() -> SearchDisplay.InitialResults
focused && query.text.isEmpty() -> SearchDisplay.Suggestions
searchResults.isEmpty() -> SearchDisplay.NoResults
else -> SearchDisplay.Results
}
}
And change state in any part of UI by passing state to other composable or by ViewModel as
fun HomeScreen(
modifier: Modifier = Modifier,
viewModel: HomeViewModel,
navigateToTutorial: (String) -> Unit,
state: SearchState<TutorialSectionModel, SuggestionModel> = rememberSearchState()
) {
Column(
modifier = modifier.fillMaxSize()
) {
SearchBar(
query = state.query,
onQueryChange = { state.query = it },
onSearchFocusChange = { state.focused = it },
onClearQuery = { state.query = TextFieldValue("") },
onBack = { state.query = TextFieldValue("") },
searching = state.searching,
focused = state.focused,
modifier = modifier
)
LaunchedEffect(state.query.text) {
state.searching = true
delay(100)
state.searchResults = viewModel.getTutorials(state.query.text)
state.searching = false
}
when (state.searchDisplay) {
SearchDisplay.InitialResults -> {
}
SearchDisplay.NoResults -> {
}
SearchDisplay.Suggestions -> {
}
SearchDisplay.Results -> {
}
}
}
}
Jetmagic is an open source framework that deals exactly with this issue while also solving other major issues that Google neglected when developing Compose. Concerning your request, you don't pass in viewmodels at all as parameters. Jetmagic follows the "hoisted state" pattern, but it manages the viewmodels for you and keeps them associated with your composables. It treats composables as resources in a way that is similar to how the older view system treats xml layouts. Instead of directly calling a composable function, you ask Jetmagic's framework to provide you with an "instance" of the composable that best matches the device's configuration. Keep in mind, under the older xml-based system, you could effectively have multiple layouts for the same screen (such as one for portrait mode and another for landscape mode). Jetmagic picks the correct one for you. When it does this, it provides you with an object that it uses to manage the state of the composable and it's related viewmodel.
You can easily access the viewmodel anywhere within your screen's hierarchy without the need to pass the viewmodel down the hierarchy as parameters. This is done in part using CompositionLocalProvider.
Jetmagic is designed to handle the top-level composables that make up your screen. Within your composable hierarchy, you still call composables as you normally do but using state hoisting where it makes sense.
The best thing is to download Jetmagic and try it out. It has a great demo that illustrates the solution you are looking for:
https://github.com/JohannBlake/Jetmagic

Categories

Resources