How to set bullet radius in BulletSpan below api level 28? - android

How to use BulletSpan(gapWidth, color, bulletRadius), below api level 28? I am unable to set bulletRadius below api level 28. Any help would be appreciated.

So the method with radius parameters do not appear until API level 28. For previous APIs, you can refer to this article.
Basically what the author did was porting the API 28+ BulletSpan to your app project so you can use the ported version to achieve setting the radius.

Writing a Custom Bullet Span works like a charm, because you will get the canvas in your hand. With this you can paint a bullet of any kind/size to your view.
open class CharBulletSpan(var charCode: String, gapWidth: Int, internal val bulletColor: Int, val bulletSize: Int, val alignment: Layout.Alignment,val typeface: Typeface) : BulletSpan(gapWidth, bulletColor) {
private var isSpanStart = true
private val space = gapWidth
var alpha = 0f
override fun getLeadingMargin(first: Boolean): Int { // Returns the amount of indentation as set by the LeadingMarginSpan.class
if (!first) {
return 0
}
return super.getLeadingMargin(first)
}
override fun drawLeadingMargin(canvas: Canvas, paint: Paint, x: Int, dir: Int, top: Int, baseline: Int, bottom: Int, text: CharSequence, start: Int, end: Int, first: Boolean, layout: Layout?) {
//This is where the magic happens. Let us get the x - translation for the bullet since we will be painting on the canvas for different text alignments
isSpanStart = (text as Spanned).getSpanStart(this) == start
val xPos = getBulletXPos(layout!!, start, x)
val selectionStart = Selection.getSelectionStart(text)
val selectionEnd = Selection.getSelectionEnd(text)
//The following block is just for a fancy purpose. When we type text and press enter and the cursor is in a new line, we can apply an alpha value to the bullet/ set a transparency. If text is typed in that line , the bullet's alpha value can be changed.
if (!text.isNullOrEmpty()) {
if (start != end && (text.subSequence(start, end).isNotEmpty() && text.subSequence(start, end) != "\n")) {
this.alpha = 255f
}
if (start == end) {
if ((start == 1 && selectionStart == 0) || (start == selectionStart && end == selectionEnd)) { // first line
this.alpha = 150f
if (!isCursorVisible) {
this.alpha = 0f
}
} else if (selectionStart != start) {
this.alpha = 0f
}
}
} else if (text != null && text.isEmpty() && start == 0 && start == end) {
this.alpha = 255f
}
if (isSpanStart) {
// Now we shall fire the bullet
renderCharBullet(canvas, paint, xPos, dir, top, baseline, bottom, text,charCode)
}
}
private fun getBulletXPos(layout: Layout, start: Int, x: Int): Int {
val width = layout.width
val lineNo = layout.getLineForOffset(start)
val lineLeft = layout.getLineLeft(lineNo)
val lineWidth = layout.getLineWidth(lineNo)
return when (alignment) {
Layout.Alignment.NORMAL -> x
Layout.Alignment.OPPOSITE -> x + (width - lineWidth).toInt()
Layout.Alignment.ALIGN_CENTER -> lineLeft.toInt() - space
else -> x
}
}
private fun renderCharBullet(canvas: Canvas?, paint: Paint?, x: Int, dir: Int, top: Int, baseline: Int, bottom: Int, text: CharSequence?, charCode: String) {
val rectF = Rect()
val newPaint = Paint()
newPaint.typeface = typeface
newPaint.textSize = bulletSize
//Constructing a new paint to compute the y - translation of the bullet for the current line
if (!text.isNullOrEmpty()) {
newPaint.getTextBounds(text.subSequence(0, 1).toString(), 0, text.subSequence(0, 1).length, rectF)
}
val oldStyle = paint?.style
paint?.textSize = bulletSize
paint?.typeface = typeface
paint?.style = Paint.Style.FILL
paint?.color = bulletColor
paint?.alpha = alpha.toInt()
if (canvas!!.isHardwareAccelerated) {
canvas.save()
canvas.translate((x + dir).toFloat(), baseline - rectF.height().div(2.0f))
canvas.drawText(charCode, 0f, rectF.height().div(2.0f), paint!!)
canvas.restore()
}
paint?.style = oldStyle
}
}

Related

Custom ImageSpan not displaying properly

