Skip to content

Commit

Permalink
Merge pull request #2636 from EpicDima/epicdima/support_squiggly_mult…
Browse files Browse the repository at this point in the history
…iline

Support multiline squiggly
  • Loading branch information
pyricau committed Mar 26, 2024
2 parents b5e059c + 64fb4e6 commit cd462bc
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 97 deletions.
@@ -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)
}
}
}
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
}
}
}
}
@@ -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(),
)
}
}
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

0 comments on commit cd462bc

Please sign in to comment.