Jetpack Compose - TextField loses focus after typing - android-jetpack-compose

I have an AlertDialog with dynamic height. It has a TextField, on which some basic validation is performed, such as making sure the content is not empty. A warning message is displayed below it if validation fails. Once the user enters text in the text field, the warning message automatically disappears.
The problem is, when the user starts typing after the warning message is already displayed, the TextField loses focus for some reason. Does anyone know why, and how to prevent this from happening? Relevant code is below the gif. Currently using compose:1.2.0-alpha04
AlertDialog
AlertDialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.width(250.dp),
onDismissRequest = { showAddMatchDialog = false },
buttons = {
var player1Name by rememberSaveable { mutableStateOf("") }
var player1NameError by rememberSaveable { mutableStateOf(false) }
var player1Score by rememberSaveable { mutableStateOf("") }
var player1ScoreError by rememberSaveable { mutableStateOf(false) }
Column(
modifier = Modifier.padding(top = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.add_match_dialog_title),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(4.dp))
PlayerRow(
nameLabel = stringResource(R.string.player1_name),
name = player1Name,
isNameError = player1NameError,
onNameClear = { player1Name = "" },
onNameChange = {
player1Name = it
player1NameError = false
},
score = player1Score,
isScoreError = player1ScoreError,
onScoreChange = {
player1Score = it
player1ScoreError = false
}
)
Spacer(Modifier.height(8.dp))
// Same thing for player 2
}
}
)
Player Row
#Composable
fun PlayerRow(
nameLabel: String,
name: String,
isNameError: Boolean,
onNameClear: () -> Unit,
onNameChange: (String) -> Unit,
score: String,
isScoreError: Boolean,
onScoreChange: (String) -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
// Name TextField
Column {
Text(
text = nameLabel,
style = MaterialTheme.typography.subtitle2
)
Spacer(Modifier.height(4.dp))
Box {
BasicTextField(
modifier = Modifier
.width(TEXT_FIELD_WIDTH.dp)
.height(TEXT_FIELD_HEIGHT.dp)
.background(
color = GrayLight,
shape = roundedCornerShape
)
.then(
if (isNameError) {
Modifier.border(
width = 1.dp,
color = Warning,
shape = roundedCornerShape
)
} else {
Modifier
}
)
.padding(start = 8.dp, end = 8.dp, top = 6.dp),
value = name,
onValueChange = onNameChange,
singleLine = true,
)
Icon(
modifier = Modifier
.padding(start = (TEXT_FIELD_WIDTH - 25).dp, top = 3.dp)
.ripplelessClickable { onNameClear() },
imageVector = Icons.Default.Clear,
contentDescription = "",
tint = Gray
)
}
}
// Score TextField
Column {
Text(
text = stringResource(R.string.score),
style = MaterialTheme.typography.subtitle2
)
Spacer(Modifier.height(4.dp))
BasicTextField(
modifier = Modifier
.size(TEXT_FIELD_HEIGHT.dp)
.background(
color = GrayLight,
shape = roundedCornerShape
)
.then(
if (isScoreError) {
Modifier.border(
width = 1.dp,
color = Warning,
shape = roundedCornerShape
)
} else {
Modifier
}
)
.padding(start = 6.dp, end = 6.dp, top = 6.dp),
value = score,
onValueChange = onScoreChange,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
),
singleLine = true,
)
}
}
if (isNameError) {
Text(
modifier = Modifier.padding(start = 20.dp),
text = stringResource(R.string.enter_player_name),
color = Warning,
fontSize = 14.sp
)
}
if (isScoreError) {
Text(
modifier = Modifier.padding(start = 20.dp),
text = stringResource(R.string.enter_player_score),
color = Warning,
fontSize = 14.sp
)
}
}
}

I had the exact same issue. The problem is this:
else {
Modifier
}
You cannot assign "Modifier" by itself when using .then()

Related

