@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,326 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
final class StrokeTextView: UIView {
|
|
4
|
+
private let label = StrokedTextLabel()
|
|
5
|
+
|
|
6
|
+
var text: String = "" {
|
|
7
|
+
didSet { updateText() }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
var color: UIColor = StrokeTextView.defaultTextColor() {
|
|
11
|
+
didSet { label.textColor = color }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
var strokeColor: UIColor = .clear {
|
|
15
|
+
didSet { label.outlineColor = strokeColor }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var strokeWidth: CGFloat = 0 {
|
|
19
|
+
didSet { label.outlineWidth = strokeWidth }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var fontSize: CGFloat = 14 {
|
|
23
|
+
didSet { updateFont() }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var fontWeight: String = "400" {
|
|
27
|
+
didSet { updateFont() }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var fontFamily: String? = nil {
|
|
31
|
+
didSet { updateFont() }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var fontStyle: StrokeTextFontStyle = .normal {
|
|
35
|
+
didSet { updateFont() }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
var allowFontScaling: Bool = true {
|
|
39
|
+
didSet { updateFont() }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
var maxFontSizeMultiplier: CGFloat? = nil {
|
|
43
|
+
didSet { updateFont() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
var lineHeight: CGFloat? = nil {
|
|
47
|
+
didSet { updateText() }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var letterSpacing: CGFloat? = nil {
|
|
51
|
+
didSet { updateText() }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
var textAlign: StrokeTextAlign = .auto {
|
|
55
|
+
didSet { updateText() }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var textDecorationLine: StrokeTextDecorationLine = .none {
|
|
59
|
+
didSet { updateText() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var textTransform: StrokeTextTransform = .none {
|
|
63
|
+
didSet { updateText() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
var numberOfLines: Int = 0 {
|
|
67
|
+
didSet {
|
|
68
|
+
label.numberOfLines = numberOfLines
|
|
69
|
+
updateLineBreakMode()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var ellipsizeMode: StrokeTextEllipsizeMode = .tail {
|
|
74
|
+
didSet { updateLineBreakMode() }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var paddingInsets: UIEdgeInsets = .zero {
|
|
78
|
+
didSet {
|
|
79
|
+
label.textInsets = paddingInsets
|
|
80
|
+
invalidateMeasurements()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override init(frame: CGRect) {
|
|
85
|
+
super.init(frame: frame)
|
|
86
|
+
isOpaque = false
|
|
87
|
+
addSubview(label)
|
|
88
|
+
label.textInsets = paddingInsets
|
|
89
|
+
updateLineBreakMode()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@available(*, unavailable)
|
|
93
|
+
required init?(coder: NSCoder) {
|
|
94
|
+
fatalError("init(coder:) has not been implemented")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
override func layoutSubviews() {
|
|
98
|
+
super.layoutSubviews()
|
|
99
|
+
label.frame = bounds
|
|
100
|
+
label.preferredMaxLayoutWidth = max(
|
|
101
|
+
0,
|
|
102
|
+
bounds.width - paddingInsets.left - paddingInsets.right
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
107
|
+
return label.sizeThatFits(size)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override var intrinsicContentSize: CGSize {
|
|
111
|
+
return label.intrinsicContentSize
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private func updateFont() {
|
|
115
|
+
let baseSize = max(1, fontSize)
|
|
116
|
+
let scaledSize = scaleTypography(baseSize)
|
|
117
|
+
let weight = fontWeight.toUIFontWeight()
|
|
118
|
+
let italic = fontStyle == .italic
|
|
119
|
+
|
|
120
|
+
label.font = resolveFont(family: fontFamily, size: scaledSize, weight: weight, italic: italic)
|
|
121
|
+
updateText()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private func updateText() {
|
|
125
|
+
let transformed = textTransform.apply(to: text)
|
|
126
|
+
|
|
127
|
+
let paragraphStyle = NSMutableParagraphStyle()
|
|
128
|
+
paragraphStyle.alignment = textAlign.toNSTextAlignment()
|
|
129
|
+
if let lh = lineHeight, lh > 0 {
|
|
130
|
+
let scaledLineHeight = scaleTypography(lh)
|
|
131
|
+
paragraphStyle.minimumLineHeight = scaledLineHeight
|
|
132
|
+
paragraphStyle.maximumLineHeight = scaledLineHeight
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
var attributes: [NSAttributedString.Key: Any] = [
|
|
136
|
+
.paragraphStyle: paragraphStyle,
|
|
137
|
+
.font: label.font as Any,
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
if let ls = letterSpacing {
|
|
141
|
+
attributes[.kern] = scaleTypography(ls)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
switch textDecorationLine {
|
|
145
|
+
case .underline:
|
|
146
|
+
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
147
|
+
case .lineThrough:
|
|
148
|
+
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
149
|
+
case .underlineLineThrough:
|
|
150
|
+
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
151
|
+
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
152
|
+
case .none:
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
label.attributedText = NSAttributedString(string: transformed, attributes: attributes)
|
|
157
|
+
invalidateMeasurements()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private func updateLineBreakMode() {
|
|
161
|
+
if numberOfLines <= 0 {
|
|
162
|
+
label.lineBreakMode = .byWordWrapping
|
|
163
|
+
invalidateMeasurements()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
switch ellipsizeMode {
|
|
168
|
+
case .head:
|
|
169
|
+
label.lineBreakMode = .byTruncatingHead
|
|
170
|
+
case .middle:
|
|
171
|
+
label.lineBreakMode = .byTruncatingMiddle
|
|
172
|
+
case .clip:
|
|
173
|
+
label.lineBreakMode = .byClipping
|
|
174
|
+
case .tail:
|
|
175
|
+
label.lineBreakMode = .byTruncatingTail
|
|
176
|
+
@unknown default:
|
|
177
|
+
label.lineBreakMode = .byTruncatingTail
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
invalidateMeasurements()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func scaleTypography(_ value: CGFloat) -> CGFloat {
|
|
184
|
+
guard value > 0, allowFontScaling else { return value }
|
|
185
|
+
let scaled = UIFontMetrics.default.scaledValue(for: value)
|
|
186
|
+
guard let maxMultiplier = maxFontSizeMultiplier, maxMultiplier > 0 else { return scaled }
|
|
187
|
+
return min(scaled, value * maxMultiplier)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private func resolveFont(
|
|
191
|
+
family: String?,
|
|
192
|
+
size: CGFloat,
|
|
193
|
+
weight: UIFont.Weight,
|
|
194
|
+
italic: Bool
|
|
195
|
+
) -> UIFont {
|
|
196
|
+
let trimmedFamily = family?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
197
|
+
|
|
198
|
+
if let name = trimmedFamily, !name.isEmpty {
|
|
199
|
+
// 1) Treat as a concrete font name (PostScript name).
|
|
200
|
+
if let fontByName = UIFont(name: name, size: size) {
|
|
201
|
+
return italic ? fontByName.withItalicTrait(size: size) : fontByName
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 2) Treat as a family name and pick the closest matching variant.
|
|
205
|
+
let familyName =
|
|
206
|
+
UIFont.familyNames.first(where: { $0.caseInsensitiveCompare(name) == .orderedSame }) ?? name
|
|
207
|
+
let candidates = UIFont.fontNames(forFamilyName: familyName).compactMap { UIFont(name: $0, size: size) }
|
|
208
|
+
if let best = bestFontMatch(in: candidates, size: size, weight: weight, italic: italic) {
|
|
209
|
+
return best
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3) Fallback to system font.
|
|
214
|
+
let system = UIFont.systemFont(ofSize: size, weight: weight)
|
|
215
|
+
return italic ? system.withItalicTrait(size: size) : system
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private func bestFontMatch(
|
|
219
|
+
in fonts: [UIFont],
|
|
220
|
+
size: CGFloat,
|
|
221
|
+
weight: UIFont.Weight,
|
|
222
|
+
italic: Bool
|
|
223
|
+
) -> UIFont? {
|
|
224
|
+
guard !fonts.isEmpty else { return nil }
|
|
225
|
+
|
|
226
|
+
let targetWeight = Double(weight.rawValue)
|
|
227
|
+
var best: UIFont = fonts[0]
|
|
228
|
+
var bestScore = Double.greatestFiniteMagnitude
|
|
229
|
+
|
|
230
|
+
for font in fonts {
|
|
231
|
+
let traits = font.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any]
|
|
232
|
+
let fontWeight = (traits?[.weight] as? CGFloat) ?? 0
|
|
233
|
+
let isItalic = font.fontDescriptor.symbolicTraits.contains(.traitItalic)
|
|
234
|
+
|
|
235
|
+
let italicPenalty = isItalic == italic ? 0.0 : 1000.0
|
|
236
|
+
let weightPenalty = Swift.abs(Double(fontWeight) - targetWeight)
|
|
237
|
+
let score = italicPenalty + weightPenalty
|
|
238
|
+
|
|
239
|
+
if score < bestScore {
|
|
240
|
+
bestScore = score
|
|
241
|
+
best = font
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return italic ? best.withItalicTrait(size: size) : best
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private func invalidateMeasurements() {
|
|
249
|
+
setNeedsLayout()
|
|
250
|
+
invalidateIntrinsicContentSize()
|
|
251
|
+
setNeedsDisplay()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
static func defaultTextColor() -> UIColor {
|
|
255
|
+
if #available(iOS 13.0, *) {
|
|
256
|
+
return .label
|
|
257
|
+
}
|
|
258
|
+
return .black
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private extension String {
|
|
263
|
+
func toUIFontWeight() -> UIFont.Weight {
|
|
264
|
+
let lower = trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
265
|
+
switch lower {
|
|
266
|
+
case "ultralight", "100": return .ultraLight
|
|
267
|
+
case "thin", "200": return .thin
|
|
268
|
+
case "light", "300": return .light
|
|
269
|
+
case "normal", "regular", "400": return .regular
|
|
270
|
+
case "medium", "500": return .medium
|
|
271
|
+
case "semibold", "600": return .semibold
|
|
272
|
+
case "bold", "700": return .bold
|
|
273
|
+
case "heavy", "800": return .heavy
|
|
274
|
+
case "black", "900": return .black
|
|
275
|
+
default:
|
|
276
|
+
if let value = Int(lower) {
|
|
277
|
+
switch value {
|
|
278
|
+
case ...150: return .ultraLight
|
|
279
|
+
case 151...250: return .thin
|
|
280
|
+
case 251...350: return .light
|
|
281
|
+
case 351...450: return .regular
|
|
282
|
+
case 451...550: return .medium
|
|
283
|
+
case 551...650: return .semibold
|
|
284
|
+
case 651...750: return .bold
|
|
285
|
+
case 751...850: return .heavy
|
|
286
|
+
default: return .black
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return .regular
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private extension UIFont {
|
|
295
|
+
func withItalicTrait(size: CGFloat) -> UIFont {
|
|
296
|
+
let descriptor = fontDescriptor
|
|
297
|
+
let traits = descriptor.symbolicTraits.union(.traitItalic)
|
|
298
|
+
guard let italicDescriptor = descriptor.withSymbolicTraits(traits) else { return self }
|
|
299
|
+
return UIFont(descriptor: italicDescriptor, size: size)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private extension StrokeTextAlign {
|
|
304
|
+
func toNSTextAlignment() -> NSTextAlignment {
|
|
305
|
+
switch self {
|
|
306
|
+
case .left: return .left
|
|
307
|
+
case .right: return .right
|
|
308
|
+
case .center: return .center
|
|
309
|
+
case .justify: return .justified
|
|
310
|
+
case .auto: return .natural
|
|
311
|
+
@unknown default: return .natural
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private extension StrokeTextTransform {
|
|
317
|
+
func apply(to input: String) -> String {
|
|
318
|
+
switch self {
|
|
319
|
+
case .uppercase: return input.uppercased()
|
|
320
|
+
case .lowercase: return input.lowercased()
|
|
321
|
+
case .capitalize: return input.capitalized
|
|
322
|
+
case .none: return input
|
|
323
|
+
@unknown default: return input
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
final class StrokedTextLabel: UILabel {
|
|
4
|
+
var textInsets: UIEdgeInsets = .zero {
|
|
5
|
+
didSet { invalidateMeasurements() }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
var outlineColor: UIColor = .clear {
|
|
9
|
+
didSet { setNeedsDisplay() }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
var outlineWidth: CGFloat = 0 {
|
|
13
|
+
didSet { invalidateMeasurements() }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override init(frame: CGRect) {
|
|
17
|
+
super.init(frame: frame)
|
|
18
|
+
numberOfLines = 0
|
|
19
|
+
lineBreakMode = .byWordWrapping
|
|
20
|
+
clipsToBounds = false
|
|
21
|
+
layer.masksToBounds = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@available(*, unavailable)
|
|
25
|
+
required init?(coder: NSCoder) {
|
|
26
|
+
fatalError("init(coder:) has not been implemented")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override func drawText(in rect: CGRect) {
|
|
30
|
+
let insetRect = rect.inset(by: textInsets)
|
|
31
|
+
|
|
32
|
+
guard outlineWidth > 0, let ctx = UIGraphicsGetCurrentContext() else {
|
|
33
|
+
super.drawText(in: insetRect)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let fillColor = textColor
|
|
38
|
+
|
|
39
|
+
ctx.setLineWidth(outlineWidth)
|
|
40
|
+
ctx.setLineJoin(.round)
|
|
41
|
+
|
|
42
|
+
ctx.setTextDrawingMode(.stroke)
|
|
43
|
+
textColor = outlineColor
|
|
44
|
+
super.drawText(in: insetRect)
|
|
45
|
+
|
|
46
|
+
ctx.setTextDrawingMode(.fill)
|
|
47
|
+
textColor = fillColor
|
|
48
|
+
super.drawText(in: insetRect)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override func textRect(
|
|
52
|
+
forBounds bounds: CGRect,
|
|
53
|
+
limitedToNumberOfLines numberOfLines: Int
|
|
54
|
+
) -> CGRect {
|
|
55
|
+
let insetBounds = bounds.inset(by: textInsets)
|
|
56
|
+
var rect = super.textRect(forBounds: insetBounds, limitedToNumberOfLines: numberOfLines)
|
|
57
|
+
// Match React Native <Text> behavior (top-aligned). UILabel may otherwise vertically center
|
|
58
|
+
// text when the bounds are taller than the rendered text.
|
|
59
|
+
rect.origin.y = insetBounds.origin.y
|
|
60
|
+
rect.origin.x -= textInsets.left
|
|
61
|
+
rect.origin.y -= textInsets.top
|
|
62
|
+
rect.size.width += textInsets.left + textInsets.right
|
|
63
|
+
rect.size.height += textInsets.top + textInsets.bottom
|
|
64
|
+
return rect
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private func invalidateMeasurements() {
|
|
68
|
+
setNeedsLayout()
|
|
69
|
+
invalidateIntrinsicContentSize()
|
|
70
|
+
setNeedsDisplay()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { callback, getHostComponent } from 'react-native-nitro-modules';
|
|
4
|
+
import StrokeTextViewConfig from '../nitrogen/generated/shared/json/StrokeTextViewConfig.json';
|
|
5
|
+
const NativeStrokeTextView = getHostComponent('StrokeTextView', () => StrokeTextViewConfig);
|
|
6
|
+
function resolveText(text, children) {
|
|
7
|
+
if (typeof text === 'string')
|
|
8
|
+
return text;
|
|
9
|
+
if (typeof children === 'string')
|
|
10
|
+
return children;
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
function toNumber(value) {
|
|
14
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
function firstNumber(...values) {
|
|
17
|
+
for (const value of values) {
|
|
18
|
+
const n = toNumber(value);
|
|
19
|
+
if (n != null)
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function toFontWeightString(value) {
|
|
25
|
+
if (typeof value === 'string')
|
|
26
|
+
return value;
|
|
27
|
+
if (typeof value === 'number')
|
|
28
|
+
return `${value}`;
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function toColorString(value) {
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
return value;
|
|
34
|
+
if (typeof value === 'number') {
|
|
35
|
+
const c = value >>> 0;
|
|
36
|
+
const a = ((c >>> 24) & 0xff) / 255;
|
|
37
|
+
const r = (c >>> 16) & 0xff;
|
|
38
|
+
const g = (c >>> 8) & 0xff;
|
|
39
|
+
const b = c & 0xff;
|
|
40
|
+
return `rgba(${r},${g},${b},${a})`;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
function warnOnInvalidChildren(children) {
|
|
45
|
+
if (!__DEV__)
|
|
46
|
+
return;
|
|
47
|
+
if (children == null)
|
|
48
|
+
return;
|
|
49
|
+
if (typeof children === 'string')
|
|
50
|
+
return;
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.warn('[StrokeText] Children must be a string. Use the `text` prop instead.');
|
|
53
|
+
}
|
|
54
|
+
export function StrokeText({ text, children, style, hybridRef, ...rest }) {
|
|
55
|
+
warnOnInvalidChildren(children);
|
|
56
|
+
const resolvedText = resolveText(text, children);
|
|
57
|
+
const flattened = StyleSheet.flatten(style);
|
|
58
|
+
const { padding, paddingVertical, paddingHorizontal, paddingTop, paddingRight, paddingBottom, paddingLeft, ...nativeProps } = rest;
|
|
59
|
+
const { color: styleColor, fontSize: styleFontSize, fontWeight: styleFontWeight, fontFamily: styleFontFamily, fontStyle: styleFontStyle, lineHeight: styleLineHeight, letterSpacing: styleLetterSpacing, textAlign: styleTextAlign, textDecorationLine: styleTextDecorationLine, textTransform: styleTextTransform, opacity: styleOpacity, includeFontPadding: styleIncludeFontPadding, padding: stylePadding, paddingVertical: stylePaddingVertical, paddingHorizontal: stylePaddingHorizontal, paddingTop: stylePaddingTop, paddingRight: stylePaddingRight, paddingBottom: stylePaddingBottom, paddingLeft: stylePaddingLeft, ...containerStyle } = flattened ?? {};
|
|
60
|
+
const strokeWidth = Math.max(0, nativeProps.strokeWidth ?? 0);
|
|
61
|
+
const strokeInset = Math.ceil(strokeWidth) / 2;
|
|
62
|
+
const baseTop = firstNumber(paddingTop, stylePaddingTop, paddingVertical, stylePaddingVertical, padding, stylePadding) ?? 0;
|
|
63
|
+
const baseRight = firstNumber(paddingRight, stylePaddingRight, paddingHorizontal, stylePaddingHorizontal, padding, stylePadding) ?? 0;
|
|
64
|
+
const baseBottom = firstNumber(paddingBottom, stylePaddingBottom, paddingVertical, stylePaddingVertical, padding, stylePadding) ?? 0;
|
|
65
|
+
const baseLeft = firstNumber(paddingLeft, stylePaddingLeft, paddingHorizontal, stylePaddingHorizontal, padding, stylePadding) ?? 0;
|
|
66
|
+
const effectiveNumberOfLines = nativeProps.numberOfLines != null && nativeProps.numberOfLines > 0
|
|
67
|
+
? nativeProps.numberOfLines
|
|
68
|
+
: undefined;
|
|
69
|
+
const effectiveEllipsizeMode = effectiveNumberOfLines == null
|
|
70
|
+
? undefined
|
|
71
|
+
: nativeProps.ellipsizeMode ?? 'tail';
|
|
72
|
+
const effectiveIncludeFontPadding = nativeProps.includeFontPadding ?? styleIncludeFontPadding ?? false;
|
|
73
|
+
return (React.createElement(View, { style: [styles.container, containerStyle] },
|
|
74
|
+
React.createElement(Text, { accessible: false, pointerEvents: "none", numberOfLines: effectiveNumberOfLines, ellipsizeMode: effectiveEllipsizeMode, allowFontScaling: nativeProps.allowFontScaling, maxFontSizeMultiplier: nativeProps.maxFontSizeMultiplier, style: [
|
|
75
|
+
style,
|
|
76
|
+
{
|
|
77
|
+
paddingTop: baseTop,
|
|
78
|
+
paddingRight: baseRight,
|
|
79
|
+
paddingBottom: baseBottom,
|
|
80
|
+
paddingLeft: baseLeft,
|
|
81
|
+
},
|
|
82
|
+
effectiveIncludeFontPadding == null
|
|
83
|
+
? null
|
|
84
|
+
: { includeFontPadding: effectiveIncludeFontPadding },
|
|
85
|
+
styles.hiddenText,
|
|
86
|
+
] }, resolvedText),
|
|
87
|
+
React.createElement(NativeStrokeTextView, { ...nativeProps, text: resolvedText, color: nativeProps.color ?? toColorString(styleColor), fontSize: nativeProps.fontSize ?? toNumber(styleFontSize), fontWeight: nativeProps.fontWeight ?? toFontWeightString(styleFontWeight), fontFamily: nativeProps.fontFamily ?? styleFontFamily, fontStyle: nativeProps.fontStyle ?? styleFontStyle, lineHeight: nativeProps.lineHeight ?? toNumber(styleLineHeight), letterSpacing: nativeProps.letterSpacing ?? toNumber(styleLetterSpacing), textAlign: nativeProps.textAlign ?? styleTextAlign, textDecorationLine: nativeProps.textDecorationLine ?? styleTextDecorationLine, textTransform: nativeProps.textTransform ?? styleTextTransform, opacity: nativeProps.opacity ?? toNumber(styleOpacity), includeFontPadding: effectiveIncludeFontPadding, numberOfLines: nativeProps.numberOfLines, ellipsizeMode: effectiveEllipsizeMode, paddingTop: baseTop, paddingRight: baseRight, paddingBottom: baseBottom, paddingLeft: baseLeft, hybridRef: hybridRef ? callback(hybridRef) : undefined, pointerEvents: "none", style: [
|
|
88
|
+
styles.overlay,
|
|
89
|
+
strokeInset === 0
|
|
90
|
+
? null
|
|
91
|
+
: {
|
|
92
|
+
top: -strokeInset,
|
|
93
|
+
right: -strokeInset,
|
|
94
|
+
bottom: -strokeInset,
|
|
95
|
+
left: -strokeInset,
|
|
96
|
+
},
|
|
97
|
+
] })));
|
|
98
|
+
}
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
container: {
|
|
101
|
+
alignSelf: 'flex-start',
|
|
102
|
+
},
|
|
103
|
+
overlay: {
|
|
104
|
+
...StyleSheet.absoluteFillObject,
|
|
105
|
+
},
|
|
106
|
+
hiddenText: {
|
|
107
|
+
opacity: 0,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
function resolveText(text, children) {
|
|
4
|
+
if (typeof text === 'string')
|
|
5
|
+
return text;
|
|
6
|
+
if (typeof children === 'string')
|
|
7
|
+
return children;
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
function toNumber(value) {
|
|
11
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
12
|
+
}
|
|
13
|
+
function firstNumber(...values) {
|
|
14
|
+
for (const value of values) {
|
|
15
|
+
const n = toNumber(value);
|
|
16
|
+
if (n != null)
|
|
17
|
+
return n;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export function StrokeText({ text, children, style, ...rest }) {
|
|
22
|
+
const resolvedText = resolveText(text, children);
|
|
23
|
+
const flattened = StyleSheet.flatten(style);
|
|
24
|
+
const { color: styleColor, fontSize: styleFontSize, fontWeight: styleFontWeight, fontFamily: styleFontFamily, fontStyle: styleFontStyle, lineHeight: styleLineHeight, letterSpacing: styleLetterSpacing, textAlign: styleTextAlign, textDecorationLine: styleTextDecorationLine, textTransform: styleTextTransform, opacity: styleOpacity, padding: stylePadding, paddingVertical: stylePaddingVertical, paddingHorizontal: stylePaddingHorizontal, paddingTop: stylePaddingTop, paddingRight: stylePaddingRight, paddingBottom: stylePaddingBottom, paddingLeft: stylePaddingLeft, ...containerStyle } = flattened ?? {};
|
|
25
|
+
const textStyle = {
|
|
26
|
+
fontSize: rest.fontSize ?? styleFontSize,
|
|
27
|
+
fontWeight: (rest.fontWeight ?? styleFontWeight),
|
|
28
|
+
fontFamily: rest.fontFamily ?? styleFontFamily,
|
|
29
|
+
fontStyle: rest.fontStyle ?? styleFontStyle,
|
|
30
|
+
lineHeight: rest.lineHeight ?? styleLineHeight,
|
|
31
|
+
letterSpacing: rest.letterSpacing ?? styleLetterSpacing,
|
|
32
|
+
textAlign: rest.textAlign ?? styleTextAlign,
|
|
33
|
+
textDecorationLine: rest.textDecorationLine ?? styleTextDecorationLine,
|
|
34
|
+
textTransform: rest.textTransform ?? styleTextTransform,
|
|
35
|
+
opacity: rest.opacity ?? styleOpacity,
|
|
36
|
+
};
|
|
37
|
+
const fillColor = rest.color ?? styleColor ?? '#000';
|
|
38
|
+
const strokeColor = rest.strokeColor ?? 'transparent';
|
|
39
|
+
const strokeWidth = Math.max(0, rest.strokeWidth ?? 0);
|
|
40
|
+
const strokeInset = Math.ceil(strokeWidth) / 2;
|
|
41
|
+
const baseTop = firstNumber(rest.paddingTop, stylePaddingTop, rest.paddingVertical, stylePaddingVertical, rest.padding, stylePadding) ?? 0;
|
|
42
|
+
const baseRight = firstNumber(rest.paddingRight, stylePaddingRight, rest.paddingHorizontal, stylePaddingHorizontal, rest.padding, stylePadding) ?? 0;
|
|
43
|
+
const baseBottom = firstNumber(rest.paddingBottom, stylePaddingBottom, rest.paddingVertical, stylePaddingVertical, rest.padding, stylePadding) ?? 0;
|
|
44
|
+
const baseLeft = firstNumber(rest.paddingLeft, stylePaddingLeft, rest.paddingHorizontal, stylePaddingHorizontal, rest.padding, stylePadding) ?? 0;
|
|
45
|
+
const effectiveNumberOfLines = rest.numberOfLines != null && rest.numberOfLines > 0
|
|
46
|
+
? rest.numberOfLines
|
|
47
|
+
: undefined;
|
|
48
|
+
const effectiveEllipsizeMode = effectiveNumberOfLines == null ? undefined : rest.ellipsizeMode ?? 'tail';
|
|
49
|
+
return (React.createElement(View, { style: [styles.container, containerStyle] },
|
|
50
|
+
React.createElement(Text, { accessible: false, pointerEvents: "none", numberOfLines: effectiveNumberOfLines, ellipsizeMode: effectiveEllipsizeMode, style: [
|
|
51
|
+
textStyle,
|
|
52
|
+
{
|
|
53
|
+
paddingTop: baseTop,
|
|
54
|
+
paddingRight: baseRight,
|
|
55
|
+
paddingBottom: baseBottom,
|
|
56
|
+
paddingLeft: baseLeft,
|
|
57
|
+
},
|
|
58
|
+
styles.hiddenText,
|
|
59
|
+
] }, resolvedText),
|
|
60
|
+
React.createElement(Text, { pointerEvents: "none", numberOfLines: effectiveNumberOfLines, ellipsizeMode: effectiveEllipsizeMode, style: [
|
|
61
|
+
textStyle,
|
|
62
|
+
{
|
|
63
|
+
color: fillColor,
|
|
64
|
+
paddingTop: baseTop + strokeInset,
|
|
65
|
+
paddingRight: baseRight + strokeInset,
|
|
66
|
+
paddingBottom: baseBottom + strokeInset,
|
|
67
|
+
paddingLeft: baseLeft + strokeInset,
|
|
68
|
+
},
|
|
69
|
+
strokeInset === 0 ? null : { maxWidth: 'none' },
|
|
70
|
+
// react-native-web uses `box-sizing: border-box` globally; with ellipsizing enabled
|
|
71
|
+
// (`overflow: hidden` + `text-overflow: ellipsis`), the extra stroke padding can reduce
|
|
72
|
+
// the content box by a couple pixels and cause false-positive ellipses for some fonts.
|
|
73
|
+
// Use `content-box` so padding doesn't shrink the text's available width.
|
|
74
|
+
strokeInset === 0 ? null : { boxSizing: 'content-box' },
|
|
75
|
+
strokeWidth > 0 && strokeColor !== 'transparent'
|
|
76
|
+
? {
|
|
77
|
+
WebkitTextStrokeWidth: `${strokeWidth}px`,
|
|
78
|
+
WebkitTextStrokeColor: strokeColor,
|
|
79
|
+
WebkitTextFillColor: fillColor,
|
|
80
|
+
textStroke: `${strokeWidth}px ${strokeColor}`,
|
|
81
|
+
textFillColor: fillColor,
|
|
82
|
+
paintOrder: 'stroke fill',
|
|
83
|
+
}
|
|
84
|
+
: null,
|
|
85
|
+
styles.overlay,
|
|
86
|
+
strokeInset === 0
|
|
87
|
+
? null
|
|
88
|
+
: {
|
|
89
|
+
top: -strokeInset,
|
|
90
|
+
right: -strokeInset,
|
|
91
|
+
bottom: -strokeInset,
|
|
92
|
+
left: -strokeInset,
|
|
93
|
+
},
|
|
94
|
+
] }, resolvedText)));
|
|
95
|
+
}
|
|
96
|
+
const styles = StyleSheet.create({
|
|
97
|
+
container: {
|
|
98
|
+
alignSelf: 'flex-start',
|
|
99
|
+
},
|
|
100
|
+
overlay: {
|
|
101
|
+
...StyleSheet.absoluteFillObject,
|
|
102
|
+
},
|
|
103
|
+
hiddenText: {
|
|
104
|
+
opacity: 0,
|
|
105
|
+
},
|
|
106
|
+
});
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { StrokeText } from './StrokeText.js';
|
package/lib/index.web.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { StrokeText } from './StrokeText.web.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HybridView, HybridViewMethods, HybridViewProps } from 'react-native-nitro-modules';
|
|
2
|
+
export type StrokeTextAlign = 'auto' | 'left' | 'right' | 'center' | 'justify';
|
|
3
|
+
export type StrokeTextDecorationLine = 'none' | 'underline' | 'line-through' | 'underline line-through';
|
|
4
|
+
export type StrokeTextTransform = 'none' | 'uppercase' | 'lowercase' | 'capitalize';
|
|
5
|
+
export type StrokeTextFontStyle = 'normal' | 'italic';
|
|
6
|
+
export type StrokeTextEllipsizeMode = 'head' | 'middle' | 'tail' | 'clip';
|
|
7
|
+
export interface StrokeTextProps extends HybridViewProps {
|
|
8
|
+
text: string;
|
|
9
|
+
color?: string;
|
|
10
|
+
strokeColor?: string;
|
|
11
|
+
strokeWidth?: number;
|
|
12
|
+
fontSize?: number;
|
|
13
|
+
fontWeight?: string;
|
|
14
|
+
fontFamily?: string;
|
|
15
|
+
fontStyle?: StrokeTextFontStyle;
|
|
16
|
+
lineHeight?: number;
|
|
17
|
+
letterSpacing?: number;
|
|
18
|
+
textAlign?: StrokeTextAlign;
|
|
19
|
+
textDecorationLine?: StrokeTextDecorationLine;
|
|
20
|
+
textTransform?: StrokeTextTransform;
|
|
21
|
+
opacity?: number;
|
|
22
|
+
allowFontScaling?: boolean;
|
|
23
|
+
maxFontSizeMultiplier?: number;
|
|
24
|
+
includeFontPadding?: boolean;
|
|
25
|
+
numberOfLines?: number;
|
|
26
|
+
ellipsizeMode?: StrokeTextEllipsizeMode;
|
|
27
|
+
padding?: number;
|
|
28
|
+
paddingVertical?: number;
|
|
29
|
+
paddingHorizontal?: number;
|
|
30
|
+
paddingTop?: number;
|
|
31
|
+
paddingRight?: number;
|
|
32
|
+
paddingBottom?: number;
|
|
33
|
+
paddingLeft?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface StrokeTextMethods extends HybridViewMethods {
|
|
36
|
+
}
|
|
37
|
+
export type StrokeTextView = HybridView<StrokeTextProps, StrokeTextMethods>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|