@whetware/react-native-stroke-text 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE.md +21 -0
  3. package/NitroStrokeText.podspec +31 -0
  4. package/README.md +45 -0
  5. package/android/CMakeLists.txt +29 -0
  6. package/android/build.gradle +142 -0
  7. package/android/fix-prefab.gradle +51 -0
  8. package/android/gradle.properties +5 -0
  9. package/android/src/main/AndroidManifest.xml +2 -0
  10. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  11. package/android/src/main/java/com/margelo/nitro/stroketext/HybridStrokeTextView.kt +107 -0
  12. package/android/src/main/java/com/margelo/nitro/stroketext/NitroStrokeTextPackage.kt +39 -0
  13. package/android/src/main/java/com/margelo/nitro/stroketext/StrokeTextView.kt +429 -0
  14. package/ios/Bridge.h +8 -0
  15. package/ios/HybridStrokeTextView.swift +114 -0
  16. package/ios/StrokeTextColor.swift +120 -0
  17. package/ios/StrokeTextView.swift +326 -0
  18. package/ios/StrokedTextLabel.swift +72 -0
  19. package/lib/StrokeText.d.ts +3 -0
  20. package/lib/StrokeText.js +109 -0
  21. package/lib/StrokeText.web.d.ts +3 -0
  22. package/lib/StrokeText.web.js +106 -0
  23. package/lib/index.d.ts +2 -0
  24. package/lib/index.js +1 -0
  25. package/lib/index.web.d.ts +2 -0
  26. package/lib/index.web.js +1 -0
  27. package/lib/specs/StrokeTextView.nitro.d.ts +37 -0
  28. package/lib/specs/StrokeTextView.nitro.js +1 -0
  29. package/lib/types.d.ts +17 -0
  30. package/lib/types.js +1 -0
  31. package/nitro.json +24 -0
  32. package/nitrogen/generated/.gitattributes +1 -0
  33. package/nitrogen/generated/android/NitroStrokeText+autolinking.cmake +83 -0
  34. package/nitrogen/generated/android/NitroStrokeText+autolinking.gradle +27 -0
  35. package/nitrogen/generated/android/NitroStrokeTextOnLoad.cpp +46 -0
  36. package/nitrogen/generated/android/NitroStrokeTextOnLoad.hpp +25 -0
  37. package/nitrogen/generated/android/c++/JHybridStrokeTextViewSpec.cpp +308 -0
  38. package/nitrogen/generated/android/c++/JHybridStrokeTextViewSpec.hpp +117 -0
  39. package/nitrogen/generated/android/c++/JStrokeTextAlign.hpp +67 -0
  40. package/nitrogen/generated/android/c++/JStrokeTextDecorationLine.hpp +64 -0
  41. package/nitrogen/generated/android/c++/JStrokeTextEllipsizeMode.hpp +64 -0
  42. package/nitrogen/generated/android/c++/JStrokeTextFontStyle.hpp +58 -0
  43. package/nitrogen/generated/android/c++/JStrokeTextTransform.hpp +64 -0
  44. package/nitrogen/generated/android/c++/views/JHybridStrokeTextViewStateUpdater.cpp +156 -0
  45. package/nitrogen/generated/android/c++/views/JHybridStrokeTextViewStateUpdater.hpp +49 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/HybridStrokeTextViewSpec.kt +209 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/NitroStrokeTextOnLoad.kt +35 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextAlign.kt +26 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextDecorationLine.kt +25 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextEllipsizeMode.kt +25 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextFontStyle.kt +23 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextTransform.kt +25 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/views/HybridStrokeTextViewManager.kt +70 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/views/HybridStrokeTextViewStateUpdater.kt +23 -0
  55. package/nitrogen/generated/ios/NitroStrokeText+autolinking.rb +60 -0
  56. package/nitrogen/generated/ios/NitroStrokeText-Swift-Cxx-Bridge.cpp +33 -0
  57. package/nitrogen/generated/ios/NitroStrokeText-Swift-Cxx-Bridge.hpp +177 -0
  58. package/nitrogen/generated/ios/NitroStrokeText-Swift-Cxx-Umbrella.hpp +58 -0
  59. package/nitrogen/generated/ios/NitroStrokeTextAutolinking.mm +33 -0
  60. package/nitrogen/generated/ios/NitroStrokeTextAutolinking.swift +26 -0
  61. package/nitrogen/generated/ios/c++/HybridStrokeTextViewSpecSwift.cpp +11 -0
  62. package/nitrogen/generated/ios/c++/HybridStrokeTextViewSpecSwift.hpp +271 -0
  63. package/nitrogen/generated/ios/c++/views/HybridStrokeTextViewComponent.mm +232 -0
  64. package/nitrogen/generated/ios/swift/HybridStrokeTextViewSpec.swift +81 -0
  65. package/nitrogen/generated/ios/swift/HybridStrokeTextViewSpec_cxx.swift +620 -0
  66. package/nitrogen/generated/ios/swift/StrokeTextAlign.swift +52 -0
  67. package/nitrogen/generated/ios/swift/StrokeTextDecorationLine.swift +48 -0
  68. package/nitrogen/generated/ios/swift/StrokeTextEllipsizeMode.swift +48 -0
  69. package/nitrogen/generated/ios/swift/StrokeTextFontStyle.swift +40 -0
  70. package/nitrogen/generated/ios/swift/StrokeTextTransform.swift +48 -0
  71. package/nitrogen/generated/shared/c++/HybridStrokeTextViewSpec.cpp +72 -0
  72. package/nitrogen/generated/shared/c++/HybridStrokeTextViewSpec.hpp +128 -0
  73. package/nitrogen/generated/shared/c++/StrokeTextAlign.hpp +88 -0
  74. package/nitrogen/generated/shared/c++/StrokeTextDecorationLine.hpp +84 -0
  75. package/nitrogen/generated/shared/c++/StrokeTextEllipsizeMode.hpp +84 -0
  76. package/nitrogen/generated/shared/c++/StrokeTextFontStyle.hpp +76 -0
  77. package/nitrogen/generated/shared/c++/StrokeTextTransform.hpp +84 -0
  78. package/nitrogen/generated/shared/c++/views/HybridStrokeTextViewComponent.cpp +388 -0
  79. package/nitrogen/generated/shared/c++/views/HybridStrokeTextViewComponent.hpp +138 -0
  80. package/nitrogen/generated/shared/json/StrokeTextViewConfig.json +35 -0
  81. package/package.json +124 -0
  82. package/react-native.config.js +16 -0