How to add a value coming from inside a compose function into a dataStore

I am building an Android app that uses Compose and Navigation. On one screen I have several form fields each in its own composable function, of which I want to store the values. I have managed to do so for a single form field that is in the main screen function as in this example like this:
#Composable
fun Screen1(navController: NavController) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dataStoreName = StoreName(context)
val savedName = dataStoreName.getValue.collectAsState(initial = "")
Column( ) {
val patientName = remember { mutableStateOf("") }
Button(onClick = {scope.launch {dataStoreName.saveValue(patientName.value)}
navController.navigate(Screen.Screen2.route) }) {
Text(text = "Save & Next")}
OutlinedTextField( value = patientName.value,
label = { Text(text = "Name") },
onValueChange = { patientName.value = it })
Text(text = "Current information stored: " + savedName.value) } }
However, it is not clear to me how to adapt it when I have several fields each in it's own composable function. When I place the mutableState variable inside the textfield function it is not recognized by the code in the save button, and when I place it outside, the variable is not recognized by the textfield function...
This is one of the textfield function as I have them now, which I would call inside the column in the Screen1 function:
#Composable
fun PatientNameField() {
val patientName by remember { mutableStateOf(TextFieldValue("")) }
OutlinedTextField(
value = patientName.value,
label = { Text(text = "Name") },
onValueChange = { patientName.value = it } )
}
For your current problem you can pass the patientName as a parameter to your PatientNameField composable as shown below. This will help to maintain the state of patientName across your Screen1 and PatientNameField composables.
#Composable
fun Screen1(navController: NavController) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dataStoreName = StoreName(context)
val savedName = dataStoreName.getValue.collectAsState(initial = "")
Column {
val patientName = remember { mutableStateOf("") }
Button(onClick = {
scope.launch { dataStoreName.saveValue(patientName.value) }
navController.navigate(Screen.Screen2.route)
}) {
Text(text = "Save & Next")
}
//pass the patientName as a parameter to your Composable
PatientNameField(patientName)
Text(text = "Current information stored: " + savedName.value)
}
}
#Composable
fun PatientNameField(patientName: MutableState<String>) {
OutlinedTextField(
value = patientName.value,
label = { Text(text = "Name") },
onValueChange = { patientName.value = it } )
}

Inserting an AnnotatedString into an EditText (Jetpack Compose)

I'm trying to solve the following issue. For example, I'm writing this text - "Hello *world*". After I stop writing, for example, after a second, the word "*world*" should be replaced by "world" in bold.
I've tried to do this, but so far it doesn't work.
val originalText = MutableStateFlow("")
val resultText = originalText
.debounce(1000)
.distinctUntilChanged()
.flatMapLatest { text ->
val result = formatText(text) // create AnnotatedString
flow { emit(result) }
}
And trying to insert to EditText:
val resultText by viewModel.resultText.collectAsState(AnnotatedString(""))
OutlinedTextField(
value = TextFieldValue(resultText),
onValueChange = {
viewModel.originalText.value = it.text
},
label = { Text("Description") },
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
)
The problem is that I can't achieve the following result: we write text to "EditText" and after a second it is formatted and inserted into the same "EditText".
Could someone tell me, please, how can I solve this issue?
I found out a solution, but I'm sorry for code. It's definitely worth improving.
ViewModel methods:
private var _wordList = mutableListOf<String>()
val wordList = _wordList
// Remove words that are not in the string
fun updateWordList(text: String) {
_wordList.forEach {
if(!text.contains(it)) {
_wordList.remove(it)
}
}
}
fun getWords(text: String) : List<String> {
val regex = Regex("\\*(.*?)[\\*]")
val matches = regex.findAll(text)
return matches.map { it.groupValues[1] }.toList()
}
fun addWords(text: String) {
val words = getWords(text)
words.forEach { word ->
if(!_wordList.contains(word)) _wordList.add(word)
}
}
A method which create an AnnotatedString:
fun getAnnotatedString(text: String, words: List<String>): AnnotatedString = buildAnnotatedString {
append(text)
words.forEach { word ->
if (text.contains(word)) {
val offsetStart = text.indexOf(word)
val offsetEnd = offsetStart + word.length
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = offsetStart,
end = offsetEnd
)
}
}
}
After that we need to create the following variables:
val words = viewModel.getWords(description)
viewModel.addWords(description)
val descResult = if (words.isEmpty()) description else description.replace("*", "")
val formattedString = formatString(descResult, viewModel.wordList)
var textFieldValueState by remember {
mutableStateOf(TextFieldValue(annotatedString = formattedString))
}
val textFieldValue = textFieldValueState.copy(annotatedString = formattedString)
And finally, we define the OutlinedTextField:
OutlinedTextField(
value = textFieldValue,
onValueChange = {
viewModel.updateWordList(it.text)
if (tmp == it.text) {
textFieldValueState = it
return#OutlinedTextField
}
description = it.text
textFieldValueState = it
},
label = { Text("Description") }
)

