Skip to content

Commit e7e04df

Browse files
committed
feat: implement spoiler overlay strategies and enhance markdown rendering
1 parent 2a979c9 commit e7e04df

43 files changed

Lines changed: 1050 additions & 382 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.swmansion.enriched.markdown.parser.Md4cFlags
1616
import com.swmansion.enriched.markdown.parser.Parser
1717
import com.swmansion.enriched.markdown.renderer.Renderer
1818
import com.swmansion.enriched.markdown.spans.ImageSpan
19+
import com.swmansion.enriched.markdown.spoiler.SpoilerMode
1920
import com.swmansion.enriched.markdown.styles.StyleConfig
2021
import com.swmansion.enriched.markdown.utils.common.FeatureFlags
2122
import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
@@ -77,6 +78,14 @@ class EnrichedMarkdown
7778
private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null
7879
private var contextMenuItemTexts: List<String> = emptyList()
7980
var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
81+
var spoilerMode: SpoilerMode = SpoilerMode.PARTICLES
82+
set(value) {
83+
if (field == value) return
84+
field = value
85+
segmentViews.filterIsInstance<EnrichedMarkdownInternalText>().forEach {
86+
it.spoilerMode = value
87+
}
88+
}
8089

8190
fun setMarkdownContent(markdown: String) {
8291
if (currentMarkdown == markdown) return
@@ -268,6 +277,7 @@ class EnrichedMarkdown
268277

269278
private fun createTextView(segment: RenderSegment.Text) =
270279
EnrichedMarkdownInternalText(context).apply {
280+
spoilerMode = this@EnrichedMarkdown.spoilerMode
271281
setIsSelectable(selectable)
272282
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && segment.needsJustify) {
273283
justificationMode = android.text.Layout.JUSTIFICATION_MODE_INTER_WORD

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.util.AttributeSet
77
import android.view.MotionEvent
88
import androidx.appcompat.widget.AppCompatTextView
99
import com.swmansion.enriched.markdown.accessibility.MarkdownAccessibilityHelper
10+
import com.swmansion.enriched.markdown.spoiler.SpoilerMode
1011
import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer
1112
import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
1213
import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
@@ -41,6 +42,7 @@ class EnrichedMarkdownInternalText
4142

4243
var spoilerOverlayDrawer: SpoilerOverlayDrawer? = null
4344
private set
45+
var spoilerMode: SpoilerMode = SpoilerMode.PARTICLES
4446
private var contextMenuItemTexts: List<String> = emptyList()
4547
private var onContextMenuItemPress: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
4648

@@ -63,7 +65,7 @@ class EnrichedMarkdownInternalText
6365
movementMethod = LinkLongPressMovementMethod.createInstance()
6466
}
6567

66-
spoilerOverlayDrawer = SpoilerOverlayDrawer.setupIfNeeded(this, styledText, spoilerOverlayDrawer)
68+
spoilerOverlayDrawer = SpoilerOverlayDrawer.setupIfNeeded(this, styledText, spoilerOverlayDrawer, spoilerMode)
6769
accessibilityHelper.invalidateAccessibilityItems()
6870
}
6971

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.swmansion.enriched.markdown.events.LinkLongPressEvent
1717
import com.swmansion.enriched.markdown.events.LinkPressEvent
1818
import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
1919
import com.swmansion.enriched.markdown.parser.Md4cFlags
20+
import com.swmansion.enriched.markdown.spoiler.SpoilerMode
2021
import com.swmansion.enriched.markdown.utils.common.FeatureFlags
2122
import com.swmansion.enriched.markdown.utils.text.interaction.TaskListToggleUtils
2223

@@ -142,6 +143,14 @@ class EnrichedMarkdownManager :
142143
// Currently only supported with flavor="commonmark" (single TextView).
143144
}
144145

