react-native-highlight-text-view 0.1.23 → 0.1.25
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.
|
@@ -7,195 +7,65 @@ import android.graphics.Paint
|
|
|
7
7
|
import android.graphics.RectF
|
|
8
8
|
import android.graphics.Typeface
|
|
9
9
|
import android.text.Editable
|
|
10
|
-
import android.text.Spannable
|
|
11
|
-
import android.text.SpannableString
|
|
12
10
|
import android.text.TextWatcher
|
|
13
|
-
import android.text.style.ReplacementSpan
|
|
14
11
|
import android.util.AttributeSet
|
|
15
12
|
import android.util.TypedValue
|
|
16
13
|
import android.view.Gravity
|
|
17
14
|
import androidx.appcompat.widget.AppCompatEditText
|
|
18
15
|
|
|
19
16
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* Custom EditText that mimics the iOS implementation by drawing per-character
|
|
18
|
+
* rounded highlights directly in onDraw(), instead of using spans.
|
|
19
|
+
*
|
|
20
|
+
* This avoids layout thrashing/flicker when lines auto-wrap and keeps padding
|
|
21
|
+
* logic independent from Android's line breaking.
|
|
22
22
|
*/
|
|
23
|
-
class RoundedBackgroundSpan(
|
|
24
|
-
internal val backgroundColor: Int,
|
|
25
|
-
internal val textColor: Int,
|
|
26
|
-
internal val paddingLeft: Float,
|
|
27
|
-
internal val paddingRight: Float,
|
|
28
|
-
internal val paddingTop: Float,
|
|
29
|
-
internal val paddingBottom: Float,
|
|
30
|
-
internal val backgroundInsetTop: Float,
|
|
31
|
-
internal val backgroundInsetBottom: Float,
|
|
32
|
-
internal val backgroundInsetLeft: Float,
|
|
33
|
-
internal val backgroundInsetRight: Float,
|
|
34
|
-
internal val cornerRadius: Float,
|
|
35
|
-
internal val isLineStart: Boolean = false,
|
|
36
|
-
internal val isLineEnd: Boolean = false
|
|
37
|
-
) : ReplacementSpan() {
|
|
38
|
-
|
|
39
|
-
override fun getSize(
|
|
40
|
-
paint: Paint,
|
|
41
|
-
text: CharSequence?,
|
|
42
|
-
start: Int,
|
|
43
|
-
end: Int,
|
|
44
|
-
fm: Paint.FontMetricsInt?
|
|
45
|
-
): Int {
|
|
46
|
-
// Only add padding at line boundaries (matches iOS behavior)
|
|
47
|
-
val width = paint.measureText(text, start, end)
|
|
48
|
-
val leftPad = if (isLineStart) paddingLeft else 0f
|
|
49
|
-
val rightPad = if (isLineEnd) paddingRight else 0f
|
|
50
|
-
return (width + leftPad + rightPad).toInt()
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
override fun draw(
|
|
54
|
-
canvas: Canvas,
|
|
55
|
-
text: CharSequence?,
|
|
56
|
-
start: Int,
|
|
57
|
-
end: Int,
|
|
58
|
-
x: Float,
|
|
59
|
-
top: Int,
|
|
60
|
-
y: Int,
|
|
61
|
-
bottom: Int,
|
|
62
|
-
paint: Paint
|
|
63
|
-
) {
|
|
64
|
-
if (text == null) return
|
|
65
|
-
|
|
66
|
-
val bgPaint = Paint().apply {
|
|
67
|
-
color = backgroundColor
|
|
68
|
-
style = Paint.Style.FILL
|
|
69
|
-
isAntiAlias = true
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
val width = paint.measureText(text, start, end)
|
|
73
|
-
|
|
74
|
-
// Use font metrics for consistent height (matches iOS)
|
|
75
|
-
val fontMetrics = paint.fontMetrics
|
|
76
|
-
val textHeight = fontMetrics.descent - fontMetrics.ascent
|
|
77
|
-
val textTop = y + fontMetrics.ascent
|
|
78
|
-
|
|
79
|
-
// Apply background insets first (shrinks from line box - EXACTLY like iOS line 45-48)
|
|
80
|
-
val insetTop = textTop + backgroundInsetTop
|
|
81
|
-
val insetHeight = textHeight - (backgroundInsetTop + backgroundInsetBottom)
|
|
82
|
-
|
|
83
|
-
// Only apply padding at line boundaries (matches iOS behavior)
|
|
84
|
-
val leftPad = if (isLineStart) paddingLeft else 0f
|
|
85
|
-
val rightPad = if (isLineEnd) paddingRight else 0f
|
|
86
|
-
|
|
87
|
-
// SELECTIVE ROUNDING STRATEGY:
|
|
88
|
-
// 1. Line Start: Round Left corners.
|
|
89
|
-
// 2. Line End: Round Right corners.
|
|
90
|
-
// 3. Middle: Square (no rounding).
|
|
91
|
-
// 4. Overlap: Minimal (1px) to seal seams.
|
|
92
|
-
|
|
93
|
-
val overlapExtension = 1f
|
|
94
|
-
|
|
95
|
-
// No extension needed for start/end boundaries
|
|
96
|
-
val leftExtend = 0f
|
|
97
|
-
|
|
98
|
-
// Extend right slightly for middle characters to seal the gap
|
|
99
|
-
val rightExtend = if (!isLineEnd) {
|
|
100
|
-
if (isLineStart) leftPad + overlapExtension else overlapExtension
|
|
101
|
-
} else {
|
|
102
|
-
0f
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Vertical overlap to eliminate gaps (reduced to prevent descender clipping)
|
|
106
|
-
val topExtend = 0f
|
|
107
|
-
val bottomExtend = 0f
|
|
108
|
-
|
|
109
|
-
// Calculate background rect
|
|
110
|
-
// NOTE: Since this is a ReplacementSpan, 'x' is the start of the span (including padding).
|
|
111
|
-
// So we draw from 'x', not 'x - leftPad'.
|
|
112
|
-
val rect = RectF(
|
|
113
|
-
x + backgroundInsetLeft - leftExtend,
|
|
114
|
-
insetTop - paddingTop - topExtend,
|
|
115
|
-
x + leftPad + width + rightPad - backgroundInsetRight + rightExtend,
|
|
116
|
-
insetTop + insetHeight + paddingBottom + bottomExtend
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
// Draw based on position
|
|
120
|
-
when {
|
|
121
|
-
isLineStart && isLineEnd -> {
|
|
122
|
-
// Single character - round all corners
|
|
123
|
-
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
|
|
124
|
-
}
|
|
125
|
-
isLineStart -> {
|
|
126
|
-
// Line START - round LEFT corners only
|
|
127
|
-
val path = android.graphics.Path()
|
|
128
|
-
path.addRoundRect(
|
|
129
|
-
rect,
|
|
130
|
-
floatArrayOf(
|
|
131
|
-
cornerRadius, cornerRadius, // top-left
|
|
132
|
-
0f, 0f, // top-right
|
|
133
|
-
0f, 0f, // bottom-right
|
|
134
|
-
cornerRadius, cornerRadius // bottom-left
|
|
135
|
-
),
|
|
136
|
-
android.graphics.Path.Direction.CW
|
|
137
|
-
)
|
|
138
|
-
canvas.drawPath(path, bgPaint)
|
|
139
|
-
}
|
|
140
|
-
isLineEnd -> {
|
|
141
|
-
// Line END - round RIGHT corners only
|
|
142
|
-
val path = android.graphics.Path()
|
|
143
|
-
path.addRoundRect(
|
|
144
|
-
rect,
|
|
145
|
-
floatArrayOf(
|
|
146
|
-
0f, 0f, // top-left
|
|
147
|
-
cornerRadius, cornerRadius, // top-right
|
|
148
|
-
cornerRadius, cornerRadius, // bottom-right
|
|
149
|
-
0f, 0f // bottom-left
|
|
150
|
-
),
|
|
151
|
-
android.graphics.Path.Direction.CW
|
|
152
|
-
)
|
|
153
|
-
canvas.drawPath(path, bgPaint)
|
|
154
|
-
}
|
|
155
|
-
else -> {
|
|
156
|
-
// Middle - NO rounded corners (Square)
|
|
157
|
-
canvas.drawRect(rect, bgPaint)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Draw text offset by left padding only if at line start
|
|
162
|
-
val textPaint = Paint(paint).apply {
|
|
163
|
-
color = textColor
|
|
164
|
-
isAntiAlias = true
|
|
165
|
-
}
|
|
166
|
-
canvas.drawText(text, start, end, x + leftPad, y.toFloat(), textPaint)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
23
|
class HighlightTextView : AppCompatEditText {
|
|
24
|
+
// Visual props
|
|
171
25
|
private var characterBackgroundColor: Int = Color.parseColor("#FFFF00")
|
|
172
26
|
private var textColorValue: Int = Color.BLACK
|
|
173
27
|
private var cornerRadius: Float = 4f
|
|
174
28
|
private var highlightBorderRadius: Float = 0f
|
|
29
|
+
|
|
30
|
+
// Per-character padding
|
|
175
31
|
private var charPaddingLeft: Float = 4f
|
|
176
32
|
private var charPaddingRight: Float = 4f
|
|
177
33
|
private var charPaddingTop: Float = 4f
|
|
178
34
|
private var charPaddingBottom: Float = 4f
|
|
35
|
+
|
|
36
|
+
// Background insets (shrink from line box)
|
|
179
37
|
private var backgroundInsetTop: Float = 0f
|
|
180
38
|
private var backgroundInsetBottom: Float = 0f
|
|
181
39
|
private var backgroundInsetLeft: Float = 0f
|
|
182
40
|
private var backgroundInsetRight: Float = 0f
|
|
41
|
+
|
|
42
|
+
// Line height control
|
|
183
43
|
private var customLineHeight: Float = 0f
|
|
44
|
+
|
|
45
|
+
// Font + alignment state
|
|
184
46
|
private var currentFontFamily: String? = null
|
|
185
47
|
private var currentFontWeight: String = "normal"
|
|
186
48
|
private var currentVerticalAlign: String? = null
|
|
49
|
+
|
|
50
|
+
// Internal flags
|
|
187
51
|
private var isUpdatingText: Boolean = false
|
|
188
|
-
|
|
52
|
+
|
|
53
|
+
// Drawing helpers
|
|
54
|
+
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
55
|
+
style = Paint.Style.FILL
|
|
56
|
+
}
|
|
57
|
+
private val backgroundRect = RectF()
|
|
58
|
+
|
|
189
59
|
var onTextChangeListener: ((String) -> Unit)? = null
|
|
190
60
|
|
|
191
61
|
constructor(context: Context?) : super(context!!) {
|
|
192
62
|
init()
|
|
193
63
|
}
|
|
194
|
-
|
|
64
|
+
|
|
195
65
|
constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) {
|
|
196
66
|
init()
|
|
197
67
|
}
|
|
198
|
-
|
|
68
|
+
|
|
199
69
|
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
|
|
200
70
|
context!!,
|
|
201
71
|
attrs,
|
|
@@ -210,42 +80,120 @@ class HighlightTextView : AppCompatEditText {
|
|
|
210
80
|
gravity = Gravity.CENTER
|
|
211
81
|
setPadding(20, 20, 20, 20)
|
|
212
82
|
textColorValue = currentTextColor
|
|
213
|
-
|
|
83
|
+
|
|
214
84
|
// Enable text wrapping
|
|
215
85
|
maxLines = Int.MAX_VALUE
|
|
216
86
|
isSingleLine = false
|
|
217
87
|
setHorizontallyScrolling(false)
|
|
218
|
-
|
|
88
|
+
|
|
89
|
+
applyLineHeightAndSpacing()
|
|
90
|
+
|
|
219
91
|
addTextChangedListener(object : TextWatcher {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
|
224
|
-
changeStart = start
|
|
225
|
-
changeEnd = start + after
|
|
226
|
-
}
|
|
227
|
-
|
|
92
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
|
93
|
+
|
|
228
94
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
|
229
|
-
|
|
95
|
+
|
|
230
96
|
override fun afterTextChanged(s: Editable?) {
|
|
231
97
|
if (!isUpdatingText) {
|
|
232
98
|
onTextChangeListener?.invoke(s?.toString() ?: "")
|
|
233
|
-
|
|
234
|
-
|
|
99
|
+
// Text changed → redraw backgrounds
|
|
100
|
+
invalidate()
|
|
235
101
|
}
|
|
236
102
|
}
|
|
237
103
|
})
|
|
238
104
|
}
|
|
239
105
|
|
|
106
|
+
// --- Drawing -----------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
override fun onDraw(canvas: Canvas) {
|
|
109
|
+
val layout = layout
|
|
110
|
+
val text = text
|
|
111
|
+
|
|
112
|
+
if (layout != null && text != null && text.isNotEmpty()) {
|
|
113
|
+
val save = canvas.save()
|
|
114
|
+
|
|
115
|
+
// Canvas is already translated by scrollX/scrollY in View.draw().
|
|
116
|
+
// Here we only need to account for view padding so our coordinates
|
|
117
|
+
// match the Layout's internal coordinate system.
|
|
118
|
+
val translateX = totalPaddingLeft
|
|
119
|
+
val translateY = totalPaddingTop
|
|
120
|
+
canvas.translate(translateX.toFloat(), translateY.toFloat())
|
|
121
|
+
|
|
122
|
+
drawCharacterBackgrounds(canvas, text, layout)
|
|
123
|
+
|
|
124
|
+
canvas.restoreToCount(save)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Let EditText draw text, cursor, selection, etc. on top of backgrounds
|
|
128
|
+
super.onDraw(canvas)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private fun drawCharacterBackgrounds(canvas: Canvas, text: CharSequence, layout: android.text.Layout) {
|
|
132
|
+
backgroundPaint.color = characterBackgroundColor
|
|
133
|
+
val paint = paint
|
|
134
|
+
val radius = if (highlightBorderRadius > 0f) highlightBorderRadius else cornerRadius
|
|
135
|
+
|
|
136
|
+
val length = text.length
|
|
137
|
+
if (length == 0) return
|
|
138
|
+
|
|
139
|
+
for (i in 0 until length) {
|
|
140
|
+
val ch = text[i]
|
|
141
|
+
// Match iOS: skip spaces and control characters for background
|
|
142
|
+
if (ch == '\n' || ch == '\t' || ch == ' ') continue
|
|
143
|
+
|
|
144
|
+
val line = layout.getLineForOffset(i)
|
|
145
|
+
val lineStart = layout.getLineStart(line)
|
|
146
|
+
val lineEnd = layout.getLineEnd(line)
|
|
147
|
+
|
|
148
|
+
// Horizontal bounds based on layout positions
|
|
149
|
+
val xStart = layout.getPrimaryHorizontal(i)
|
|
150
|
+
val isLastCharInLine = i == lineEnd - 1
|
|
151
|
+
val xEnd = if (!isLastCharInLine && i + 1 < length) {
|
|
152
|
+
layout.getPrimaryHorizontal(i + 1)
|
|
153
|
+
} else {
|
|
154
|
+
// Fallback for last character in the line/text
|
|
155
|
+
xStart + paint.measureText(text, i, i + 1)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Vertical bounds based on line box (includes line spacing)
|
|
159
|
+
val lineTop = layout.getLineTop(line).toFloat()
|
|
160
|
+
val lineBottom = layout.getLineBottom(line).toFloat()
|
|
161
|
+
|
|
162
|
+
var left = xStart
|
|
163
|
+
var right = xEnd
|
|
164
|
+
var top = lineTop
|
|
165
|
+
var bottom = lineBottom
|
|
166
|
+
|
|
167
|
+
// First shrink by background insets (from the line box)
|
|
168
|
+
top += backgroundInsetTop
|
|
169
|
+
bottom -= backgroundInsetBottom
|
|
170
|
+
left += backgroundInsetLeft
|
|
171
|
+
right -= backgroundInsetRight
|
|
172
|
+
|
|
173
|
+
// Then expand outward by per-character padding
|
|
174
|
+
left -= charPaddingLeft
|
|
175
|
+
right += charPaddingRight
|
|
176
|
+
top -= charPaddingTop
|
|
177
|
+
bottom += charPaddingBottom
|
|
178
|
+
|
|
179
|
+
if (right <= left || bottom <= top) continue
|
|
180
|
+
|
|
181
|
+
backgroundRect.set(left, top, right, bottom)
|
|
182
|
+
canvas.drawRoundRect(backgroundRect, radius, radius, backgroundPaint)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Public API used from the ViewManager ------------------------------------
|
|
187
|
+
|
|
240
188
|
fun setCharacterBackgroundColor(color: Int) {
|
|
241
189
|
characterBackgroundColor = color
|
|
242
|
-
|
|
190
|
+
invalidate()
|
|
243
191
|
}
|
|
244
|
-
|
|
192
|
+
|
|
245
193
|
override fun setTextColor(color: Int) {
|
|
246
194
|
super.setTextColor(color)
|
|
247
195
|
textColorValue = color
|
|
248
|
-
|
|
196
|
+
invalidate()
|
|
249
197
|
}
|
|
250
198
|
|
|
251
199
|
fun setCharPadding(left: Float, top: Float, right: Float, bottom: Float) {
|
|
@@ -254,35 +202,40 @@ class HighlightTextView : AppCompatEditText {
|
|
|
254
202
|
charPaddingRight = right
|
|
255
203
|
charPaddingBottom = bottom
|
|
256
204
|
updateViewPadding()
|
|
257
|
-
|
|
205
|
+
applyLineHeightAndSpacing()
|
|
206
|
+
invalidate()
|
|
258
207
|
}
|
|
259
|
-
|
|
208
|
+
|
|
260
209
|
fun setCharPaddingLeft(padding: Float) {
|
|
261
210
|
charPaddingLeft = padding
|
|
262
211
|
updateViewPadding()
|
|
263
|
-
|
|
212
|
+
applyLineHeightAndSpacing()
|
|
213
|
+
invalidate()
|
|
264
214
|
}
|
|
265
|
-
|
|
215
|
+
|
|
266
216
|
fun setCharPaddingRight(padding: Float) {
|
|
267
217
|
charPaddingRight = padding
|
|
268
218
|
updateViewPadding()
|
|
269
|
-
|
|
219
|
+
applyLineHeightAndSpacing()
|
|
220
|
+
invalidate()
|
|
270
221
|
}
|
|
271
|
-
|
|
222
|
+
|
|
272
223
|
fun setCharPaddingTop(padding: Float) {
|
|
273
224
|
charPaddingTop = padding
|
|
274
225
|
updateViewPadding()
|
|
275
|
-
|
|
226
|
+
applyLineHeightAndSpacing()
|
|
227
|
+
invalidate()
|
|
276
228
|
}
|
|
277
|
-
|
|
229
|
+
|
|
278
230
|
fun setCharPaddingBottom(padding: Float) {
|
|
279
231
|
charPaddingBottom = padding
|
|
280
232
|
updateViewPadding()
|
|
281
|
-
|
|
233
|
+
applyLineHeightAndSpacing()
|
|
234
|
+
invalidate()
|
|
282
235
|
}
|
|
283
|
-
|
|
236
|
+
|
|
284
237
|
private fun updateViewPadding() {
|
|
285
|
-
//
|
|
238
|
+
// Keep view padding in sync so backgrounds are not clipped at the edges
|
|
286
239
|
setPadding(
|
|
287
240
|
charPaddingLeft.toInt(),
|
|
288
241
|
charPaddingTop.toInt(),
|
|
@@ -290,27 +243,27 @@ class HighlightTextView : AppCompatEditText {
|
|
|
290
243
|
charPaddingBottom.toInt()
|
|
291
244
|
)
|
|
292
245
|
}
|
|
293
|
-
|
|
246
|
+
|
|
294
247
|
fun setCornerRadius(radius: Float) {
|
|
295
248
|
cornerRadius = radius
|
|
296
|
-
|
|
249
|
+
invalidate()
|
|
297
250
|
}
|
|
298
|
-
|
|
251
|
+
|
|
299
252
|
fun setHighlightBorderRadius(radius: Float) {
|
|
300
253
|
highlightBorderRadius = radius
|
|
301
|
-
|
|
254
|
+
invalidate()
|
|
302
255
|
}
|
|
303
|
-
|
|
256
|
+
|
|
304
257
|
fun setFontWeight(weight: String) {
|
|
305
258
|
currentFontWeight = weight
|
|
306
259
|
updateFont()
|
|
307
260
|
}
|
|
308
|
-
|
|
261
|
+
|
|
309
262
|
fun setFontFamilyProp(family: String?) {
|
|
310
263
|
currentFontFamily = family
|
|
311
264
|
updateFont()
|
|
312
265
|
}
|
|
313
|
-
|
|
266
|
+
|
|
314
267
|
private fun updateFont() {
|
|
315
268
|
// Parse font weight to integer (100-900)
|
|
316
269
|
val weight = when (currentFontWeight) {
|
|
@@ -325,7 +278,7 @@ class HighlightTextView : AppCompatEditText {
|
|
|
325
278
|
"900" -> 900
|
|
326
279
|
else -> 400
|
|
327
280
|
}
|
|
328
|
-
|
|
281
|
+
|
|
329
282
|
// Get base typeface
|
|
330
283
|
val baseTypeface = if (currentFontFamily != null) {
|
|
331
284
|
when (currentFontFamily?.lowercase()) {
|
|
@@ -342,7 +295,7 @@ class HighlightTextView : AppCompatEditText {
|
|
|
342
295
|
} else {
|
|
343
296
|
Typeface.DEFAULT
|
|
344
297
|
}
|
|
345
|
-
|
|
298
|
+
|
|
346
299
|
// Apply font weight - use API 28+ method for better weight support
|
|
347
300
|
val typeface = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
|
348
301
|
Typeface.create(baseTypeface, weight, false)
|
|
@@ -351,16 +304,18 @@ class HighlightTextView : AppCompatEditText {
|
|
|
351
304
|
val style = if (weight >= 600) Typeface.BOLD else Typeface.NORMAL
|
|
352
305
|
Typeface.create(baseTypeface, style)
|
|
353
306
|
}
|
|
354
|
-
|
|
307
|
+
|
|
355
308
|
this.typeface = typeface
|
|
356
|
-
|
|
309
|
+
applyLineHeightAndSpacing()
|
|
310
|
+
invalidate()
|
|
357
311
|
}
|
|
358
|
-
|
|
312
|
+
|
|
359
313
|
fun setVerticalAlign(align: String?) {
|
|
360
314
|
currentVerticalAlign = align
|
|
361
315
|
updateVerticalAlignment()
|
|
316
|
+
invalidate()
|
|
362
317
|
}
|
|
363
|
-
|
|
318
|
+
|
|
364
319
|
private fun updateVerticalAlignment() {
|
|
365
320
|
// Preserve horizontal alignment when updating vertical
|
|
366
321
|
val horizontalGravity = gravity and Gravity.HORIZONTAL_GRAVITY_MASK
|
|
@@ -369,50 +324,56 @@ class HighlightTextView : AppCompatEditText {
|
|
|
369
324
|
"bottom" -> Gravity.BOTTOM
|
|
370
325
|
else -> Gravity.CENTER_VERTICAL
|
|
371
326
|
}
|
|
372
|
-
|
|
327
|
+
|
|
373
328
|
gravity = horizontalGravity or verticalGravity
|
|
374
329
|
}
|
|
375
|
-
|
|
330
|
+
|
|
376
331
|
fun setBackgroundInsetTop(inset: Float) {
|
|
377
332
|
backgroundInsetTop = inset
|
|
378
|
-
|
|
333
|
+
invalidate()
|
|
379
334
|
}
|
|
380
|
-
|
|
335
|
+
|
|
381
336
|
fun setBackgroundInsetBottom(inset: Float) {
|
|
382
337
|
backgroundInsetBottom = inset
|
|
383
|
-
|
|
338
|
+
invalidate()
|
|
384
339
|
}
|
|
385
|
-
|
|
340
|
+
|
|
386
341
|
fun setBackgroundInsetLeft(inset: Float) {
|
|
387
342
|
backgroundInsetLeft = inset
|
|
388
|
-
|
|
343
|
+
invalidate()
|
|
389
344
|
}
|
|
390
|
-
|
|
345
|
+
|
|
391
346
|
fun setBackgroundInsetRight(inset: Float) {
|
|
392
347
|
backgroundInsetRight = inset
|
|
393
|
-
|
|
348
|
+
invalidate()
|
|
394
349
|
}
|
|
395
|
-
|
|
350
|
+
|
|
396
351
|
fun setCustomLineHeight(lineHeight: Float) {
|
|
397
352
|
customLineHeight = lineHeight
|
|
398
|
-
|
|
353
|
+
applyLineHeightAndSpacing()
|
|
354
|
+
invalidate()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
override fun setTextSize(unit: Int, size: Float) {
|
|
358
|
+
super.setTextSize(unit, size)
|
|
359
|
+
applyLineHeightAndSpacing()
|
|
399
360
|
}
|
|
400
361
|
|
|
401
|
-
fun setTextProp(
|
|
402
|
-
if (this.text?.toString() !=
|
|
362
|
+
fun setTextProp(newText: String) {
|
|
363
|
+
if (this.text?.toString() != newText) {
|
|
403
364
|
isUpdatingText = true
|
|
404
|
-
setText(
|
|
405
|
-
applyCharacterBackgrounds()
|
|
365
|
+
setText(newText)
|
|
406
366
|
// Move cursor to end of text after setting
|
|
407
367
|
post {
|
|
408
368
|
if (hasFocus()) {
|
|
409
|
-
text
|
|
369
|
+
text?.length?.let { setSelection(it) }
|
|
410
370
|
}
|
|
411
371
|
}
|
|
412
372
|
isUpdatingText = false
|
|
373
|
+
invalidate()
|
|
413
374
|
}
|
|
414
375
|
}
|
|
415
|
-
|
|
376
|
+
|
|
416
377
|
fun setAutoFocus(autoFocus: Boolean) {
|
|
417
378
|
if (autoFocus && isFocusable && isFocusableInTouchMode) {
|
|
418
379
|
postDelayed({
|
|
@@ -424,286 +385,23 @@ class HighlightTextView : AppCompatEditText {
|
|
|
424
385
|
}, 100)
|
|
425
386
|
}
|
|
426
387
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// Check if a newline was inserted - if so, expand region to include char before it
|
|
440
|
-
val hasNewline = textStr.substring(start, minOf(end, textStr.length)).contains('\n')
|
|
441
|
-
|
|
442
|
-
// Expand the region to include entire lines that were affected
|
|
443
|
-
val layout = layout
|
|
444
|
-
val expandedStart: Int
|
|
445
|
-
val expandedEnd: Int
|
|
446
|
-
|
|
447
|
-
if (layout != null && textStr.isNotEmpty()) {
|
|
448
|
-
val startLine = layout.getLineForOffset(minOf(start, textStr.length - 1))
|
|
449
|
-
val endLine = layout.getLineForOffset(minOf(end, textStr.length - 1))
|
|
450
|
-
expandedStart = layout.getLineStart(startLine)
|
|
451
|
-
expandedEnd = layout.getLineEnd(endLine)
|
|
452
|
-
} else {
|
|
453
|
-
// If newline inserted, include character before it
|
|
454
|
-
expandedStart = if (hasNewline) maxOf(0, start - 2) else maxOf(0, start - 1)
|
|
455
|
-
expandedEnd = minOf(textStr.length, end + 1)
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Remove existing spans in the affected lines
|
|
459
|
-
val existingSpans = editable.getSpans(expandedStart, expandedEnd, RoundedBackgroundSpan::class.java)
|
|
460
|
-
for (span in existingSpans) {
|
|
461
|
-
editable.removeSpan(span)
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Apply spans with correct line boundary flags immediately
|
|
465
|
-
val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
|
|
466
|
-
|
|
467
|
-
for (i in expandedStart until expandedEnd) {
|
|
468
|
-
if (i >= textStr.length) break
|
|
469
|
-
|
|
470
|
-
val char = textStr[i]
|
|
471
|
-
val shouldHighlight = when {
|
|
472
|
-
char == '\n' || char == '\t' -> false
|
|
473
|
-
char == ' ' -> {
|
|
474
|
-
val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
|
|
475
|
-
val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
|
|
476
|
-
!hasSpaceBefore && !hasSpaceAfter
|
|
477
|
-
}
|
|
478
|
-
else -> true
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (shouldHighlight) {
|
|
482
|
-
// ALWAYS check newlines first (for manual line breaks)
|
|
483
|
-
val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
|
|
484
|
-
val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
|
|
485
|
-
|
|
486
|
-
var isAtLineStart = i == 0 || hasNewlineBefore
|
|
487
|
-
var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
|
|
488
|
-
|
|
489
|
-
// Only use layout for auto-wrapped lines (not manual newlines)
|
|
490
|
-
if (!hasNewlineBefore && !hasNewlineAfter && layout != null && i < textStr.length) {
|
|
491
|
-
try {
|
|
492
|
-
val line = layout.getLineForOffset(i)
|
|
493
|
-
val lineStart = layout.getLineStart(line)
|
|
494
|
-
val lineEnd = layout.getLineEnd(line)
|
|
495
|
-
// Only override if this is an auto-wrapped boundary
|
|
496
|
-
if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
|
|
497
|
-
isAtLineStart = true
|
|
498
|
-
}
|
|
499
|
-
if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
|
|
500
|
-
isAtLineEnd = true
|
|
501
|
-
}
|
|
502
|
-
} catch (e: Exception) {
|
|
503
|
-
// Layout might not be ready, keep newline-based detection
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
val span = RoundedBackgroundSpan(
|
|
508
|
-
characterBackgroundColor,
|
|
509
|
-
textColorValue,
|
|
510
|
-
charPaddingLeft,
|
|
511
|
-
charPaddingRight,
|
|
512
|
-
charPaddingTop,
|
|
513
|
-
charPaddingBottom,
|
|
514
|
-
backgroundInsetTop,
|
|
515
|
-
backgroundInsetBottom,
|
|
516
|
-
backgroundInsetLeft,
|
|
517
|
-
backgroundInsetRight,
|
|
518
|
-
radius,
|
|
519
|
-
isAtLineStart,
|
|
520
|
-
isAtLineEnd
|
|
521
|
-
)
|
|
522
|
-
editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
388
|
+
|
|
389
|
+
// --- Layout helpers ----------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
private fun applyLineHeightAndSpacing() {
|
|
392
|
+
if (customLineHeight > 0f) {
|
|
393
|
+
// customLineHeight comes from JS as "points"; convert to px using scaledDensity
|
|
394
|
+
val metrics = resources.displayMetrics
|
|
395
|
+
val desiredLineHeightPx = customLineHeight * metrics.scaledDensity
|
|
396
|
+
val textHeightPx = textSize
|
|
397
|
+
if (textHeightPx > 0f) {
|
|
398
|
+
val multiplier = desiredLineHeightPx / textHeightPx
|
|
399
|
+
setLineSpacing(0f, multiplier)
|
|
523
400
|
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
isUpdatingText = false
|
|
527
|
-
|
|
528
|
-
// JERK FIX: Skip the post-update during fast typing to prevent layout thrashing
|
|
529
|
-
// Only update boundaries when user stops typing (reduces update frequency)
|
|
530
|
-
removeCallbacks(boundaryUpdateCheck)
|
|
531
|
-
postDelayed(boundaryUpdateCheck, 200)
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Runnable for delayed boundary check
|
|
535
|
-
private val boundaryUpdateCheck = Runnable {
|
|
536
|
-
if (!isUpdatingText) {
|
|
537
|
-
updateAutoWrappedLineBoundaries()
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* Full re-application of spans (used when props change, not during typing).
|
|
543
|
-
* iOS-style: Work directly with editable, no setText() call.
|
|
544
|
-
*/
|
|
545
|
-
private fun applyCharacterBackgrounds() {
|
|
546
|
-
val editable = editableText ?: return
|
|
547
|
-
val textStr = editable.toString()
|
|
548
|
-
if (textStr.isEmpty()) return
|
|
549
|
-
|
|
550
|
-
// Apply line height if specified, or add spacing for padding
|
|
551
|
-
if (customLineHeight > 0) {
|
|
552
|
-
val lineSpacingMultiplier = customLineHeight / textSize
|
|
553
|
-
setLineSpacing(0f, lineSpacingMultiplier)
|
|
554
401
|
} else {
|
|
555
|
-
//
|
|
402
|
+
// Default: add extra spacing equal to vertical padding so backgrounds don't collide
|
|
556
403
|
val extraSpacing = charPaddingTop + charPaddingBottom
|
|
557
404
|
setLineSpacing(extraSpacing, 1.0f)
|
|
558
405
|
}
|
|
559
|
-
|
|
560
|
-
isUpdatingText = true
|
|
561
|
-
|
|
562
|
-
// Remove all existing spans
|
|
563
|
-
val existingSpans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
|
|
564
|
-
for (span in existingSpans) {
|
|
565
|
-
editable.removeSpan(span)
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Apply spans to all characters with correct line boundary flags
|
|
569
|
-
val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
|
|
570
|
-
val layoutObj = layout
|
|
571
|
-
|
|
572
|
-
for (i in textStr.indices) {
|
|
573
|
-
val char = textStr[i]
|
|
574
|
-
|
|
575
|
-
val shouldHighlight = when {
|
|
576
|
-
char == '\n' || char == '\t' -> false
|
|
577
|
-
char == ' ' -> {
|
|
578
|
-
val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
|
|
579
|
-
val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
|
|
580
|
-
!hasSpaceBefore && !hasSpaceAfter
|
|
581
|
-
}
|
|
582
|
-
else -> true
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (shouldHighlight) {
|
|
586
|
-
// ALWAYS check newlines first (for manual line breaks)
|
|
587
|
-
val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
|
|
588
|
-
val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
|
|
589
|
-
|
|
590
|
-
var isAtLineStart = i == 0 || hasNewlineBefore
|
|
591
|
-
var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
|
|
592
|
-
|
|
593
|
-
// Only use layout for auto-wrapped lines (not manual newlines)
|
|
594
|
-
if (!hasNewlineBefore && !hasNewlineAfter && layoutObj != null && i < textStr.length) {
|
|
595
|
-
try {
|
|
596
|
-
val line = layoutObj.getLineForOffset(i)
|
|
597
|
-
val lineStart = layoutObj.getLineStart(line)
|
|
598
|
-
val lineEnd = layoutObj.getLineEnd(line)
|
|
599
|
-
// Only override if this is an auto-wrapped boundary
|
|
600
|
-
if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
|
|
601
|
-
isAtLineStart = true
|
|
602
|
-
}
|
|
603
|
-
if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
|
|
604
|
-
isAtLineEnd = true
|
|
605
|
-
}
|
|
606
|
-
} catch (e: Exception) {
|
|
607
|
-
// Layout might not be ready, keep newline-based detection
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
val span = RoundedBackgroundSpan(
|
|
612
|
-
characterBackgroundColor,
|
|
613
|
-
textColorValue,
|
|
614
|
-
charPaddingLeft,
|
|
615
|
-
charPaddingRight,
|
|
616
|
-
charPaddingTop,
|
|
617
|
-
charPaddingBottom,
|
|
618
|
-
backgroundInsetTop,
|
|
619
|
-
backgroundInsetBottom,
|
|
620
|
-
backgroundInsetLeft,
|
|
621
|
-
backgroundInsetRight,
|
|
622
|
-
radius,
|
|
623
|
-
isAtLineStart,
|
|
624
|
-
isAtLineEnd
|
|
625
|
-
)
|
|
626
|
-
editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
isUpdatingText = false
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Update line boundary flags only for auto-wrapped lines.
|
|
635
|
-
* This is called after layout completes to handle text wrapping.
|
|
636
|
-
* Only updates spans that are at auto-wrapped line boundaries.
|
|
637
|
-
* Optimized to skip updates when layout hasn't changed.
|
|
638
|
-
*/
|
|
639
|
-
private fun updateAutoWrappedLineBoundaries() {
|
|
640
|
-
if (isUpdatingText) return
|
|
641
|
-
|
|
642
|
-
val layout = layout ?: return
|
|
643
|
-
val editable = editableText ?: return
|
|
644
|
-
val textStr = editable.toString()
|
|
645
|
-
if (textStr.isEmpty()) return
|
|
646
|
-
|
|
647
|
-
// Validate that layout is ready and has valid dimensions
|
|
648
|
-
if (width <= 0 || layout.lineCount == 0) return
|
|
649
|
-
|
|
650
|
-
val spans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
|
|
651
|
-
if (spans.isEmpty()) return
|
|
652
|
-
|
|
653
|
-
isUpdatingText = true
|
|
654
|
-
var hasChanges = false
|
|
655
|
-
|
|
656
|
-
for (span in spans) {
|
|
657
|
-
val spanStart = editable.getSpanStart(span)
|
|
658
|
-
val spanEnd = editable.getSpanEnd(span)
|
|
659
|
-
|
|
660
|
-
if (spanStart < 0 || spanStart >= textStr.length) continue
|
|
661
|
-
|
|
662
|
-
try {
|
|
663
|
-
val line = layout.getLineForOffset(spanStart)
|
|
664
|
-
val lineStart = layout.getLineStart(line)
|
|
665
|
-
val lineEnd = layout.getLineEnd(line)
|
|
666
|
-
|
|
667
|
-
// Determine actual line boundaries (includes auto-wrap)
|
|
668
|
-
val isAtLineStart = spanStart == lineStart
|
|
669
|
-
val isAtLineEnd = spanEnd == lineEnd
|
|
670
|
-
|
|
671
|
-
// Only update if this is an auto-wrapped line boundary (not a newline boundary)
|
|
672
|
-
val isNewlineBoundary = (spanStart > 0 && textStr[spanStart - 1] == '\n') ||
|
|
673
|
-
(spanEnd < textStr.length && textStr[spanEnd] == '\n')
|
|
674
|
-
|
|
675
|
-
// Only recreate span if it's at an auto-wrapped boundary and flags are wrong
|
|
676
|
-
if (!isNewlineBoundary && (isAtLineStart != span.isLineStart || isAtLineEnd != span.isLineEnd)) {
|
|
677
|
-
val newSpan = RoundedBackgroundSpan(
|
|
678
|
-
span.backgroundColor,
|
|
679
|
-
span.textColor,
|
|
680
|
-
span.paddingLeft,
|
|
681
|
-
span.paddingRight,
|
|
682
|
-
span.paddingTop,
|
|
683
|
-
span.paddingBottom,
|
|
684
|
-
span.backgroundInsetTop,
|
|
685
|
-
span.backgroundInsetBottom,
|
|
686
|
-
span.backgroundInsetLeft,
|
|
687
|
-
span.backgroundInsetRight,
|
|
688
|
-
span.cornerRadius,
|
|
689
|
-
isAtLineStart,
|
|
690
|
-
isAtLineEnd
|
|
691
|
-
)
|
|
692
|
-
editable.removeSpan(span)
|
|
693
|
-
editable.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
694
|
-
hasChanges = true
|
|
695
|
-
}
|
|
696
|
-
} catch (e: Exception) {
|
|
697
|
-
// Layout state is invalid, skip this update
|
|
698
|
-
continue
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
isUpdatingText = false
|
|
703
|
-
|
|
704
|
-
// Only invalidate if we actually made changes
|
|
705
|
-
if (hasChanges) {
|
|
706
|
-
invalidate()
|
|
707
|
-
}
|
|
708
406
|
}
|
|
709
407
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-highlight-text-view",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"description": "A native text input for React Native that supports inline text highlighting",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|