So I have Two ViewModels in my Calculator App in which I all reference in my Compose NavGraph so I can use the same ViewModel instance. I set a Boolean State(historyCheck) in the first ViewModel and I set it too true to "Clear" the History I set which is a history of Calculations I am trying to retrieve from both ViewModels. The issue now is that the Boolean State "strCalcViewModel.historyCheck" changes before the variable above it get's assigned which then makes the 'if' statement I setup fail which in turns makes the whole Implementation also fail as it is always set to false.
This is my code Below...
My Compose NavGraph.
#Composable
fun ComposeNavigation(
navController: NavHostController,
) {
/**
* Here We declare an Instance of our Two ViewModels, their states and History States. This is because we don't want to have the same States for the two Screens.
*/
val strCalcViewModel = viewModel<CalculatorViewModel>()
val sciCalcViewModel = viewModel<ScientificCalculatorViewModel>()
val strCalcState = strCalcViewModel.strState
val sciCalcState = sciCalcViewModel.sciState
val strHistoryState = strCalcViewModel.historyState
val sciHistoryState = sciCalcViewModel.historyState
// This holds our current available 'HistoryState' based on where the Calculation was performed(Screens) by the USER.
var currHistory by remember { mutableStateOf(CalculatorHistoryState()) }
if(strCalcViewModel.historyCheck) {
currHistory = strHistoryState
strCalcViewModel.historyCheck = false // this gets assigned before the 'currHistory' variable above thereBy making the the if to always be false
} else {
currHistory = sciHistoryState
}
NavHost(
navController = navController,
startDestination = "main_screen",
) {
composable("main_screen") {
MainScreen(
navController = navController, state = strCalcState, viewModel = strCalcViewModel
)
}
composable("first_screen") {
FirstScreen(
navController = navController, state = sciCalcState, viewModel = sciCalcViewModel
)
}
composable("second_screen") {
SecondScreen(
navController = navController, historyState = currHistory, viewModel = strCalcViewModel
)
}
}
}
Then my ViewModel
private const val TAG = "CalculatorViewModel"
class CalculatorViewModel : ViewModel() {
var strState by mutableStateOf(CalculatorState())
// This makes our state accessible by outside classes but still readable
private set
var historyState by mutableStateOf(CalculatorHistoryState())
private set
private var leftBracket by mutableStateOf(true)
private var check = 0
var checkState by mutableStateOf(false)
var historyCheck by mutableStateOf(false)
// Function to Register our Click events
fun onAction(action : CalculatorAction) {
when(action) {
is CalculatorAction.Number -> enterNumber(action.number)
is CalculatorAction.Decimal -> enterDecimal()
is CalculatorAction.Clear -> {
strState = CalculatorState()
check = 0
}
is CalculatorAction.ClearHistory -> checkState = true
is CalculatorAction.Operation -> enterStandardOperations(action.operation)
is CalculatorAction.Calculate -> performStandardCalculations()
is CalculatorAction.Delete -> performDeletion()
is CalculatorAction.Brackets -> enterBrackets()
}
}
// We are Basically making the click events possible by modifying the 'state'
private fun performStandardCalculations() {
val primaryStateChar = strState.primaryTextState.last()
val primaryState = strState.primaryTextState
val secondaryState = strState.secondaryTextState
if (!(primaryStateChar == '(' || primaryStateChar == '%')) {
strState = strState.copy(
primaryTextState = secondaryState
)
strState = strState.copy(secondaryTextState = "")
// Below, we store our Calculated Values in the History Screen after it has been Calculated by the USER.
historyState = historyState.copy(
historySecondaryState = secondaryState
)
historyState = historyState.copy(
historyPrimaryState = primaryState
)
historyCheck = true // this is where I assign it to true when I complete my Calculations and pass it to the history State
} else {
strState = strState.copy(
secondaryTextState = "Format error"
)
strState = strState.copy(
color = ferrari
)
}
}
}
You're checking the if condition and assigning a new value to your viewModel variable in the Compose function, it's not correct! you should use side-effects
LaunchedEffect(strCalcViewModel.historyCheck) {
if(strCalcViewModel.historyCheck) {
currHistory = strHistoryState
strCalcViewModel.historyCheck = false
} else {
currHistory = sciHistoryState
}}
Whenever there is a new change in strCalcViewModel.historyCheck this block will run
you can check out here for more info Side-effects in Compose
Based on Sadegh.t's Answer I got it working but didn't write it the exact same way and used a different Implementation which I will post now.
I Still used a side-effect but instead of checking for a change in the "historyCheck", I checked for a change in the 'State' itself and also instead of using a Boolean variable, I used the State itself for the basis of the Condition. So here is my answer based on Sadegh.t's original answer.
var currHistory by remember { mutableStateOf(CalculatorHistoryState()) }
LaunchedEffect(key1 = strCalcState) {
if(strCalcState.secondaryTextState.isEmpty()) {
currHistory = strHistoryState
}
}
LaunchedEffect(key1 = sciCalcState) {
if(sciCalcState.secondaryTextState.isEmpty()) {
currHistory = sciHistoryState
}
}
I have a MutableStateFlow<List<AttendanceState>>,
var attendanceStates = MutableStateFlow<List<AttendanceState>>(arrayListOf())
private set
My AttendanceState data class.
data class AttendanceState (
var memberId: Int,
var memberName: String = "",
var isPresent: Boolean = false,
var leaveApplied: Boolean = false
)
The list is rendered by a LazyColumn
The LazyColumn contains Checkboxes.
If i update the checkbox, the event is propagated to the ViewModel and from there I'm changing the value in the list
attendanceStates.value[event.index].copy(leaveApplied = event.hasApplied)
attendanceStates.value = attendanceStates.value.toList()
But this is not updating the LazyColumn.
Snippet of my implementation:
val attendances by viewModel.attendanceStates.collectAsState()
LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) {
Log.e("Attendance","Lazy Column Recomposition")
items(attendances.size) { index ->
AttendanceCheckBox(attendanceState = attendances[index], modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), onAttendanceStatusChangeListener = { viewModel.onEvent(AttendanceEvent.IsPresentStatusChanged(index, it)) }, onLeaveAppliedStatusChangeListener = { viewModel.onEvent(AttendanceEvent.IsLeaveAppliedStatusChanged(index, it)) })
}
}
Re-composition is not happening.
Try this:
viewModelScope.launch {
val helper = ArrayList(attendanceStates.value)
helper[event.index] = helper[event.index].copy(leaveApplied = event.hasApplied)
attendanceStates.emit(helper)
}
Changing an item's properties will not trigger a StateFlow, you have to replace the whole item with the changed item and emit a new list.
I would recommend SnapshotStateList instead of a standard List, this will guarantee an update without having to create a new instance of it like what you would do with an ordinary List, assuming you call AttendanceState instance copy() and updating at least one of its properties with a different value.
var attendanceStates = MutableStateFlow<SnapshotStateList>(mutableStateListOf())
private set
I would also recommend changing the way you use your LazyColumn where items are mapped by their keys not just by their index position,
LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) {
items(attendances, key = {it.memberId}) {
AttendanceCheckBox(...)
}
}
and if you still need the index position.
LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = 24.dp)) {
itemsIndexed(attendances, key = { index, item ->
item.memberId
}) { item, index ->
AttendanceCheckBox(...)
}
}
You should also use update when updating your StateFlow instead of modifying its value directly to make it concurrently safe.
attendanceStates.update { list ->
val idx = event.idx
list[idx] = list[idx].copy(leaveApplied = event.hasApplied)
list
}
I have as string from an outside source that contains HTML tags in this format:
"Hello, I am <b> bold</b> text"
Before Compose I would have CDATA at the start of my HTML String, used Html.fromHtml() to convert to a Spanned and passed it to the TextView. The TextView would them have the word bold in bold.
I have tried to replicated this with Compose but I can't find the exact steps to allow me to achieve it successfully.
Any suggestions are gratefully received.
There is yet no official Composable to do this. For now i'm using an AndroidView with a TextView inside. Not the best solution, but it's simple and that solves the problem.
#Composable
fun HtmlText(html: String, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier,
factory = { context -> TextView(context) },
update = { it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) }
)
}
If you have tags in the HTML you need to set the TextView property movementMethod = LinkMovementMethod.getInstance() to make the links clickable.
I am using this little helper function that converts some of the Span (Spanned) into a SpanStyle (AnnotatedString/Compose) replacement.
/**
* Converts a [Spanned] into an [AnnotatedString] trying to keep as much formatting as possible.
*
* Currently supports `bold`, `italic`, `underline` and `color`.
*/
fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
val spanned = this#toAnnotatedString
append(spanned.toString())
getSpans(0, spanned.length, Any::class.java).forEach { span ->
val start = getSpanStart(span)
val end = getSpanEnd(span)
when (span) {
is StyleSpan -> when (span.style) {
Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end)
}
is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
}
}
}
Since I'm using a Kotlin Multiplatform project with Android Jetpack Compose and JetBrains Compose for Desktop, I don't really have the option of just falling back to Android's TextView.
So I took inspiration from turbohenoch's answer and did my best to expand it to be able to interpret multiple (possibly nested) HTML formatting tags.
The code can definitely be improved, and it's not at all robust to HTML errors, but I did test it with text that contained <u> and <b> tags and it works fine for that at least.
Here's the code:
/**
* The tags to interpret. Add tags here and in [tagToStyle].
*/
private val tags = linkedMapOf(
"<b>" to "</b>",
"<i>" to "</i>",
"<u>" to "</u>"
)
/**
* The main entry point. Call this on a String and use the result in a Text.
*/
fun String.parseHtml(): AnnotatedString {
val newlineReplace = this.replace("<br>", "\n")
return buildAnnotatedString {
recurse(newlineReplace, this)
}
}
/**
* Recurses through the given HTML String to convert it to an AnnotatedString.
*
* #param string the String to examine.
* #param to the AnnotatedString to append to.
*/
private fun recurse(string: String, to: AnnotatedString.Builder) {
//Find the opening tag that the given String starts with, if any.
val startTag = tags.keys.find { string.startsWith(it) }
//Find the closing tag that the given String starts with, if any.
val endTag = tags.values.find { string.startsWith(it) }
when {
//If the String starts with a closing tag, then pop the latest-applied
//SpanStyle and continue recursing.
tags.any { string.startsWith(it.value) } -> {
to.pop()
recurse(string.removeRange(0, endTag!!.length), to)
}
//If the String starts with an opening tag, apply the appropriate
//SpanStyle and continue recursing.
tags.any { string.startsWith(it.key) } -> {
to.pushStyle(tagToStyle(startTag!!))
recurse(string.removeRange(0, startTag.length), to)
}
//If the String doesn't start with an opening or closing tag, but does contain either,
//find the lowest index (that isn't -1/not found) for either an opening or closing tag.
//Append the text normally up until that lowest index, and then recurse starting from that index.
tags.any { string.contains(it.key) || string.contains(it.value) } -> {
val firstStart = tags.keys.map { string.indexOf(it) }.filterNot { it == -1 }.minOrNull() ?: -1
val firstEnd = tags.values.map { string.indexOf(it) }.filterNot { it == -1 }.minOrNull() ?: -1
val first = when {
firstStart == -1 -> firstEnd
firstEnd == -1 -> firstStart
else -> min(firstStart, firstEnd)
}
to.append(string.substring(0, first))
recurse(string.removeRange(0, first), to)
}
//There weren't any supported tags found in the text. Just append it all normally.
else -> {
to.append(string)
}
}
}
/**
* Get a [SpanStyle] for a given (opening) tag.
* Add your own tag styling here by adding its opening tag to
* the when clause and then instantiating the appropriate [SpanStyle].
*
* #return a [SpanStyle] for the given tag.
*/
private fun tagToStyle(tag: String): SpanStyle {
return when (tag) {
"<b>" -> {
SpanStyle(fontWeight = FontWeight.Bold)
}
"<i>" -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
"<u>" -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
//This should only throw if you add a tag to the [tags] Map and forget to add it
//to this function.
else -> throw IllegalArgumentException("Tag $tag is not valid.")
}
}
I did my best to make clear comments, but here's a quick explanation. The tags variable is a map of the tags to track, with the keys being the opening tags and the values being their corresponding closing tags. Anything here needs to also be handled in the tagToStyle() function, so that the code can get a proper SpanStyle for each tag.
It then recursively scans the input String, looking for tracked opening and closing tags.
If the String it's given starts with a closing tag, it'll pop the most recently-applied SpanStyle (removing it from text appended from then on) and call the recursive function on the String with that tag removed.
If the String it's given starts with an opening tag, it'll push the corresponding SpanStyle (using tagToStyle()) and then call the recursive function on the String with that tag removed.
If the String it's given doesn't start with either a closing or opening tag, but does contain at least one of either, it'll find the first occurrence of any tracked tag (opening or closing), normally append all text in the given String up until that index, and then call the recursive function on the String starting at the index of the first tracked tag it finds.
If the String it's given doesn't have any tags, it'll just append normally, without adding or removing any styling.
Since I'm using this in an app being actively developed, I'll probably continue to update it as needed. Assuming nothing drastic changes, the latest version should be available on its GitHub repository.
For simple use case you can do something like this:
private fun String.parseBold(): AnnotatedString {
val parts = this.split("<b>", "</b>")
return buildAnnotatedString {
var bold = false
for (part in parts) {
if (bold) {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(part)
}
} else {
append(part)
}
bold = !bold
}
}
}
And use this AnnotatedString in #Composable
Text(text = "Hello, I am <b> bold</b> text".parseBold())
Of course this gets trickier as you try to support more tags.
You may try compose-html, which is an Android library that provides HTML support for Jetpack Compose texts.
As the composable Text layout doesn't provide any HTML support. This library fills that gap by exposing the composable HtmlText layout, which is built on top of the Text layout and the Span/Spannable Android classes (the implementation is based in #Sven answer). Its API goes as follows:
HtmlText(
text = htmlString,
linkClicked = { link ->
Log.d("linkClicked", link)
}
)
And these are all the available parameters that allows you to change the default behaviour:
fun HtmlText(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
linkClicked: (String) -> Unit = {},
fontSize: TextUnit = 14.sp,
flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT,
URLSpanStyle: SpanStyle = SpanStyle(
color = linkTextColor(),
textDecoration = TextDecoration.Underline
)
)
HtmlText supports almost as many HTML tags as android.widget.TextView does, with the exception of <img> tag and <ul>, being the latter partially supported, as HtmlText
renders properly the elements of the list but it does not add the bullet (•)
Here is my solution that also supports hyperlinks:
#Composable
fun HtmlText(
html: String,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
hyperlinkStyle: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onHyperlinkClick: (uri: String) -> Unit = {}
) {
val spanned = remember(html) {
HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY, null, null)
}
val annotatedText = remember(spanned, hyperlinkStyle) {
buildAnnotatedString {
append(spanned.toString())
spanned.getSpans(0, spanned.length, Any::class.java).forEach { span ->
val startIndex = spanned.getSpanStart(span)
val endIndex = spanned.getSpanEnd(span)
when (span) {
is StyleSpan -> {
span.toSpanStyle()?.let {
addStyle(style = it, start = startIndex, end = endIndex)
}
}
is UnderlineSpan -> {
addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start = startIndex, end = endIndex)
}
is URLSpan -> {
addStyle(style = hyperlinkStyle.toSpanStyle(), start = startIndex, end = endIndex)
addStringAnnotation(tag = Tag.Hyperlink.name, annotation = span.url, start = startIndex, end = endIndex)
}
}
}
}
}
ClickableText(
annotatedText,
modifier = modifier,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = onTextLayout
) {
annotatedText.getStringAnnotations(tag = Tag.Hyperlink.name, start = it, end = it).firstOrNull()?.let {
onHyperlinkClick(it.item)
}
}
}
private fun StyleSpan.toSpanStyle(): SpanStyle? {
return when (style) {
Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
Typeface.BOLD_ITALIC -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)
else -> null
}
}
private enum class Tag {
Hyperlink
}
Compose Text() does not support HTML yet. It's only just gone to Beta so maybe it will arrive.
The solution we implemented for now (and this is not perfect) was to fall back on the old TextView control, which Compose will allow you to do.
https://developer.android.com/jetpack/compose/interop#views-in-compose
https://proandroiddev.com/jetpack-compose-interop-part-1-using-traditional-views-and-layouts-in-compose-with-androidview-b6f1b1c3eb1
Following the guide on Styling with HTML markup, and combining it with Sven's answer, I came up with this function that can be used like the built-in stringResource() function:
/**
* Load a styled string resource with formatting.
*
* #param id the resource identifier
* #param formatArgs the format arguments
* #return the string data associated with the resource
*/
#Composable
fun annotatedStringResource(#StringRes id: Int, vararg formatArgs: Any): AnnotatedString {
val text = stringResource(id, *formatArgs)
val spanned = remember(text) {
HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
return remember(spanned) {
buildAnnotatedString {
append(spanned.toString())
spanned.getSpans(0, spanned.length, Any::class.java).forEach { span ->
val start = spanned.getSpanStart(span)
val end = spanned.getSpanEnd(span)
when (span) {
is StyleSpan -> when (span.style) {
Typeface.BOLD ->
addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
Typeface.ITALIC ->
addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
Typeface.BOLD_ITALIC ->
addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
),
start,
end,
)
}
is UnderlineSpan ->
addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
is ForegroundColorSpan ->
addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
}
}
}
}
}
I built my solution on Nieto's answer.
I wanted to be able to style the text in HtmlText by using the Compose theme attributes.
So I added the parameters color and style, which Text also offers, and translated them for TextView.
Here is my solution:
#Composable
fun HtmlText(
html: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
style: TextStyle = LocalTextStyle.current,
) {
val textColor = color
.takeOrElse { style.color }
.takeOrElse { LocalContentColor.current.copy(alpha = LocalContentAlpha.current) }
.toArgb()
val density = LocalDensity.current
val textSize = with(density) {
style.fontSize
.takeOrElse { LocalTextStyle.current.fontSize }
.toPx()
}
val lineHeight = with(density) {
style.lineHeight
.takeOrElse { LocalTextStyle.current.lineHeight }
.roundToPx()
}
val formattedText = remember(html) {
HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
AndroidView(
modifier = modifier,
factory = { context ->
AppCompatTextView(context).apply {
setTextColor(textColor)
// I haven't found out how to extract the typeface from style so I created my_font_family.xml and set it here
typeface = ResourcesCompat.getFont(context, R.font.my_font_family)
setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
if (style.lineHeight.isSp) {
this.lineHeight = lineHeight
} else {
// Line Height could not be set
}
},
update = { it.text = formattedText }
)
}
Convert to String:
Text(text = Html.fromHtml(htmlSource).toString())
NOTE: Nesting the same tag is not supported (<b>Foo<b>Bar</b></b>)
enum class AnnotatedStringTags(tag: String, val style: SpanStyle) {
BOLD("b", SpanStyle(fontWeight = FontWeight.Bold)),
ITALIC("i", SpanStyle(fontStyle = FontStyle.Italic)),
UNDERLINE("u", SpanStyle(textDecoration = TextDecoration.Underline));
val startTag = "<$tag>"
val endTag = "</$tag>"
companion object {
val ALL = values()
}
}
val REGEX_BR = """<\\?br\\?>""".toRegex()
fun String.toAnnotatedString() = buildAnnotatedString { fromHtml(replace(REGEX_BR, "\n")) }
private fun AnnotatedString.Builder.fromHtml(string: String) {
var currentString = string
while (currentString.isNotEmpty()) {
val tagPositionPair = AnnotatedStringTags.ALL.asSequence()
.map { it to currentString.indexOf(it.startTag) }
.filter { (_, idx) -> idx >= 0 }
.minByOrNull { (_, idx) -> idx }
if (tagPositionPair == null) {
// No more tags found
append(currentString)
return
}
val (tag, idx) = tagPositionPair
val endIdx = currentString.indexOf(tag.endTag)
if (endIdx < 0)
throw IllegalStateException("Cannot find end tag for starting tag ${tag.startTag}")
if (idx > 0)
append(currentString.substring(0 until idx))
withStyle(tag.style) {
append(
buildAnnotatedString {
fromHtml(currentString.substring((idx + tag.startTag.length) until endIdx))
}
)
}
currentString = currentString.substring(endIdx + tag.endTag.length)
}
}
Thanks to #Nieto for your answer. This is the improvised version with clickable links as per his suggestion
#Composable
fun HtmlText(html: String, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier,
factory = { context ->
val textView = TextView(context)
textView.movementMethod = LinkMovementMethod.getInstance()
textView
},
update = { it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) }
)
}
In my case I needed to use placeholders in string resources with html tags for bold text in compose.
I solved it with the method below htmlStringResource which is basing on Spanned.toAnnotatedString() from Svens answer
#Composable
fun htmlStringResource(#StringRes resourceId: Int, vararg formatArgs: Any): AnnotatedString {
val htmlString = stringResource(id = resourceId, *formatArgs)
return HtmlCompat.fromHtml(htmlString, HtmlCompat.FROM_HTML_MODE_COMPACT).toAnnotatedString()
}
<string name="price"><![CDATA[<b>%1$s€</b> per year]]></string>
Text(text = htmlStringResource(resourceId = string.price, "39.90"))