I'm trying to create a custom class that extends ImageSpan because I need some kind of margin/padding on the spans.
What I figured I need to do is to override the getSize function to return a bigger width so the spans get graphically spaced.
The problem is that as soon as I override the getSize function my view gets completely screwed up. My educated guess is then that I'm doing something stupid inside that funcion, but I can't get what.
Custom class code:
class PaddingImageSpan(drawable: Drawable, private val offset: Float = 0f) : ImageSpan(drawable) {
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
val width = paint.measureText(text, start, end)
val fontMetricsInt = paint.fontMetricsInt
if (fm != null){
fm.ascent = fontMetricsInt.ascent
fm.bottom = fontMetricsInt.bottom
fm.descent = fontMetricsInt.descent
fm.leading = fontMetricsInt.leading
fm.top = fontMetricsInt.top
}
println(width)
return width.roundToInt()
}
}
I figured it out. I'm posting the solution so if someone looks for it he can find it!
My problem was I was using the text metrics instead of the drawable metrics.
This is the correct code:
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
val rect = drawable.bounds
if (fm != null) {
fm.ascent = -rect.bottom
fm.descent = 0
fm.top = fm.ascent
fm.bottom = 0
}
return rect.right// + offset
}
That said, the cleaner way that I could come up with to space spannable is not by working on the spannable class but changing the setBounds() values.

Crash with "trying to use a recycled bitmap" using Glide 4.11, can transformations cause this?

