How to replace parts of Text with TextFields in Jetpack Compose? - android-jetpack-compose

For example i have this:
TAKE, O take those lips away
That so sweetly were forsworn,
And those eyes, the break of day,
Lights that do mislead the morn:
But my kisses bring again,
Bring again—
Seals of love, but seal’d in vain,
Seal’d in vain!
– William Shakespeare
And i want to replace some words to TextField(in Compose) or to EditText(in XML).
Example:
TAKE, O take those lips textfield
That so were forsworn,
And those eyes, the break of day,
Lights that do mislead the morn:
But my textfield bring again,
Bring again—
textfield of love, textfield seal’d in vain,
Seal’d in vain!
– William Shakespeare
Can you advise me the way to realise it? Maybe specific libraries?

First of all, I highlighted the words to be replaced by * so that they can be easily found using a regular expression.
val string = """
TAKE, O take those lips *away*
That so sweetly were forsworn,
And those eyes, the break of day,
Lights that do mislead the morn:
But my *kisses* bring again,
Bring again—
*Seals* of love, *but* seal’d in vain,
Seal’d in vain!
– William Shakespeare
""".trimIndent()
val matches = remember(string) { Regex("\\*\\w+\\*").findAll(string) }
Using the onTextLayout argument in Compose Text, you can get a lot of information about the text to be rendered, including the positions of each character. And the indexes of the characters you need to replace are already defined by a regular expression.
All you have to do is place the text fields at the appropriate positions.
I use BasicTextField because it doesn't have the extra padding that TextField has, so the size is easy to match with Text. I set its background to white so that the original text doesn't shine through. If you have an unusual background, a gradient for example, you can also make the text transparent with annotated text as shown in the documentation, then the BasicTextField can be left transparent.
The SubcomposeLayout is a great tool for creating such layouts without waiting a next recomposition to use onTextLayout result.
val textLayoutState = remember { mutableStateOf<TextLayoutResult?>(null) }
val textFieldTexts = remember(matches.count()) { List(matches.count()) { "" }.toMutableStateList() }
val style = MaterialTheme.typography.body1
SubcomposeLayout { constraints ->
val text = subcompose("text") {
Text(
text = string,
style = style,
onTextLayout = {
textLayoutState.value = it
},
)
}[0].measure(constraints)
val textLayout = textLayoutState.value ?: run {
// shouldn't happen as textLayoutState is updated during sub-composition
return#SubcomposeLayout layout(0, 0) {}
}
val wordsBounds = matches.map {
// I expect all selected words to be on a single line
// otherwise path bounds will take both lines
textLayout
.getPathForRange(it.range.first, it.range.last + 1)
.getBounds()
}
val textFields = wordsBounds.mapIndexed { i, wordBounds ->
subcompose("textField$i") {
BasicTextField(
value = textFieldTexts[i],
onValueChange = {
textFieldTexts[i] = it
},
onTextLayout = {
println("${it.size}")
},
textStyle = style,
modifier = Modifier
.border(1.dp, Color.LightGray)
.background(Color.White)
)
}[0].measure(Constraints(
maxWidth = floor(wordBounds.width).toInt(),
)) to wordBounds.topLeft
}
layout(text.width, text.height) {
text.place(0, 0)
textFields.forEach {
val (placeable, position) = it
placeable.place(floor(position.x).toInt(), floor(position.y).toInt())
}
}
}
Result:

Related

how to progressively add drawable to a canvas?

