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
- * iOS-style span: Draws rounded background for each character.
21
- * Padding is only applied at line boundaries (first/last character of each line).
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
- private var changeStart = 0
221
- private var changeEnd = 0
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
- applyCharacterBackgroundsIncremental(s, changeStart, changeEnd)
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
- applyCharacterBackgrounds()
190
+ invalidate()
243
191
  }
244
-
192
+
245
193
  override fun setTextColor(color: Int) {
246
194
  super.setTextColor(color)
247
195
  textColorValue = color
248
- applyCharacterBackgrounds()
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
- applyCharacterBackgrounds()
205
+ applyLineHeightAndSpacing()
206
+ invalidate()
258
207
  }
259
-
208
+
260
209
  fun setCharPaddingLeft(padding: Float) {
261
210
  charPaddingLeft = padding
262
211
  updateViewPadding()
263
- applyCharacterBackgrounds()
212
+ applyLineHeightAndSpacing()
213
+ invalidate()
264
214
  }
265
-
215
+
266
216
  fun setCharPaddingRight(padding: Float) {
267
217
  charPaddingRight = padding
268
218
  updateViewPadding()
269
- applyCharacterBackgrounds()
219
+ applyLineHeightAndSpacing()
220
+ invalidate()
270
221
  }
271
-
222
+
272
223
  fun setCharPaddingTop(padding: Float) {
273
224
  charPaddingTop = padding
274
225
  updateViewPadding()
275
- applyCharacterBackgrounds()
226
+ applyLineHeightAndSpacing()
227
+ invalidate()
276
228
  }
277
-
229
+
278
230
  fun setCharPaddingBottom(padding: Float) {
279
231
  charPaddingBottom = padding
280
232
  updateViewPadding()
281
- applyCharacterBackgrounds()
233
+ applyLineHeightAndSpacing()
234
+ invalidate()
282
235
  }
283
-
236
+
284
237
  private fun updateViewPadding() {
285
- // Sync View padding with char padding to prevent clipping of background
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
- applyCharacterBackgrounds()
249
+ invalidate()
297
250
  }
298
-
251
+
299
252
  fun setHighlightBorderRadius(radius: Float) {
300
253
  highlightBorderRadius = radius
301
- applyCharacterBackgrounds()
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
- applyCharacterBackgrounds()
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
- applyCharacterBackgrounds()
333
+ invalidate()
379
334
  }
380
-
335
+
381
336
  fun setBackgroundInsetBottom(inset: Float) {
382
337
  backgroundInsetBottom = inset
383
- applyCharacterBackgrounds()
338
+ invalidate()
384
339
  }
385
-
340
+
386
341
  fun setBackgroundInsetLeft(inset: Float) {
387
342
  backgroundInsetLeft = inset
388
- applyCharacterBackgrounds()
343
+ invalidate()
389
344
  }
390
-
345
+
391
346
  fun setBackgroundInsetRight(inset: Float) {
392
347
  backgroundInsetRight = inset
393
- applyCharacterBackgrounds()
348
+ invalidate()
394
349
  }
395
-
350
+
396
351
  fun setCustomLineHeight(lineHeight: Float) {
397
352
  customLineHeight = lineHeight
398
- applyCharacterBackgrounds()
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(text: String) {
402
- if (this.text?.toString() != text) {
362
+ fun setTextProp(newText: String) {
363
+ if (this.text?.toString() != newText) {
403
364
  isUpdatingText = true
404
- setText(text)
405
- applyCharacterBackgrounds()
365
+ setText(newText)
406
366
  // Move cursor to end of text after setting
407
367
  post {
408
368
  if (hasFocus()) {
409
- text.length.let { setSelection(it) }
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
- * iOS-style incremental update: Only update spans for changed region.
430
- * This is called during typing and only touches the modified characters.
431
- */
432
- private fun applyCharacterBackgroundsIncremental(editable: Editable?, start: Int, end: Int) {
433
- if (editable == null) return
434
- val textStr = editable.toString()
435
- if (textStr.isEmpty()) return
436
-
437
- isUpdatingText = true
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
- // Add line spacing to accommodate vertical padding and prevent overlap
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.23",
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",