Skip to content

Commit 76f37d3

Browse files
authored
feat: add streaming animation support for newly appended content (#139)
* feat: add streaming animation support for newly appended content * refactor: replace ValueAnimator with TailFadeInAnimator for improved fade-in effect * refactor: enhance fade-in animation handling by consolidating animator logic * feat: add TODO for streaming animation support in github flavor
1 parent 520cec0 commit 76f37d3

11 files changed

Lines changed: 336 additions & 6 deletions

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ class EnrichedMarkdownManager :
123123
// No-op on Android — only used on iOS
124124
}
125125

126+
@ReactProp(name = "streamingAnimation", defaultBoolean = false)
127+
override fun setStreamingAnimation(
128+
view: EnrichedMarkdown?,
129+
streamingAnimation: Boolean,
130+
) {
131+
// TODO: Add streaming animation support for github flavor.
132+
// Currently only supported with flavor="commonmark" (single TextView).
133+
}
134+
126135
override fun setPadding(
127136
view: EnrichedMarkdown,
128137
left: Int,

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

Lines changed: 27 additions & 4 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.styles.StyleConfig
19+
import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
1920
import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
2021
import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
2122
import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
@@ -66,6 +67,10 @@ class EnrichedMarkdownText
6667
private var maxFontSizeMultiplier: Float = 0f
6768
private var allowTrailingMargin: Boolean = false
6869

70+
private var streamingAnimation: Boolean = false
71+
private var previousTextLength: Int = 0
72+
private var fadeAnimator: TailFadeInAnimator? = null
73+
6974
init {
7075
setupAsMarkdownTextView(accessibilityHelper)
7176
}
@@ -130,6 +135,18 @@ class EnrichedMarkdownText
130135
scheduleRenderIfNeeded()
131136
}
132137

138+
fun setStreamingAnimation(enabled: Boolean) {
139+
if (streamingAnimation == enabled) return
140+
streamingAnimation = enabled
141+
if (enabled) {
142+
previousTextLength = text?.length ?: 0
143+
} else {
144+
fadeAnimator?.cancelAll()
145+
fadeAnimator = null
146+
previousTextLength = 0
147+
}
148+
}
149+
133150
private fun updateMeasurementStoreFontScaling() {
134151
MeasurementStore.updateFontScalingSettings(id, allowFontScaling, maxFontSizeMultiplier)
135152
}
@@ -189,22 +206,28 @@ class EnrichedMarkdownText
189206
}
190207

191208
private fun applyRenderedText(styledText: CharSequence) {
209+
val tailStart = previousTextLength
210+
192211
text = styledText
193212

194-
// setText on a selectable TextView can reset movementMethod, so re-apply if needed
195213
if (movementMethod !is LinkLongPressMovementMethod) {
196214
movementMethod = LinkLongPressMovementMethod.createInstance()
197215
}
198216

199-
// Register ImageSpans from the collector
200217
renderer.getCollectedImageSpans().forEach { span ->
201218
span.registerTextView(this)
202219
}
203220

204221
layoutManager.invalidateLayout()
205-
206-
// Update accessibility items for TalkBack navigation
207222
accessibilityHelper.invalidateAccessibilityItems()
223+
224+
if (streamingAnimation) {
225+
if (fadeAnimator == null) {
226+
fadeAnimator = TailFadeInAnimator(this)
227+
}
228+
fadeAnimator?.animate(tailStart, styledText.length)
229+
previousTextLength = styledText.length
230+
}
208231
}
209232

210233
fun setIsSelectable(selectable: Boolean) {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ class EnrichedMarkdownTextManager :
144144
// Required by the codegen interface but is a no-op on Android.
145145
}
146146