I know there are already a bunch of questions related to the "trying to use a recycled bitmap" crash, but none helped me.
Details:
There are no calls to Bitmap.recycle() anywhere in this project
All images are loaded using Glide (4.11.0)
Glide calls are all simple, and not using placeholders
The crash seems to happen randomly when switching fragments
Only thing I could think of were the transformations.
There are only 2 transformations in this project.
CircleTransformation (clip image as a circle with a custom radius):
class CircleTransformation(private val radius: Float) : BitmapTransformation() {
companion object {
private const val ID = "com.project.transformation.circle"
private val ID_BYTES: ByteArray = ID.toByteArray()
}
public override fun transform(pool: BitmapPool, source: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
val paint = Paint()
paint.isAntiAlias = true
paint.shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
val halfWidth = source.width / 2f
val output = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
canvas.drawCircle(
halfWidth,
(source.height / 2).toFloat(),
halfWidth * radius,
paint
)
return output
}
// Caching helpers
override fun equals(other: Any?): Boolean {
return other is CircleTransformation && other.hashCode() == hashCode()
}
override fun hashCode(): Int {
return ID.hashCode()
}
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID_BYTES)
}
}
And ClipWhiteTransformation (remove white border from image):
class ClipWhiteTransformation() : BitmapTransformation() {
companion object {
private const val ID = "com.project.transformation.clipWhite"
private val ID_BYTES: ByteArray = ID.toByteArray()
// Config
const val white = 253 // White pixel, if all channels are equal or greater than this
const val transparent = 50 // Transparent pixel, if Less than this
}
public override fun transform(pool: BitmapPool, source: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
val width = source.width - 1
val height = source.height - 1
val halfX = width / 2
val halfY = height / 2
var startY = 0
// Left Margin
var left = 0
for (x in 0 until halfX) {
val pixel = source.getPixel(x, halfY)
// Transparent?
if (Color.alpha(pixel) < transparent) continue
// Not white?
if (Color.red(pixel) < white || Color.green(pixel) < white || Color.blue(pixel) < white) {
left = x
if (x > 2) {
startY = 2
}
break
}
}
// Right Margin
var right = 0
for (x in 0 until halfX) {
val pixel = source.getPixel(width - x, halfY)
// Transparent?
if (Color.alpha(pixel) < transparent) continue
// Not white?
if (Color.red(pixel) < white || Color.green(pixel) < white || Color.blue(pixel) < white) {
right = x
if (x > 2) {
startY = 2
}
break
}
}
// Top Margin
var top = 0
for (y in startY until halfY) {
val pixel = source.getPixel(halfX, y)
// Transparent?
if (Color.alpha(pixel) < transparent) continue
// Not white?
if (Color.red(pixel) < white || Color.green(pixel) < white || Color.blue(pixel) < white) {
top = y
break
}
}
// Bottom Margin
var bottom = 0
for (y in startY until halfY) {
val pixel = source.getPixel(halfX, height - y)
// Transparent?
if (Color.alpha(pixel) < transparent) continue
// Not white?
if (Color.red(pixel) < white || Color.green(pixel) < white || Color.blue(pixel) < white) {
bottom = y
break
}
}
// Clip, scale and return
val newWidth = width - (left + right)
val newHeight = height - (top + bottom)
val scale = if (abs(newWidth - outWidth) > abs(newHeight - outHeight)) outWidth / newWidth.toFloat() else outHeight / newHeight.toFloat()
val matrix = Matrix().apply { setScale(scale, scale) }
return Bitmap.createBitmap(source, left, top, newWidth, newHeight, matrix, false)
}
// Caching helpers
override fun equals(other: Any?): Boolean {
return other is ClipWhiteTransformation && other.hashCode() == hashCode()
}
override fun hashCode(): Int {
return ID.hashCode()
}
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(ID_BYTES)
}
}
Was using the BitmapPool initially, removing it didn't stop the crash.
By the way, this is the extension used to load images:
fun ImageView.setURL(url: String,
#DrawableRes error: Int? = null,
#DrawableRes placeholder: Int? = null,
size: Int? = null,
options: ((RequestBuilder<Drawable>) -> Unit)? = null,
completion: ((resource: Drawable?) -> Unit)? = null) {
// No URL, use Placeholder if exists, if not, use the error image
if (url.isEmpty()) {
placeholder?.also{ setImageResource(it) } ?: run { error?.also{ setImageResource(it) } }
return
}
Glide.with(applicationInstance) // (I'm using an application instance directly here)
.load(url).apply {
completion?.also { completion ->
this.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
completion(null)
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
completion(resource)
return false
}
})
}
}
.apply { size?.also { this.override(it)} }
.apply { options?.invoke(this) }
.placeholder(placeholder ?: 0)
.error(error ?: 0)
.transition(DrawableTransitionOptions.withCrossFade(350))
.into(this)
}
Sorry for pasting so much code here (hope it's useful to someone).
Can these transformations or loader cause the crash?
To shape(circle/square/oval) your image You do not need to Transform your image .
MaterialDesign has introduce ShapeableImageView which let you shape your image at runtime, also you can add border with color .
add matrial dependecies :
implementation 'com.google.android.material:material:1.3.0-alpha01'
Add shapeableImageView in your xyz.xml:
<com.google.android.material.imageview.ShapeableImageView
android:id="#+id/imgStudent"
android:layout_width="100dp"
android:layout_height="100dp"
app:shapeAppearanceOverlay="#style/circleImageView"
android:padding="2dp"
app:strokeColor="#color/white"
app:strokeWidth="5dp"
android:scaleType="centerCrop"
android:adjustViewBounds="true"
tools:srcCompat="#drawable/ic_kid_placeholder"
/>
Add style inside res/values/style.xml file
<style name="circleImageView" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">50%</item>
<item name="android:shadowRadius">100</item>
<item name="android:shadowColor">#color/gray</item>
<item name="backgroundOverlayColorAlpha">12</item>
</style>
And at last load your image with glide .

textview.settext text has many replacementspans on android 10 occured ANR

It takes a lot of time in view.onMeasure when textview set text(spannable) has many ReplacementSpans on android 10 phone, so it is occurred ANR.
I tested on Samsung G981N and G975N phones(android 10)
According to android profiler, android.graphics.text.LineBreaker.nComputeLineBreaks runs most of running times.
Here the image, and below the code I tested.
(compileSdkVersion 29)
(replace A*100 to K*2 using ReplacementSpan)
(textLength = 3000 runs normally, but 30000 occurred ANR)
private fun test() {
val text = StringBuffer()
val textLength = 30000
val spanLength = 100
var count = 0
while (count < textLength) {
text.append("A")
count++
}
val spannable = text.toSpannable()
var index = 0
while (index < (textLength / spanLength)) {
val span = TestReplacementSpan()
spannable.setSpan(span, index * spanLength, index * spanLength + spanLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
index++
}
binding.textview.text = spannable
}
class TestReplacementSpan : ReplacementSpan() {
private val replaceText = "KK"
override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: FontMetricsInt?): Int {
if (fm != null) {
paint.getFontMetricsInt(fm)
}
return Math.round(paint.measureText(replaceText, 0, replaceText.length))
}
override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
canvas.drawText(replaceText, 0, replaceText.length, Math.max(x, 0f), y.toFloat(), paint)
}
}
add below code and it is solved
textview.breakStrategy = Layout.BREAK_STRATEGY_SIMPLE

Dotted underline TextView not wrapping to the next line using SpannableString in Android

