From 64fb4e61aebfb0dabe1f529e58d79732d280189a Mon Sep 17 00:00:00 2001 From: EpicDima Date: Sun, 18 Feb 2024 03:10:38 +0300 Subject: [PATCH] Support multiline squiggly --- .../leakcanary/internal/LeakCanaryTextView.kt | 55 +++++ .../java/leakcanary/internal/SquigglySpan.kt | 101 +-------- .../internal/SquigglySpanRenderer.kt | 202 ++++++++++++++++++ .../res/layout/leak_canary_leak_header.xml | 2 +- .../main/res/layout/leak_canary_ref_row.xml | 2 +- 5 files changed, 265 insertions(+), 97 deletions(-) create mode 100644 leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/LeakCanaryTextView.kt create mode 100644 leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpanRenderer.kt diff --git a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/LeakCanaryTextView.kt b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/LeakCanaryTextView.kt new file mode 100644 index 0000000000..b3f7153570 --- /dev/null +++ b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/LeakCanaryTextView.kt @@ -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) + } + } +} diff --git a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpan.kt b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpan.kt index aecf52721e..213bc306fb 100644 --- a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpan.kt +++ b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpan.kt @@ -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 @@ -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 - } - } } } diff --git a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpanRenderer.kt b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpanRenderer.kt new file mode 100644 index 0000000000..36229b28d8 --- /dev/null +++ b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/SquigglySpanRenderer.kt @@ -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(), + ) + } +} diff --git a/leakcanary/leakcanary-android-core/src/main/res/layout/leak_canary_leak_header.xml b/leakcanary/leakcanary-android-core/src/main/res/layout/leak_canary_leak_header.xml index 44472458f6..eddb7b72c5 100644 --- a/leakcanary/leakcanary-android-core/src/main/res/layout/leak_canary_leak_header.xml +++ b/leakcanary/leakcanary-android-core/src/main/res/layout/leak_canary_leak_header.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" > - -