147+
@ReactProp(name = "streamingAnimation", defaultBoolean = false)
148+
override fun setStreamingAnimation(
149+
view: EnrichedMarkdownText?,
150+
streamingAnimation: Boolean,
151+
) {
152+
view?.setStreamingAnimation(streamingAnimation)
153+
}
154+
147155
override fun setPadding(
148156
view: EnrichedMarkdownText,
149157
left: Int,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.swmansion.enriched.markdown.spans
2+
3+
import android.text.TextPaint
4+
import android.text.style.CharacterStyle
5+
import androidx.annotation.FloatRange
6+
7+
class FadeInSpan : CharacterStyle() {
8+
@set:FloatRange(from = 0.0, to = 1.0)
9+
var alpha: Float = 0f
10+
11+
override fun updateDrawState(tp: TextPaint) {
12+
tp.color = multiplyAlpha(tp.color, alpha)
13+
tp.underlineColor = multiplyAlpha(tp.underlineColor, alpha)
14+
}
15+
16+
private fun multiplyAlpha(
17+
color: Int,
18+
alpha: Float,
19+
): Int {
20+
if (alpha >= 1f) return color
21+
if (alpha <= 0f) return color and 0x00FFFFFF
22+
val a = ((color ushr 24) * alpha).toInt()
23+
return (a shl 24) or (color and 0x00FFFFFF)
24+
}
25+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.swmansion.enriched.markdown.utils.text
2+
3+
import android.animation.Animator
4+
import android.animation.AnimatorListenerAdapter
5+
import android.animation.ValueAnimator
6+
import android.text.Spannable
7+
import android.view.animation.LinearInterpolator
8+
import android.widget.TextView
9+
import com.swmansion.enriched.markdown.spans.FadeInSpan
10+
import java.lang.ref.WeakReference
11+
12+
class TailFadeInAnimator(
13+
textView: TextView,
14+
) {
15+
private val viewRef = WeakReference(textView)
16+
17+
private val activeAnimations = mutableMapOf<FadeInSpan, ValueAnimator>()
18+
19+
fun animate(
20+
tailStart: Int,
21+
tailEnd: Int,
22+
) {
23+
if (tailEnd <= tailStart) return
24+
25+
val textView = viewRef.get() ?: return
26+
val spannable = textView.text as? Spannable ?: return
27+
28+
val fadeSpan = FadeInSpan().apply { alpha = 0f }
29+
spannable.setSpan(fadeSpan, tailStart, tailEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
30+
31+
val animator =
32+
ValueAnimator.ofFloat(0f, 1f).apply {
33+
duration = FADE_DURATION_MS
34+
interpolator = LinearInterpolator()
35+
36+
addUpdateListener { anim ->
37+
fadeSpan.alpha = anim.animatedValue as Float
38+
viewRef.get()?.invalidate()
39+
}
40+
41+
addListener(
42+
object : AnimatorListenerAdapter() {
43+
override fun onAnimationEnd(animation: Animator) {
44+
cleanup(fadeSpan)
45+
}
46+
47+
override fun onAnimationCancel(animation: Animator) {
48+
cleanup(fadeSpan)
49+
}
50+
},
51+
)
52+
}
53+
54+
activeAnimations[fadeSpan] = animator
55+
animator.start()
56+
}
57+
58+
private fun cleanup(span: FadeInSpan) {
59+
activeAnimations.remove(span)
60+
val spannable = viewRef.get()?.text as? Spannable ?: return
61+
span.alpha = 1f
62+
spannable.removeSpan(span)
63+
}
64+
65+
fun cancelAll() {
66+
val spans = activeAnimations.keys.toList()
67+
spans.forEach { activeAnimations[it]?.cancel() }
68+
activeAnimations.clear()
69+
}
70+
71+
companion object {
72+
private const val FADE_DURATION_MS = 150L
73+
}
74+
}

ios/EnrichedMarkdownText.mm

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#import "CodeBlockBackground.h"
55
#import "ENRMImageAttachment.h"
66
#import "ENRMMarkdownParser.h"
7+
#import "ENRMTailFadeInAnimator.h"
78
#import "EditMenuUtils.h"
89
#import "FontScaleObserver.h"
910
#import "FontUtils.h"
@@ -64,6 +65,10 @@ @implementation EnrichedMarkdownText {
6465
CGFloat _lastElementMarginBottom;
6566
BOOL _allowTrailingMargin;
6667
BOOL _enableLinkPreview;
68+
BOOL _streamingAnimation;
69+
70+
NSUInteger _previousTextLength;
71+
ENRMTailFadeInAnimator *_fadeAnimator;
6772

6873
AccessibilityInfo *_accessibilityInfo;
6974
NSMutableArray<UIAccessibilityElement *> *_accessibilityElements;
@@ -332,12 +337,13 @@ - (void)renderMarkdownSynchronously:(NSString *)markdownString
332337

333338
- (void)applyRenderedText:(NSMutableAttributedString *)attributedText
334339
{
340+
NSUInteger tailStart = _previousTextLength;
341+
335342
NSLayoutManager *layoutManager = _textView.layoutManager;
336343
if ([layoutManager isKindOfClass:[TextViewLayoutManager class]]) {
337344
[layoutManager setValue:_config forKey:@"config"];
338345
}
339346

340-
// Attachments access the text view via associated object on the container
341347
objc_setAssociatedObject(_textView.textContainer, kTextViewKey, _textView, OBJC_ASSOCIATION_ASSIGN);
342348

343349
_textView.attributedText = attributedText;
@@ -356,10 +362,17 @@ - (void)applyRenderedText:(NSMutableAttributedString *)attributedText
356362

357363
_accessibilityNeedsRebuild = YES;
358364

359-
// Next run loop — layout must settle before revealing content
360365
if (_textView.hidden) {
361366
dispatch_async(dispatch_get_main_queue(), ^{ self->_textView.hidden = NO; });
362367
}
368+
369+
if (_streamingAnimation) {
370+
if (!_fadeAnimator) {
371+
_fadeAnimator = [[ENRMTailFadeInAnimator alloc] initWithTextView:_textView];
372+
}
373+
[_fadeAnimator animateFrom:tailStart to:attributedText.length];
374+
_previousTextLength = attributedText.length;
375+
}
363376
}
364377

365378
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
@@ -431,6 +444,17 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
431444

432445
_enableLinkPreview = newViewProps.enableLinkPreview;
433446

447+
if (newViewProps.streamingAnimation != oldViewProps.streamingAnimation) {
448+
_streamingAnimation = newViewProps.streamingAnimation;
449+
if (_streamingAnimation) {
450+
_previousTextLength = _textView.attributedText.length;
451+
} else {
452+
[_fadeAnimator cancel];
453+
_fadeAnimator = nil;
454+
_previousTextLength = 0;
455+
}
456+
}
457+
434458
if (markdownChanged || stylePropChanged || md4cFlagsChanged || allowTrailingMarginChanged) {
435459
NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()];
436460
[self renderMarkdownContent:markdownString];

ios/utils/ENRMTailFadeInAnimator.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#pragma once
2+
#import <UIKit/UIKit.h>
3+
4+
NS_ASSUME_NONNULL_BEGIN
5+
6+
@interface ENRMTailFadeInAnimator : NSObject
7+
8+
- (instancetype)initWithTextView:(UITextView *)textView;
9+
10+
- (void)animateFrom:(NSUInteger)tailStart to:(NSUInteger)tailEnd;
11+
- (void)cancel;
12+
13+
@end
14+
15+
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)