2

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
Cullub
  • 2,306
  • 2
  • 24
  • 44
  • Current ideas: https://stackoverflow.com/questions/41779934/how-is-staticlayout-used-in-android/41779935#41779935 and https://stackoverflow.com/questions/42219292/how-does-breakiterator-work-in-android/42219474#42219474 combined might work, but I'm not experienced enough to be comfortable with that. – Cullub Mar 23 '20 at 22:56
  • FYI, my TextView also contains ClickableSpans for those chords, so an answer would have to allow those spans to remain. – Cullub Mar 23 '20 at 22:57
  • Sorry, I am pretty good in android but not in guitar tablature. I need some basic info about tablature, like what is C, Am, F and G?. And what is your data structure? is chords and words are in a single string or in map or in array format? – Aditya Sonel Mar 25 '20 at 11:59
  • can you show some data xml how you are displaying – Srikanth G Mar 25 '20 at 15:32
  • @Heisenberg good questions. I updated the question with a TL;DR and added that info. Basically, `C`, `Am`, `F`, and `G` tell the guitarist what note to play. The guitarist can tell when to play that note by what word it's over. For data structure, the entire tab is in one giant string, both chords and lyrics. However, it's formatted with `[ch]` / `[/ch]` tags wrapped around chords, and `[tab]` / `[/tab]` tags wrapped around sets of lines that should get wrapped together (one line of chords and one of lyrics). Hope that helps. – Cullub Mar 26 '20 at 01:47
  • @SrikanthG Everything goes in one big monospaced `TextView` that's inside a `ConstraintLayout`. If you need to look at the XML, it's [here](https://github.com/cullub/Tabs-Lite/blob/master/app/src/main/res/layout/fragment_tab_detail.xml). The fragment code (which unfortunately is giant at the moment) is [here](https://github.com/cullub/Tabs-Lite/blob/master/app/src/main/java/com/gbros/tabslite/TabDetailFragment.kt). Ctrl-F for `fun processTabContent` -- that's where I do my processing right now. – Cullub Mar 26 '20 at 01:54

2 Answers2

3

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.

Hein Htet Aung
  • 834
  • 4
  • 14
  • Those assumptions are true. I'm not at my computer to test it but I'll let you know! – Cullub Mar 27 '20 at 22:21
  • Also, Into and Verse don't need to be handled, as you've noted. Those are left plain text – Cullub Mar 27 '20 at 22:22
  • I tried in different width. Should be okay ;). Oh, as you described, you need to use monospace since it's using space characters for chords spacing. – Hein Htet Aung Mar 28 '20 at 04:34
  • Just got it working in large part due to you! Congrats, I think you've earned the bounty! We ended up doing a lot of extra work to get this working with internal clickable spans within each `[tab]`, so I believe a coworker is going to post the final product Monday morning, which I plan on accepting. Don't worry though! Bounty goes to you! – Cullub Mar 30 '20 at 03:41
  • 1
    Haha, Bounty is fine. I want to fix if u found another use cases. Thanks anyway. Glad I could help u. :D – Hein Htet Aung Mar 30 '20 at 03:44
1

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
}
Jerry G
  • 734
  • 6
  • 8