在具有跨度的 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
约束条件:
- 我使用等宽文本字体来保持对齐
- 和弦 (
C
, F
, Am
, G
) 是可点击的,因此如果您自定义实现 TextView,它仍然必须能够处理 ClickableSpans
或以其他方式保持它们可点击
- Kotlin 或 Java(或 XML)都可以
如果有帮助,这是我的一个开源项目,所以源代码可以在 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]
请注意,和弦(吉他手会演奏的音符,如 C
或 F
)包含在 [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
我认为这个解决方案可能会解决问题。但是有一些假设,
- 每句歌词都以
[tab]
开始,以[/tab]
结束
- 和弦与歌词总是用
\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
}
总结
我有一个字符串[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
约束条件:
- 我使用等宽文本字体来保持对齐
- 和弦 (
C
,F
,Am
,G
) 是可点击的,因此如果您自定义实现 TextView,它仍然必须能够处理ClickableSpans
或以其他方式保持它们可点击 - Kotlin 或 Java(或 XML)都可以
如果有帮助,这是我的一个开源项目,所以源代码可以在 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]
请注意,和弦(吉他手会演奏的音符,如 C
或 F
)包含在 [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
我认为这个解决方案可能会解决问题。但是有一些假设,
- 每句歌词都以
[tab]
开始,以[/tab]
结束 - 和弦与歌词总是用
\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
}