Using style in AndroidView Compose - android

How can we use style property inside AndroidView in Compose? The below code snippet doesn't work.
AndroidView(factory = { context ->
Button(context).apply {
text = "Turn On"
style = "#style/button_style"
}
})

"#style/button_style" can only be used inside XML.
When you set styles/colors/drawables and other resources form code, you need to use R.style.button_style instead.
Also android android.widget.Button doesn't have style parameter, but you can pass it with a context into the initializer, like this:
AndroidView(factory = { context ->
val style = R.style.GreenText
android.widget.Button(ContextThemeWrapper(context, style), null, style).apply {
text = "Turn On"
}
})
UPDATE: here's a more detailed example of AndroidView usage. You can set initial values during factory, and you can update your view state depending on Compose state variables inside update block
var flag by remember { mutableStateOf(false) }
Switch(checked = flag, onCheckedChange = { flag = !flag })
AndroidView(
factory = { context ->
val style = R.style.GreenText
android.widget.Button(ContextThemeWrapper(context, style), null, style).apply {
text = "Turn On"
// set initial text color from resources
setTextColor(ContextCompat.getColor(context, R.color.black))
}
},
update = { button ->
// set dynamic color while updating
// depending on a state variable
button.setTextColor((if (flag) Color.Red else Color.Black).toArgb())
}
)

Related

Jetpack Compose - Why remembered custom object not holding changes after recomposition

I have a custom object with a boolean value that I want to observe on my screen to change my layout.
Example:
data class Book(isFavorited: Boolean)
#Composable
fun ShowBook(book: Book, onConfirm: (Book) -> Unit){
val bookState = remember { mutableStateOf(book) }
//just an example
val color = if(bookState.value.isFavorited) Color.Red else Color.White
//changing the value of isFavorited, should do recomposition?
Button(onClick { bookState.value.isFavorited = true }){
Text("add to fav")
}
Button(onClick { bookState.value.isFavorited = false }){
Text("remove from fav")
}
Button(onClick { onConfirm.invoke(bookState.value) }){
Text("Confirm")
}
}
If I press the "add to fav" button and set to my bookState a true value for isFavorited, and after that I press "Confirm", the value that I'll receive is still false (default value).
Does Anyone know what's happening?
Because I through that changing the bookState attributions it should recompose and set the correct value to the object.
I did a couple of improvements in your code... It should work now.
In order to perform a recomposition, you must have to create a new instance of the object. In the code below this is done by calling the copy function just changing the params that you want (in case the class has more fields).
data class Book(val isFavorited: Boolean)
#Composable
fun ShowBook(book: Book, onConfirm: (Book) -> Unit) {
// using "by" to avoid use the ".value" everywhere
var bookState by remember { mutableStateOf(book) }
// just an example
val color = if (bookState.isFavorited) Color.Red else Color.White
// changing the value of isFavorited, should do recomposition?
Button(onClick = { bookState = bookState.copy(isFavorited = true) }){
Text("add to fav")
}
Button(onClick = { bookState = bookState.copy(isFavorited = false) }){
Text("remove from fav")
}
// You don't need the "invoke" here.
// It can be useful if the lambda is nullable:
// onConfirm?.invoke(bookState)
Button(onClick = { onConfirm(bookState) }){
Text("Confirm")
}
}
So, if you want to have Book class as holder of state you could do it like this:
data class Book(val isSelected: MutableState<Boolean> = mutableStateOf(true))
and usage would be:
val book = remember { Book() }
val color = if (book.isSelected.value) Color.Red else Color.White
Changes of fields that do not wrap values inside MutableState wont trigger recompositing.

How to trigger recomposition after updateConfiguration?