I have points generated one by one, and when a new point is generated, I want to draw a line segment connecting with the previous point. Like this:
var x by remember { mutableStateOf( 0.0f)}
var y by remember { mutableStateOf( 0.5f)}
var pStart by remember { mutableStateOf(Offset(0f, 0.5f))}
Canvas(modifier = Modifier.fillMaxSize()) {
canvasWidth = size.width
canvasHeight = size.height
val pEnd = Offset(x * canvasWidth, (1-y) * canvasHeight)
val col = if (pEnd.y < pStart.y) Color.Green else Color.Red
drawLine(
start = pStart,
end = pEnd,
strokeWidth = 4f,
color = col
)
pStart = pEnd
}
But this only draws the segment in a flash and no segments stay on the screen.
I know I can save the points to a list and redraw all the segments whenever a new point is added. But I just hope to economize. Is it possible?
There's no practical other way. You COULD in fact, keep track of just two points, adding a whole new Canvas (all Transparent and Filling the maximum Size, stacked on top of one another), for each extra point that is added. This does seem a bit impractical, but maybe try it out and do some benchmarking to see which one checks out. This is the only OTHER way I could think of, where you do not have to store all the points and recompose every time a point is added, since all the other lines would technically be frozen in space.
In response to the somewhat (unreasonably) aggressive comment below, here's some sample code. I assume you have a stream of new points coming in so a LiveData object is assumed to be the source of that, which I shall be converting to a MutableState<T> for my use-case.
var latestPoint by liveData.collectAsState()
var recordedPoint by remember { mutableStateOf(latestPoint) }
var triggerDraw by remember { mutableStateOf(false) }
var canvasList = mutableStateListOf<#Composable () -> Unit>Canvas>() // Canvas is the Composable
if(triggerDraw){
canvasList.add(
Canvas(){
/* you have the recordedPoint, and latestPoint, simply draw a line here */
}
)
triggerDraw = false
}
LaunchedEffect(latestPoint){
triggerDraw = true
}
canvasList.forEach {
it() // Invoke the Composable here
}
Thanks Dad!

Reading font size from resource file with Jetpack Compose [duplicate]

