react-native-highlight-text-view 0.1.20 → 0.1.22

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.
@@ -16,6 +16,10 @@ import android.util.TypedValue
16
16
  import android.view.Gravity
17
17
  import androidx.appcompat.widget.AppCompatEditText
18
18
 
19
+ /**
20
+ * iOS-style span: Draws rounded background for each character.
21
+ * Padding is only applied at line boundaries (first/last character of each line).
22
+ */
19
23
  class RoundedBackgroundSpan(
20
24
  internal val backgroundColor: Int,
21
25
  internal val textColor: Int,
@@ -28,10 +32,8 @@ class RoundedBackgroundSpan(
28
32
  internal val backgroundInsetLeft: Float,
29
33
  internal val backgroundInsetRight: Float,
30
34
  internal val cornerRadius: Float,
31
- internal val isFirstInGroup: Boolean = false,
32
- internal val isLastInGroup: Boolean = false,
33
- private val isStartOfLine: Boolean = false,
34
- private val isEndOfLine: Boolean = false
35
+ internal val isLineStart: Boolean = false,
36
+ internal val isLineEnd: Boolean = false
35
37
  ) : ReplacementSpan() {
36
38
 
37
39
  override fun getSize(
@@ -41,10 +43,10 @@ class RoundedBackgroundSpan(
41
43
  end: Int,
42
44
  fm: Paint.FontMetricsInt?
43
45
  ): Int {
46
+ // Only add padding at line boundaries (matches iOS behavior)
44
47
  val width = paint.measureText(text, start, end)
45
- // Add padding for word boundaries AND line boundaries (for consistent alignment)
46
- val leftPad = if (isFirstInGroup || isStartOfLine) paddingLeft else 0f
47
- val rightPad = if (isLastInGroup || isEndOfLine) paddingRight else 0f
48
+ val leftPad = if (isLineStart) paddingLeft else 0f
49
+ val rightPad = if (isLineEnd) paddingRight else 0f
48
50
  return (width + leftPad + rightPad).toInt()
49
51
  }
50
52
 
@@ -59,7 +61,8 @@ class RoundedBackgroundSpan(
59
61
  bottom: Int,
60
62
  paint: Paint
61
63
  ) {
62
- // Draw background with padding
64
+ if (text == null) return
65
+
63
66
  val bgPaint = Paint().apply {
64
67
  color = backgroundColor
65
68
  style = Paint.Style.FILL
@@ -68,83 +71,99 @@ class RoundedBackgroundSpan(
68
71
 
69
72
  val width = paint.measureText(text, start, end)
70
73
 
71
- // Use font metrics for consistent height (matches iOS behavior)
74
+ // Use font metrics for consistent height (matches iOS)
72
75
  val fontMetrics = paint.fontMetrics
73
76
  val textHeight = fontMetrics.descent - fontMetrics.ascent
74
77
  val textTop = y + fontMetrics.ascent
75
78
 
76
- // Add padding for word AND line boundaries (consistent alignment)
77
- val isReallyFirst = isFirstInGroup || isStartOfLine
78
- val isReallyLast = isLastInGroup || isEndOfLine
79
- val leftPad = if (isReallyFirst) paddingLeft else 0f
80
- val rightPad = if (isReallyLast) paddingRight else 0f
81
-
82
- // Small overlap to eliminate gaps between characters
83
- val overlapExtension = 2f
84
- val leftOverlap = if (!isReallyFirst) overlapExtension else 0f
85
- val rightOverlap = if (!isReallyLast) overlapExtension else 0f
86
-
87
- // Apply background insets first (shrinks from line box)
79
+ // Apply background insets first (shrinks from line box - EXACTLY like iOS line 45-48)
88
80
  val insetTop = textTop + backgroundInsetTop
89
81
  val insetHeight = textHeight - (backgroundInsetTop + backgroundInsetBottom)
90
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
106
+ val topExtend = 4f
107
+ val bottomExtend = 4f
108
+
91
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'.
92
112
  val rect = RectF(
93
- x - leftOverlap + backgroundInsetLeft,
94
- insetTop - paddingTop,
95
- x + width + leftPad + rightPad + rightOverlap - backgroundInsetRight,
96
- insetTop + insetHeight + paddingBottom
113
+ x + backgroundInsetLeft - leftExtend,
114
+ insetTop - paddingTop - topExtend,
115
+ x + leftPad + width + rightPad - backgroundInsetRight + rightExtend,
116
+ insetTop + insetHeight + paddingBottom + bottomExtend
97
117
  )
98
118
 
99
- // Draw background with selective corner rounding (matches iOS behavior)
100
- // iOS draws per-character backgrounds with full corner radius, so we do the same
119
+ // Draw based on position
101
120
  when {
102
- isReallyFirst && isReallyLast -> {
103
- // Single character or isolated group - round all corners (matches iOS)
121
+ isLineStart && isLineEnd -> {
122
+ // Single character - round all corners
104
123
  canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
105
124
  }
106
- isReallyFirst -> {
107
- // First character (word start or line start) - round left corners only
125
+ isLineStart -> {
126
+ // Line START - round LEFT corners only
108
127
  val path = android.graphics.Path()
109
128
  path.addRoundRect(
110
129
  rect,
111
130
  floatArrayOf(
112
131
  cornerRadius, cornerRadius, // top-left
113
- 0f, 0f, // top-right (flat for connection)
114
- 0f, 0f, // bottom-right (flat for connection)
132
+ 0f, 0f, // top-right
133
+ 0f, 0f, // bottom-right
115
134
  cornerRadius, cornerRadius // bottom-left
116
135
  ),
117
136
  android.graphics.Path.Direction.CW
118
137
  )
119
138
  canvas.drawPath(path, bgPaint)
120
139
  }
121
- isReallyLast -> {
122
- // Last character (word end or line end) - round right corners only
140
+ isLineEnd -> {
141
+ // Line END - round RIGHT corners only
123
142
  val path = android.graphics.Path()
124
143
  path.addRoundRect(
125
144
  rect,
126
145
  floatArrayOf(
127
- 0f, 0f, // top-left (flat for connection)
146
+ 0f, 0f, // top-left
128
147
  cornerRadius, cornerRadius, // top-right
129
148
  cornerRadius, cornerRadius, // bottom-right
130
- 0f, 0f // bottom-left (flat for connection)
149
+ 0f, 0f // bottom-left
131
150
  ),
132
151
  android.graphics.Path.Direction.CW
133
152
  )
134
153
  canvas.drawPath(path, bgPaint)
135
154
  }
136
155
  else -> {
137
- // Middle character - no rounded corners for seamless connection
156
+ // Middle - NO rounded corners (Square)
138
157
  canvas.drawRect(rect, bgPaint)
139
158
  }
140
159
  }
141
160
 
142
- // Draw text with left padding offset only if first in group
161
+ // Draw text offset by left padding only if at line start
143
162
  val textPaint = Paint(paint).apply {
144
163
  color = textColor
145
164
  isAntiAlias = true
146
165
  }
147
- canvas.drawText(text!!, start, end, x + leftPad, y.toFloat(), textPaint)
166
+ canvas.drawText(text, start, end, x + leftPad, y.toFloat(), textPaint)
148
167
  }
149
168
  }
150
169
 
@@ -198,12 +217,21 @@ class HighlightTextView : AppCompatEditText {
198
217
  setHorizontallyScrolling(false)
199
218
 
200
219
  addTextChangedListener(object : TextWatcher {
201
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
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
+
202
228
  override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
229
+
203
230
  override fun afterTextChanged(s: Editable?) {
204
231
  if (!isUpdatingText) {
205
232
  onTextChangeListener?.invoke(s?.toString() ?: "")
206
- applyCharacterBackgrounds()
233
+
234
+ applyCharacterBackgroundsIncremental(s, changeStart, changeEnd)
207
235
  }
208
236
  }
209
237
  })
@@ -225,29 +253,44 @@ class HighlightTextView : AppCompatEditText {
225
253
  charPaddingTop = top
226
254
  charPaddingRight = right
227
255
  charPaddingBottom = bottom
256
+ updateViewPadding()
228
257
  applyCharacterBackgrounds()
229
258
  }
230
259
 
231
260
  fun setCharPaddingLeft(padding: Float) {
232
261
  charPaddingLeft = padding
262
+ updateViewPadding()
233
263
  applyCharacterBackgrounds()
234
264
  }
235
265
 
236
266
  fun setCharPaddingRight(padding: Float) {
237
267
  charPaddingRight = padding
268
+ updateViewPadding()
238
269
  applyCharacterBackgrounds()
239
270
  }
240
271
 
241
272
  fun setCharPaddingTop(padding: Float) {
242
273
  charPaddingTop = padding
274
+ updateViewPadding()
243
275
  applyCharacterBackgrounds()
244
276
  }
245
277
 
246
278
  fun setCharPaddingBottom(padding: Float) {
247
279
  charPaddingBottom = padding
280
+ updateViewPadding()
248
281
  applyCharacterBackgrounds()
249
282
  }
250
283
 
284
+ private fun updateViewPadding() {
285
+ // Sync View padding with char padding to prevent clipping of background
286
+ setPadding(
287
+ charPaddingLeft.toInt(),
288
+ charPaddingTop.toInt(),
289
+ charPaddingRight.toInt(),
290
+ charPaddingBottom.toInt()
291
+ )
292
+ }
293
+
251
294
  fun setCornerRadius(radius: Float) {
252
295
  cornerRadius = radius
253
296
  applyCharacterBackgrounds()
@@ -360,25 +403,149 @@ class HighlightTextView : AppCompatEditText {
360
403
  isUpdatingText = true
361
404
  setText(text)
362
405
  applyCharacterBackgrounds()
406
+ // Move cursor to end of text after setting
407
+ post {
408
+ if (hasFocus()) {
409
+ text.length.let { setSelection(it) }
410
+ }
411
+ }
363
412
  isUpdatingText = false
364
413
  }
365
414
  }
366
415
 
367
416
  fun setAutoFocus(autoFocus: Boolean) {
368
417
  if (autoFocus && isFocusable && isFocusableInTouchMode) {
369
- post {
418
+ postDelayed({
370
419
  requestFocus()
420
+ // Move cursor to end of text
421
+ text?.length?.let { setSelection(it) }
371
422
  val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager
372
- imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
423
+ imm?.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_FORCED)
424
+ }, 100)
425
+ }
426
+ }
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)
373
523
  }
374
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
+ }
375
539
  }
376
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
+ */
377
545
  private fun applyCharacterBackgrounds() {
378
- val text = text?.toString() ?: return
379
- if (text.isEmpty()) return
380
-
381
- val spannable = SpannableString(text)
546
+ val editable = editableText ?: return
547
+ val textStr = editable.toString()
548
+ if (textStr.isEmpty()) return
382
549
 
383
550
  // Apply line height if specified
384
551
  if (customLineHeight > 0) {
@@ -386,29 +553,56 @@ class HighlightTextView : AppCompatEditText {
386
553
  setLineSpacing(0f, lineSpacingMultiplier)
387
554
  }
388
555
 
389
- // Apply character-by-character for proper line wrapping
390
- for (i in text.indices) {
391
- val char = text[i]
556
+ isUpdatingText = true
557
+
558
+ // Remove all existing spans
559
+ val existingSpans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
560
+ for (span in existingSpans) {
561
+ editable.removeSpan(span)
562
+ }
563
+
564
+ // Apply spans to all characters with correct line boundary flags
565
+ val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
566
+ val layoutObj = layout
567
+
568
+ for (i in textStr.indices) {
569
+ val char = textStr[i]
392
570
 
393
- // Check if this is a space that should be highlighted
394
571
  val shouldHighlight = when {
395
- char == '\n' || char == '\t' -> false // Never highlight newlines or tabs
572
+ char == '\n' || char == '\t' -> false
396
573
  char == ' ' -> {
397
- // Highlight space only if it's a single space (not multiple consecutive)
398
- val hasSpaceBefore = i > 0 && text[i - 1] == ' '
399
- val hasSpaceAfter = i < text.length - 1 && text[i + 1] == ' '
574
+ val hasSpaceBefore = i > 0 && textStr[i - 1] == ' '
575
+ val hasSpaceAfter = i < textStr.length - 1 && textStr[i + 1] == ' '
400
576
  !hasSpaceBefore && !hasSpaceAfter
401
577
  }
402
- else -> true // Highlight all other characters
578
+ else -> true
403
579
  }
404
580
 
405
581
  if (shouldHighlight) {
406
- // Determine if this is the first or last character in a word group
407
- val isFirst = i == 0 || !shouldHighlightChar(text, i - 1)
408
- val isLast = i == text.length - 1 || !shouldHighlightChar(text, i + 1)
582
+ // ALWAYS check newlines first (for manual line breaks)
583
+ val hasNewlineBefore = i > 0 && textStr[i - 1] == '\n'
584
+ val hasNewlineAfter = i + 1 < textStr.length && textStr[i + 1] == '\n'
585
+
586
+ var isAtLineStart = i == 0 || hasNewlineBefore
587
+ var isAtLineEnd = i == textStr.length - 1 || hasNewlineAfter
409
588
 
410
- // Use highlightBorderRadius if specified, otherwise use cornerRadius (matches iOS)
411
- val radius = if (highlightBorderRadius > 0) highlightBorderRadius else cornerRadius
589
+ // Only use layout for auto-wrapped lines (not manual newlines)
590
+ if (!hasNewlineBefore && !hasNewlineAfter && layoutObj != null && i < textStr.length) {
591
+ try {
592
+ val line = layoutObj.getLineForOffset(i)
593
+ val lineStart = layoutObj.getLineStart(line)
594
+ val lineEnd = layoutObj.getLineEnd(line)
595
+ // Only override if this is an auto-wrapped boundary
596
+ if (i == lineStart && textStr.getOrNull(i - 1) != '\n') {
597
+ isAtLineStart = true
598
+ }
599
+ if ((i + 1) == lineEnd && textStr.getOrNull(i + 1) != '\n') {
600
+ isAtLineEnd = true
601
+ }
602
+ } catch (e: Exception) {
603
+ // Layout might not be ready, keep newline-based detection
604
+ }
605
+ }
412
606
 
413
607
  val span = RoundedBackgroundSpan(
414
608
  characterBackgroundColor,
@@ -422,62 +616,60 @@ class HighlightTextView : AppCompatEditText {
422
616
  backgroundInsetLeft,
423
617
  backgroundInsetRight,
424
618
  radius,
425
- isFirst,
426
- isLast
619
+ isAtLineStart,
620
+ isAtLineEnd
427
621
  )
428
- spannable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
622
+ editable.setSpan(span, i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
429
623
  }
430
624
  }
431
625
 
432
- // Save current selection to prevent cursor jumping (smooth editing)
433
- val currentSelection = selectionStart
434
-
435
- isUpdatingText = true
436
- setText(spannable)
437
-
438
- // Restore cursor position if valid (prevents jerking during editing)
439
- if (currentSelection >= 0 && currentSelection <= text.length) {
440
- setSelection(currentSelection)
441
- }
442
626
  isUpdatingText = false
443
-
444
- // Detect line wraps after layout is ready
445
- post { detectLineWraps() }
446
627
  }
447
628
 
448
- private fun detectLineWraps() {
629
+ /**
630
+ * Update line boundary flags only for auto-wrapped lines.
631
+ * This is called after layout completes to handle text wrapping.
632
+ * Only updates spans that are at auto-wrapped line boundaries.
633
+ * Optimized to skip updates when layout hasn't changed.
634
+ */
635
+ private fun updateAutoWrappedLineBoundaries() {
636
+ if (isUpdatingText) return
637
+
449
638
  val layout = layout ?: return
450
- val text = text as? Spannable ?: return
451
- val textStr = text.toString()
452
- val spans = text.getSpans(0, text.length, RoundedBackgroundSpan::class.java)
639
+ val editable = editableText ?: return
640
+ val textStr = editable.toString()
641
+ if (textStr.isEmpty()) return
642
+
643
+ // Validate that layout is ready and has valid dimensions
644
+ if (width <= 0 || layout.lineCount == 0) return
645
+
646
+ val spans = editable.getSpans(0, editable.length, RoundedBackgroundSpan::class.java)
647
+ if (spans.isEmpty()) return
648
+
649
+ isUpdatingText = true
650
+ var hasChanges = false
453
651
 
454
652
  for (span in spans) {
455
- val spanStart = text.getSpanStart(span)
456
- val spanEnd = text.getSpanEnd(span)
653
+ val spanStart = editable.getSpanStart(span)
654
+ val spanEnd = editable.getSpanEnd(span)
655
+
656
+ if (spanStart < 0 || spanStart >= textStr.length) continue
457
657
 
458
- if (spanStart >= 0 && spanStart < text.length) {
658
+ try {
459
659
  val line = layout.getLineForOffset(spanStart)
460
660
  val lineStart = layout.getLineStart(line)
461
661
  val lineEnd = layout.getLineEnd(line)
462
662
 
463
- // Check for manual line break (\n) before this character
464
- val hasNewlineBefore = spanStart > 0 && textStr[spanStart - 1] == '\n'
465
- // Check for manual line break (\n) after this character
466
- val hasNewlineAfter = spanEnd < textStr.length && textStr[spanEnd] == '\n'
663
+ // Determine actual line boundaries (includes auto-wrap)
664
+ val isAtLineStart = spanStart == lineStart
665
+ val isAtLineEnd = spanEnd == lineEnd
467
666
 
468
- // Check if this is the last line of text
469
- val isLastLine = line == layout.lineCount - 1
667
+ // Only update if this is an auto-wrapped line boundary (not a newline boundary)
668
+ val isNewlineBoundary = (spanStart > 0 && textStr[spanStart - 1] == '\n') ||
669
+ (spanEnd < textStr.length && textStr[spanEnd] == '\n')
470
670
 
471
- // Check if this char is at start of visual line (wrapped OR after \n)
472
- val isAtLineStart = (spanStart == lineStart && !span.isFirstInGroup) || hasNewlineBefore
473
-
474
- // Check if this char is at end of visual line (wrapped OR before \n OR end of last line)
475
- // CRITICAL: Ensure last character of entire text gets rounded corners
476
- val isAtLineEnd = (spanEnd == lineEnd && !span.isLastInGroup) || hasNewlineAfter ||
477
- (isLastLine && spanEnd == lineEnd)
478
-
479
- if (isAtLineStart || isAtLineEnd) {
480
- // Create new span with line boundary flags
671
+ // Only recreate span if it's at an auto-wrapped boundary and flags are wrong
672
+ if (!isNewlineBoundary && (isAtLineStart != span.isLineStart || isAtLineEnd != span.isLineEnd)) {
481
673
  val newSpan = RoundedBackgroundSpan(
482
674
  span.backgroundColor,
483
675
  span.textColor,
@@ -490,31 +682,24 @@ class HighlightTextView : AppCompatEditText {
490
682
  span.backgroundInsetLeft,
491
683
  span.backgroundInsetRight,
492
684
  span.cornerRadius,
493
- span.isFirstInGroup,
494
- span.isLastInGroup,
495
685
  isAtLineStart,
496
686
  isAtLineEnd
497
687
  )
498
- text.removeSpan(span)
499
- text.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
688
+ editable.removeSpan(span)
689
+ editable.setSpan(newSpan, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
690
+ hasChanges = true
500
691
  }
692
+ } catch (e: Exception) {
693
+ // Layout state is invalid, skip this update
694
+ continue
501
695
  }
502
696
  }
503
- invalidate()
504
- }
505
-
506
- private fun shouldHighlightChar(text: String, index: Int): Boolean {
507
- if (index < 0 || index >= text.length) return false
508
- val char = text[index]
509
-
510
- return when {
511
- char == '\n' || char == '\t' -> false
512
- char == ' ' -> {
513
- val hasSpaceBefore = index > 0 && text[index - 1] == ' '
514
- val hasSpaceAfter = index < text.length - 1 && text[index + 1] == ' '
515
- !hasSpaceBefore && !hasSpaceAfter
516
- }
517
- else -> true
697
+
698
+ isUpdatingText = false
699
+
700
+ // Only invalidate if we actually made changes
701
+ if (hasChanges) {
702
+ invalidate()
518
703
  }
519
704
  }
520
705
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-highlight-text-view",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",