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