@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.
- package/CHANGELOG.md +7 -0
- package/LICENSE.md +21 -0
- package/NitroStrokeText.podspec +31 -0
- package/README.md +45 -0
- package/android/CMakeLists.txt +29 -0
- package/android/build.gradle +142 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/margelo/nitro/stroketext/HybridStrokeTextView.kt +107 -0
- package/android/src/main/java/com/margelo/nitro/stroketext/NitroStrokeTextPackage.kt +39 -0
- package/android/src/main/java/com/margelo/nitro/stroketext/StrokeTextView.kt +429 -0
- package/ios/Bridge.h +8 -0
- package/ios/HybridStrokeTextView.swift +114 -0
- package/ios/StrokeTextColor.swift +120 -0
- package/ios/StrokeTextView.swift +326 -0
- package/ios/StrokedTextLabel.swift +72 -0
- package/lib/StrokeText.d.ts +3 -0
- package/lib/StrokeText.js +109 -0
- package/lib/StrokeText.web.d.ts +3 -0
- package/lib/StrokeText.web.js +106 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +1 -0
- package/lib/index.web.d.ts +2 -0
- package/lib/index.web.js +1 -0
- package/lib/specs/StrokeTextView.nitro.d.ts +37 -0
- package/lib/specs/StrokeTextView.nitro.js +1 -0
- package/lib/types.d.ts +17 -0
- package/lib/types.js +1 -0
- package/nitro.json +24 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/NitroStrokeText+autolinking.cmake +83 -0
- package/nitrogen/generated/android/NitroStrokeText+autolinking.gradle +27 -0
- package/nitrogen/generated/android/NitroStrokeTextOnLoad.cpp +46 -0
- package/nitrogen/generated/android/NitroStrokeTextOnLoad.hpp +25 -0
- package/nitrogen/generated/android/c++/JHybridStrokeTextViewSpec.cpp +308 -0
- package/nitrogen/generated/android/c++/JHybridStrokeTextViewSpec.hpp +117 -0
- package/nitrogen/generated/android/c++/JStrokeTextAlign.hpp +67 -0
- package/nitrogen/generated/android/c++/JStrokeTextDecorationLine.hpp +64 -0
- package/nitrogen/generated/android/c++/JStrokeTextEllipsizeMode.hpp +64 -0
- package/nitrogen/generated/android/c++/JStrokeTextFontStyle.hpp +58 -0
- package/nitrogen/generated/android/c++/JStrokeTextTransform.hpp +64 -0
- package/nitrogen/generated/android/c++/views/JHybridStrokeTextViewStateUpdater.cpp +156 -0
- package/nitrogen/generated/android/c++/views/JHybridStrokeTextViewStateUpdater.hpp +49 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/HybridStrokeTextViewSpec.kt +209 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/NitroStrokeTextOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextAlign.kt +26 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextDecorationLine.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextEllipsizeMode.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextFontStyle.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/StrokeTextTransform.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/views/HybridStrokeTextViewManager.kt +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/stroketext/views/HybridStrokeTextViewStateUpdater.kt +23 -0
- package/nitrogen/generated/ios/NitroStrokeText+autolinking.rb +60 -0
- package/nitrogen/generated/ios/NitroStrokeText-Swift-Cxx-Bridge.cpp +33 -0
- package/nitrogen/generated/ios/NitroStrokeText-Swift-Cxx-Bridge.hpp +177 -0
- package/nitrogen/generated/ios/NitroStrokeText-Swift-Cxx-Umbrella.hpp +58 -0
- package/nitrogen/generated/ios/NitroStrokeTextAutolinking.mm +33 -0
- package/nitrogen/generated/ios/NitroStrokeTextAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridStrokeTextViewSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridStrokeTextViewSpecSwift.hpp +271 -0
- package/nitrogen/generated/ios/c++/views/HybridStrokeTextViewComponent.mm +232 -0
- package/nitrogen/generated/ios/swift/HybridStrokeTextViewSpec.swift +81 -0
- package/nitrogen/generated/ios/swift/HybridStrokeTextViewSpec_cxx.swift +620 -0
- package/nitrogen/generated/ios/swift/StrokeTextAlign.swift +52 -0
- package/nitrogen/generated/ios/swift/StrokeTextDecorationLine.swift +48 -0
- package/nitrogen/generated/ios/swift/StrokeTextEllipsizeMode.swift +48 -0
- package/nitrogen/generated/ios/swift/StrokeTextFontStyle.swift +40 -0
- package/nitrogen/generated/ios/swift/StrokeTextTransform.swift +48 -0
- package/nitrogen/generated/shared/c++/HybridStrokeTextViewSpec.cpp +72 -0
- package/nitrogen/generated/shared/c++/HybridStrokeTextViewSpec.hpp +128 -0
- package/nitrogen/generated/shared/c++/StrokeTextAlign.hpp +88 -0
- package/nitrogen/generated/shared/c++/StrokeTextDecorationLine.hpp +84 -0
- package/nitrogen/generated/shared/c++/StrokeTextEllipsizeMode.hpp +84 -0
- package/nitrogen/generated/shared/c++/StrokeTextFontStyle.hpp +76 -0
- package/nitrogen/generated/shared/c++/StrokeTextTransform.hpp +84 -0
- package/nitrogen/generated/shared/c++/views/HybridStrokeTextViewComponent.cpp +388 -0
- package/nitrogen/generated/shared/c++/views/HybridStrokeTextViewComponent.hpp +138 -0
- package/nitrogen/generated/shared/json/StrokeTextViewConfig.json +35 -0
- package/package.json +124 -0
- 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,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
|
+
}
|