I done the dotted underline textview using this Dotted underline in TextView using SpannableString in Android. But dotted underline textview not wrapping to the next line. I have attached screenshot for reference. Please advice your ideas. Thanks
class DottedUnderlineSpan(mColor: Int, private val mSpan: String) : ReplacementSpan() {
private val paint: Paint
private var width: Int = 0
private var spanLength: Float = 0f
private val lengthIsCached = false
internal var strokeWidth: Float = 0f
internal var dashPathEffect: Float = 0f
internal var offsetY: Float = 0f
init {
strokeWidth = 5f
dashPathEffect = 4f
offsetY = 14f
paint = Paint()
paint.color = mColor
paint.style = Paint.Style.STROKE
paint.pathEffect = DashPathEffect(floatArrayOf(dashPathEffect, dashPathEffect), 0f)
paint.strokeWidth = strokeWidth
}
override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
width = paint.measureText(text, start, end).toInt()
return width
}
override fun draw(canvas: Canvas, text: CharSequence, start: Int,
end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
canvas.drawText(text, start, end, x, y.toFloat(), paint)
if (!lengthIsCached)
spanLength = paint.measureText(mSpan)
val path = Path()
path.moveTo(x, y + offsetY)
path.lineTo(x + spanLength, y + offsetY)
canvas.drawPath(path, this.paint)
}
}
*Set dotted line using SpannableStringbuilder *
DottedUnderlineSpan dottedUnderlineSpan = new DottedUnderlineSpan(underlineColor, dottedString);
strBuilder.setSpan(dottedUnderlineSpan, start, end, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
Error:
Expected:
The problem is that a ReplacementSpan cannot cross a line boundary. See Drawing a rounded corner background on text for more information on this issue.
You could use the solution from the blog post mentioned above, but we can simplify that solution based upon your requirements as follows:
Here is the general procedure:
Place Annotation spans around the text in the TextView that we want to underline.
Let the text be laid out and catch the TextView just before drawing using a predraw listener. At this point the text is laid out as it will be displayed on the screen.
Replace each Annotation span with one or more DottedUnderlineSpans ensuring that each underline span does not cross a line boundary.
Strip trailing white space from the ReplacementSpan since we don't want to underline trailing white space.
Replace the text in the TextView.
A little complicated, but it will allow the use of the DottedUnderlineSpan class. This may not be a 100% solution since the width of the ReplacementSpan may vary from the width of the text under certain circumstances.
I do, however, recommend that you use a custom TextView with annotations to mark the placement of the underlines. This is probably going to be the easiest to do and to understand and is unlikely to have unforeseen side effects. The general procedure is to mark the text with annotation spans as above, but interpret these annotation spans in the draw() function of a custom text view to produce the underlines.
I have put together a small project to demonstrate these methods. The output looks like the following for a TextView with no underlined text, one with underlined text using the DottedUnderlineSpan and one with underlined text in a custom TextView.
MainActivity.kt
class MainActivity : AppCompatActivity() {
private lateinit var textView0: TextView
private lateinit var textView1: TextView
private lateinit var textView2: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
textView0 = findViewById(R.id.textView0)
textView1 = findViewById(R.id.textView1)
textView2 = findViewById<UnderlineTextView>(R.id.textView2)
if (savedInstanceState != null) {
textView1.text = SpannableString(savedInstanceState.getCharSequence("textView1"))
removeUnderlineSpans(textView1)
textView2.text = SpannableString(savedInstanceState.getCharSequence("textView2"))
} else {
val stringToUnderline = resources.getString(R.string.string_to_underline)
val spannableString0 = SpannableString(stringToUnderline)
val spannableString1 = SpannableString(stringToUnderline)
val spannableString2 = SpannableString(stringToUnderline)
// Get a good selection of underlined text
val toUnderline = listOf(
"production or conversion cycle",
"materials",
"into",
"goods",
"production and conversion cycle, where raw materials are transformed",
"saleable finished goods."
)
toUnderline.forEach { str -> setAnnotation(spannableString0, str) }
textView0.text = spannableString0
toUnderline.forEach { str -> setAnnotation(spannableString1, str) }
textView1.setText(spannableString1, TextView.BufferType.SPANNABLE)
toUnderline.forEach { str -> setAnnotation(spannableString2, str) }
textView2.setText(spannableString2, TextView.BufferType.SPANNABLE)
}
// Let the layout proceed and catch processing before drawing occurs to add underlines.
textView1.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
textView1.viewTreeObserver.removeOnPreDrawListener(this)
setUnderlinesForAnnotations(textView1)
return false
}
}
)
}
// The following is used of the manifest file specifies
// <activity android:configChanges="orientation">; otherwise, orientation processing
// occurs in onCreate()
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
removeUnderlineSpans(textView1)
textView1.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
textView1.viewTreeObserver.removeOnPreDrawListener(this)
setUnderlinesForAnnotations(textView1)
return false
}
}
)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putCharSequence("textView1", textView1.text)
outState.putCharSequence("textView2", textView2.text)
}
private fun setAnnotation(spannableString: SpannableString, subStringToUnderline: String) {
val dottedAnnotation =
Annotation(ANNOTATION_FOR_UNDERLINE_KEY, ANNOTATION_FOR_UNDERLINE_IS_DOTTED)
val start = spannableString.indexOf(subStringToUnderline)
if (start >= 0) {
val end = start + subStringToUnderline.length
spannableString.setSpan(dottedAnnotation, start, end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}
}
private fun setUnderlinesForAnnotations(textView: TextView) {
val text = SpannableString(textView.text)
val spans =
text.getSpans(0, text.length, Annotation::class.java).filter { span ->
span.key == ANNOTATION_FOR_UNDERLINE_KEY
}
if (spans.isNotEmpty()) {
val layout = textView.layout
spans.forEach { span ->
setUnderlineForAnnotation(text, span, layout)
}
textView.setText(text, TextView.BufferType.SPANNABLE)
}
}
private fun setUnderlineForAnnotation(text: Spannable, span: Annotation, layout: Layout) {
// Offset of first character in span
val spanStart = text.getSpanStart(span)
// Offset of first character *past* the end of the span.
val spanEnd = text.getSpanEnd(span)
// text.removeSpan(span)
// The span starts on this line
val startLine = layout.getLineForOffset(spanStart)
// Offset of the line that holds the last character of the span. Since
// spanEnd is the offset of the first character past the end of the span, we need
// to subtract one in case the span ends at the end of a line.
val endLine = layout.getLineForOffset(spanEnd)
for (line in startLine..endLine) {
// Offset to first character of the line.
val lineStart = layout.getLineStart(line)
// Offset to the character just past the end of this line.
val lineEnd = layout.getLineEnd(line)
// segStart..segEnd covers the part of the span on this line.
val segStart = max(spanStart, lineStart)
var segEnd = min(spanEnd, lineEnd)
// Don't want to underline end-of-line white space.
while ((segEnd > segStart) and Character.isWhitespace(text[segEnd - 1])) {
segEnd--
}
if (segEnd > segStart) {
val dottedUnderlineSpan = DottedUnderlineSpan()
text.setSpan(
dottedUnderlineSpan, segStart, segEnd, Spanned.SPAN_INCLUSIVE_INCLUSIVE
)
}
}
}
private fun removeUnderlineSpans(textView: TextView) {
val text = SpannableString(textView.text)
val spans = text.getSpans(0, text.length, DottedUnderlineSpan::class.java)
spans.forEach { span ->
text.removeSpan(span)
}
textView.setText(text, TextView.BufferType.SPANNABLE)
}
companion object {
const val ANNOTATION_FOR_UNDERLINE_KEY = "underline"
const val ANNOTATION_FOR_UNDERLINE_IS_DOTTED = "dotted"
}
}
DottedUnderlineSpan
I reworked this a little.
class DottedUnderlineSpan(
lineColor: Int = Color.RED,
dashPathEffect: DashPathEffect =
DashPathEffect(
floatArrayOf(DASHPATH_INTERVAL_ON, DASHPATH_INTERVAL_OFF), 0f
),
dashStrokeWidth: Float = DOTTEDSTROKEWIDTH
) : ReplacementSpan() {
private val mPaint = Paint()
private val mPath = Path()
init {
with(mPaint) {
color = lineColor
style = Paint.Style.STROKE
pathEffect = dashPathEffect
strokeWidth = dashStrokeWidth
}
}
override fun getSize(
paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?
): Int {
return paint.measureText(text, start, end).toInt()
}
override fun draw(
canvas: Canvas, text: CharSequence, start: Int,
end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint
) {
canvas.drawText(text, start, end, x, y.toFloat(), paint)
val spanLength = paint.measureText(text.subSequence(start, end).toString())
val offsetY =
paint.fontMetrics.bottom - paint.fontMetrics.descent + TEXT_TO_UNDERLINE_SEPARATION
mPath.reset()
mPath.moveTo(x, y + offsetY)
mPath.lineTo(x + spanLength, y + offsetY)
canvas.drawPath(mPath, mPaint)
}
companion object {
const val DOTTEDSTROKEWIDTH = 5f
const val DASHPATH_INTERVAL_ON = 4f
const val DASHPATH_INTERVAL_OFF = 4f
const val TEXT_TO_UNDERLINE_SEPARATION = 3
}
}
UnderlineTextView
class UnderlineTextView #JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private val mPath = Path()
private val mPaint = Paint()
init {
with(mPaint) {
color = Color.RED
style = Paint.Style.STROKE
pathEffect =
DashPathEffect(
floatArrayOf(DASHPATH_INTERVAL_ON, DASHPATH_INTERVAL_OFF), 0f
)
strokeWidth = DOTTEDSTROKEWIDTH
}
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
// Underline goes on top of the text.
if (text is Spanned && layout != null) {
canvas.withTranslation(totalPaddingStart.toFloat(), totalPaddingTop.toFloat()) {
drawUnderlines(canvas, text as Spanned)
}
}
}
private fun drawUnderlines(canvas: Canvas, allText: Spanned) {
val spans =
allText.getSpans(0, allText.length, Annotation::class.java).filter { span ->
span.key == ANNOTATION_FOR_UNDERLINE_KEY && span.value == ANNOTATION_FOR_UNDERLINE_IS_DOTTED
}
if (spans.isNotEmpty()) {
spans.forEach { span ->
drawUnderline(canvas, allText, span)
}
}
}
private fun drawUnderline(canvas: Canvas, allText: Spanned, span: Annotation) {
// Offset of first character in span
val spanStart = allText.getSpanStart(span)
// Offset of first character *past* the end of the span.
val spanEnd = allText.getSpanEnd(span)
// The span starts on this line
val startLine = layout.getLineForOffset(spanStart)
// Offset of the line that holds the last character of the span. Since
// spanEnd is the offset of the first character past the end of the span, we need
// to subtract one in case the span ends at the end of a line.
val endLine = layout.getLineForOffset(spanEnd - 1)
for (line in startLine..endLine) {
// Offset of first character of the line.
val lineStart = layout.getLineStart(line)
// The segment always start somewhere on the start line. For other lines, the segment
// starts at zero.
val segStart = if (line == startLine) {
max(spanStart, lineStart)
} else {
0
}
// Offset to the character just past the end of this line.
val lineEnd = layout.getLineEnd(line)
// segStart..segEnd covers the part of the span on this line.
val segEnd = min(spanEnd, lineEnd)
// Get x-axis coordinate for the underline to compute the span length. This is OK
// since the segment we are looking at is confined to a single line.
val startStringOnLine = layout.getPrimaryHorizontal(segStart)
val endStringOnLine =
if (segEnd == lineEnd) {
// If segment ends at the line's end, then get the rightmost position on
// the line not imcluding trailing white space which we don't want to underline.
layout.getLineRight(line)
} else {
// The segment's end is on this line, so get offset to end of the last character
// in the segment.
layout.getPrimaryHorizontal(segEnd)
}
val spanLength = endStringOnLine - startStringOnLine
// Get the y-coordinate for the underline.
val offsetY = layout.getLineBaseline(line) + TEXT_TO_UNDERLINE_SEPARATION
// Now draw the underline.
mPath.reset()
mPath.moveTo(startStringOnLine, offsetY)
mPath.lineTo(startStringOnLine + spanLength, offsetY)
canvas.drawPath(mPath, mPaint)
}
}
fun setUnderlineColor(underlineColor: Int) {
mPaint.color = underlineColor
}
companion object {
const val DOTTEDSTROKEWIDTH = 5f
const val DASHPATH_INTERVAL_ON = 4f
const val DASHPATH_INTERVAL_OFF = 4f
const val TEXT_TO_UNDERLINE_SEPARATION = 3f
const val ANNOTATION_FOR_UNDERLINE_KEY = "underline"
const val ANNOTATION_FOR_UNDERLINE_IS_DOTTED = "dotted"
}
}
activity_main.xml
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".MainActivity">
<TextView
android:id="#+id/Label0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Plain Text"
app:layout_constraintBottom_toTopOf="#+id/textView0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="#+id/textView0"
android:layout_width="188dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#DDD6D6"
android:paddingBottom="2dp"
android:text="#string/string_to_underline"
android:textAppearance="#style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="#+id/label1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/Label0" />
<TextView
android:id="#+id/label1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="DottedUndelineSpan"
app:layout_constraintBottom_toTopOf="#+id/textView1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/textView0" />
<TextView
android:id="#+id/textView1"
android:layout_width="188dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#DDD6D6"
android:paddingBottom="2dp"
android:text="#string/string_to_underline"
android:textAppearance="#style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="#+id/label2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/label1" />
<TextView
android:id="#+id/label2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="UnderlineTextView"
app:layout_constraintBottom_toTopOf="#+id/textView2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/textView1" />
<com.example.dottedunderlinespan.UnderlineTextView
android:id="#+id/textView2"
android:layout_width="188dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#DDD6D6"
android:paddingBottom="2dp"
android:text="#string/string_to_underline"
android:textAppearance="#style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/label2" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
I made a simple example that I have posted on Github (https://github.com/jaindiv26/DottedTextSample). I've followed the Deva's approach and made some adjustment, it's working for multiple lines too. Check out this example.
Dotted underline in TextView using SpannableString in Android.
1. Make DottedLineSpan Common Class.
class DottedLineSpan extends ReplacementSpan {
private Paint p = new Paint();
private int mWidth;
private String mSpan;
private float mSpanLength = 0F;
private boolean mLengthIsCached = false;
private Float mOffsetY = 0f;
DottedLineSpan(int _color, String _spannedText, Context context){
float mStrokeWidth = context.getResources().getDimension(R.dimen.stroke_width);
float mDashPathEffect = context.getResources().getDimension(R.dimen.dash_path_effect);
mOffsetY = context.getResources().getDimension(R.dimen.offset_y);
p = new Paint();
p.setColor(_color);
p.setStyle(Paint.Style.STROKE);
p.setPathEffect(new DashPathEffect(new float[]{mDashPathEffect, mDashPathEffect}, 0));
p.setStrokeWidth(mStrokeWidth);
mSpan = _spannedText;
mSpanLength = _spannedText.length();
}
#Override
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
mWidth = (int) paint.measureText(text, start, end);
return mWidth;
}
#Override
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
canvas.drawText(text, start, end, x, y, paint);
if(!mLengthIsCached)
mSpanLength = paint.measureText(mSpan);
Path path = new Path();
path.moveTo(x, y + mOffsetY);
path.lineTo(x + mSpanLength, y + mOffsetY);
canvas.drawPath(path, this.p);
}
}
2. Use this code in your activity.
public class MainActivity extends AppCompatActivity {
private TextView textView;
#Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
textView = findViewById(R.id.textView);
String string = "Android is a mobile operating system based on a modified version of the Linux kernel and other open source software, designed primarily for touchscreen mobile devices such as smartphones and tablets. ";
String textToUnderline = "modified version of the Linux kernel";
SpannableString text = new SpannableString(string);
int[] range = getStartingAndEndOfSentence(string, textToUnderline);
DottedLineSpan dottedLineSpan = new DottedLineSpan(R.color.colorPrimary, textToUnderline, this);
text.setSpan(dottedLineSpan, range[0], range[1], Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(text);
}
int[] getStartingAndEndOfSentence(String wholeString, String partOfAString) {
int[] range = new int[2];
String[] s1 = wholeString.split("\\s+");
String[] s2 = partOfAString.split("\\s+");
if (s2.length == 1) {
String word = s2[0];
range[0] = wholeString.indexOf(word);
range[1] = range[0] + word.length();
} else {
int length = 0;
for (int i = 0; i < s1.length; i++) {
length = length + s1[i].length() + 1;
if (s1[i].equals(s2[0])) {
if(s1[i+1].equals(s2[1])) {
range[0] = length - (s1[i].length() + 1);
range[1] = range[0] + partOfAString.length();
break;
}
}
}
}
return range;
}
}