Prevent closing bottom sheet with background click with accompanist library

I have tried accompanist library for navigation from this article and I want to prevent bottom sheet to be closed when I click on the background of bottom sheet (the gray area) and to make it non clickable at all, how can I achieve this?
This is the code from the link
#Composable
fun MyApp() {
val navController = rememberNavController()
val bottomSheetNavigator = rememberBottomSheetNavigator()
navController.navigatorProvider += bottomSheetNavigator
ModalBottomSheetLayout(
bottomSheetNavigator = bottomSheetNavigator
) {
NavHost(navController, startDestination = "home") {
composable(route = "home") {
Button(onClick = { navController.navigate("sheet") }) {
Text("Click me to see something cool!")
}
}
bottomSheet(route = "sheet") {
Text("This is a cool bottom sheet!")
Button(onClick = { navController.navigate("home") }) {
Text("Take me back, please!")
}
Spacer(modifier = Modifier.padding(200.dp))
}
}
}
}
ModalBottomSheetLayout has this sheetState parameter set as following:
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
You can prevent the dismiss behaviour by passing this parameter as following:
// This prevents dismissing the ModalBottomSheet
val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
confirmStateChange = { false }
)

How to use detectTransformGestures but not consuming all pointer event

I was making a fullscreen photo viewer which contain a pager (used HorizontalPager) and each page, user can zoom in/out and pan the image, but still able to swipe through pages.
My idea is swiping page will occurs when the image is not zoomed in (scale factor = 1), if it's zoomed in (scale factor > 1) then dragging/swiping will pan the image around.
Here is the code for the HorizontalPager that contain my customized zoomable Image:
#ExperimentalPagerApi
#Composable
fun ViewPagerSlider(pagerState: PagerState, urls: List<String>) {
var scale = remember {
mutableStateOf(1f)
}
var transX = remember {
mutableStateOf(0f)
}
var transY = remember {
mutableStateOf(0f)
}
HorizontalPager(
count = urls.size,
state = pagerState,
modifier = Modifier
.padding(0.dp, 40.dp, 0.dp, 40.dp),
) { page ->
Image(
painter = rememberImagePainter(
data = urls[page],
emptyPlaceholder = R.drawable.img_default_post,
),
contentScale = ContentScale.FillHeight,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
translationX = transX.value,
translationY = transY.value,
scaleX = scale.value,
scaleY = scale.value,
)
.pointerInput(scale.value) {
detectTransformGestures { _, pan, zoom, _ ->
scale.value = when {
scale.value < 1f -> 1f
scale.value > 3f -> 3f
else -> scale.value * zoom
}
if (scale.value > 1f) {
transX.value = transX.value + (pan.x * scale.value)
transY.value = transY.value + (pan.y * scale.value)
} else {
transX.value = 0f
transY.value = 0f
}
}
}
)
}
}
So my image is zoomed in maximum 3f, and cannot zoom out smaller than 0.
I cannot swipe to change to another page if detectTransformGestures is in my code. If I put the detectTransformGestures based on the factor (scale = 1, make it swipeable to another page if not zoomed in), then it will be a "deadlock" as I cannot zoom in because there is no listener.
I don't know if there is some how to make it possible...
Thank you guys for your time!
I had to do something similar, and came up with this:
private fun ZoomableImage(
modifier: Modifier = Modifier,
bitmap: ImageBitmap,
maxScale: Float = 1f,
minScale: Float = 3f,
contentScale: ContentScale = ContentScale.Fit,
isRotation: Boolean = false,
isZoomable: Boolean = true,
lazyState: LazyListState
) {
val scale = remember { mutableStateOf(1f) }
val rotationState = remember { mutableStateOf(1f) }
val offsetX = remember { mutableStateOf(1f) }
val offsetY = remember { mutableStateOf(1f) }
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.clip(RectangleShape)
.background(Color.Transparent)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { /* NADA :) */ },
onDoubleClick = {
if (scale.value >= 2f) {
scale.value = 1f
offsetX.value = 1f
offsetY.value = 1f
} else scale.value = 3f
},
)
.pointerInput(Unit) {
if (isZoomable) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
do {
val event = awaitPointerEvent()
scale.value *= event.calculateZoom()
if (scale.value > 1) {
coroutineScope.launch {
lazyState.setScrolling(false)
}
val offset = event.calculatePan()
offsetX.value += offset.x
offsetY.value += offset.y
rotationState.value += event.calculateRotation()
coroutineScope.launch {
lazyState.setScrolling(true)
}
} else {
scale.value = 1f
offsetX.value = 1f
offsetY.value = 1f
}
} while (event.changes.any { it.pressed })
}
}
}
}
) {
Image(
bitmap = bitmap,
contentDescription = null,
contentScale = contentScale,
modifier = modifier
.align(Alignment.Center)
.graphicsLayer {
if (isZoomable) {
scaleX = maxOf(maxScale, minOf(minScale, scale.value))
scaleY = maxOf(maxScale, minOf(minScale, scale.value))
if (isRotation) {
rotationZ = rotationState.value
}
translationX = offsetX.value
translationY = offsetY.value
}
}
)
}
}
It is zoomable, rotatable (if you want it), supports pan if the image is zoomed in, has support for double-click zoom-in and zoom-out and also supports being used inside a scrollable element. I haven't come up with a solution to limit how far can the user pan the image yet.
It uses combinedClickable so the double-click zoom works without interfering with the other gestures, and pointerInput for the zoom, pan and rotation.
It uses this extension function to control the LazyListState, but if you need it for ScrollState it shouldn't be hard to modify it to suit your needs:
suspend fun LazyListState.setScrolling(value: Boolean) {
scroll(scrollPriority = MutatePriority.PreventUserInput) {
when (value) {
true -> Unit
else -> awaitCancellation()
}
}
}
Feel free to modify it for your needs.
Solution for HorizontalPager with better ux on swipe:
val pagerState = rememberPagerState()
val scrollEnabled = remember { mutableStateOf(true) }
HorizontalPager(
count = ,
state = pagerState,
userScrollEnabled = scrollEnabled.value,
) { }
#OptIn(ExperimentalFoundationApi::class)
#Composable
fun ZoomablePagerImage(
modifier: Modifier = Modifier,
painter: Painter,
scrollEnabled: MutableState<Boolean>,
minScale: Float = 1f,
maxScale: Float = 5f,
contentScale: ContentScale = ContentScale.Fit,
isRotation: Boolean = false,
) {
var targetScale by remember { mutableStateOf(1f) }
val scale = animateFloatAsState(targetValue = maxOf(minScale, minOf(maxScale, targetScale)))
var rotationState by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(1f) }
var offsetY by remember { mutableStateOf(1f) }
val configuration = LocalConfiguration.current
val screenWidthPx = with(LocalDensity.current) { configuration.screenWidthDp.dp.toPx() }
Box(
modifier = Modifier
.clip(RectangleShape)
.background(Color.Transparent)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { },
onDoubleClick = {
if (targetScale >= 2f) {
targetScale = 1f
offsetX = 1f
offsetY = 1f
scrollEnabled.value = true
} else targetScale = 3f
},
)
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
do {
val event = awaitPointerEvent()
val zoom = event.calculateZoom()
targetScale *= zoom
val offset = event.calculatePan()
if (targetScale <= 1) {
offsetX = 1f
offsetY = 1f
targetScale = 1f
scrollEnabled.value = true
} else {
offsetX += offset.x
offsetY += offset.y
if (zoom > 1) {
scrollEnabled.value = false
rotationState += event.calculateRotation()
}
val imageWidth = screenWidthPx * scale.value
val borderReached = imageWidth - screenWidthPx - 2 * abs(offsetX)
scrollEnabled.value = borderReached <= 0
if (borderReached < 0) {
offsetX = ((imageWidth - screenWidthPx) / 2f).withSign(offsetX)
if (offset.x != 0f) offsetY -= offset.y
}
}
} while (event.changes.any { it.pressed })
}
}
}
) {
Image(
painter = painter,
contentDescription = null,
contentScale = contentScale,
modifier = modifier
.align(Alignment.Center)
.graphicsLayer {
this.scaleX = scale.value
this.scaleY = scale.value
if (isRotation) {
rotationZ = rotationState
}
this.translationX = offsetX
this.translationY = offsetY
}
)
}
}
If you can create a mutable state variable that keeps track of the zoom factor, you can add the pointerInput modifier when the zoom factor is greater than one and leave it out when it is greater than one. Something like this:
var zoomFactorGreaterThanOne by remember { mutableStateOf(false) }
Image(
painter = rememberImagePainter(
data = urls[page],
emptyPlaceholder = R.drawable.img_default_post,
),
contentScale = ContentScale.FillHeight,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
translationX = transX.value,
translationY = transY.value,
scaleX = scale.value,
scaleY = scale.value,
)
.run {
if (zoomFactorGreaterThanOne != 1.0f) {
this.pointerInput(scale.value) {
detectTransformGestures { _, pan, zoom, _ ->
zoomFactorGreaterThanOne = scale.value > 1
scale.value = when {
scale.value < 1f -> 1f
scale.value > 3f -> 3f
else -> scale.value * zoom
}
if (scale.value > 1f) {
transX.value = transX.value + (pan.x * scale.value)
transY.value = transY.value + (pan.y * scale.value)
} else {
transX.value = 0f
transY.value = 0f
}
}
}
} else {
this
}
}
)

