I have a TextView with maxlines=3 and I would like to use my own ellipsis, instead of
"Lore ipsum ..."
I need
"Lore ipsum ... [See more]"
in order to give the user a clue that clicking on the view is going to expand the full text.
Is it possible ?
I was thinking about check whether TextView has ellipsis and in such a case add the text "[See more]" and after that set ellipsis just before, but I couldn't find the way to do that.
Maybe if I find the position where the text is cutted, I can disable the ellipsis and make a substring and later add "... [See more]", but again I dont know how to get that position.
I've finally managed it in this way (may be not the best one):
private void setLabelAfterEllipsis(TextView textView, int labelId, int maxLines){
if(textView.getLayout().getEllipsisCount(maxLines-1)==0) {
return; // Nothing to do
}
int start = textView.getLayout().getLineStart(0);
int end = textView.getLayout().getLineEnd(textView.getLineCount() - 1);
String displayed = textView.getText().toString().substring(start, end);
int displayedWidth = getTextWidth(displayed, textView.getTextSize());
String strLabel = textView.getContext().getResources().getString(labelId);
String ellipsis = "...";
String suffix = ellipsis + strLabel;
int textWidth;
String newText = displayed;
textWidth = getTextWidth(newText + suffix, textView.getTextSize());
while(textWidth>displayedWidth){
newText = newText.substring(0, newText.length()-1).trim();
textWidth = getTextWidth(newText + suffix, textView.getTextSize());
}
textView.setText(newText + suffix);
}
private int getTextWidth(String text, float textSize){
Rect bounds = new Rect();
Paint paint = new Paint();
paint.setTextSize(textSize);
paint.getTextBounds(text, 0, text.length(), bounds);
int width = (int) Math.ceil( bounds.width());
return width;
}
I think the answer from #jmhostalet will degrade the performance (especially when dealing with lists and lots of TextViews) because the TextView draws the text more than once. I've created a custom TextView that solves this in the onMeasure() and therefore only draws the text once.
I've originally posted my answer here: https://stackoverflow.com/a/52589927/1680301
And here's the link to the repo: https://github.com/TheCodeYard/EllipsizedTextView
Here's a nice way to do it with a Kotlin extension.
Note that we need to wait for the view to layout before we can measure and append the suffix.
In TextViewExtensions.kt
fun TextView.setEllipsizedSuffix(maxLines: Int, suffix: String) {
addOnLayoutChangeListener(object: View.OnLayoutChangeListener {
override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
val allText = text.toString()
var newText = allText
val tvWidth = width
val textSize = textSize
if(!TextUtil.textHasEllipsized(newText, tvWidth, textSize, maxLines)) return
while (TextUtil.textHasEllipsized(newText, tvWidth, textSize, maxLines)) {
newText = newText.substring(0, newText.length - 1).trim()
}
//now replace the last few chars with the suffix if we can
val endIndex = newText.length - suffix.length - 1 //minus 1 just to make sure we have enough room
if(endIndex > 0) {
newText = "${newText.substring(0, endIndex).trim()}$suffix"
}
text = newText
removeOnLayoutChangeListener(this)
}
})
}
In TextUtil.kt
fun textHasEllipsized(text: String, tvWidth: Int, textSize: Float, maxLines: Int): Boolean {
val paint = Paint()
paint.textSize = textSize
val size = paint.measureText(text).toInt()
return size > tvWidth * maxLines
}
Then actually using it like this
myTextView.setEllipsizedSuffix(2, "...See more")
Note: if your text comes from a server and may have new line characters, then you can use this method to determine if the text has ellipsized.
fun textHasEllipsized(text: String, tvWidth: Int, textSize: Float, maxLines: Int): Boolean {
val paint = Paint()
paint.textSize = textSize
val size = paint.measureText(text).toInt()
val newLineChars = StringUtils.countMatches(text, "\n")
return size > tvWidth * maxLines || newLineChars >= maxLines
}
StringUtils is from implementation 'org.apache.commons:commons-lang3:3.4'
#George #jmhostalet i was doing this in my recycler view and it degraded the whole performance. `
ViewTreeObserver vto = previewContent.getViewTreeObserver();
vto.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
#Override
public boolean onPreDraw() {
try {
Layout layout = previewContent.getLayout();
int index1 = layout.getLineStart(previewContent.getMaxLines());
if (index1 > 10 && index1 < ab.getPreviewContent().length()) {
String s =
previewContent.getText().toString().substring(0, index1 - 10);
previewContent
.setText(Html.fromHtml(
s + "<font color='#DC5530'>...और पढ़ें</font>"));
}
return true;
}catch (Exception e)
{
Crashlytics.logException(e);
}
return true;
}
});`
Here is a solution for Kotlin.
The yourTextView.post{} is necessary because the textview won't be ellipsized until after it is rendered.
val customSuffix = "... [See more]"
yourTextView.post {
if (yourTextView.layout.getEllipsisStart(-1) != -1) {
val newText = yourTextView.text.removeRange(
yourTextView.layout.getEllipsisStart(-1) - customSuffix.length, yourTextView.text.length
)
yourTextView.text = String.format("%s%s", newText, customSuffix)
}
}
Related
class EllipsizedTextView #JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0): AppCompatTextView(context, attrs, defStyleAttr) {
private var ellipsis = getDefaultEllipsis().toString()
private var ellipsisColor = getDefaultEllipsisColor()
private var isEllipsis = false
private var ellipsizedText: CharSequence? = null
private var callbackEllipsized: MoreClickableSpan? = null
init {
attrs?.let { attributeSet ->
context.theme.obtainStyledAttributes(attributeSet, R.styleable.EllipsizedTextView, 0, 0).apply {
ellipsis = getString(R.styleable.EllipsizedTextView_ellipsis) ?: getDefaultEllipsis().toString()
ellipsisColor = getColor(R.styleable.EllipsizedTextView_ellipsisColor, getDefaultEllipsisColor())
recycle()
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
/*
Prepare to set custom ellipsize text
*/
val availableScreenWidth = measuredWidth - compoundPaddingLeft.toFloat() - compoundPaddingRight.toFloat()
var availableTextWidth = availableScreenWidth * maxLines
ellipsizedText = TextUtils.ellipsize(text, paint, availableTextWidth, ellipsize, false) { start, end ->
isEllipsis = start != 0 && end >= 65
}
if (isEllipsis) { // check if current text is ellipsized or not
printLog("isEllipsis: $ellipsizedText ellipsis: $ellipsis, text : $text")
// If the ellipsizedText is different than the original text, this means that it didn't fit and got indeed ellipsized.
// Calculate the new availableTextWidth by taking into consideration the size of the custom ellipsis, too.
availableTextWidth = (availableScreenWidth - paint.measureText(ellipsis)) * maxLines
ellipsizedText = TextUtils.ellipsize(text, paint, availableTextWidth, ellipsize, false){ start, end ->
isEllipsis = start != 0 && end >= 65
}
}
setEllipsizedText(ellipsizedText, isEllipsis)
}
private fun setEllipsizedText(value: CharSequence?, isEllipsized: Boolean){
printLog("setEllipsizedText > $isEllipsized")
if(isEllipsized){
val resultText = "$value$ellipsis"
val startPoint = resultText.indexOf(ellipsis)
val endPoint = startPoint + ellipsis.length
val spannable = SpannableString(resultText).apply {
callbackEllipsized?.let { callback ->
printLog("setEllipsizedText > implement callbackEllipsized")
// set event click on spannable
setSpan(callback, startPoint, endPoint, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// set color on target text while enable to click
setSpan(ForegroundColorSpan(ellipsisColor), startPoint, endPoint, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
//val defaultEllipsisStart = value.indexOf(getDefaultEllipsis())
//val defaultEllipsisEnd = defaultEllipsisStart + 1
//text = spannableStringBuilder.append(ellipsizedText).replace(defaultEllipsisStart, defaultEllipsisEnd, ellipsisSpannable)
text = spannable
movementMethod = LinkMovementMethod.getInstance()
printLog("setEllipsizedText > text : $text")
}
}
private fun getDefaultEllipsis(): Char {
return Typography.ellipsis
}
private fun getDefaultEllipsisColor(): Int {
return textColors.defaultColor
}
fun setActionClickEllipse(callback: MoreClickableSpan){
this.callbackEllipsized = callback
}
fun isReadMore(): Boolean = isEllipsis
#Suppress("unused_parameter")
fun printLog(msg: String) { }
open class MoreClickableSpan : ClickableSpan() {
override fun onClick(widget: View) {}
override fun updateDrawState(ds: TextPaint) {}
}
}
How to implement ellipsized text with rules if more than 65 character? my existing code below like this
Why don't you add this to your TextView in the xml file?It will limit it to 65 chars and it will show ellipse after that
android:maxLength="65"
android:ellipsize="end"
android:maxLines="1"
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;
}
}
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
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
}
}
I'd like to have Spannable that looks like error in IDEs - underline with another color.
I've tried to create ColorUnderlineSpan class that extends android UnderlineSpan, but it makes all the text another color (i need to add colored underline only):
/**
* Underline Span with color
*/
public class ColorUnderlineSpan extends android.text.style.UnderlineSpan {
private int underlineColor;
public ColorUnderlineSpan(int underlineColor) {
super();
this.underlineColor = underlineColor;
}
#Override
public void updateDrawState(TextPaint ds) {
super.updateDrawState(ds);
ds.setColor(underlineColor);
}
}
I've also found DynamicDrawableSpan class but i can't see canvas bounds to draw to.
It would be great to get any Spannable impl with abstract draw method with bounds argument.
This isn't the prettiest solution, but it ended up working for me:
public class CustomUnderlineSpan implements LineBackgroundSpan {
int color;
Paint p;
int start, end;
public CustomUnderlineSpan(int underlineColor, int underlineStart, int underlineEnd) {
super();
color = underlineColor;
this.start = underlineStart;
this.end = underlineEnd;
p = new Paint();
p.setColor(color);
p.setStrokeWidth(3F);
p.setStyle(Paint.Style.FILL_AND_STROKE);
}
#Override
public void drawBackground(Canvas c, Paint p, int left, int right, int top, int baseline, int bottom, CharSequence text, int start, int end, int lnum) {
if (this.end < start) return;
if (this.start > end) return;
int offsetX = 0;
if (this.start > start) {
offsetX = (int)p.measureText(text.subSequence(start, this.start).toString());
}
int length = (int)p.measureText(text.subSequence(Math.max(start, this.start), Math.min(end, this.end)).toString());
c.drawLine(offsetX, baseline + 3F, length + offsetX, baseline + 3F, this.p);
}
It's weird because you have to specify the character index to start and end your underlining with, but it worked for me.
The answer #korbonix posted works fine. I've made some improvements in Kotlin and supporting multiline TextViews:
class ColorUnderlineSpan(val underlineColor: Int, val underlineStart: Int, val underlineEnd: Int): LineBackgroundSpan {
val paint = Paint()
init {
paint.color = underlineColor
paint.strokeWidth = 3.0f
paint.style = Paint.Style.FILL_AND_STROKE
}
override fun drawBackground(c: Canvas?, p: Paint?, left: Int, right: Int, top: Int, baseline: Int, bottom: Int, text: CharSequence?, start: Int, end: Int, lnum: Int) {
if (!(underlineStart < underlineEnd)) {
throw Error("underlineEnd should be greater than underlineStart")
}
if (underlineStart > end || underlineEnd < start) {
return
}
var offsetX = 0
if (underlineStart > start) {
offsetX = p?.measureText(text?.subSequence(start, underlineStart).toString())?.toInt() ?: 0
}
val length: Int = p?.measureText(text?.subSequence(Math.max(start, underlineStart), Math.min(end, underlineEnd)).toString())?.toInt()
?: 0
c?.drawLine(offsetX.toFloat(), baseline + 3.0f, (length + offsetX).toFloat(), baseline + 3.0f, paint)
}
}
And here is a sample usage. textText is the TextView. The text is 127 characters long and it underlines from the position 112 to the 127.
Important: For reasons I don't fully understand the span length should be set to the full length of the text. Otherwise the component doesn't even get called. Feel free to educate me on why's that.
// Sets link color
val spannable = SpannableString(getString(R.string.forgot_text))
spannable.setSpan(
ColorUnderlineSpan(Color.RED), 112, 127),
0, 127, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
textText.text = spannable