Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multiline squiggly #2636

Merged
merged 1 commit into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package leakcanary.internal

import android.content.Context
import android.graphics.Canvas
import android.text.Layout
import android.text.Spanned
import android.util.AttributeSet
import android.widget.TextView

/**
* Modified TextView to fully support SquigglySpan.
*/
internal class LeakCanaryTextView(
context: Context,
attrs: AttributeSet,
) : TextView(context, attrs) {
private val singleLineRenderer: SquigglySpanRenderer by lazy { SingleLineRenderer(context) }
private val multiLineRenderer: SquigglySpanRenderer by lazy { MultiLineRenderer(context) }

override fun onDraw(canvas: Canvas) {
if (text is Spanned && layout != null) {
val checkpoint = canvas.save()
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
try {
drawSquigglyLine(canvas, text as Spanned, layout)
} finally {
canvas.restoreToCount(checkpoint)
}
}
super.onDraw(canvas)
}

private fun drawSquigglyLine(canvas: Canvas, text: Spanned, layout: Layout) {
// ideally the calculations here should be cached since they are not cheap. However, proper
// invalidation of the cache is required whenever anything related to text has changed.
val squigglySpans = text.getSpans(0, text.length, SquigglySpan::class.java)
for (span in squigglySpans) {
val spanStart = text.getSpanStart(span)
val spanEnd = text.getSpanEnd(span)
val startLine = layout.getLineForOffset(spanStart)
val endLine = layout.getLineForOffset(spanEnd)

// start can be on the left or on the right depending on the language direction.
val startOffset = (layout.getPrimaryHorizontal(spanStart)
+ -1 * layout.getParagraphDirection(startLine)).toInt()

// end can be on the left or on the right depending on the language direction.
val endOffset = (layout.getPrimaryHorizontal(spanEnd)
+ layout.getParagraphDirection(endLine)).toInt()

val renderer = if (startLine == endLine) singleLineRenderer else multiLineRenderer
renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,91 +16,25 @@
package leakcanary.internal

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.text.SpannableStringBuilder
import android.text.style.ReplacementSpan
import android.text.TextPaint
import android.text.style.CharacterStyle
import android.text.style.UnderlineSpan
import com.squareup.leakcanary.core.R
import kotlin.math.sin
import leakcanary.internal.navigation.getColorCompat

/**
* Inspired from https://github.com/flavienlaurent/spans and
* https://github.com/andyxialm/WavyLineView
*/
internal class SquigglySpan(context: Context) : ReplacementSpan() {
internal class SquigglySpan(context: Context) : CharacterStyle() {
private val referenceColor: Int = context.getColorCompat(R.color.leak_canary_reference)

private val squigglyPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val path: Path
private val referenceColor: Int
private val halfStrokeWidth: Float
private val amplitude: Float
private val halfWaveHeight: Float
private val periodDegrees: Float

private var width: Int = 0

init {
val resources = context.resources
squigglyPaint.style = Paint.Style.STROKE
squigglyPaint.color = context.getColorCompat(R.color.leak_canary_leak)
val strokeWidth =
resources.getDimensionPixelSize(R.dimen.leak_canary_squiggly_span_stroke_width)
.toFloat()
squigglyPaint.strokeWidth = strokeWidth

halfStrokeWidth = strokeWidth / 2
amplitude = resources.getDimensionPixelSize(R.dimen.leak_canary_squiggly_span_amplitude)
.toFloat()
periodDegrees =
resources.getDimensionPixelSize(R.dimen.leak_canary_squiggly_span_period_degrees)
.toFloat()
path = Path()
val waveHeight = 2 * amplitude + strokeWidth
halfWaveHeight = waveHeight / 2
referenceColor = context.getColorCompat(R.color.leak_canary_reference)
}

override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
width = paint.measureText(text, start, end)
.toInt()
return width
}

override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
squigglyHorizontalPath(
path,
x + halfStrokeWidth,
x + width - halfStrokeWidth,
bottom - halfWaveHeight,
amplitude, periodDegrees
)
canvas.drawPath(path, squigglyPaint)

paint.color = referenceColor
canvas.drawText(text, start, end, x, y.toFloat(), paint)
override fun updateDrawState(textPaint: TextPaint) {
textPaint.color = referenceColor
}

companion object {

fun replaceUnderlineSpans(
builder: SpannableStringBuilder,
context: Context
Expand All @@ -113,28 +47,5 @@ internal class SquigglySpan(context: Context) : ReplacementSpan() {
builder.setSpan(SquigglySpan(context), start, end, 0)
}
}

@Suppress("LongParameterList")
private fun squigglyHorizontalPath(
path: Path,
left: Float,
right: Float,
centerY: Float,
amplitude: Float,
periodDegrees: Float
) {
path.reset()

var y: Float
path.moveTo(left, centerY)
val period = (2 * Math.PI / periodDegrees).toFloat()

var x = 0f
while (x <= right - left) {
y = (amplitude * sin((40 + period * x).toDouble()) + centerY).toFloat()
path.lineTo(left + x, y)
x += 1f
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package leakcanary.internal

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.os.Build
import android.text.Layout
import com.squareup.leakcanary.core.R
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import leakcanary.internal.navigation.getColorCompat

/**
* The idea with a multiline span from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
*/
internal abstract class SquigglySpanRenderer(context: Context) {
private val squigglyPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val path: Path
private val halfStrokeWidth: Float
private val amplitude: Float
private val halfWaveHeight: Float
private val periodDegrees: Float

init {
val resources = context.resources
squigglyPaint.style = Paint.Style.STROKE
squigglyPaint.color = context.getColorCompat(R.color.leak_canary_leak)
val strokeWidth =
resources.getDimensionPixelSize(R.dimen.leak_canary_squiggly_span_stroke_width)
.toFloat()
squigglyPaint.strokeWidth = strokeWidth

halfStrokeWidth = strokeWidth / 2
amplitude = resources.getDimensionPixelSize(R.dimen.leak_canary_squiggly_span_amplitude)
.toFloat()
periodDegrees =
resources.getDimensionPixelSize(R.dimen.leak_canary_squiggly_span_period_degrees)
.toFloat()
path = Path()
val waveHeight = 2 * amplitude + strokeWidth
halfWaveHeight = waveHeight / 2
}

abstract fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
)

protected fun Canvas.drawSquigglyHorizontalPath(left: Float, right: Float, bottom: Float) {
squigglyHorizontalPath(
path = path,
left = left + halfStrokeWidth,
right = right - halfStrokeWidth,
centerY = bottom - halfWaveHeight,
amplitude = amplitude,
periodDegrees = periodDegrees
)
drawPath(path, squigglyPaint)
}

protected fun getLineBottom(layout: Layout, line: Int): Int {
var lineBottom = layout.getLineBottomWithoutSpacing(line)
if (line == layout.lineCount - 1) {
lineBottom -= layout.bottomPadding
}
return lineBottom
}

companion object {
/**
* Android system default line spacing extra
*/
private const val DEFAULT_LINESPACING_EXTRA = 0f

/**
* Android system default line spacing multiplier
*/
private const val DEFAULT_LINESPACING_MULTIPLIER = 1f

private fun squigglyHorizontalPath(
path: Path,
left: Float,
right: Float,
centerY: Float,
amplitude: Float,
periodDegrees: Float
) {
path.reset()

var y: Float
path.moveTo(left, centerY)
val period = (2 * Math.PI / periodDegrees).toFloat()

var x = 0f
while (x <= right - left) {
y = (amplitude * sin((40 + period * x).toDouble()) + centerY).toFloat()
path.lineTo(left + x, y)
x += 1f
}
}

private fun Layout.getLineBottomWithoutSpacing(line: Int): Int {
val lineBottom = getLineBottom(line)
val lastLineSpacingNotAdded = Build.VERSION.SDK_INT >= 19
val isLastLine = line == lineCount - 1

val lineBottomWithoutSpacing: Int
val lineSpacingExtra = spacingAdd
val lineSpacingMultiplier = spacingMultiplier
val hasLineSpacing = lineSpacingExtra != DEFAULT_LINESPACING_EXTRA
|| lineSpacingMultiplier != DEFAULT_LINESPACING_MULTIPLIER

lineBottomWithoutSpacing = if (!hasLineSpacing || isLastLine && lastLineSpacingNotAdded) {
lineBottom
} else {
val extra = if (lineSpacingMultiplier.compareTo(DEFAULT_LINESPACING_MULTIPLIER) != 0) {
val lineHeight = getLineTop(line + 1) - getLineTop(line)
lineHeight - (lineHeight - lineSpacingExtra) / lineSpacingMultiplier
} else {
lineSpacingExtra
}

(lineBottom - extra).toInt()
}

return lineBottomWithoutSpacing
}
}
}

/**
* Draws the background for text that starts and ends on the same line.
*/
internal class SingleLineRenderer(context: Context) : SquigglySpanRenderer(context) {
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
) {
canvas.drawSquigglyHorizontalPath(
left = min(startOffset, endOffset).toFloat(),
right = max(startOffset, endOffset).toFloat(),
bottom = getLineBottom(layout, startLine).toFloat(),
)
}
}

/**
* Draws the background for text that starts and ends on different lines.
*/
internal class MultiLineRenderer(context: Context) : SquigglySpanRenderer(context) {
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
) {
val isRtl = layout.getParagraphDirection(startLine) == Layout.DIR_RIGHT_TO_LEFT
val lineEndOffset = if (isRtl) {
layout.getLineLeft(startLine)
} else {
layout.getLineRight(startLine)
}

canvas.drawSquigglyHorizontalPath(
left = startOffset.toFloat(),
right = lineEndOffset,
bottom = getLineBottom(layout, startLine).toFloat(),
)

for (line in startLine + 1 until endLine) {
canvas.drawSquigglyHorizontalPath(
left = layout.getLineLeft(line),
right = layout.getLineRight(line),
bottom = getLineBottom(layout, line).toFloat(),
)
}

val lineStartOffset = if (isRtl) {
layout.getLineRight(startLine)
} else {
layout.getLineLeft(startLine)
}

canvas.drawSquigglyHorizontalPath(
left = lineStartOffset,
right = endOffset.toFloat(),
bottom = getLineBottom(layout, endLine).toFloat(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<TextView
<leakcanary.internal.LeakCanaryTextView
android:id="@+id/leak_canary_header_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down
Loading
Loading