简体   繁体   中英

Wrap two lines at a time in an Android TextView with spans

Summary

I have a string [tab] [ch]C[/ch] [ch]Am[/ch] \\n I heard there was a secret chord[/tab]

When the TextView is big enough to hold it with no wrapping it should (and does) look like this:

  C                  Am         
I heard there was a secret chord

When the line(s) are too long to fit in the TextView, I want it to wrap like this:

  C                
I heard there was a 
  Am
secret chord

Right now it wraps like this (like you'd expect if it was just text)

  C                
 Am         
I heard there was a
secret chord

Constraints:

  • I use a monospace text font to keep alignment
  • The chords ( C , F , Am , G ) are clickable so if you make a custom implementation of TextView, it still has to be able to handle ClickableSpans or otherwise keep them clickable
  • Kotlin or Java (or XML) is fine

If it's helpful, this is for an open source project of mine, so the source is available on Github. Here's the fragment source (look for fun processTabContent(text: CharSequence) -- that's where I process the text right now. Here's the layout xml .


Input Format

My data is stored in a single string (this can't be changed -- I get it from an API). Here's how the above tab would be formatted:

[Intro]\n[tab][ch]C[/ch] [ch]Am[/ch] [ch]C[/ch] [ch]Am[/ch][/tab]\n[Verse 1][tab]      [ch]C[ch]                  [ch]Am[/ch]                         I heard there was a secret chord               [/tab][tab]      [ch]C[/ch]                     [ch]Am[/ch]\nThat David played, and it pleased the Lord[/tab][tab]   [ch]C[/ch]                [ch]F[/ch]               [ch]G[/ch]\n But you don't really care for music, do you?[/tab]

Note that the chords (notes that a guitarist would play, like C or F ) are wrapped in [ch] tags. I currently have code that finds these, removes the [ch] tags, and wraps each chord in a ClickableSpan . On click, my application shows another fragment with instructions how to play the chord on a guitar. This is only important in that the answer to this question must allow these chords to be clicked like this still.

What I'm doing right now (that isn't working)

As you may have noticed by now, it's the [tab] tags that we're going to have to focus on for this question. Right now, I'm going through the string and replacing [tab] with a newline and removing all instances of [/tab] . This works fine if my TextView 's text size is small enough that entire lines fit on the device screen. However, when the word wrap kicks in I start having problems.

This:

  C                  Am         
I heard there was a secret chord

Should wrap to this:

  C                
I heard there was a 
  Am
secret chord

But instead wraps like this:

  C                
 Am         
I heard there was a
secret chord

I think this solution might solve the issue. But there are some assumption,

  1. Every lyric starts with [tab] and end with [/tab]
  2. It is always separated with \\n between chords and lyric

And I believe you need to cleanse the data before you use it. Since, it is likely possible to handle Intro, Verse easily, I will focus on lyric tab only.

Here is the sample data for single lyric

[tab] [ch]C[/ch] [ch]F[/ch] [ch]G[/ch] \\n But you don't really care for music, do you?[/tab]

Firstly, We need to remove some unwanted blocks.

val inputStr = singleLyric
      .replace("[tab]", "")
      .replace("[/tab]", "")
      .replace("[ch]", "")
      .replace("[/ch]", "")

After that, I separated the chords and lyric

val indexOfLineBreak = inputStr.indexOf("\n")
val chords = inputStr.substring(0, indexOfLineBreak)
val lyrics = inputStr.substring(indexOfLineBreak + 1, inputStr.length).trim()

After we clean the data, we can start to set the data.

text_view.text = lyrics
text_view.post {
  val lineCount = text_view.lineCount
  var currentLine = 0
  var newStr = ""

  if (lineCount <= 1) {// if it's not multi line, no need to manipulate data
    newStr += chords + "\n" + lyrics
  } else {

    val chordsCount = chords.count()
    while (currentLine < lineCount) {
      //get start and end index of selected line
      val lineStart = text_view.layout.getLineStart(currentLine)
      val lineEnd = text_view.layout.getLineEnd(currentLine)

      // add chord substring
      if (lineEnd <= chordsCount) //chords string can be shorter than lyric
        newStr += chords.substring(lineStart, lineEnd) + "\n"
      else if (lineStart < chordsCount) //it can be no more chords data to show
        newStr += chords.substring(lineStart, chordsCount) + "\n"

      // add lyric substring
      newStr += lyrics.substring(lineStart, lineEnd) + "\n"
      currentLine++
    }

  }
  text_view.text = newStr
}

Idea is simple. After we set the lyric data to textview, we can get line count. With the current line number, we can get starting index and ending index of the selected line. With the indexes, we can manipulate the string. Hope this can help u.

This is based off of Hein Htet Aung's answer . The general idea is that you have two lines passed in ( singleLyric ), but the lines might have to be processed before appending them (hence the middle while loop). For convenience, this was written with a parameter appendTo that the lyric will be appended to. It returns a finished SpannableStringBuilder with the lyric appended. It would be used like this:

ssb = SpannableStringBuilder()
for (lyric in listOfDoubleLyricLines) {
    ssb = processLyricLine(lyric, ssb)
}
textView.movementMethod = LinkMovementMethod.getInstance() // without LinkMovementMethod, link can not click
textView.setText(ssb, TextView.BufferType.SPANNABLE)

Here's the processing function:

private fun processLyricLine(singleLyric: CharSequence, appendTo: SpannableStringBuilder): SpannableStringBuilder {
    val indexOfLineBreak = singleLyric.indexOf("\n")
    var chords: CharSequence = singleLyric.subSequence(0, indexOfLineBreak).trimEnd()
    var lyrics: CharSequence = singleLyric.subSequence(indexOfLineBreak + 1, singleLyric.length).trimEnd()
    var startLength = appendTo.length
    var result = appendTo

    // break lines ahead of time
    // thanks @Andro https://stackoverflow.com/a/11498125
    val availableWidth = binding.tabContent.width.toFloat() //- binding.tabContent.textSize / resources.displayMetrics.scaledDensity

    while (lyrics.isNotEmpty() || chords.isNotEmpty()) {
        // find good word break spot at end
        val plainChords = chords.replace("[/?ch]".toRegex(), "")
        val wordCharsToFit = findMultipleLineWordBreak(listOf(plainChords, lyrics), binding.tabContent.paint, availableWidth)

        // make chord substring
        var i = 0
        while (i < min(wordCharsToFit, chords.length)) {
            if (i+3 < chords.length && chords.subSequence(i .. i+3) == "[ch]"){
                //we found a chord; add it.
                chords = chords.removeRange(i .. i+3)        // remove [ch]
                val start = i

                while(chords.subSequence(i .. i+4) != "[/ch]"){
                    // find end
                    i++
                }
                // i is now 1 past the end of the chord name
                chords = chords.removeRange(i .. i+4)        // remove [/ch]

                result = result.append(chords.subSequence(start until i))

                //make a clickable span
                val chordName = chords.subSequence(start until i)
                val clickableSpan = makeSpan(chordName)
                result.setSpan(clickableSpan, startLength+start, startLength+i, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
            } else {
                result = result.append(chords[i])
                i++
            }
        }
        result = result.append("\r\n")

        // make lyric substring
        val thisLine = lyrics.subSequence(0, min(wordCharsToFit, lyrics.length))
        result = result.append(thisLine).append("\r\n")

        // update for next pass through
        chords = chords.subSequence(i, chords.length)
        lyrics = lyrics.subSequence(thisLine.length, lyrics.length)
        startLength = result.length
    }

    return result
}

And finally, I found the need to break my text at words rather than just at the max line length, so here's the word break finder function for that:

private fun findMultipleLineWordBreak(lines: List<CharSequence>, paint: TextPaint, availableWidth: Float): Int{
    val breakingChars = "‐–〜゠= \t\r\n"  // all the chars that we'll break a line at
    var totalCharsToFit: Int = 0

    // find max number of chars that will fit on a line
    for (line in lines) {
        totalCharsToFit = max(totalCharsToFit, paint.breakText(line, 0, line.length,
                true, availableWidth, null))
    }
    var wordCharsToFit = totalCharsToFit

    // go back from max until we hit a word break
    var allContainWordBreakChar: Boolean
    do {
        allContainWordBreakChar = true
        for (line in lines) {
            allContainWordBreakChar = allContainWordBreakChar
                    && (line.length <= wordCharsToFit || breakingChars.contains(line[wordCharsToFit]))
        }
    } while (!allContainWordBreakChar && --wordCharsToFit > 0)

    // if we had a super long word, just break at the end of the line
    if (wordCharsToFit < 1){
        wordCharsToFit = totalCharsToFit
    }

    return wordCharsToFit
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM