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
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* Custom EditText that mimics the iOS implementation by drawing per-character
|
|
18
|
+
* rounded highlights directly in onDraw(), instead of using spans.
|
|
19
|
+
*
|
|
20
|
+
* This avoids layout thrashing/flicker when lines auto-wrap and keeps padding
|
|
21
|
+
* logic independent from Android's line breaking.
|
|
22
22
|
*/
|
|
23
|
-
class RoundedBackgroundSpan(
|
|
24
|
-
internal val backgroundColor: Int,
|
|
25
|
-
internal val textColor: Int,
|
|
26
|
-
internal val paddingLeft: Float,
|
|
27
|
-
internal val paddingRight: Float,
|
|
28
|
-
internal val paddingTop: Float,
|
|
29
|
-
internal val paddingBottom: Float,
|
|
30
|
-
internal val backgroundInsetTop: Float,
|
|
31
|
-
internal val backgroundInsetBottom: Float,
|
|
32
|
-
internal val backgroundInsetLeft: Float,
|
|
33
|
-
internal val backgroundInsetRight: Float,
|
|
34
|
-
internal val cornerRadius: Float,
|
|
35
|
-
internal val isLineStart: Boolean = false,
|
|
36
|
-
internal val isLineEnd: Boolean = false,
|
|
37
|
-
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
+
invalidate()
|
|
245
191
|
}
|
|
246
|
-
|
|
192
|
+
|
|
247
193
|
override fun setTextColor(color: Int) {
|
|
248
194
|
super.setTextColor(color)
|
|
249
195
|
textColorValue = color
|
|
250
|
-
|
|
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
|
-
|
|
205
|
+
applyLineHeightAndSpacing()
|
|
206
|
+
invalidate()
|
|
260
207
|
}
|
|
261
|
-
|
|
208
|
+
|
|
262
209
|
fun setCharPaddingLeft(padding: Float) {
|
|
263
210
|
charPaddingLeft = padding
|
|
264
211
|
updateViewPadding()
|
|
265
|
-
|
|
212
|
+
applyLineHeightAndSpacing()
|
|
213
|
+
invalidate()
|
|
266
214
|
}
|
|
267
|
-
|
|
215
|
+
|
|
268
216
|
fun setCharPaddingRight(padding: Float) {
|
|
269
217
|
charPaddingRight = padding
|
|
270
218
|
updateViewPadding()
|
|
271
|
-
|
|
219
|
+
applyLineHeightAndSpacing()
|
|
220
|
+
invalidate()
|
|
272
221
|
}
|
|
273
|
-
|
|
222
|
+
|
|
274
223
|
fun setCharPaddingTop(padding: Float) {
|
|
275
224
|
charPaddingTop = padding
|
|
276
225
|
updateViewPadding()
|
|
277
|
-
|
|
226
|
+
applyLineHeightAndSpacing()
|
|
227
|
+
invalidate()
|
|
278
228
|
}
|
|
279
|
-
|
|
229
|
+
|
|
280
230
|
fun setCharPaddingBottom(padding: Float) {
|
|
281
231
|
charPaddingBottom = padding
|
|
282
232
|
updateViewPadding()
|
|
283
|
-
|
|
233
|
+
applyLineHeightAndSpacing()
|
|
234
|
+
invalidate()
|
|
284
235
|
}
|
|
285
|
-
|
|
236
|
+
|
|
286
237
|
private fun updateViewPadding() {
|
|
287
|
-
//
|
|
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
|
-
|
|
249
|
+
invalidate()
|
|
299
250
|
}
|
|
300
|
-
|
|
251
|
+
|
|
301
252
|
fun setHighlightBorderRadius(radius: Float) {
|
|
302
253
|
highlightBorderRadius = radius
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
invalidate()
|
|
381
334
|
}
|
|
382
|
-
|
|
335
|
+
|
|
383
336
|
fun setBackgroundInsetBottom(inset: Float) {
|
|
384
337
|
backgroundInsetBottom = inset
|
|
385
|
-
|
|
338
|
+
invalidate()
|
|
386
339
|
}
|
|
387
|
-
|
|
340
|
+
|
|
388
341
|
fun setBackgroundInsetLeft(inset: Float) {
|
|
389
342
|
backgroundInsetLeft = inset
|
|
390
|
-
|
|
343
|
+
invalidate()
|
|
391
344
|
}
|
|
392
|
-
|
|
345
|
+
|
|
393
346
|
fun setBackgroundInsetRight(inset: Float) {
|
|
394
347
|
backgroundInsetRight = inset
|
|
395
|
-
|
|
348
|
+
invalidate()
|
|
396
349
|
}
|
|
397
|
-
|
|
350
|
+
|
|
398
351
|
fun setCustomLineHeight(lineHeight: Float) {
|
|
399
352
|
customLineHeight = lineHeight
|
|
400
|
-
|
|
353
|
+
applyLineHeightAndSpacing()
|
|
354
|
+
invalidate()
|
|
401
355
|
}
|
|
402
356
|
|
|
403
|
-
fun
|
|
404
|
-
|
|
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(
|
|
407
|
-
applyCharacterBackgrounds()
|
|
365
|
+
setText(newText)
|
|
408
366
|
// Move cursor to end of text after setting
|
|
409
367
|
post {
|
|
410
368
|
if (hasFocus()) {
|
|
411
|
-
text
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
//
|
|
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.
|
|
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",
|