Skip to content

Commit ca1ffb0

Browse files
authored
feat(android): add html support (#52)
* feat(android): add html support - add Android Nitro module with Kotlin view, Fabric state updater, and JNI bridge - share component descriptor/state logic so Fabric can hydrate props on Android - update TypeScript entrypoint, codegen pipeline, and example app for Android support * feat: add support for `onTextLayout` * feat: implement fragment background color support in NitroText * feat(android): html support
1 parent 369ad10 commit ca1ffb0

50 files changed

Lines changed: 2763 additions & 24 deletions

Some content is hidden

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

android/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ include_directories(
2525
# Add custom ShadowNode implementation
2626
target_sources(
2727
${PACKAGE_NAME} PRIVATE
28+
../cpp/NitroHtmlUtils.cpp
29+
../cpp/NitroHtmlUtils.hpp
2830
../cpp/NitroTextShadowNode.cpp
2931
../cpp/NitroTextShadowNode.hpp
3032
../cpp/NitroTextComponentDescriptor.cpp

android/build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ dependencies {
137137
// Add a dependency on NitroModules
138138
implementation project(":react-native-nitro-modules")
139139

140+
implementation "org.jsoup:jsoup:1.18.1"
141+
140142
}
141143

142144
if (isNewArchitectureEnabled()) {
@@ -145,4 +147,4 @@ if (isNewArchitectureEnabled()) {
145147
libraryName = "NitroText"
146148
codegenJavaPackageName = "com.nitrotext"
147149
}
148-
}
150+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.nitrotext
2+
3+
import android.text.Selection
4+
import android.text.Spannable
5+
import android.text.method.MovementMethod
6+
import android.text.style.ClickableSpan
7+
import android.view.MotionEvent
8+
import android.widget.TextView
9+
10+
internal object HtmlLinkMovementMethod : MovementMethod {
11+
override fun initialize(widget: TextView?, text: Spannable?) = Unit
12+
override fun onKeyDown(widget: TextView?, text: Spannable?, keyCode: Int, event: android.view.KeyEvent?) = false
13+
override fun onKeyUp(widget: TextView?, text: Spannable?, keyCode: Int, event: android.view.KeyEvent?) = false
14+
override fun onKeyOther(widget: TextView?, text: Spannable?, event: android.view.KeyEvent?) = false
15+
override fun onTrackballEvent(widget: TextView?, text: Spannable?, event: MotionEvent?) = false
16+
override fun onTakeFocus(widget: TextView?, text: Spannable?, direction: Int) = Unit
17+
override fun canSelectArbitrarily() = false
18+
override fun onGenericMotionEvent(widget: TextView?, text: Spannable?, event: MotionEvent?) = false
19+
20+
override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean {
21+
val action = event.action
22+
23+
if (action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_DOWN) {
24+
return false
25+
}
26+
27+
val layout = widget.layout ?: return false
28+
val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
29+
val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
30+
31+
val line = layout.getLineForVertical(y)
32+
val offset = layout.getOffsetForHorizontal(line, x.toFloat())
33+
34+
val links = buffer.getSpans(offset, offset, ClickableSpan::class.java)
35+
if (links.isNotEmpty()) {
36+
val link = links[0]
37+
if (action == MotionEvent.ACTION_UP) {
38+
link.onClick(widget)
39+
} else {
40+
Selection.setSelection(buffer, buffer.getSpanStart(link), buffer.getSpanEnd(link))
41+
}
42+
return true
43+
}
44+
45+
return false
46+
}
47+
}

android/src/main/java/com/nitrotext/HybridNitroText.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ class HybridNitroText(val context: ThemedReactContext) : HybridNitroTextSpec(),
2222
impl.setFragments(value)
2323
}
2424

25+
override var renderer: NitroRenderer?
26+
get() = null
27+
set(value) {
28+
impl.setRenderer(value)
29+
}
30+
31+
override var richTextStyleRules: Array<RichTextStyleRule>?
32+
get() = null
33+
set(value) {
34+
impl.setRichTextStyleRules(value)
35+
}
36+
2537
override var selectable: Boolean?
2638
get() = null
2739
set(value) {

android/src/main/java/com/nitrotext/NitroTextImpl.kt

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,28 @@ import android.text.SpannableStringBuilder
77
import android.text.TextUtils
88
import android.text.style.AbsoluteSizeSpan
99
import android.text.style.BackgroundColorSpan
10+
import android.text.style.ClickableSpan
1011
import android.text.style.ForegroundColorSpan
1112
import android.text.style.StrikethroughSpan
1213
import android.text.style.StyleSpan
1314
import android.text.style.TypefaceSpan
1415
import android.text.style.UnderlineSpan
1516
import android.view.Gravity
17+
import android.text.method.ArrowKeyMovementMethod
18+
import android.text.method.LinkMovementMethod
1619
import androidx.appcompat.widget.AppCompatTextView
1720
import com.facebook.react.uimanager.PixelUtil
1821
import com.margelo.nitro.nitrotext.*
1922
import androidx.core.graphics.toColorInt
23+
import com.nitrotext.renderers.NitroHtmlRenderer
24+
import com.nitrotext.spans.NitroLineHeightSpan
2025

2126
class NitroTextImpl(private val view: AppCompatTextView) {
2227
// Stored props
2328
private var fragments: Array<Fragment>? = null
2429
private var text: String? = null
30+
private var renderer: NitroRenderer = NitroRenderer.PLAINTEXT
31+
private var richTextStyleRules: Array<RichTextStyleRule>? = null
2532

2633
private var selectable: Boolean? = null
2734
private var selectionColor: String? = null
@@ -56,7 +63,11 @@ class NitroTextImpl(private val view: AppCompatTextView) {
5663
applyAlignment()
5764

5865
val frags = fragments
59-
if (!frags.isNullOrEmpty()) {
66+
val content = text
67+
68+
if (renderer == NitroRenderer.HTML && !content.isNullOrEmpty()) {
69+
applyHtml(content)
70+
} else if (!frags.isNullOrEmpty()) {
6071
applyFragments(frags)
6172
} else {
6273
applySimpleText()
@@ -68,6 +79,8 @@ class NitroTextImpl(private val view: AppCompatTextView) {
6879
// Setters
6980
fun setFragments(value: Array<Fragment>?) { fragments = value }
7081
fun setText(value: String?) { text = value }
82+
fun setRenderer(value: NitroRenderer?) { renderer = value ?: NitroRenderer.PLAINTEXT }
83+
fun setRichTextStyleRules(value: Array<RichTextStyleRule>?) { richTextStyleRules = value }
7184

7285
fun setSelectable(value: Boolean?) { selectable = value }
7386
fun setSelectionColor(value: String?) { selectionColor = value }
@@ -212,7 +225,7 @@ class NitroTextImpl(private val view: AppCompatTextView) {
212225
}
213226
containerLineHeightPx?.let { applyLineHeightSpan(builder, 0, builder.length, it) }
214227
view.text = builder
215-
228+
216229
// Apply default text color for runs without explicit color
217230
view.setTextColor(resolvedFontColor())
218231
}
@@ -265,6 +278,59 @@ class NitroTextImpl(private val view: AppCompatTextView) {
265278
return parsed ?: Color.BLACK
266279
}
267280

281+
private fun baseRichTextStyle(): RichTextStyle {
282+
return RichTextStyle(
283+
fontColor = fontColor,
284+
fragmentBackgroundColor = fragmentBackgroundColor,
285+
fontSize = fontSize,
286+
fontWeight = fontWeight,
287+
fontStyle = fontStyle,
288+
fontFamily = fontFamily,
289+
lineHeight = lineHeight,
290+
letterSpacing = letterSpacing,
291+
textAlign = textAlign,
292+
textTransform = textTransform,
293+
textDecorationLine = textDecorationLine,
294+
textDecorationColor = textDecorationColor,
295+
textDecorationStyle = textDecorationStyle,
296+
marginTop = null,
297+
marginBottom = null,
298+
marginLeft = null,
299+
marginRight = null,
300+
)
301+
}
302+
303+
private fun applyHtml(html: String) {
304+
val renderer = NitroHtmlRenderer(
305+
context = view.context,
306+
defaultTextSizePx = view.textSize,
307+
allowFontScaling = allowFontScaling,
308+
maxFontSizeMultiplier = maxFontSizeMultiplier,
309+
)
310+
val spannable = renderer.render(html, baseRichTextStyle(), richTextStyleRules)
311+
trimTrailingNewlines(spannable)
312+
view.text = spannable
313+
val hasLinks = spannable.getSpans(0, spannable.length, ClickableSpan::class.java).isNotEmpty()
314+
view.linksClickable = hasLinks
315+
val isSelectable = selectable == true
316+
view.movementMethod = when {
317+
hasLinks && isSelectable -> LinkMovementMethod.getInstance()
318+
hasLinks -> HtmlLinkMovementMethod
319+
isSelectable -> ArrowKeyMovementMethod.getInstance()
320+
else -> null
321+
}
322+
view.setTextIsSelectable(isSelectable)
323+
view.setTextColor(resolvedFontColor())
324+
}
325+
326+
private fun trimTrailingNewlines(builder: SpannableStringBuilder) {
327+
var length = builder.length
328+
while (length > 0 && builder[length - 1] == '\n') {
329+
builder.delete(length - 1, length)
330+
length = builder.length
331+
}
332+
}
333+
268334
private fun resolveLineHeight(value: Double?): Float? {
269335
val raw = value ?: return null
270336
val px = if (allowFontScaling) {
@@ -294,8 +360,4 @@ class NitroTextImpl(private val view: AppCompatTextView) {
294360
}
295361
builder.setSpan(NitroLineHeightSpan(lineHeightPx), start, end, flags)
296362
}
297-
298-
companion object {
299-
private const val TAG = "NitroTextImpl"
300-
}
301363
}

0 commit comments

Comments
 (0)