在具有跨度的 Android TextView 中一次换行两行

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

总结

我有一个字符串[tab] [ch]C[/ch] [ch]Am[/ch] \n I heard there was a secret chord[/tab]

当 TextView 大到足以容纳它而无需包装时,它应该(并且确实)看起来像这样:

  C                  Am         
I heard there was a secret chord

当行太长而无法放入 TextView 时,我希望它像这样换行:

  C                
I heard there was a 
  Am
secret chord

现在它像这样换行(如果它只是文本,就像您期望的那样)

  C                
 Am         
I heard there was a
secret chord

约束条件:

如果有帮助,这是我的一个开源项目,所以源代码可以在 Github 上找到。这是 fragment source (look for fun processTabContent(text: CharSequence) -- that's where I process the text right now. Here's the layout xml.


输入格式

我的数据存储在单个字符串中(无法更改——我从 API 中获取)。上述标签的格式如下:

[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]

请注意,和弦(吉他手会演奏的音符,如 CF)包含在 [ch] 标签中。我目前有代码可以找到这些,删除 [ch] 标签,并将每个和弦包装在 ClickableSpan 中。单击时,我的应用程序会显示另一个片段,其中包含如何在吉他上弹奏和弦的说明。这一点很重要,因为这个问题的答案必须允许这些和弦仍然像这样被点击。

我现在正在做的事情(没有用)

您现在可能已经注意到,[tab] 标签是我们必须针对此问题重点关注的。现在,我正在检查字符串并将 [tab] 替换为换行符并删除 [/tab] 的所有实例。如果我的 TextView 的文本大小足够小以至于整行都适合设备屏幕,则此方法可以正常工作。但是,当自动换行出现时,我开始遇到问题。

这个:

  C                  Am         
I heard there was a secret chord

应该换成这样:

  C                
I heard there was a 
  Am
secret chord

而是像这样换行:

  C                
 Am         
I heard there was a
secret chord

我认为这个解决方案可能会解决问题。但是有一些假设,

  1. 每句歌词都以[tab]开始,以[/tab]结束
  2. 和弦与歌词总是用\n分隔

而且我相信您需要在使用数据之前清理数据。因为,很可能很容易处理 Intro, Verse,所以我将只关注歌词 tab

这是单句歌词的示例数据

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

首先,我们需要删除一些不需要的块。

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

之后我把和弦和歌词分开了

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

我们清理完数据之后,就可以开始设置数据了。

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
}

想法很简单。将歌词数据设置到textview后,我们可以获取行数。使用当前行号,我们可以获得所选行的起始索引和结束索引。有了索引,我们就可以操作字符串了。希望这可以帮助你。

这是基于 。一般的想法是您传入了两行 (singleLyric),但是在附加它们之前可能必须处理这些行(因此中间的 while 循环)。为方便起见,这是用参数 appendTo 编写的,歌词将附加到该参数。它 returns 完成 SpannableStringBuilder 并附加了歌词。它会像这样使用:

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)

处理函数如下:

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 
    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
}

最后,我发现需要按字词而不是按最大行长来分词,所以这里是分词查找器功能:

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
}