When I plug in fontSize = dimensionResource(id = R.dimen.textLabelTextSize) where the dimens or 54sp or 60sp depending on the device, I get an error on Text() "None of the following functions can be called with the arguments supplied." But when I put a hard-coded value like 54sp it's fine. What's strange is for the padding modifier dimensionResource (in dp) is working fine.
Text(
text = textLabelItem.textLabel,
modifier = Modifier
.padding(
start = dimensionResource(id = R.dimen.textLabelPaddingVertical),
top = dimensionResource(id = R.dimen.textLabelPaddingHorizontalTop),
end = dimensionResource(id = R.dimen.textLabelPaddingVertical),
bottom = dimensionResource(id = R.dimen.textLabelPaddingHorizontalBottom)
)
.background(colorResource(id = R.color.textLabelBg))
.border(
width = 2.dp,
color = colorResource(id = R.color.textLabelBorder),
shape = RoundedCornerShape(8.dp)
),
color = colorResource(id = android.R.color.background_dark),
fontSize = dimensionResource(id = R.dimen.textLabelTextSize),
fontWeight = FontWeight.Bold
)
The answer is very simple, you just forgot to handle the result from dimensionResource. You need to just use the value of it to have it as float. Then you use sp extension and you are ready to go.
I created my own extension for this:
#Composable
#ReadOnlyComposable
fun fontDimensionResource(#DimenRes id: Int) = dimensionResource(id = id).value.sp
So instead using dimensionResource(R.dimen.your_font_size) use fontDimensionResource(R.dimen.your_font_size)
Final solution:
Text(text = "", fontSize = fontDimensionResource(id = R.dimen.your_font_size))
To convert from dp to sp, you need to take into account font scaling - that is the point of using sp for text. This means when the user changes the system font scale, that the app responds to this change.
Does not scale the text
If we request dimensionResource() in kotlin, we get a dp value that is not scaled yet. You can confirm this in the sourcecode where that function is defined to return a Dp:
fun dimensionResource(#DimenRes id: Int): Dp {.....}
A basic conversion to a value.sp does not apply the required scaling, so any solution relying on this type of basic calculation will not work correctly.
unscaledSize = dimensionResource(R.dimen.sp_size).value.sp
(where R.dimen.sp_size is a dimension resource declared with sp sizing)
This does not scale the text size correctly.
Better solution
To do it correctly, we need to look at the DisplayMetrics and the current scaledDensity value, defined as:
/**
* A scaling factor for fonts displayed on the display. This is the same
* as {#link #density}, except that it may be adjusted in smaller
* increments at runtime based on a user preference for the font size.
*/
public float scaledDensity;
This scaling value must be applied to the dimension that is fetched, to return something that can be used as sp:
val scaledSize = with(LocalContext.current.resources) {
(getDimension(R.dimen.sp_size) / displayMetrics.scaledDensity).sp
}
Warning: this will only work correctly for dimensions defined as sp!
Handling different dimension types
An even better solution would check what type of dimension resource is being accessed, and would then calculate based on that i.e. dp, sp or px.
This does require working with TypedValue and TypedArray, which makes it a bit more complex, but sample code can be found in the TypedArrayUtils from the MDC Theme Adapter:
internal fun TypedArray.getTextUnitOrNull(
index: Int,
density: Density
): TextUnit? {
val tv = tempTypedValue.getOrSet { TypedValue() }
if (getValue(index, tv) && tv.type == TypedValue.TYPE_DIMENSION) {
return when (tv.complexUnitCompat) {
// For SP values, we convert the value directly to an TextUnit.Sp
TypedValue.COMPLEX_UNIT_SP -> TypedValue.complexToFloat(tv.data).sp
// For DIP values, we convert the value to an TextUnit.Em (roughly equivalent)
TypedValue.COMPLEX_UNIT_DIP -> TypedValue.complexToFloat(tv.data).em
// For another other types, we let the TypedArray flatten to a px value, and
// we convert it to an Sp based on the current density
else -> with(density) { getDimension(index, 0f).toSp() }
}
}
return null
}
Best solution
Ideally, we should not be pulling out resources and converting them when working with Compose. We should be using theme constants instead.
We are probably all on this page because we have some layouts in XML with others in Compose. We are likely going through the conversion process.
The best way to deal with this type of conversion is to use the Material Components MDC-Android Compose Theme Adapter to handle all of these cases.
It works with much more than just a text size calculation and is where we should be aiming to get to as part of our migration to Compose.
It happens because the function dimensionResource returns a Dp value and fontSize works with Sp values.
Currently you can't use it.
The method dimensionResource returns dp value. To get sp value from this add .value.sp at the end like this:
fontSize = dimensionResource(id = R.dimen.textLabelTextSize).value.sp
Consider creating this
#OptIn(ExperimentalUnitApi::class)
#Composable
#ReadOnlyComposable
fun textSizeResource(#DimenRes id: Int): TextUnit {
val context = LocalContext.current
val density = LocalDensity.current
val pxValue = context.resources.getDimension(id)
return TextUnit(pxValue / density.density, TextUnitType.Sp)
}
and using it as follows
Text(text = "abc", fontSize = textSizeResource(id = R.dimen.text_large))

Jetpack Compose: Find how many lines a text will take before composition

I am trying to determine how many lines a certain text will occupy on the screen before composition. Is there a way to do this?
You can use onTextLayout on Text to get line count and some other features.
var lineCount = 1
Text(text= "", onTextLayout = {textLayoutResult: TextLayoutResult ->
lineCount = textLayoutResult.lineCount
})
While the accepted answer is correct there is an alternative way, which doesn't even require a Composable function:
val paragraph = androidx.compose.ui.text.Paragraph(
text = "Foo",
style = MaterialTheme.typography.body1,
constraints = Constraints(maxWidth = maxWidthInPx),
density = LocalDensity.current,
fontFamilyResolver = LocalFontFamilyResolver.current,
)
paragraph.lineCount
This might be better suited if it's required to know the lineCount beforehand.

Stubborn emoji won't combine: 👨‍❤‍💋‍👨

Even after #user3441734 solved most of my problems 🙇, there are a few emoji that I can't seem to render properly when converting from a [String:String] to String.
Here's some Playground-ready code to illustrate the problem:
var u = ""
u = "1f468-1f468-1f467-1f467" // 👨‍👨‍👧‍👧
//u = "1f918-1f3ff" // 🤘🏿
//u = "1f468-2764-1f48b-1f468" // 👨‍❤‍💋‍👨 (broken)
//u = "1f3c7-1f3fb" // 🏇‍🏻 (broken)
let unicodeArray = u.characters.split("-")
.map(String.init)
.map {String(UnicodeScalar(Int($0,radix: 16) ?? 0))}
if let last = unicodeArray.last {
let separator: String
switch (unicodeArray.first, last) {
// Failed attempt to get tone applied to jockey
case let (horse_racing, _) where horse_racing == "\u{1f3c7}":
separator = "\u{200d}"
case let (_, tone) where "\u{1f3fb}"..."\u{1f3ff}" ~= tone:
separator = ""
case let (_, regionalIndicatorSymbol) where "\u{1f1e6}"..."\u{1f1ff}" ~= regionalIndicatorSymbol:
separator = ""
default:
separator = "\u{200d}"
}
print(unicodeArray.joinWithSeparator(separator))
}
Uncomment each assignment to u in turn to see the problem in action. The 3rd and 4th values should render like so:
and
Thoughts…
It turns out that a long-press on the race horse fails to show skin tones on iOS, so let's assume that's just an oversight, perhaps related to the near-impossibility of judging the jockey's skin tone at standard emoji sizes. I still can't figure out the problem with u = "1f468-2764-1f48b-1f468"
Apologies if this question comes out at all unclear. Chrome and Safari have different behaviors w.r.t these combo-emoji, so only the linked images are guaranteed to appear to you the way I see them on my end. 😢
These emoji are all either skin-tone renderings or tokens of same-sex affection. Is there some sort of bizarre latent racism & homophobia lurking in the system?! 😱 (Cue the conspiracy theories.)
Note that my attempt to use the zero-width joiner, u{200d} didn't help.
So, bug in Apple & Chrome's handling of certain emoji, or is there yet another idiosyncrasy of the standard that I've missed?
There's no conspiracy, the bugs are in your code.
The first character can be produced with:
U+1F468 U+200D U+2764 U+FE0F U+200D U+1F48B U+200D U+1F468
Note the ZERO WIDTH JOINER (U+200D) between each character, and the VARIATION SELECTOR-16 selector (U+FE0F) on the HEAVY BLACK HEART (U+2764) to ensure the emoji presentation style is used.
Refer to this table for a complete list of implemented multi-person groupings.
U+1F3C7 HORSE RACING is not an emoji modifier base, and so it does not support skin tone modifiers.

How can I prevent orphans in a label in swift?

I have a label that can have one or two lines. If it has two lines, I want the second line to have at least two (or maybe three) words, never just one. Any ideas about how I can accomplish that using swift?
Thanks in advance!
Daniel
Edit: I edited out my silly first thoughts that didn't really help.
Ok, after looking around a lot I came up with what I think is the best solution.
I wrote this function:
func numberOfLinesInLabelForText(text: String) -> Int {
let attributes = [NSFontAttributeName : UIFont.preferredFontForTextStyle(UIFontTextStyleHeadline)]
let screenSize: CGRect = UIScreen.mainScreen().bounds
let labelSize = text!.boundingRectWithSize(CGSizeMake((screenSize.width - 30), CGFloat.max), options: NSStringDrawingOptions.UsesLineFragmentOrigin, attributes: attributes, context: nil)
let lines = floor(CGFloat(labelSize.height) / bookTitleLabel.font.lineHeight)
return Int(lines)
}
You put in the string that will be displayed in the label and it gives you how many lines the label will have. I'm using dynamic type and the Headline style for this particular label, hence the preferredFontForTextStyle(UIFontTextStyleHeadline) part, but you can change that to the font and size your label uses.
Then I use (screenSize.width - 30) for my label's width because it's width is not fixed, so I'm using the screen size minus leading and trailing. This is probably not the most elegant solution, I'm open to better suggestions.
The rest is pretty straightforward.
After I have the number of lines I can do this:
func splittedString(text: String) -> String {
if numberOfLinesInLabel(text) == 2 {
var chars = Array(text.characters)
var i = chars.count / 2
var x = chars.count / 2
while chars[i] != " " && chars[x] != " " {
i--
x++
}
if chars[i] == " " {
chars.insert("\n", atIndex: i+1)
} else {
chars.insert("\n", atIndex: x+1)
}
return String(chars)
}
}
Instead of just avoiding orphans I decided to split the string in two at the breaking point nearest to its half, so that's what this last function does, but it wouldn't be hard to tweak it to suit your needs.
And there you have it!

Resources