@@ -0,0 +1,429 @@
1
+ package com.margelo.nitro.stroketext
2
+
3
+ import android.graphics.Canvas
4
+ import android.graphics.Color
5
+ import android.graphics.Paint
6
+ import android.graphics.Paint.FontMetricsInt
7
+ import android.graphics.Typeface
8
+ import android.os.Build
9
+ import android.text.Layout
10
+ import android.text.Spannable
11
+ import android.text.SpannableString
12
+ import android.text.TextUtils
13
+ import android.text.style.LineHeightSpan
14
+ import android.util.TypedValue
15
+ import android.view.Gravity
16
+ import android.widget.TextView
17
+ import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
18
+ import com.facebook.react.uimanager.ThemedReactContext
19
+ import com.facebook.react.views.text.DefaultStyleValuesUtil
20
+ import com.facebook.react.views.text.ReactTypefaceUtils
21
+ import java.lang.reflect.Modifier
22
+ import kotlin.math.ceil
23
+ import kotlin.math.max
24
+ import kotlin.math.min
25
+
26
+ internal class StrokeTextView(context: ThemedReactContext) : TextView(context) {
27
+ var rawText: String = ""
28
+ var color: Int = resolvedDefaultTextColor()
29
+ var strokeColor: Int = Color.TRANSPARENT
30
+ var strokeWidthDp: Double = 0.0
31
+ var strokeWidthPx: Float = 0f
32
+
33
+ var fontSizePx: Float = spToPx(14.0)
34
+ var fontWeight: String? = null
35
+ var fontFamily: String? = null
36
+ var fontStyle: StrokeTextFontStyle = StrokeTextFontStyle.NORMAL
37
+ var lineHeightPx: Float? = null
38
+ var letterSpacingPx: Float? = null
39
+
40
+ var textAlign: StrokeTextAlign = StrokeTextAlign.AUTO
41
+ var textDecorationLine: StrokeTextDecorationLine = StrokeTextDecorationLine.NONE
42
+ var textTransform: StrokeTextTransform = StrokeTextTransform.NONE
43
+
44
+ var numberOfLines: Int = 0
45
+ var ellipsizeMode: StrokeTextEllipsizeMode? = null
46
+
47
+ var paddingAllPx: Float? = null
48
+ var paddingVerticalPx: Float? = null
49
+ var paddingHorizontalPx: Float? = null
50
+ var paddingTopPx: Float? = null
51
+ var paddingRightPx: Float? = null
52
+ var paddingBottomPx: Float? = null
53
+ var paddingLeftPx: Float? = null
54
+
55
+ init {
56
+ // Default to no font padding to avoid Android's extra ascent/descent insets shifting the
57
+ // glyphs downward. (React Native <Text> defaults includeFontPadding=true, but most designs
58
+ // expect iOS/web-like top alignment.)
59
+ gravity = Gravity.TOP or Gravity.START
60
+ includeFontPadding = false
61
+ }
62
+
63
+ fun invalidateTextLayout() {
64
+ applyProps()
65
+ requestLayout()
66
+ invalidate()
67
+ }
68
+
69
+ override fun onDraw(canvas: Canvas) {
70
+ // Draw stroke behind fill, using TextView's layout so metrics match RN <Text/> as closely as
71
+ // possible (especially for bold fonts).
72
+ val layout = layout
73
+ if (layout != null && strokeWidthPx > 0f && strokeColor != Color.TRANSPARENT) {
74
+ val textPaint = paint
75
+ val prevStyle = textPaint.style
76
+ val prevStrokeWidth = textPaint.strokeWidth
77
+ val prevStrokeJoin = textPaint.strokeJoin
78
+ val prevStrokeCap = textPaint.strokeCap
79
+ val prevColor = textPaint.color
80
+ val prevUnderline = textPaint.isUnderlineText
81
+ val prevStrike = textPaint.isStrikeThruText
82
+
83
+ val saveCount = canvas.save()
84
+ val compoundPaddingLeft = compoundPaddingLeft
85
+ val extendedPaddingTop = extendedPaddingTop
86
+
87
+ canvas.translate(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat())
88
+ canvas.translate(-scrollX.toFloat(), -scrollY.toFloat())
89
+
90
+ // Only stroke the glyph outlines; keep underline/strike in the fill pass.
91
+ textPaint.isUnderlineText = false
92
+ textPaint.isStrikeThruText = false
93
+
94
+ textPaint.style = Paint.Style.STROKE
95
+ textPaint.strokeJoin = Paint.Join.ROUND
96
+ textPaint.strokeCap = Paint.Cap.ROUND
97
+ textPaint.strokeWidth = strokeWidthPx
98
+ textPaint.color = strokeColor
99
+ layout.draw(canvas)
100
+
101
+ canvas.restoreToCount(saveCount)
102
+
103
+ textPaint.style = prevStyle
104
+ textPaint.strokeWidth = prevStrokeWidth
105
+ textPaint.strokeJoin = prevStrokeJoin
106
+ textPaint.strokeCap = prevStrokeCap
107
+ textPaint.color = prevColor
108
+ textPaint.isUnderlineText = prevUnderline
109
+ textPaint.isStrikeThruText = prevStrike
110
+ }
111
+
112
+ super.onDraw(canvas)
113
+ }
114
+
115
+ private fun applyProps() {
116
+ // Padding: apply stroke inset in native to compensate for the overlay expansion in JS.
117
+ val inset = strokeInsetPx()
118
+ val left = floorToInt(resolvePadding(paddingLeftPx, paddingHorizontalPx, paddingAllPx) + inset)
119
+ val top = floorToInt(resolvePadding(paddingTopPx, paddingVerticalPx, paddingAllPx) + inset)
120
+ val right = floorToInt(resolvePadding(paddingRightPx, paddingHorizontalPx, paddingAllPx) + inset)
121
+ val bottom = floorToInt(resolvePadding(paddingBottomPx, paddingVerticalPx, paddingAllPx) + inset)
122
+ setPadding(left, top, right, bottom)
123
+
124
+ // Font
125
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizePx)
126
+ typeface = resolveTypeface(fontFamily, fontWeight, fontStyle)
127
+
128
+ // Mirror RN's CustomStyleSpan flags.
129
+ applyCustomStyleTextFlags(paint, fontFamily, fontWeight, fontStyle)
130
+
131
+ // Letter spacing is specified in px/pt; TextView expects em.
132
+ val letterSpacingEm =
133
+ if (letterSpacingPx != null && !letterSpacingPx!!.isNaN() && fontSizePx > 0f) {
134
+ letterSpacingPx!! / fontSizePx
135
+ } else {
136
+ 0f
137
+ }
138
+ letterSpacing = letterSpacingEm
139
+
140
+ // Text decorations
141
+ val underline =
142
+ textDecorationLine == StrokeTextDecorationLine.UNDERLINE ||
143
+ textDecorationLine == StrokeTextDecorationLine.UNDERLINE_LINE_THROUGH
144
+ val strike =
145
+ textDecorationLine == StrokeTextDecorationLine.LINE_THROUGH ||
146
+ textDecorationLine == StrokeTextDecorationLine.UNDERLINE_LINE_THROUGH
147
+ paint.isUnderlineText = underline
148
+ paint.isStrikeThruText = strike
149
+
150
+ // Alignment
151
+ val horizontalGravity =
152
+ when (textAlign) {
153
+ StrokeTextAlign.RIGHT -> Gravity.END
154
+ StrokeTextAlign.CENTER -> Gravity.CENTER_HORIZONTAL
155
+ else -> Gravity.START
156
+ }
157
+ gravity = Gravity.TOP or horizontalGravity
158
+
159
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
160
+ justificationMode =
161
+ if (textAlign == StrokeTextAlign.JUSTIFY) Layout.JUSTIFICATION_MODE_INTER_WORD
162
+ else Layout.JUSTIFICATION_MODE_NONE
163
+ }
164
+
165
+ // Line limits / ellipsizing
166
+ val maxLines = if (numberOfLines > 0) numberOfLines else Int.MAX_VALUE
167
+ setMaxLines(maxLines)
168
+ ellipsize =
169
+ if (numberOfLines <= 0) {
170
+ null
171
+ } else {
172
+ when (ellipsizeMode ?: StrokeTextEllipsizeMode.TAIL) {
173
+ StrokeTextEllipsizeMode.HEAD -> TextUtils.TruncateAt.START
174
+ StrokeTextEllipsizeMode.MIDDLE -> TextUtils.TruncateAt.MIDDLE
175
+ StrokeTextEllipsizeMode.CLIP -> null
176
+ StrokeTextEllipsizeMode.TAIL -> TextUtils.TruncateAt.END
177
+ }
178
+ }
179
+
180
+ // Colors
181
+ setTextColor(color)
182
+
183
+ // Text + transform + line height (set last so layout is created with the final paint settings).
184
+ val transformedText = applyTextTransform(rawText, textTransform)
185
+ val textForLayout: CharSequence =
186
+ if (lineHeightPx != null && !lineHeightPx!!.isNaN() && transformedText.isNotEmpty()) {
187
+ SpannableString(transformedText).apply {
188
+ setSpan(
189
+ StrokeTextLineHeightSpan(lineHeightPx!!),
190
+ 0,
191
+ length,
192
+ Spannable.SPAN_INCLUSIVE_INCLUSIVE,
193
+ )
194
+ }
195
+ } else {
196
+ transformedText
197
+ }
198
+ setText(textForLayout)
199
+ }
200
+
201
+ private fun resolvePadding(specific: Float?, axis: Float?, all: Float?): Float {
202
+ return specific ?: axis ?: all ?: 0f
203
+ }
204
+
205
+ private fun strokeInsetPx(): Float {
206
+ if (strokeWidthDp <= 0.0) return 0f
207
+ val insetDp = (ceil(strokeWidthDp) / 2.0).toFloat()
208
+ return dpToPx(insetDp.toDouble())
209
+ }
210
+
211
+ private fun floorToInt(value: Float): Int {
212
+ // React Native consistently floors padding values when applying them to native views.
213
+ return kotlin.math.floor(value.toDouble()).toInt()
214
+ }
215
+
216
+ private fun resolveTypeface(
217
+ family: String?,
218
+ weight: String?,
219
+ style: StrokeTextFontStyle,
220
+ ): Typeface {
221
+ val fam = family?.takeIf { it.isNotBlank() }
222
+ val weightInt = ReactTypefaceUtils.parseFontWeight(weight)
223
+ val styleInt = if (style == StrokeTextFontStyle.ITALIC) Typeface.ITALIC else Typeface.NORMAL
224
+ return ReactTypefaceUtils.applyStyles(null, styleInt, weightInt, fam, context.assets)
225
+ }
226
+
227
+ private fun applyCustomStyleTextFlags(
228
+ paint: Paint,
229
+ family: String?,
230
+ weight: String?,
231
+ style: StrokeTextFontStyle,
232
+ ) {
233
+ val isCustomFamily = !family.isNullOrBlank()
234
+ val isCustomItalic = style == StrokeTextFontStyle.ITALIC
235
+ val isCustomWeight =
236
+ ReactTypefaceUtils.parseFontWeight(weight) != com.facebook.react.common.ReactConstants.UNSET
237
+ val hasCustomStyle = isCustomFamily || isCustomItalic || isCustomWeight
238
+
239
+ // Mirror React Native's CustomStyleSpan defaults.
240
+ paint.isSubpixelText = hasCustomStyle
241
+ paint.isLinearText = hasCustomStyle && isAndroidLinearTextEnabled()
242
+ }
243
+
244
+ private fun isAndroidLinearTextEnabled(): Boolean {
245
+ return try {
246
+ val method =
247
+ ReactNativeFeatureFlags::class.java.methods.firstOrNull {
248
+ it.name == "enableAndroidLinearText" && it.parameterCount == 0
249
+ } ?: return false
250
+ val receiver = if (Modifier.isStatic(method.modifiers)) null else ReactNativeFeatureFlags
251
+ (method.invoke(receiver) as? Boolean) == true
252
+ } catch (_: Throwable) {
253
+ false
254
+ }
255
+ }
256
+
257
+ fun resolvedDefaultTextColor(): Int {
258
+ return DefaultStyleValuesUtil.getDefaultTextColor(context)?.defaultColor ?: Color.BLACK
259
+ }
260
+
261
+ private fun applyTextTransform(text: String, transform: StrokeTextTransform): String {
262
+ return when (transform) {
263
+ StrokeTextTransform.UPPERCASE -> text.uppercase()
264
+ StrokeTextTransform.LOWERCASE -> text.lowercase()
265
+ StrokeTextTransform.CAPITALIZE -> capitalizeWords(text)
266
+ StrokeTextTransform.NONE -> text
267
+ }
268
+ }
269
+
270
+ private fun capitalizeWords(input: String): String {
271
+ val sb = StringBuilder(input.length)
272
+ var cap = true
273
+ for (c in input) {
274
+ if (c.isWhitespace()) {
275
+ cap = true
276
+ sb.append(c)
277
+ } else {
278
+ sb.append(if (cap) c.titlecaseChar() else c)
279
+ cap = false
280
+ }
281
+ }
282
+ return sb.toString()
283
+ }
284
+
285
+ private class StrokeTextLineHeightSpan(heightPx: Float) : LineHeightSpan {
286
+ private val lineHeight: Int = ceil(heightPx.toDouble()).toInt()
287
+
288
+ override fun chooseHeight(
289
+ text: CharSequence,
290
+ start: Int,
291
+ end: Int,
292
+ spanstartv: Int,
293
+ v: Int,
294
+ fm: FontMetricsInt,
295
+ ) {
296
+ val leading = lineHeight - ((-fm.ascent) + fm.descent)
297
+ fm.ascent -= ceil(leading / 2.0f).toInt()
298
+ fm.descent += kotlin.math.floor(leading / 2.0f).toInt()
299
+
300
+ if (start == 0) {
301
+ fm.top = fm.ascent
302
+ }
303
+ if (end == text.length) {
304
+ fm.bottom = fm.descent
305
+ }
306
+ }
307
+ }
308
+
309
+ companion object {
310
+ fun parseColor(color: String?): Int? {
311
+ val trimmed = color?.trim().orEmpty()
312
+ if (trimmed.isEmpty()) return null
313
+
314
+ val lower = trimmed.lowercase()
315
+ if (lower.startsWith("#")) return parseHexColor(trimmed)
316
+ if (lower.startsWith("rgba(")) return parseRgba(trimmed)
317
+ if (lower.startsWith("rgb(")) return parseRgb(trimmed)
318
+
319
+ return runCatching { Color.parseColor(trimmed) }.getOrNull()
320
+ }
321
+
322
+ private fun parseHexColor(color: String): Int? {
323
+ var hex = color.removePrefix("#").trim()
324
+ if (hex.isEmpty()) return null
325
+
326
+ if (hex.length == 3) {
327
+ // #RGB
328
+ hex = "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}"
329
+ } else if (hex.length == 4) {
330
+ // #RGBA (CSS Color Module Level 4 / React Native)
331
+ hex = "${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}"
332
+ }
333
+
334
+ return when (hex.length) {
335
+ 6 -> {
336
+ val rgb = hex.toLongOrNull(16) ?: return null
337
+ val r = ((rgb shr 16) and 0xFF).toInt()
338
+ val g = ((rgb shr 8) and 0xFF).toInt()
339
+ val b = (rgb and 0xFF).toInt()
340
+ Color.argb(255, r, g, b)
341
+ }
342
+ 8 -> {
343
+ // #RRGGBBAA (CSS Color Module Level 4 / React Native)
344
+ val r = hex.substring(0, 2).toIntOrNull(16) ?: return null
345
+ val g = hex.substring(2, 4).toIntOrNull(16) ?: return null
346
+ val b = hex.substring(4, 6).toIntOrNull(16) ?: return null
347
+ val a = hex.substring(6, 8).toIntOrNull(16) ?: return null
348
+ Color.argb(a, r, g, b)
349
+ }
350
+ else -> null
351
+ }
352
+ }
353
+
354
+ private fun parseRgb(color: String): Int? {
355
+ val inner = color.removePrefix("rgb(").removeSuffix(")")
356
+ val parts = inner.split(",").map { it.trim() }
357
+ if (parts.size != 3) return null
358
+ val r = parts[0].toIntOrNull()?.coerceIn(0, 255) ?: return null
359
+ val g = parts[1].toIntOrNull()?.coerceIn(0, 255) ?: return null
360
+ val b = parts[2].toIntOrNull()?.coerceIn(0, 255) ?: return null
361
+ return Color.rgb(r, g, b)
362
+ }
363
+
364
+ private fun parseRgba(color: String): Int? {
365
+ val inner = color.removePrefix("rgba(").removeSuffix(")")
366
+ val parts = inner.split(",").map { it.trim() }
367
+ if (parts.size != 4) return null
368
+ val r = parts[0].toIntOrNull()?.coerceIn(0, 255) ?: return null
369
+ val g = parts[1].toIntOrNull()?.coerceIn(0, 255) ?: return null
370
+ val b = parts[2].toIntOrNull()?.coerceIn(0, 255) ?: return null
371
+ val aFloat = parts[3].toFloatOrNull() ?: return null
372
+ val a = min(255, max(0, (aFloat * 255f).toInt()))
373
+ return Color.argb(a, r, g, b)
374
+ }
375
+
376
+ fun dpToPx(dp: Float, displayMetrics: android.util.DisplayMetrics): Float {
377
+ return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, displayMetrics)
378
+ }
379
+
380
+ fun spToPx(sp: Double, displayMetrics: android.util.DisplayMetrics): Float {
381
+ return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp.toFloat(), displayMetrics)
382
+ }
383
+
384
+ fun spToPx(
385
+ sp: Double,
386
+ displayMetrics: android.util.DisplayMetrics,
387
+ maxFontSizeMultiplier: Float?,
388
+ ): Float {
389
+ val density = displayMetrics.density
390
+ if (density == 0f) return spToPx(sp, displayMetrics)
391
+
392
+ val fontScale = displayMetrics.scaledDensity / density
393
+ val effectiveFontScale =
394
+ if (
395
+ maxFontSizeMultiplier == null ||
396
+ maxFontSizeMultiplier.isNaN() ||
397
+ maxFontSizeMultiplier <= 0f ||
398
+ maxFontSizeMultiplier < 1f
399
+ ) {
400
+ fontScale
401
+ } else {
402
+ min(fontScale, maxFontSizeMultiplier)
403
+ }
404
+
405
+ return (sp.toFloat() * density * effectiveFontScale)
406
+ }
407
+
408
+ fun textToPx(
409
+ value: Double,
410
+ allowFontScaling: Boolean,
411
+ maxFontSizeMultiplier: Float?,
412
+ displayMetrics: android.util.DisplayMetrics,
413
+ ): Float {
414
+ return if (allowFontScaling) {
415
+ spToPx(value, displayMetrics, maxFontSizeMultiplier)
416
+ } else {
417
+ dpToPx(value.toFloat(), displayMetrics)
418
+ }
419
+ }
420
+ }
421
+
422
+ private fun spToPx(sp: Double): Float {
423
+ return spToPx(sp, resources.displayMetrics)
424
+ }
425
+
426
+ private fun dpToPx(dp: Double): Float {
427
+ return dpToPx(dp.toFloat(), resources.displayMetrics)
428
+ }
429
+ }
package/ios/Bridge.h ADDED
@@ -0,0 +1,8 @@
1
+ //
2
+ // Bridge.h
3
+ // NitroStrokeText
4
+ //
5
+ // Created by Marc Rousavy on 22.07.24.
6
+ //
7
+
8
+ #pragma once
@@ -0,0 +1,114 @@
1
+ import NitroModules
2
+ import UIKit
3
+
4
+ final class HybridStrokeTextView: HybridStrokeTextViewSpec {
5
+ typealias ViewType = StrokeTextView
6
+
7
+ let view = StrokeTextView()
8
+
9
+ var text: String = ""
10
+ var color: String? = nil
11
+ var strokeColor: String? = nil
12
+ var strokeWidth: Double? = nil
13
+ var fontSize: Double? = nil
14
+ var fontWeight: String? = nil
15
+ var fontFamily: String? = nil
16
+ var fontStyle: StrokeTextFontStyle? = nil
17
+ var lineHeight: Double? = nil
18
+ var letterSpacing: Double? = nil
19
+ var textAlign: StrokeTextAlign? = nil
20
+ var textDecorationLine: StrokeTextDecorationLine? = nil
21
+ var textTransform: StrokeTextTransform? = nil
22
+ var opacity: Double? = nil
23
+ var allowFontScaling: Bool? = nil
24
+ var maxFontSizeMultiplier: Double? = nil
25
+ var includeFontPadding: Bool? = nil
26
+ var numberOfLines: Double? = nil
27
+ var ellipsizeMode: StrokeTextEllipsizeMode? = nil
28
+ var padding: Double? = nil
29
+ var paddingVertical: Double? = nil
30
+ var paddingHorizontal: Double? = nil
31
+ var paddingTop: Double? = nil
32
+ var paddingRight: Double? = nil
33
+ var paddingBottom: Double? = nil
34
+ var paddingLeft: Double? = nil
35
+
36
+ func afterUpdate() {
37
+ view.text = text
38
+
39
+ if let color = color, let parsed = StrokeTextColor.parse(color) {
40
+ view.color = parsed
41
+ } else {
42
+ view.color = StrokeTextView.defaultTextColor()
43
+ }
44
+
45
+ if let strokeColor = strokeColor, let parsed = StrokeTextColor.parse(strokeColor) {
46
+ view.strokeColor = parsed
47
+ } else {
48
+ view.strokeColor = .clear
49
+ }
50
+
51
+ let resolvedStrokeWidth = max(0, strokeWidth ?? 0)
52
+ view.strokeWidth = CGFloat(resolvedStrokeWidth)
53
+
54
+ view.fontSize = CGFloat(fontSize ?? 14)
55
+ view.fontWeight = fontWeight ?? "400"
56
+ view.fontFamily = fontFamily
57
+ view.fontStyle = fontStyle ?? .normal
58
+
59
+ view.allowFontScaling = allowFontScaling ?? true
60
+ if let multiplier = maxFontSizeMultiplier, multiplier.isFinite, multiplier >= 1 {
61
+ view.maxFontSizeMultiplier = CGFloat(multiplier)
62
+ } else {
63
+ view.maxFontSizeMultiplier = nil
64
+ }
65
+
66
+ view.lineHeight = lineHeight.map { CGFloat($0) }
67
+ view.letterSpacing = letterSpacing.map { CGFloat($0) }
68
+ view.textAlign = textAlign ?? .auto
69
+ view.textDecorationLine = textDecorationLine ?? .none
70
+ view.textTransform = textTransform ?? .none
71
+
72
+ view.numberOfLines = Int(numberOfLines ?? 0)
73
+ view.ellipsizeMode = ellipsizeMode ?? .tail
74
+
75
+ view.alpha = CGFloat(opacity ?? 1)
76
+
77
+ let baseInsets = resolvedPaddingInsets(
78
+ padding: padding,
79
+ paddingVertical: paddingVertical,
80
+ paddingHorizontal: paddingHorizontal,
81
+ paddingTop: paddingTop,
82
+ paddingRight: paddingRight,
83
+ paddingBottom: paddingBottom,
84
+ paddingLeft: paddingLeft
85
+ )
86
+ let strokeInset = CGFloat(ceil(resolvedStrokeWidth) / 2.0)
87
+ view.paddingInsets = UIEdgeInsets(
88
+ top: baseInsets.top + strokeInset,
89
+ left: baseInsets.left + strokeInset,
90
+ bottom: baseInsets.bottom + strokeInset,
91
+ right: baseInsets.right + strokeInset
92
+ )
93
+ }
94
+
95
+ private func resolvedPaddingInsets(
96
+ padding: Double?,
97
+ paddingVertical: Double?,
98
+ paddingHorizontal: Double?,
99
+ paddingTop: Double?,
100
+ paddingRight: Double?,
101
+ paddingBottom: Double?,
102
+ paddingLeft: Double?
103
+ ) -> UIEdgeInsets {
104
+ func resolve(_ specific: Double?, _ axis: Double?, _ all: Double?) -> CGFloat {
105
+ return CGFloat(specific ?? axis ?? all ?? 0)
106
+ }
107
+
108
+ let top = resolve(paddingTop, paddingVertical, padding)
109
+ let bottom = resolve(paddingBottom, paddingVertical, padding)
110
+ let left = resolve(paddingLeft, paddingHorizontal, padding)
111
+ let right = resolve(paddingRight, paddingHorizontal, padding)
112
+ return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
113
+ }
114
+ }
@@ -0,0 +1,120 @@
1
+ import UIKit
2
+
3
+ enum StrokeTextColor {
4
+ static func parse(_ colorString: String) -> UIColor? {
5
+ let trimmed = colorString.trimmingCharacters(in: .whitespacesAndNewlines)
6
+ if trimmed.isEmpty {
7
+ return nil
8
+ }
9
+
10
+ if trimmed.hasPrefix("#") {
11
+ return parseHex(trimmed)
12
+ }
13
+
14
+ let lower = trimmed.lowercased()
15
+ if let keyword = parseKeyword(lower) {
16
+ return keyword
17
+ }
18
+ if lower.hasPrefix("rgba(") {
19
+ return parseRGBA(trimmed)
20
+ }
21
+ if lower.hasPrefix("rgb(") {
22
+ return parseRGB(trimmed)
23
+ }
24
+
25
+ return nil
26
+ }
27
+
28
+ private static func parseHex(_ hexString: String) -> UIColor? {
29
+ var hex = String(hexString.dropFirst())
30
+
31
+ if hex.count == 3 {
32
+ hex = hex.map { "\($0)\($0)" }.joined()
33
+ }
34
+
35
+ if hex.count == 4 {
36
+ // #RGBA (CSS Color Module Level 4 / React Native)
37
+ hex = hex.map { "\($0)\($0)" }.joined()
38
+ }
39
+
40
+ let alpha: CGFloat
41
+ let rgbHex: String
42
+
43
+ switch hex.count {
44
+ case 6:
45
+ alpha = 1
46
+ rgbHex = hex
47
+ case 8:
48
+ // #RRGGBBAA (CSS Color Module Level 4 / React Native)
49
+ rgbHex = String(hex.prefix(6))
50
+ let a = String(hex.suffix(2))
51
+ guard let aByte = UInt8(a, radix: 16) else { return nil }
52
+ alpha = CGFloat(aByte) / 255.0
53
+ default:
54
+ return nil
55
+ }
56
+
57
+ guard let rgb = UInt32(rgbHex, radix: 16) else { return nil }
58
+
59
+ let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
60
+ let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
61
+ let b = CGFloat(rgb & 0x0000FF) / 255.0
62
+ return UIColor(red: r, green: g, blue: b, alpha: alpha)
63
+ }
64
+
65
+ private static func parseKeyword(_ lower: String) -> UIColor? {
66
+ switch lower {
67
+ case "transparent", "clear":
68
+ return .clear
69
+ case "black":
70
+ return .black
71
+ case "white":
72
+ return .white
73
+ case "red":
74
+ return .red
75
+ case "green":
76
+ return .green
77
+ case "blue":
78
+ return .blue
79
+ case "cyan", "aqua":
80
+ return .cyan
81
+ case "magenta", "fuchsia":
82
+ return .magenta
83
+ case "yellow":
84
+ return .yellow
85
+ case "gray", "grey":
86
+ return .gray
87
+ case "lightgray", "lightgrey":
88
+ return .lightGray
89
+ case "darkgray", "darkgrey":
90
+ return .darkGray
91
+ default:
92
+ return nil
93
+ }
94
+ }
95
+
96
+ private static func parseRGB(_ rgbString: String) -> UIColor? {
97
+ let inner = rgbString.dropFirst(4).dropLast(1)
98
+ let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
99
+ guard parts.count == 3 else { return nil }
100
+ guard
101
+ let r = Double(parts[0]),
102
+ let g = Double(parts[1]),
103
+ let b = Double(parts[2])
104
+ else { return nil }
105
+ return UIColor(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: 1)
106
+ }
107
+
108
+ private static func parseRGBA(_ rgbaString: String) -> UIColor? {
109
+ let inner = rgbaString.dropFirst(5).dropLast(1)
110
+ let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
111
+ guard parts.count == 4 else { return nil }
112
+ guard
113
+ let r = Double(parts[0]),
114
+ let g = Double(parts[1]),
115
+ let b = Double(parts[2]),
116
+ let a = Double(parts[3])
117
+ else { return nil }
118
+ return UIColor(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: a)
119
+ }
120
+ }