I want to change my app language dynamically, without the need to restart the Activity for the results to take effect. What I am doing now is to add a mutable Boolean state that is switch and is used by all Text elements.
To change the language I call the following code inside the clickable callback (I use the box as a dummy object, just to test):
val configuration = LocalConfiguration.current
val resources = LocalContext.current.resources
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.2.dw)
.background(Color.Red)
.clickable {
// to change the language
val locale = Locale("bg")
configuration.setLocale(locale)
resources.updateConfiguration(configuration, resources.displayMetrics)
viewModel.updateLanguage()
}
) {
}
Then it switches the language value using the updateLanguage() method
#HiltViewModel
class CityWeatherViewModel #Inject constructor(
private val getCityWeather: GetCityWeather
) : ViewModel() {
private val _languageSwitch = mutableStateOf(true)
var languageSwitch: State<Boolean> = _languageSwitch
fun updateLanguage() {
_languageSwitch.value = !_languageSwitch.value
}
}
The problem is that in order to update each Text composable, I need to pass the viewmodel to all descendant that use Text and then use some bad logic to force update each time, some changes in the view model occur.
#Composable
fun SomeChildDeepInTheHierarchy(viewModel: CityWeatherViewModel, #StringRes textResId: Int) {
Text(
text = stringResource(id = if (viewModel.languageSwitch.value) textResId else textResId),
color = Color.White,
fontSize = 2.sp,
fontWeight = FontWeight.Light,
fontFamily = RobotoFont
)
}
It works, but that is some really BAD logic, and the code is very ugly! Is there a standard way of changing the Locale using Jetpack Compose dynamically?
The easiest solution is to recreate the activity after configuration change:
val context = LocalContext.current
Button({
// ...
resources.updateConfiguration(configuration, resources.displayMetrics)
context.findActivity()?.recreate()
}) {
Text(stringResource(R.string.some_string))
}
findActivity:
fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
If for some reason you don't wanna do that, you can override LocalContext with the new configuration like this:
MainActivity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val context = LocalContext.current
CompositionLocalProvider(
LocalMutableContext provides remember { mutableStateOf(context) },
) {
CompositionLocalProvider(
LocalContext provides LocalMutableContext.current.value,
) {
// your app
}
}
}
}
}
val LocalMutableContext = staticCompositionLocalOf<MutableState<Context>> {
error("LocalMutableContext not provided")
}
In your view:
val configuration = LocalConfiguration.current
val context = LocalContext.current
val mutableContext = LocalMutableContext.current
Button(onClick = {
val locale = Locale(if (configuration.locale.toLanguageTag() == "bg") "en_US" else "bg")
configuration.setLocale(locale)
mutableContext.value = context.createConfigurationContext(configuration)
}) {
Text(stringResource(R.string.some_string))
}
Note that remember will not live through system configuration change, e.g. screen rotation, you probably need to store the selected locale somewhere, e.g. in DataStore, and provide the needed configuration instead of my initial context when providing LocalMutableContext.
p.s. in both cases you don't need a flag in the view model, if you have resources placed according to documentation, e.g. in values-bg/strings.xml, and so on, stringResource is gonna work out of the box.

To change text color and background color for an annotated in TextClickableText after it is clicked

I would like to change the text color and the background color of an annotated clickable text.
I looked into the examples for clickable text in google developer but did not find anything related to this.
ClickableText(
text = annotatedText,
onClick = { offset ->
// We check if there is an *URL* annotation attached to the text
// at the clicked position
annotatedText.getStringAnnotations(tag = "URL", start = offset,
end = offset)
.firstOrNull()?.let { annotation ->
// To change the text color when it is clicked <<<<<<<<<<<<< here
}
}
)
What I want to do is to highlight the word the user has clicked.
For example:
If the user clicks the first letter "Text", it would look like the following.
Although I don't think this is very efficient, this code should do the trick:
#Composable
fun SelectableText(
annotatedText: AnnotatedString,
onTextClicked: (Int) -> Unit,
) {
ClickableText(
text = annotatedText,
onClick = { offset ->
annotatedText.spanStyles.indexOfFirst {
it.start <= offset && offset <= it.end
}.takeIf { it != -1 }?.also {
onTextClicked(it)
}
}
)
}
#Composable
fun View() {
var annotatedText by remember {
mutableStateOf(AnnotatedString("Test", listOf(AnnotatedString.Range(SpanStyle(), 0, 4))))
}
SelectableText(annotatedText, onTextClicked = {
val styles = annotatedText.spanStyles.toMutableList()
styles[it] = styles[it].copy(SpanStyle(background = Color.Cyan))
annotatedText = AnnotatedString(annotatedText.text, styles, annotatedText.paragraphStyles)
})
}
A custom SelectableText #Composable is created, with a callback which is called when a span is clicked. I am using spans and not annotations because this is where the style is defined.
When clicked, the parent #Composable (here, View) reacts to the event, grabs a copy of the spanStyles and modifies the one which was clicked. Then it updates the annotatedText. (You might want to "turn off" the selection on the rest of spans.)

How to disable simultaneous clicks on multiple items in Jetpack Compose List / Column / Row (out of the box debounce?)