146+
@ReactProp(name = "spoilerMode")
147+
override fun setSpoilerMode(
148+
view: EnrichedMarkdown?,
149+
mode: String?,
150+
) {
151+
view?.spoilerMode = SpoilerMode.fromString(mode)
152+
}
153+
145154
@ReactProp(name = "contextMenuItems")
146155
override fun setContextMenuItems(
147156
view: EnrichedMarkdown?,

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.swmansion.enriched.markdown.accessibility.MarkdownAccessibilityHelper
1616
import com.swmansion.enriched.markdown.parser.Md4cFlags
1717
import com.swmansion.enriched.markdown.parser.Parser
1818
import com.swmansion.enriched.markdown.renderer.Renderer
19+
import com.swmansion.enriched.markdown.spoiler.SpoilerMode
1920
import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer
2021
import com.swmansion.enriched.markdown.styles.StyleConfig
2122
import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
@@ -80,6 +81,7 @@ class EnrichedMarkdownText
8081
private var fadeAnimator: TailFadeInAnimator? = null
8182
var spoilerOverlayDrawer: SpoilerOverlayDrawer? = null
8283
private set
84+
var spoilerMode: SpoilerMode = SpoilerMode.PARTICLES
8385

8486
init {
8587
setupAsMarkdownTextView(accessibilityHelper)
@@ -236,7 +238,7 @@ class EnrichedMarkdownText
236238
span.registerTextView(this)
237239
}
238240

239-
spoilerOverlayDrawer = SpoilerOverlayDrawer.setupIfNeeded(this, styledText, spoilerOverlayDrawer)
241+
spoilerOverlayDrawer = SpoilerOverlayDrawer.setupIfNeeded(this, styledText, spoilerOverlayDrawer, spoilerMode)
240242

241243
layoutManager.invalidateLayout()
242244
accessibilityHelper.invalidateAccessibilityItems()

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.swmansion.enriched.markdown.events.LinkLongPressEvent
1717
import com.swmansion.enriched.markdown.events.LinkPressEvent
1818
import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
1919
import com.swmansion.enriched.markdown.parser.Md4cFlags
20+
import com.swmansion.enriched.markdown.spoiler.SpoilerMode
2021
import com.swmansion.enriched.markdown.utils.common.FeatureFlags
2122
import com.swmansion.enriched.markdown.utils.text.interaction.TaskListTapUtils
2223
import com.swmansion.enriched.markdown.utils.text.interaction.TaskListToggleUtils
@@ -162,6 +163,14 @@ class EnrichedMarkdownTextManager :
162163
view?.setStreamingAnimation(streamingAnimation)
163164
}
164165

