在Android TextView中使用spans一次换两行

3

概要

我有一个字符串[tab] [ch]C[/ch] [ch]Am[/ch] \n 我听说有一个秘密和弦[/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

约束条件:

  • 我使用等宽字体以保持对齐
  • 和弦(CFAmG)是可点击的,因此如果您制作自定义的 TextView 实现,它仍然必须能够处理 ClickableSpans 或者保持它们可点击
  • Kotlin 或 Java(或 XML)都可以

如果有帮助的话,这是我的一个开源项目,因此源代码可在 Github 上找到。这是 片段源码(查找 fun processTabContent(text: CharSequence) -- 那是我目前处理文本的地方)。这是 布局 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

当前的想法是:结合https://dev59.com/hVgR5IYBdhLWcg3wEZ2V#41779935和https://dev59.com/25_ha4cB1Zd3GeqPtx4g#42219474可能会起作用,但我没有足够的经验来感到舒适。 - Cullub
我的TextView中还包含ClickableSpans,因此答案必须允许这些span保持不变。 - Cullub
抱歉,我在Android方面很擅长,但对吉他谱不是很了解。我需要一些关于谱的基本信息,比如C、Am、F和G是什么?你的数据结构是什么?和弦和歌词是在单个字符串、映射还是数组格式中? - Aditya
你可以展示一些数据 XML,说明你是如何显示的吗? - Srikanth G
@Heisenberg好问题。我已经更新了问题并添加了那些信息。基本上,CAmFG告诉吉他手该演奏哪个音符。吉他手可以根据它所在的单词来确定演奏该音符的时间。对于数据结构来说,整个选项卡都在一个巨大的字符串中,包括和弦和歌词。但是,它使用 [ch] / [/ch] 标签包装和弦,并使用 [tab] / [/tab] 标签包装应该一起包装的几行(一行和弦和一行歌词)。希望这有所帮助。 - Cullub
@SrikanthG 所有内容都放在一个大的等宽 TextView 中,该 TextView 位于 ConstraintLayout 内。如果您需要查看 XML,请单击此处(https://github.com/cullub/Tabs-Lite/blob/master/app/src/main/res/layout/fragment_tab_detail.xml)。片段代码(目前非常庞大)在此处(https://github.com/cullub/Tabs-Lite/blob/master/app/src/main/java/com/gbros/tabslite/TabDetailFragment.kt)。Ctrl-F 查找 fun processTabContent - 这是我目前进行处理的地方。 - Cullub
2个回答

3
我认为这个解决方案可能会解决问题。但有一些假设:
  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上之后,我们可以获取行数。有了当前行号,我们就可以得到选定行的起始索引和结束索引。有了这些索引,我们就可以操作字符串。希望这能对您有所帮助。


这些假设是正确的。我现在不在电脑旁测试,但我会让你知道的! - Cullub
另外,正如您所指出的那样,Into和Verse不需要处理。它们保留为纯文本。 - Cullub
我尝试了不同的宽度。应该没问题;)哦,正如你所描述的那样,你需要使用等宽字体,因为它使用空格字符来调整和弦间距。 - Hein Htet Aung
刚刚成功了,很大程度上要归功于你!恭喜,我认为你赢得了悬赏!我们最终做了很多额外的工作,以使每个 [tab] 内部可点击跨度正常工作,因此我相信一位同事将在周一早上发布最终产品,而我打算接受它。不过别担心!悬赏归你! - Cullub
1
哈哈,赏金没问题。如果你发现其他用例,我想修复它。无论如何,谢谢。很高兴能帮助你。 :D - Hein Htet Aung

2

这是基于Hein Htet Aung的答案。 总体思路是你有两行传入(singleLyric),但这些行可能需要在附加它们之前进行处理(因此出现了中间的while循环)。 为方便起见,使用参数appendTo将歌词附加到其中。 它返回一个已完成的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 https://dev59.com/h2bWa4cB1Zd3GeqPTiX0#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
}

最后,我发现需要在单词处而不仅仅是在最大行长度处断开文本,因此这里是用于查找单词断点的函数:

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
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接