How to move a word in an android textview above the next word using text span?

I'm trying to move a word above the next word in an android textview like in the attached image (example image). I have managed to shift the word upwards (like a superscript) with spannablestringbuilder, but I can't find a way to shift the right part of the text left in order to fill the gap. Does anyone have any idea how can this be done?
This is the function I've written so far:
/**
* Adds clickable spans for words that are contained between "[" and "]"
*
* #param imString The string on which to apply clickable spans
*/
private fun addClickablePart(imString: String): SpannableStringBuilder
{
var string = imString
val spannableStringBuilder = SpannableStringBuilder((string.replace("[", "")).replace("]", ""))
var startIndex = string.indexOf("[")
while (startIndex != -1)
{
string = string.replaceFirst("[", "")
val endIndex = string.indexOf("]", startIndex)
string = string.replaceFirst("]", "")
val clickString = string.substring(startIndex, endIndex)
spannableStringBuilder.setSpan(
object: ClickableSpan()
{
override fun onClick(view: View)
{
HelperFunction.showToast(this#SongActivity, clickString)
}
override fun updateDrawState(text: TextPaint)
{
super.updateDrawState(text)
text.isUnderlineText = false
text.color = ContextCompat.getColor(this#SongActivity, R.color.colorAccent)
text.textSize = HelperFunction.spToPx(this#SongActivity, 12).toFloat()
text.baselineShift += (text.ascent()).toInt() // move chord upwards
text.typeface = Typeface.create(ResourcesCompat.getFont(this#SongActivity, R.font.roboto_mono), Typeface.BOLD) // set text to bold
}
},
startIndex, endIndex, 0)
startIndex = string.indexOf("[", endIndex)
}
return spannableStringBuilder
}
You can use HTML in your TextView to achieve this, for example the HTML/CSS code for the superscript notation would be:
<!DOCTYPE html>
<html>
<head>
<style>
sup {
vertical-align: super;
font-size: medium;
color: red;
position: relative; left: -2.5em; top: -0.5em;
}
</style>
</head>
<body>
<p>word <sup>topword</sup></p>
</body>
</html>
Please note that this is just an example and you need to fix the alignment.
To display it in a TextView use the following code:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
textView.setText(Html.fromHtml(htmlVal, Html.FROM_HTML_MODE_COMPACT));
} else {
textView.setText(Html.fromHtml(htmlVal));
}
I've managed to solve the problem using ReplacementSpan. I'm posting the code below.
This is the custom ReplacementSpan class which draws the words on the canvas at the desired positions:
inner class ChordSpan: ReplacementSpan()
{
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: FontMetricsInt?): Int
{
val mText = text!!.subSequence(start, end).toString().replace("[", "")
var chordString = ""
var regularString = mText
if (mText.contains("]"))
{
chordString = mText.substringBefore("]")
regularString = mText.substringAfter("]")
}
val chordStringTextPaint = getChordStringTextPaint(paint)
val regularStringTextPaint = getRegularStringTextPaint(paint)
return max(chordStringTextPaint.measureText(chordString), regularStringTextPaint.measureText(regularString)).toInt()
}
private fun getChordStringTextPaint(paint: Paint): TextPaint
{
val textPaint = TextPaint(paint)
textPaint.textSize = textPaint.textSize / 1.5F
textPaint.typeface = Typeface.DEFAULT_BOLD
textPaint.color = ContextCompat.getColor(this#SongActivity, R.color.colorAccent)
return textPaint
}
private fun getRegularStringTextPaint(paint: Paint): TextPaint
{
return TextPaint(paint)
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint)
{
val mText = text!!.subSequence(start, end).toString().replace("[", "")
var chordString = ""
var regularString = mText
if (mText.contains("]"))
{
chordString = mText.substringBefore("]")
regularString = mText.substringAfter("]")
}
val chordStringTextPaint = getChordStringTextPaint(paint)
val regularStringTextPaint = getRegularStringTextPaint(paint)
canvas.drawText(chordString, x, y.toFloat(), chordStringTextPaint)
canvas.drawText(regularString, x, y.toFloat() + (bottom - top) / 2.5F, regularStringTextPaint)
}
}
And this is the function that applies the spans on the hole text:
private fun formatDisplayOfLyricsWithChords(string: String): SpannableString
{
val mString = "$string\n\n"
val endOfStringIndex = mString.length
val spannableString = SpannableString(mString)
var startIndex = 0
while (startIndex != -1 && startIndex != endOfStringIndex)
{
var possibleEndIndex = mString.indexOf("[", startIndex + 1)
if (possibleEndIndex == -1)
{
possibleEndIndex = endOfStringIndex + 1
}
var endOfRowIndex = mString.indexOf("\n", startIndex + 1)
if (endOfRowIndex == -1)
{
endOfRowIndex = endOfStringIndex + 1
}
val endIndex = minOf(possibleEndIndex, endOfRowIndex, endOfStringIndex)
spannableString.setSpan(ChordSpan(), startIndex, endIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
if (mString[startIndex] == '[')
{
val startIndexClick = startIndex
val endIndexClick = mString.indexOf("]", startIndex + 1)
val chord = mString.substring(startIndexClick + 1, endIndexClick)
spannableString.setSpan(
object: ClickableSpan()
{
override fun onClick(view: View)
{
handleClickOnChord(chord)
}
},
startIndexClick, endIndexClick, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
startIndex = endIndex
if (startIndex == endOfRowIndex)
{
startIndex++
}
}
return spannableString
}
I took inspiration from this answer from a similar question: https://stackoverflow.com/a/24091284/8211969

Categories

Resources