react-native-advanced-text 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,11 @@ import { AdvancedText } from "react-native-advanced-text";
38
38
  highlightColor: '#6baeffb5',
39
39
  borderRadius: 6,
40
40
  },
41
+ {
42
+ index: 7,
43
+ highlightColor: '#818CF8',
44
+ underline: true, // a line under the word instead of a background
45
+ },
41
46
  ]}
42
47
  fontSize={24}
43
48
  color={'#FFFFFF'}
@@ -47,6 +52,13 @@ import { AdvancedText } from "react-native-advanced-text";
47
52
  />
48
53
  ```
49
54
 
55
+ ## Underlined words
56
+
57
+ An entry in `highlightedWords` with `underline: true` draws a line under the
58
+ word in `highlightColor` instead of filling its background (`borderRadius` is
59
+ ignored). A word can be both highlighted and underlined: list it twice, once
60
+ with `underline: true` and once without.
61
+
50
62
  ## Sizing
51
63
 
52
64
  `AdvancedText` measures its own height, like React Native's built‑in `<Text>`.
@@ -3,9 +3,12 @@ package com.advancedtext
3
3
  import android.content.Context
4
4
  import android.graphics.Canvas
5
5
  import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Path
6
8
  import android.graphics.Point
7
9
  import android.graphics.RectF
8
10
  import android.os.Build
11
+ import android.text.Layout
9
12
  import android.text.SpannableString
10
13
  import android.text.Spannable
11
14
  import android.text.Spanned
@@ -58,6 +61,13 @@ class AdvancedTextView : TextView {
58
61
  // text. setText() would clear any selection the user is making.
59
62
  private var wordColorSpans: Array<ForegroundColorSpan?> = emptyArray()
60
63
 
64
+ // Reused on every draw for the highlight backgrounds.
65
+ private val highlightPaint = Paint(Paint.ANTI_ALIAS_FLAG)
66
+ private val rangePath = Path()
67
+ private val lineClipPath = Path()
68
+ private val rangeBounds = RectF()
69
+ private val underlineBounds = RectF()
70
+
61
71
  constructor(context: Context?) : super(context) { init() }
62
72
  constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { init() }
63
73
  constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init() }
@@ -382,43 +392,27 @@ class AdvancedTextView : TextView {
382
392
  private fun drawHighlightedWordBackgrounds(canvas: Canvas) {
383
393
  val currentLayout = layout ?: return
384
394
  val density = resources.displayMetrics.density
385
- val originalColor = paint.color
386
395
 
387
396
  highlightedWords.forEach { highlightedWord ->
388
397
  val wordPos = wordPositions.find { it.index == highlightedWord.index } ?: return@forEach
389
- val start = wordPos.start
390
- val end = wordPos.extendedEnd.coerceAtMost(currentText.length)
391
-
392
- if (start >= end) return@forEach
393
-
394
- val startLine = currentLayout.getLineForOffset(start)
395
- val safeEndOffset = (end - 1).coerceAtLeast(start)
396
- val endLine = currentLayout.getLineForOffset(safeEndOffset)
397
-
398
- paint.color = parseColor(highlightedWord.highlightColor)
399
-
400
- for (line in startLine..endLine) {
401
- val lineStartOffset = maxOf(start, currentLayout.getLineStart(line))
402
- val lineEndOffset = minOf(end, currentLayout.getLineEnd(line))
403
- if (lineStartOffset >= lineEndOffset) continue
404
-
405
- val left = currentLayout.getPrimaryHorizontal(lineStartOffset)
406
- val isLastWordOnLine = lineEndOffset >= currentLayout.getLineEnd(line)
407
- val right = if (isLastWordOnLine) {
408
- currentLayout.getLineRight(line)
409
- } else {
410
- currentLayout.getPrimaryHorizontal(lineEndOffset)
411
- }
412
- val top = currentLayout.getLineTop(line).toFloat()
413
- val bottom = currentLayout.getLineBottom(line).toFloat()
414
- val rect = RectF(
415
- totalPaddingLeft + left,
416
- totalPaddingTop + top,
417
- totalPaddingLeft + right,
418
- totalPaddingTop + bottom
398
+ if (highlightedWord.underline) {
399
+ // The word, but not the space after it.
400
+ drawRangeUnderline(
401
+ canvas,
402
+ currentLayout,
403
+ wordPos.start,
404
+ wordPos.end.coerceAtMost(currentText.length),
405
+ parseColor(highlightedWord.highlightColor)
406
+ )
407
+ } else {
408
+ drawRangeBackground(
409
+ canvas,
410
+ currentLayout,
411
+ wordPos.start,
412
+ wordPos.extendedEnd.coerceAtMost(currentText.length),
413
+ parseColor(highlightedWord.highlightColor),
414
+ highlightedWord.borderRadius * density
419
415
  )
420
- val radius = highlightedWord.borderRadius * density
421
- canvas.drawRoundRect(rect, radius, radius, paint)
422
416
  }
423
417
  }
424
418
 
@@ -426,43 +420,94 @@ class AdvancedTextView : TextView {
426
420
  if (indicatorMode != "fill" && indicatorWordIndex >= 0 && indicatorColor.isNotEmpty()) {
427
421
  val wordPos = wordPositions.find { it.index == indicatorWordIndex }
428
422
  if (wordPos != null) {
429
- val start = wordPos.start
430
- val end = wordPos.end.coerceAtMost(currentText.length)
431
-
432
- if (start < end) {
433
- val startLine = currentLayout.getLineForOffset(start)
434
- val safeEndOffset = (end - 1).coerceAtLeast(start)
435
- val endLine = currentLayout.getLineForOffset(safeEndOffset)
436
-
437
- paint.color = parseColor(indicatorColor)
438
-
439
- for (line in startLine..endLine) {
440
- val lineStartOffset = maxOf(start, currentLayout.getLineStart(line))
441
- val lineEndOffset = minOf(end, currentLayout.getLineEnd(line))
442
- if (lineStartOffset >= lineEndOffset) continue
443
-
444
- val left = currentLayout.getPrimaryHorizontal(lineStartOffset)
445
- val isLastWordOnLine = lineEndOffset >= currentLayout.getLineEnd(line)
446
- val right = if (isLastWordOnLine) {
447
- currentLayout.getLineRight(line)
448
- } else {
449
- currentLayout.getPrimaryHorizontal(lineEndOffset)
450
- }
451
- val top = currentLayout.getLineTop(line).toFloat()
452
- val bottom = currentLayout.getLineBottom(line).toFloat()
453
- val rect = RectF(
454
- totalPaddingLeft + left,
455
- totalPaddingTop + top,
456
- totalPaddingLeft + right,
457
- totalPaddingTop + bottom
458
- )
459
- canvas.drawRoundRect(rect, 0f, 0f, paint)
460
- }
461
- }
423
+ drawRangeBackground(
424
+ canvas,
425
+ currentLayout,
426
+ wordPos.start,
427
+ wordPos.end.coerceAtMost(currentText.length),
428
+ parseColor(indicatorColor),
429
+ 0f
430
+ )
462
431
  }
463
432
  }
433
+ }
434
+
435
+ // Fills the background of text[start, end), one box per line.
436
+ private fun drawRangeBackground(
437
+ canvas: Canvas,
438
+ layout: Layout,
439
+ start: Int,
440
+ end: Int,
441
+ color: Int,
442
+ radius: Float
443
+ ) {
444
+ highlightPaint.color = color
445
+ forEachLineOfRange(layout, start, end) {
446
+ canvas.drawRoundRect(rangeBounds, radius, radius, highlightPaint)
447
+ }
448
+ }
449
+
450
+ // Draws a line under text[start, end), one per line it spans, just below
451
+ // the baseline like a text underline.
452
+ private fun drawRangeUnderline(
453
+ canvas: Canvas,
454
+ layout: Layout,
455
+ start: Int,
456
+ end: Int,
457
+ color: Int
458
+ ) {
459
+ val density = resources.displayMetrics.density
460
+ val thickness = UNDERLINE_THICKNESS_DP * density
461
+ val gap = UNDERLINE_GAP_DP * density
462
+ highlightPaint.color = color
463
+ forEachLineOfRange(layout, start, end) { line ->
464
+ val top = layout.getLineBaseline(line) + totalPaddingTop + gap
465
+ underlineBounds.set(rangeBounds.left, top, rangeBounds.right, top + thickness)
466
+ canvas.drawRoundRect(underlineBounds, thickness / 2, thickness / 2, highlightPaint)
467
+ }
468
+ }
469
+
470
+ // Calls draw for each line text[start, end) spans, with rangeBounds set to
471
+ // that line's part of the range in view coordinates. The edges come from
472
+ // Layout.getSelectionPath, which follows the text's direction: a
473
+ // right-to-left word (Arabic) starts at its right edge, so treating
474
+ // getPrimaryHorizontal(start) as the left edge put boxes in the wrong place.
475
+ private inline fun forEachLineOfRange(
476
+ layout: Layout,
477
+ start: Int,
478
+ end: Int,
479
+ draw: (line: Int) -> Unit
480
+ ) {
481
+ if (start >= end) return
482
+
483
+ val startLine = layout.getLineForOffset(start)
484
+ val endLine = layout.getLineForOffset(end - 1)
485
+ for (line in startLine..endLine) {
486
+ val segmentStart = maxOf(start, layout.getLineStart(line))
487
+ val segmentEnd = minOf(end, layout.getLineEnd(line))
488
+ if (segmentStart >= segmentEnd) continue
489
+
490
+ rangePath.reset()
491
+ layout.getSelectionPath(segmentStart, segmentEnd, rangePath)
492
+
493
+ // A range reaching the end of a line counts as running onto the
494
+ // next one, so the selection path fills out to the view's edge.
495
+ // Clip to this line's text to stop the box at the last word.
496
+ lineClipPath.reset()
497
+ lineClipPath.addRect(
498
+ layout.getLineLeft(line),
499
+ layout.getLineTop(line).toFloat(),
500
+ layout.getLineRight(line),
501
+ layout.getLineBottom(line).toFloat(),
502
+ Path.Direction.CW
503
+ )
504
+ rangePath.op(lineClipPath, Path.Op.INTERSECT)
505
+ rangePath.computeBounds(rangeBounds, true)
506
+ if (rangeBounds.isEmpty) continue
464
507
 
465
- paint.color = originalColor
508
+ rangeBounds.offset(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
509
+ draw(line)
510
+ }
466
511
  }
467
512
 
468
513
  private fun parseColor(colorString: String): Int {
@@ -589,5 +634,10 @@ class AdvancedTextView : TextView {
589
634
  data class HighlightedWord(
590
635
  val index: Int,
591
636
  val highlightColor: String,
592
- val borderRadius: Float = 0f
637
+ val borderRadius: Float = 0f,
638
+ // Draws a line under the word instead of filling its background.
639
+ val underline: Boolean = false
593
640
  )
641
+
642
+ private const val UNDERLINE_THICKNESS_DP = 1.5f
643
+ private const val UNDERLINE_GAP_DP = 2.5f
@@ -54,7 +54,10 @@ class AdvancedTextViewManager : SimpleViewManager<AdvancedTextView>() {
54
54
  wordMap.getDouble("borderRadius").toFloat()
55
55
  } else {
56
56
  0f
57
- }
57
+ },
58
+ underline = wordMap.hasKey("underline") &&
59
+ !wordMap.isNull("underline") &&
60
+ wordMap.getBoolean("underline")
58
61
  )
59
62
  )
60
63
  }
@@ -31,6 +31,9 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
31
31
 
32
32
  @property (nonatomic, strong) NSMutableArray<NSDictionary *> *wordRanges;
33
33
  @property (nonatomic, strong) NSMutableDictionary<NSNumber *, NSDictionary *> *highlightStyles;
34
+ // Underline colors by word index, kept apart from highlightStyles so a word
35
+ // can be both highlighted and underlined.
36
+ @property (nonatomic, strong) NSMutableDictionary<NSNumber *, UIColor *> *underlineStyles;
34
37
  @property (nonatomic, strong) NSArray<NSString *> *menuOptions;
35
38
  @property (nonatomic, assign) CGFloat fontSize;
36
39
  @property (nonatomic, strong) NSString *fontWeight;
@@ -109,6 +112,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
109
112
 
110
113
  _wordRanges = [NSMutableArray array];
111
114
  _highlightStyles = [NSMutableDictionary dictionary];
115
+ _underlineStyles = [NSMutableDictionary dictionary];
112
116
  _fontSize = 16.0;
113
117
  _fontWeight = @"normal";
114
118
  _textColor = [UIColor labelColor];
@@ -237,7 +241,8 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
237
241
  const auto &newHW = newViewProps.highlightedWords[i];
238
242
  if (oldHW.index != newHW.index ||
239
243
  oldHW.highlightColor != newHW.highlightColor ||
240
- oldHW.borderRadius != newHW.borderRadius) {
244
+ oldHW.borderRadius != newHW.borderRadius ||
245
+ oldHW.underline != newHW.underline) {
241
246
  highlightsChanged = YES;
242
247
  break;
243
248
  }
@@ -410,6 +415,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
410
415
  highlightedWords.size());
411
416
  @try {
412
417
  [_highlightStyles removeAllObjects];
418
+ [_underlineStyles removeAllObjects];
413
419
 
414
420
  for (const auto &hw : highlightedWords) {
415
421
  NSInteger index = hw.index;
@@ -417,7 +423,9 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
417
423
  UIColor *color = [self hexStringToColor:colorString];
418
424
  CGFloat borderRadius = static_cast<CGFloat>(hw.borderRadius);
419
425
 
420
- if (color) {
426
+ if (color && hw.underline) {
427
+ _underlineStyles[@(index)] = color;
428
+ } else if (color) {
421
429
  _highlightStyles[@(index)] = @{
422
430
  @"color": color,
423
431
  @"borderRadius": @(MAX(borderRadius, 0.0))
@@ -499,6 +507,7 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
499
507
 
500
508
 
501
509
  [self applyWordColorsToString:attributedString];
510
+ [self applyUnderlinesToString:attributedString];
502
511
 
503
512
  _textView.attributedText = attributedString;
504
513
  [_textView setNeedsDisplay];
@@ -545,6 +554,29 @@ static UIFont *ATResolveFont(NSString *family, CGFloat size)
545
554
  }
546
555
  }
547
556
 
557
+ // A text underline under each underlined word, but not the space after it.
558
+ - (void)applyUnderlinesToString:(NSMutableAttributedString *)string
559
+ {
560
+ if (_underlineStyles.count == 0) {
561
+ return;
562
+ }
563
+
564
+ for (NSDictionary *wordInfo in _wordRanges) {
565
+ UIColor *color = _underlineStyles[wordInfo[@"index"]];
566
+ if (!color) {
567
+ continue;
568
+ }
569
+ NSRange wordRange = [wordInfo[@"range"] rangeValue];
570
+ if (NSMaxRange(wordRange) > string.length) {
571
+ break;
572
+ }
573
+ [string addAttributes:@{
574
+ NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle),
575
+ NSUnderlineColorAttributeName: color
576
+ } range:wordRange];
577
+ }
578
+ }
579
+
548
580
  // Recolors the live textStorage instead of reassigning attributedText, so the
549
581
  // indicator can advance while the user is selecting text: attribute-only
550
582
  // edits keep selectedRange and the edit menu, assigning attributedText resets them.
@@ -8,6 +8,9 @@ interface HighlightedWord {
8
8
  index: Int32;
9
9
  highlightColor: string;
10
10
  borderRadius?: Float;
11
+ // Underlines the word in highlightColor instead of filling its background.
12
+ // A word can have both: list it once with and once without.
13
+ underline?: boolean;
11
14
  }
12
15
 
13
16
  export interface NativeProps extends ViewProps {
@@ -4,6 +4,7 @@ interface HighlightedWord {
4
4
  index: Int32;
5
5
  highlightColor: string;
6
6
  borderRadius?: Float;
7
+ underline?: boolean;
7
8
  }
8
9
  export interface NativeProps extends ViewProps {
9
10
  text: string;
@@ -1 +1 @@
1
- {"version":3,"file":"AdvancedTextViewNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/AdvancedTextViewNativeComponent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAG,KAAK,EAAG,MAAM,2CAA2C,CAAC;AAEpG,UAAU,eAAe;IACvB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,KAAK,CAAC;CACtB;AAED,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAClD,WAAW,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAC;IACjE,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1E,QAAQ,CAAC,EAAE,KAAK,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,KAAK,CAAC;IACnB,aAAa,CAAC,EAAE,KAAK,CAAC;IACtB,kBAAkB,CAAC,EAAE,KAAK,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;;AAED,wBAAuE"}
1
+ {"version":3,"file":"AdvancedTextViewNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/AdvancedTextViewNativeComponent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAG,KAAK,EAAG,MAAM,2CAA2C,CAAC;AAEpG,UAAU,eAAe;IACvB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,KAAK,CAAC;IAGrB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAClD,WAAW,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACpC,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAC;IACjE,WAAW,CAAC,EAAE,kBAAkB,CAAC;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1E,QAAQ,CAAC,EAAE,KAAK,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,KAAK,CAAC;IACnB,aAAa,CAAC,EAAE,KAAK,CAAC;IACtB,kBAAkB,CAAC,EAAE,KAAK,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;;AAED,wBAAuE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-advanced-text",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": " Advanced text component for React Native with custom select options.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -8,6 +8,9 @@ interface HighlightedWord {
8
8
  index: Int32;
9
9
  highlightColor: string;
10
10
  borderRadius?: Float;
11
+ // Underlines the word in highlightColor instead of filling its background.
12
+ // A word can have both: list it once with and once without.
13
+ underline?: boolean;
11
14
  }
12
15
 
13
16
  export interface NativeProps extends ViewProps {