I have implemented a column of buttons in jetpack compose. We realized it is possible to click multiple items at once (with multiple fingers for example), and we would like to disable this feature.
Is there an out of the box way to disable multiple simultaneous clicks on children composables by using a parent column modifier?
Here is an example of the current state of my ui, notice there are two selected items and two unselected items.
Here is some code of how it is implemented (stripped down)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(nestedScrollParams.childScrollState),
) {
viewDataList.forEachIndexed { index, viewData ->
Row(modifier = modifier.fillMaxWidth()
.height(dimensionResource(id = 48.dp)
.background(colorResource(id = R.color.large_button_background))
.clickable { onClick(viewData) },
verticalAlignment = Alignment.CenterVertically
) {
//Internal composables, etc
}
}
Check this solution. It has similar behavior to splitMotionEvents="false" flag. Use this extension with your Column modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.coroutineScope
fun Modifier.disableSplitMotionEvents() =
pointerInput(Unit) {
coroutineScope {
var currentId: Long = -1L
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo ->
when {
pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value
pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1
pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume()
else -> Unit
}
}
}
}
}
}
Here are four solutions:
Click Debounce (ViewModel)r
For this, you need to use a viewmodel. The viewmodel handles the click event. You should pass in some id (or data) that identifies the item being clicked. In your example, you could pass an id that you assign to each item (such as a button id):
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
class MyViewModel : ViewModel() {
val debounceState = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
debounceState
.debounce(300)
.collect { buttonId ->
if (buttonId != null) {
when (buttonId) {
ButtonIds.Support -> displaySupport()
ButtonIds.About -> displayAbout()
ButtonIds.TermsAndService -> displayTermsAndService()
ButtonIds.Privacy -> displayPrivacy()
}
}
}
}
}
fun onItemClick(buttonId: String) {
debounceState.value = buttonId
}
}
object ButtonIds {
const val Support = "support"
const val About = "about"
const val TermsAndService = "termsAndService"
const val Privacy = "privacy"
}
The debouncer ignores any clicks that come in within 500 milliseconds of the last one received. I've tested this and it works. You'll never be able to click more than one item at a time. Although you can touch two at a time and both will be highlighted, only the first one you touch will generate the click handler.
Click Debouncer (Modifier)
This is another take on the click debouncer but is designed to be used as a Modifier. This is probably the one you will want to use the most. Most apps will make the use of scrolling lists that let you tap on a list item. If you quickly tap on an item multiple times, the code in the clickable modifier will execute multiple times. This can be a nuisance. While users normally won't tap multiple times, I've seen even accidental double clicks trigger the clickable twice. Since you want to avoid this throughout your app on not just lists but buttons as well, you probably should use a custom modifier that lets you fix this issue without having to resort to the viewmodel approach shown above.
Create a custom modifier. I've named it onClick:
fun Modifier.onClick(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = {
App.debounceClicks {
onClick.invoke()
}
},
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
You'll notice that in the code above, I'm using App.debounceClicks. This of course doesn't exist in your app. You need to create this function somewhere in your app where it is globally accessible. This could be a singleton object. In my code, I use a class that inherits from Application, as this is what gets instantiated when the app starts:
class App : Application() {
override fun onCreate() {
super.onCreate()
}
companion object {
private val debounceState = MutableStateFlow { }
init {
GlobalScope.launch(Dispatchers.Main) {
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
debounceState
.debounce(300)
.collect { onClick ->
onClick.invoke()
}
}
}
fun debounceClicks(onClick: () -> Unit) {
debounceState.value = onClick
}
}
}
Don't forget to include the name of your class in your AndroidManifest:
<application
android:name=".App"
Now instead of using clickable, use onClick instead:
Text("Do Something", modifier = Modifier.onClick { })
Globally disable multi-touch
In your main activity, override dispatchTouchEvent:
class MainActivity : AppCompatActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return ev?.getPointerCount() == 1 && super.dispatchTouchEvent(ev)
}
}
This disables multi-touch globally. If your app has a Google Maps, you will want to add some code to to dispatchTouchEvent to make sure it remains enabled when the screen showing the map is visible. Users will use two fingers to zoom on a map and that requires multi-touch enabled.
State Managed Click Handler
Use a single click event handler that stores the state of which item is clicked. When the first item calls the click, it sets the state to indicate that the click handler is "in-use". If a second item attempts to call the click handler and "in-use" is set to true, it just returns without performing the handler's code. This is essentially the equivalent of a synchronous handler but instead of blocking, any further calls just get ignored.
The most simple approach that I found for this issue is to save the click state for each Item on the list, and update the state to 'true' if an item is clicked.
NOTE: Using this approach works properly only in a use-case where the list will be re-composed after the click handling; for example navigating to another Screen when the item click is performed.
Otherwise if you stay in the same Composable and try to click another item, the second click will be ignored and so on.
for example:
#Composable
fun MyList() {
// Save the click state in a MutableState
val isClicked = remember {
mutableStateOf(false)
}
LazyColumn {
items(10) {
ListItem(index = "$it", state = isClicked) {
// Handle the click
}
}
}
}
ListItem Composable:
#Composable
fun ListItem(
index: String,
state: MutableState<Boolean>,
onClick: () -> Unit
) {
Text(
text = "Item $index",
modifier = Modifier
.clickable {
// If the state is true, escape the function
if (state.value)
return#clickable
// else, call onClick block
onClick()
state.value = true
}
)
}
Trying to turn off multi-touch, or adding single click to the modifier, is not flexible enough. I borrowed the idea from #Johann‘s code. Instead of disabling at the app level, I can call it only when I need to disable it.
Here is an Alternative solution:
class ClickHelper private constructor() {
private val now: Long
get() = System.currentTimeMillis()
private var lastEventTimeMs: Long = 0
fun clickOnce(event: () -> Unit) {
if (now - lastEventTimeMs >= 300L) {
event.invoke()
}
lastEventTimeMs = now
}
companion object {
#Volatile
private var instance: ClickHelper? = null
fun getInstance() =
instance ?: synchronized(this) {
instance ?: ClickHelper().also { instance = it }
}
}
}
then you can use it anywhere you want:
Button(onClick = { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
or:
Text(modifier = Modifier.clickable { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
fun singleClick(onClick: () -> Unit): () -> Unit {
var latest: Long = 0
return {
val now = System.currentTimeMillis()
if (now - latest >= 300) {
onClick()
latest = now
}
}
}
Then you can use
Button(onClick = singleClick {
// TODO
})
Here is my solution.
It's based on https://stackoverflow.com/a/69914674/7011814
by I don't use GlobalScope (here is an explanation why) and I don't use MutableStateFlow as well (because its combination with GlobalScope may cause a potential memory leak).
Here is a head stone of the solution:
#OptIn(FlowPreview::class)
#Composable
fun <T>multipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> T
) : T {
val debounceState = remember {
MutableSharedFlow<() -> Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
val result = content(
object : MultipleEventsCutterManager {
override fun processEvent(event: () -> Unit) {
debounceState.tryEmit(event)
}
}
)
LaunchedEffect(true) {
debounceState
.debounce(CLICK_COLLAPSING_INTERVAL)
.collect { onClick ->
onClick.invoke()
}
}
return result
}
#OptIn(FlowPreview::class)
#Composable
fun MultipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> Unit
) {
multipleEventsCutter(content)
}
The first function can be used as a wrapper around your code like this:
MultipleEventsCutter { multipleEventsCutterManager ->
Button(
onClick = { multipleClicksCutter.processEvent(onClick) },
...
) {
...
}
}
And you can use the second one to create your own modifier, like next one:
fun Modifier.clickableSingle(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
multipleEventsCutter { manager ->
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = { manager.processEvent { onClick() } },
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
}
Just add two lines in your styles. This will disable multitouch in whole application:
<style name="AppTheme" parent="...">
...
<item name="android:windowEnableSplitTouch">false</item>
<item name="android:splitMotionEvents">false</item>
</style>

Jetpack Compose: Nested weight in custom component

Say that I have a button that expands when tapped the first time to reveal text, and performs an action on the second tap--thus acting as a sort of confirmation before performing the action.
#Compose
fun ExpandingConfirmButton(onConfirm: () -> Unit) {
// expanded state of the button
val (expanded, setExpanded) = remember { mutableStateOf(false) }
// animating weight. make the button larger when it is tapped once
val weight = animate(if (expanded) 10F else 2F)
Button(onClick = {
// only fire the action after two taps
if(expanded) {
onConfirm()
}
setExpanded(!expanded)
}) {
Icon(Icons.Dfault.Delete)
// only show the text if the button has already been clicked once,
// otherwise just show the icon
if(expanded) {
Text(text = "Delete", softWrap = false)
}
}
}
And I would use that button like so:
#Composable
fun PersonList(people: List<Person>) {
// some database service exposed via an ambient
val dataService = DataService.current
LazyColumnFor(items = people) {
Row() {
Text(text = it.firstName, modifier = Modifier.weight(10F))
// on the first tap, the button should take up half of the row
ExpandingConfirmButton(onConfirm = { dataService.deletePerson(it) })
}
}
}
This all seems pretty straight-forward. Indeed, before I had split the ExpandingConfirmButton into it's own component and instead had the Button() it wraps directly in my PersonList component, it worked flawlessly.
However, it seems that the Row doesn't quite know what to do with the button when the weight changes when it is inside it's own component. The text inside the button displays, but the size does not change. Does this have to do with the Row's RowScope not getting utilized by the Modifier on the Button component inside ExpandingConfirmButton? If so, how do I go about using it?
What I ended up doing is basically just using the .animateContentSize() modifier.
#Compose
fun ExpandingConfirmButton(onConfirm: () -> Unit) {
// expanded state of the button
val (expanded, setExpanded) = remember { mutableStateOf(false) }
Button(
modifier = Modifier.animateContentSize(), // do this
onClick = {
// only fire the action after two taps
if(expanded) {
onConfirm()
}
setExpanded(!expanded)
}) {
Icon(Icons.Default.Delete)
// only show the text if the button has already been clicked once,
// otherwise just show the icon
if(expanded) {
Text(text = "Delete", softWrap = false)
}
}
}
It really is just that easy. No messing with manual widths, weights, or anything like that.

Categories

Resources