How can be saved the destinations of nested navigation graph's id in the bottom navigation Jetpack Compose?

I am facing some problems with navigation's back stacks in Jetpack Compose. The following diagram is my desired scenario.
I have bottom bar and two items Home and Setting. And I want to make both as nested graphs. In HomeNavGraph, HomeRootScreen is startDestination and can navigate to HomeDetail1Screen and HomeDetail2. Setting tab also likes that. And I want to save those nested graph states like When I click HomeRoot -> HomeDetail1 -> HomeDetail2, at that time I click Setting tab in BottomBar. And click back to Home, I want my current Screen to HomeDeatil2Screen.
The followings are my codes. This is my Destination class.
sealed class Destinations(
val route: String
) {
object HomeRoot : Destinations(route = "home_root_screen")
object HomeDetail1 : Destinations(route = "home_detail1_screen")
object HomeDetail2 : Destinations(route = "home_detail2_screen")
object SettingRoot : Destinations(route = "setting_root_screen")
object SettingDetail1 : Destinations(route = "setting_detail1_screen")
object SettingDetail2 : Destinations(route = "setting_detail2_screen")
}
const val BOTTOM_NAV_ROUTE = "btn_nav"
const val HOME_ROUTE = "home"
const val SETTING_ROUTE = "setting"
This is my BottomBarNavGraph.
#Composable
fun BottomBarNavGraph(
navController: NavHostController
) {
NavHost(
navController = navController,
startDestination = HOME_ROUTE,
route = BOTTOM_NAV_ROUTE
) {
homeNavGraph(navController)
settingNavGraph(navController)
}
}
This is HomeNavGraph.
fun NavGraphBuilder.homeNavGraph(
navController: NavHostController
) {
navigation(
startDestination = Destinations.HomeRoot.route,
route = HOME_ROUTE
) {
composable(route = Destinations.HomeRoot.route) { HomeScreen(navController = navController) }
composable(route = Destinations.HomeDetail1.route + "/{argText}") {
val arg = it.arguments?.getString("argText") ?: "Nothing Typed"
HomeDetail1Screen(
text = arg,
navController = navController
)
}
composable(route = Destinations.HomeDetail2.route) { HomeDetail2Screen(navController = navController) }
}
}
This is SettingNavGraph.
fun NavGraphBuilder.settingNavGraph(
navController: NavHostController
) {
navigation(
startDestination = Destinations.SettingRoot.route,
route = SETTING_ROUTE
) {
composable(
route = Destinations.SettingRoot.route
) {
SettingScreen(navController = navController)
}
composable(
route = Destinations.SettingDetail1.route
) {
SettingDetail1Screen(navController = navController)
}
composable(
route = Destinations.SettingDetail2.route
) {
SettingDetail2Screen(navController = navController)
}
}
}
This is my BottomBarDestinations.
sealed class BottomBarDestinations(
val route: String,
val title: String,
val icon: ImageVector
) {
object Home : BottomBarDestinations(
route = HOME_ROUTE,
title = "Home",
icon = Icons.Default.Home
)
object Setting : BottomBarDestinations(
route = SETTING_ROUTE,
title = "Setting",
icon = Icons.Default.Settings
)
}
The followings are my setup of BottomBar in navHost.
#Composable
fun BottomBar(navController: NavHostController) {
val screens = listOf(
BottomBarDestinations.Home,
BottomBarDestinations.Setting
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestinations = navBackStackEntry?.destination
BottomNavigation {
screens.forEach {
AddItem(
screen = it,
currentDestinations = currentDestinations,
navController = navController
)
}
}
}
#Composable
fun RowScope.AddItem(
screen: BottomBarDestinations,
currentDestinations: NavDestination?,
navController: NavHostController
) {
BottomNavigationItem(
label = {
Text(text = screen.title)
},
icon = {
Icon(
imageVector = screen.icon,
contentDescription = "Nav Icon"
)
},
selected = currentDestinations?.hierarchy?.any { it.route == screen.route } == true,
unselectedContentColor = LocalContentColor.current.copy(
alpha = ContentAlpha.disabled
),
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
restoreState = true
}
}
)
}
#Composable
fun MainScreen(
) {
val navController = rememberNavController()
Scaffold(bottomBar = {
BottomBar(navController = navController)
}) {
BottomBarNavGraph(navController = navController)
}
}
Please help me with this. I stack with this. If you want to check the source code. You can check this in branch
bottom_nav_graph
https://github.com/kyawlinnthant/JetpackComposeNavigationComponent/tree/nested_graph
If you need to save state of route (Screen) or graph, you need to use following technique:
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id){
inclusive = true
saveState = true
}
launchSingleTop = true
restoreState = true
}
BTW, I can't use
navController.graph.findStartDestination().id
I specify the start destination by myself (in your case will be "home_root_screen").

Resources