166+
@ReactProp(name = "spoilerMode")
167+
override fun setSpoilerMode(
168+
view: EnrichedMarkdownText?,
169+
mode: String?,
170+
) {
171+
view?.spoilerMode = SpoilerMode.fromString(mode)
172+
}
173+
165174
@ReactProp(name = "contextMenuItems")
166175
override fun setContextMenuItems(
167176
view: EnrichedMarkdownText?,

android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ class SpanStyleCache(
2323
val codeFontFamily: String = style.codeStyle.fontFamily
2424
val codeFontSize: Float = style.codeStyle.fontSize
2525
val codeColor: Int = style.codeStyle.color
26-
val spoilerParticleColor: Int = style.spoilerStyle.particleColor
26+
val spoilerColor: Int = style.spoilerStyle.color
2727
val spoilerParticleDensity: Float = style.spoilerStyle.particleDensity
2828
val spoilerParticleSpeed: Float = style.spoilerStyle.particleSpeed
29+
val spoilerSolidBorderRadius: Float = style.spoilerStyle.solidBorderRadius
2930

3031
private fun buildColorsToPreserve(style: StyleConfig): IntArray {
3132
val paragraphColor = style.paragraphStyle.color
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.swmansion.enriched.markdown.spoiler
2+
3+
import android.graphics.Canvas
4+
import android.graphics.Paint
5+
import com.swmansion.enriched.markdown.spans.SpoilerSpan
6+
import com.swmansion.enriched.markdown.styles.SpoilerStyle
7+
8+
/**
9+
* Draws animated particles over unrevealed spoiler spans.
10+
* On reveal, particles scatter outward and the overlay fades.
11+
*/
12+
class ParticleStrategy(
13+
private val animator: SpoilerAnimator,
14+
) : SpoilerStrategy {
15+
private val segments = mutableMapOf<SegmentKey, SpoilerParticleDrawable>()
16+
private val backgroundPaint = Paint()
17+
18+
private var particleColor = 0
19+
private var particleDensity = 0f
20+
private var particleSpeed = 0f
21+
22+
override fun applyStyle(style: SpoilerStyle) {
23+
this.particleColor = style.color
24+
this.particleDensity = style.particleDensity
25+
this.particleSpeed = style.particleSpeed
26+
}
27+
28+
override fun drawSegment(
29+
canvas: Canvas,
30+
context: SpoilerDrawContext,
31+
key: SegmentKey,
32+
rect: SegmentRect,
33+
) {
34+
val drawable =
35+
segments.getOrPut(key) {
36+
SpoilerParticleDrawable(particleColor, particleDensity, particleSpeed)
37+
.also { animator.register(it) }
38+
}
39+
drawable.setSize(rect.width, rect.height)
40+
41+
backgroundPaint.color = SpoilerOverlayDrawer.colorWithAlpha(context.backgroundColor, drawable.overallAlpha)
42+
canvas.drawRect(rect.left, rect.top, rect.left + rect.width, rect.top + rect.height, backgroundPaint)
43+
drawable.draw(canvas, rect.left, rect.top)
44+
}
45+
46+
override fun pruneStaleSegments(activeKeys: Set<SegmentKey>) {
47+
val staleKeys = segments.keys - activeKeys
48+
for (key in staleKeys) {
49+
segments.remove(key)?.let { animator.unregister(it) }
50+
}
51+
}
52+
53+
override fun revealSpan(
54+
span: SpoilerSpan,
55+
context: SpoilerDrawContext,
56+
onAllComplete: () -> Unit,
57+
) {
58+
revealSegments(
59+
span = span,
60+
segmentKeys = segments.keys,
61+
onAllComplete = onAllComplete,
62+
cleanup = { keys -> keys.forEach { segments.remove(it)?.let { d -> animator.unregister(d) } } },
63+
onSegment = { key, onComplete -> segments[key]?.startReveal(onComplete) },
64+
)
65+
animator.ensureRunning()
66+
}
67+
68+
override fun stop() {
69+
segments.values.forEach { animator.unregister(it) }
70+
segments.clear()
71+
}
72+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.swmansion.enriched.markdown.spoiler
2+
3+
data class SegmentKey(
4+
val spanIdentity: Int,
5+
val line: Int,
6+
)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.swmansion.enriched.markdown.spoiler
2+
3+
import android.graphics.Canvas
4+
import android.graphics.Paint
5+
import android.graphics.RectF
6+
import android.os.SystemClock
7+
import com.swmansion.enriched.markdown.spans.SpoilerSpan
8+
import com.swmansion.enriched.markdown.styles.SpoilerStyle
9+
10+
/**
11+
* Draws an opaque solid rectangle over unrevealed spoiler spans (Discord-style).
12+
* On reveal, the rectangle fades out with a quadratic ease-out curve.
13+
*/
14+
class SolidStrategy : SpoilerStrategy {
15+
private class SegmentState(
16+
var alpha: Float = 1f,
17+
var revealing: Boolean = false,
18+
var revealStartTime: Long = -1L,
19+
var revealFinished: Boolean = false,
20+
var revealCallback: (() -> Unit)? = null,
21+
)
22+
23+
private val segments = mutableMapOf<SegmentKey, SegmentState>()
24+
private val solidPaint = Paint()
25+
private val rectF = RectF()
26+
27+
private var color = 0
28+
private var borderRadius = 0f
29+
30+
override fun applyStyle(style: SpoilerStyle) {
31+
this.color = style.color
32+
this.borderRadius = style.solidBorderRadius *
33+
android.content.res.Resources
34+
.getSystem()
35+
.displayMetrics.density
36+
}
37+
38+
override fun drawSegment(
39+
canvas: Canvas,
40+
context: SpoilerDrawContext,
41+
key: SegmentKey,
42+
rect: SegmentRect,
43+
) {
44+
val state = segments.getOrPut(key) { SegmentState() }
45+
46+
if (state.revealing) {
47+
val now = SystemClock.uptimeMillis()
48+
if (state.revealStartTime < 0L) state.revealStartTime = now
49+
val progress = ((now - state.revealStartTime).toFloat() / REVEAL_DURATION_MS).coerceIn(0f, 1f)
50+
state.alpha = (1f - progress) * (1f - progress)
51+
if (progress >= 1f && !state.revealFinished) {
52+
state.revealFinished = true
53+
state.revealCallback?.invoke()
54+
state.revealCallback = null
55+
}
56+
}
57+
58+
if (!state.revealFinished) {
59+
solidPaint.color = SpoilerOverlayDrawer.colorWithAlpha(this.color, state.alpha)
60+
rectF.set(rect.left, rect.top, rect.left + rect.width, rect.top + rect.height)
61+
canvas.drawRoundRect(rectF, borderRadius, borderRadius, solidPaint)
62+
}
63+
64+
if (state.revealing && !state.revealFinished) {
65+
context.textView.postInvalidateOnAnimation()
66+
}
67+
}
68+
69+
override fun pruneStaleSegments(activeKeys: Set<SegmentKey>) {
70+
val staleKeys = segments.keys - activeKeys
71+
for (key in staleKeys) {
72+
segments.remove(key)
73+
}
74+
}
75+
76+
override fun revealSpan(
77+
span: SpoilerSpan,
78+
context: SpoilerDrawContext,
79+
onAllComplete: () -> Unit,
80+
) {
81+
revealSegments(
82+
span = span,
83+
segmentKeys = segments.keys,
84+
onAllComplete = onAllComplete,
85+
cleanup = { keys -> keys.forEach { segments.remove(it) } },
86+
onSegment = { key, onComplete ->
87+
segments[key]?.let { state ->
88+
state.revealing = true
89+
state.revealCallback = onComplete
90+
}
91+
},
92+
)
93+
context.textView.invalidate()
94+
}
95+
96+
override fun stop() {
97+
segments.clear()
98+
}
99+
100+
companion object {
101+
private const val REVEAL_DURATION_MS = 450L
102+
}
103+
}

android/src/main/java/com/swmansion/enriched/markdown/spoiler/SpoilerAnimator.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,15 @@ class SpoilerAnimator(
4545
lastFrameTime = currentTimeMs
4646

4747
isIterating = true
48-
var hasActiveDrawables = false
4948
for (drawable in drawables) {
5049
drawable.update(deltaTime, currentTimeMs)
51-
if (drawable.hasActiveParticles()) hasActiveDrawables = true
5250
}
5351
isIterating = false
5452
drainPendingRemovals()
5553

5654
textView.invalidate()
5755

58-
if (hasActiveDrawables || drawables.isNotEmpty()) {
56+
if (drawables.isNotEmpty()) {
5957
Choreographer.getInstance().postFrameCallback(this)
6058
} else {
6159
running = false
@@ -87,6 +85,8 @@ class SpoilerAnimator(
8785
fun stop() {
8886
running = false
8987
Choreographer.getInstance().removeFrameCallback(frameCallback)
88+
drawables.clear()
89+
pendingRemovals.clear()
9090
}
9191

9292
private fun drainPendingRemovals() {

0 commit comments

Comments
 (0)