diff --git a/demo-android/build.gradle.kts b/demo-android/build.gradle.kts index 09c96a1..2c1e933 100644 --- a/demo-android/build.gradle.kts +++ b/demo-android/build.gradle.kts @@ -42,6 +42,7 @@ android { dependencies { implementation(projects.pangutextAndroid) implementation(com.highcapable.betterandroid.ui.component) + implementation(com.highcapable.betterandroid.ui.component.adapter) implementation(com.highcapable.betterandroid.ui.extension) implementation(com.highcapable.betterandroid.system.extension) implementation(androidx.core.core.ktx) diff --git a/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/ListActivity.kt b/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/ListActivity.kt index d7c241f..a18aa7a 100644 --- a/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/ListActivity.kt +++ b/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/ListActivity.kt @@ -35,6 +35,7 @@ class ListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + binding.recyclerView.bindAdapter { onBindData { listData } onBindItemView { binding, text, _ -> diff --git a/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/MainActivity.kt b/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/MainActivity.kt index 61be721..c22ebef 100644 --- a/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/MainActivity.kt +++ b/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/MainActivity.kt @@ -46,9 +46,11 @@ class MainActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + binding.root.handleOnWindowInsetsChanged(animated = true) { linearLayout, insetsWrapper -> linearLayout.setInsetsPadding(insetsWrapper.safeDrawing) } + listOf( binding.textViewPanguText, binding.textViewPanguTextCjkSpacingRatio, diff --git a/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/base/BaseActivity.kt b/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/base/BaseActivity.kt index bee2adc..b5e1946 100644 --- a/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/base/BaseActivity.kt +++ b/demo-android/src/main/java/com/highcapable/pangutext/demo/ui/base/BaseActivity.kt @@ -32,6 +32,7 @@ open class BaseActivity : AppBindingActivity() { override fun onPrepareContentView(savedInstanceState: Bundle?): LayoutInflater { val inflater = super.onPrepareContentView(savedInstanceState) PanguTextFactory2.inject(inflater) + return inflater } } \ No newline at end of file diff --git a/gradle/sweet-dependency/sweet-dependency-config.yaml b/gradle/sweet-dependency/sweet-dependency-config.yaml index bfc9a4e..93b53c6 100644 --- a/gradle/sweet-dependency/sweet-dependency-config.yaml +++ b/gradle/sweet-dependency/sweet-dependency-config.yaml @@ -31,11 +31,13 @@ plugins: libraries: com.highcapable.betterandroid: ui-component: - version: 1.0.7 + version: 1.0.8 + ui-component-adapter: + version: 1.0.0 ui-extension: - version: 1.0.6 + version: 1.0.7 system-extension: - version: 1.0.2 + version: 1.0.3 com.highcapable.kavaref: kavaref-core: version: 1.0.1 diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/PanguText.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/PanguText.kt index 9955d95..42ee788 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/PanguText.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/PanguText.kt @@ -88,6 +88,7 @@ object PanguText { fun format(resources: Resources, @Px textSize: Float, text: CharSequence, config: PanguTextConfig = globalConfig): CharSequence { if (!config.isEnabled) return text if (text.isBlank()) return text + val formatted = format(text, PH, config) return text.applySpans(formatted, resources, textSize, config) } @@ -110,9 +111,11 @@ object PanguText { @JvmStatic fun format(text: CharSequence, whiteSpace: Char = ' ', config: PanguTextConfig = globalConfig): CharSequence { if (!config.isEnabled) return text + // In any case, always perform a cleanup operation before accepting text. val processed = text.clearSpans() val patterns = config.excludePatterns.toTypedArray() + return if ((config.isProcessedSpanned || text !is Spanned) && text.isNotBlank() && text.length > 1) PanguPatterns.matchAndReplace(processed, whiteSpace, *patterns) else processed @@ -136,6 +139,7 @@ object PanguText { whiteSpace: Char = PH ): CharSequence { val builder = SpannableStringBuilder(formatted) + formatted.forEachIndexed { index, c -> // Add spacing to the previous character. if (c == whiteSpace && index in 0..formatted.lastIndex) { @@ -143,20 +147,26 @@ object PanguText { builder.setSpan(span, index - 1, index, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) } } + // Delete the placeholder character. for (i in (builder.length - 1) downTo 0) { if (builder[i] == whiteSpace) builder.delete(i, i + 1) } + // Find the [PanguMarginSpan.Placeholder] subscript in [builder] and use [PanguMarginSpan] to set it to [original]. val builderSpans = builder.getSpans(0, builder.length, classOf()) val spannable = if (this !is Spannable) SpannableString(this) else this + // Add new [PanguMarginSpan]. builderSpans.forEach { val start = builder.getSpanStart(it) val end = builder.getSpanEnd(it) + val span = PanguMarginSpan.create(resources, textSize, config.cjkSpacingRatio) spannable.setSpan(span, start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - }; builder.clear() + } + + builder.clear() return spannable } @@ -170,12 +180,16 @@ object PanguText { */ private fun CharSequence.clearSpans(): CharSequence { if (this !is Spannable || isBlank() || !hasSpan()) return this + getSpans(0, length, classOf()).forEach { span -> val start = getSpanStart(span) - val end = getSpanEnd(span) + val end = getSpanEnd(span) + // Clear the [PanguMarginSpan]. if (start < length && end > 0) removeSpan(span) - }; return this + } + + return this } /** @@ -186,6 +200,7 @@ object PanguText { private inline fun CharSequence.hasSpan(): Boolean { val spannable = this as? Spanned ?: return false val spans = spannable.getSpans(0, spannable.length, classOf()) + return spans.isNotEmpty() } } \ No newline at end of file diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguMarginSpan.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguMarginSpan.kt index 1a67204..ee272bc 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguMarginSpan.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguMarginSpan.kt @@ -76,18 +76,24 @@ internal class PanguMarginSpan(@Px val margin: Int) : ReplacementSpan() { // Get background color. val color = span.backgroundColor val originalColor = paint.color + // Save the current [paint] color. paint.color = color + // Get the width of the text. val textWidth = paint.measureText(text, start, end) + // Draw background rectangle. canvas.drawRect(x, top.toFloat(), x + textWidth + margin, bottom.toFloat(), paint) + // Restore original color. paint.color = originalColor } span is CharacterStyle && paint is TextPaint -> span.updateDrawState(paint) } - }; text?.let { canvas.drawText(it, start, end, x, y.toFloat(), paint) } + } + + text?.let { canvas.drawText(it, start, end, x, y.toFloat(), paint) } } /** diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguTextWatcher.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguTextWatcher.kt index 2d51d35..59f1856 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguTextWatcher.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/core/PanguTextWatcher.kt @@ -25,7 +25,7 @@ import android.text.Editable import android.text.TextWatcher import android.widget.EditText import android.widget.TextView -import com.highcapable.betterandroid.system.extension.tool.SystemVersion +import com.highcapable.betterandroid.system.extension.tool.AndroidVersion import com.highcapable.kavaref.KavaRef.Companion.asResolver import com.highcapable.pangutext.android.PanguText import com.highcapable.pangutext.android.PanguTextConfig @@ -56,21 +56,24 @@ class PanguTextWatcher internal constructor(private val base: TextView, private */ private val isAutoRemeasureText get() = config.isAutoRemeasureText && base !is EditText && (base.maxLines == 1 || - SystemVersion.require(SystemVersion.Q, base.maxLines == 1) { base.isSingleLine }) + AndroidVersion.require(AndroidVersion.Q, base.maxLines == 1) { base.isSingleLine }) override fun afterTextChanged(editable: Editable?) { editable?.let { PanguText.format(base.resources, base.textSize, it, config) } if (!isAutoRemeasureText) return + val currentWatchers = mutableListOf() textWatchers?.also { currentWatchers.addAll(it) // Avoid triggering events again during processing. it.clear() } + // Reset the text to trigger remeasurement. base.text = editable // Re-add to continue listening to text changes. textWatchers?.addAll(currentWatchers) + currentWatchers.clear() } override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/PanguText.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/PanguText.kt index f2b34d7..75c80db 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/PanguText.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/PanguText.kt @@ -57,6 +57,7 @@ fun PanguTextConfig(copyFromGlobal: Boolean = true, body: PanguTextConfig.() -> @JvmOverloads fun TextView.injectPanguText(injectHint: Boolean = true, config: PanguTextConfig = PanguText.globalConfig) { if (!config.isEnabled) return + setTextWithPangu(this.text, config) if (injectHint) setHintWithPangu(this.hint, config) } @@ -75,11 +76,15 @@ fun TextView.injectPanguText(injectHint: Boolean = true, config: PanguTextConfig @JvmOverloads fun TextView.injectRealTimePanguText(injectHint: Boolean = true, config: PanguTextConfig = PanguText.globalConfig) { if (!config.isEnabled) return + val observerKey = R.id.tag_inject_real_time_pangu_text val isRepeated = getTag(observerKey) == true + // It will no longer be executed if it exceeds one time. if (isRepeated) return + injectPanguText(injectHint, config) + var currentHint = this.hint val textWatcher = PanguTextWatcher(base = this, config) val listener = ViewTreeObserver.OnGlobalLayoutListener { @@ -88,13 +93,17 @@ fun TextView.injectRealTimePanguText(injectHint: Boolean = true, config: PanguTe self.setHintWithPangu(self.hint, config) currentHint = self.hint } + setTag(observerKey, true) doOnAttach { addTextChangedListener(textWatcher) + // Add a global layout listener to monitor the hint text changes. if (injectHint) viewTreeObserver?.addOnGlobalLayoutListener(listener) + doOnDetach { removeTextChangedListener(textWatcher) + // Remove the global layout listener when the view is detached. if (injectHint) viewTreeObserver?.removeOnGlobalLayoutListener(listener) setTag(observerKey, false) @@ -112,7 +121,10 @@ fun TextView.injectRealTimePanguText(injectHint: Boolean = true, config: PanguTe @JvmOverloads fun TextView.setTextWithPangu(text: CharSequence?, config: PanguTextConfig = PanguText.globalConfig) { if (!config.isEnabled) return - this.text = text?.let { PanguText.format(resources, textSize, it, config) } + + this.text = text?.let { + PanguText.format(resources, textSize, it, config) + } } /** @@ -125,5 +137,8 @@ fun TextView.setTextWithPangu(text: CharSequence?, config: PanguTextConfig = Pan @JvmOverloads fun TextView.setHintWithPangu(text: CharSequence?, config: PanguTextConfig = PanguText.globalConfig) { if (!config.isEnabled) return - this.hint = text?.let { PanguText.format(resources, textSize, it, config) } + + this.hint = text?.let { + PanguText.format(resources, textSize, it, config) + } } \ No newline at end of file diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/Replacement.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/Replacement.kt index 4882437..26eaa10 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/Replacement.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/extension/Replacement.kt @@ -41,26 +41,36 @@ import java.util.regex.Matcher internal fun CharSequence.replaceAndPreserveSpans(regex: Regex, replacement: String, vararg excludePatterns: Regex) = runCatching { val builder = SpannableStringBuilder(this) + val matcher = regex.toPattern().matcher(this) val excludeMatchers = excludePatterns.map { it.toPattern().matcher(this) } val excludeIndexs = mutableSetOf>() + excludeMatchers.forEach { while (it.find()) excludeIndexs.add(it.start() to it.end()) } + var offset = 0 + // Offset adjustment to account for changes in the text length after replacements. while (matcher.find()) { val start = matcher.start() + offset val end = matcher.end() + offset + // Skip the replacement if the matched range is excluded. // The character range offset is adjusted by 1 to avoid the exclusion of the matched range. if (excludeIndexs.any { it.first <= start + 1 && it.second >= end - 1 }) continue + // Perform the replacement. val replacementText = matcher.buildReplacementText(replacement) + builder.replace(start, end, replacementText) + // Adjust offset based on the length of the replacement. offset += replacementText.length - (end - start) - }; builder + } + + builder }.onFailure { Log.w(PangutextAndroidProperties.PROJECT_NAME, "Failed to replace span text content.", it) }.getOrNull() ?: this @@ -74,23 +84,29 @@ internal fun CharSequence.replaceAndPreserveSpans(regex: Regex, replacement: Str private fun Matcher.buildReplacementText(replacement: String): String { val matcher = this var result = replacement + // Check for group references (like $1, $2, ...). val pattern = "\\$(\\d+)".toRegex() result = pattern.replace(result) { matchResult -> val groupIndex = matchResult.groupValues[1].toInt() + if (groupIndex <= matcher.groupCount()) matcher.group(groupIndex) ?: "" else "" } + // Check for named groups (like ${groupName}). val namedGroupPattern = "\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)\\}".toRegex() result = namedGroupPattern.replace(result) { matchResult -> val groupName = matchResult.groupValues[1] val groupIndex = matcher.getNamedGroupIndex(groupName) + if (groupIndex >= 0) matcher.group(groupIndex) ?: "" else "" - }; return result + } + + return result } /** @@ -105,5 +121,6 @@ private fun Matcher.getNamedGroupIndex(groupName: String): Int { .firstFieldOrNull { name = "namedGroups" }?.of(this)?.getQuietly>() + return namedGroups?.get(groupName) ?: -1 } \ No newline at end of file diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextFactory2.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextFactory2.kt index 4f63628..39f55f3 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextFactory2.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextFactory2.kt @@ -107,6 +107,7 @@ class PanguTextFactory2 private constructor(private val base: LayoutInflater.Fac if (original is PanguTextFactory2) return run { Log.w(PangutextAndroidProperties.PROJECT_NAME, "PanguTextFactory2 was already injected.") } + val replacement = PanguTextFactory2(original) if (original != null) inflater.asResolver().optional(silent = true).firstFieldOrNull { diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextPatcher.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextPatcher.kt index 19d0dab..11012c3 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextPatcher.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguTextPatcher.kt @@ -19,6 +19,8 @@ * * This file is created by fankes on 2025/3/4. */ +@file:Suppress("unused") + package com.highcapable.pangutext.android.factory import android.view.View diff --git a/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguWidget.kt b/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguWidget.kt index 5560e75..c4c820b 100644 --- a/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguWidget.kt +++ b/pangutext-android/src/main/java/com/highcapable/pangutext/android/factory/PanguWidget.kt @@ -26,11 +26,11 @@ import android.util.AttributeSet import android.util.Log import android.view.View import android.widget.TextView +import androidx.core.content.withStyledAttributes import androidx.core.view.doOnAttach import com.highcapable.betterandroid.ui.extension.component.base.getBooleanOrNull import com.highcapable.betterandroid.ui.extension.component.base.getFloatOrNull import com.highcapable.betterandroid.ui.extension.component.base.getStringOrNull -import com.highcapable.betterandroid.ui.extension.component.base.obtainStyledAttributes import com.highcapable.kavaref.KavaRef.Companion.resolve import com.highcapable.kavaref.extension.classOf import com.highcapable.kavaref.extension.isNotSubclassOf @@ -66,12 +66,14 @@ internal object PanguWidget { }.toClassOrNull()?.let { viewClass -> // Avoid creating unnecessary components for waste. if (viewClass isNotSubclassOf classOf()) return null + val twoParams = viewClass.resolve() .optional(silent = true) .firstConstructorOrNull { parameters(Context::class, AttributeSet::class) } val onceParam = viewClass.resolve() .optional(silent = true) .firstConstructorOrNull { parameters(Context::class) } + // Catching when the attrs value initialization failed. runCatching { twoParams?.create(context, attrs) }.onFailure { Log.w(PangutextAndroidProperties.PROJECT_NAME, "Failed to create instance of $viewClass using (Context, AttributeSet).", it) @@ -81,8 +83,10 @@ internal object PanguWidget { Log.w(PangutextAndroidProperties.PROJECT_NAME, "Failed to create instance of $viewClass, this process will be ignored.", it) }.getOrNull() } + // Ignore if the instance is not a [TextView]. if (instance !is TextView) return null + return startInjection(instance, attrs) } @@ -99,34 +103,45 @@ internal object PanguWidget { config: PanguTextConfig = PanguText.globalConfig ): TV { var sConfig = config + if (instance is PanguTextView) { val configCopy = sConfig.copy() + instance.configurePanguText(configCopy) sConfig = configCopy + if (!sConfig.isEnabled) return instance - } else instance.obtainStyledAttributes(attrs, R.styleable.PanguTextHelper) { - val isEnabled = it.getBooleanOrNull(R.styleable.PanguTextHelper_panguText_enabled) - val isProcessedSpanned = it.getBooleanOrNull(R.styleable.PanguTextHelper_panguText_processedSpanned) - val isAutoRemeasureText = it.getBooleanOrNull(R.styleable.PanguTextHelper_panguText_autoRemeasureText) - val cjkSpacingRatio = it.getFloatOrNull(R.styleable.PanguTextHelper_panguText_cjkSpacingRatio) - val excludePatterns = it.getStringOrNull(R.styleable.PanguTextHelper_panguText_excludePatterns) + } else instance.context.withStyledAttributes(attrs, R.styleable.PanguTextHelper) { + val isEnabled = getBooleanOrNull(R.styleable.PanguTextHelper_panguText_enabled) + val isProcessedSpanned = getBooleanOrNull(R.styleable.PanguTextHelper_panguText_processedSpanned) + val isAutoRemeasureText = getBooleanOrNull(R.styleable.PanguTextHelper_panguText_autoRemeasureText) + val cjkSpacingRatio = getFloatOrNull(R.styleable.PanguTextHelper_panguText_cjkSpacingRatio) + + val excludePatterns = getStringOrNull(R.styleable.PanguTextHelper_panguText_excludePatterns) ?.split(TEXT_REGEX_SPLITE_SYMBOL)?.mapNotNull { regex -> runCatching { regex.toRegex() }.onFailure { th -> Log.e(PangutextAndroidProperties.PROJECT_NAME, "Invalid exclude pattern of $instance: $regex", th) }.getOrNull() }?.toTypedArray() ?: emptyArray() + if (isEnabled == false) return instance + if (isProcessedSpanned != null || isAutoRemeasureText != null || cjkSpacingRatio != null || excludePatterns.isNotEmpty()) { val configCopy = sConfig.copy() + configCopy.isProcessedSpanned = isProcessedSpanned ?: sConfig.isProcessedSpanned configCopy.isAutoRemeasureText = isAutoRemeasureText ?: sConfig.isAutoRemeasureText configCopy.cjkSpacingRatio = cjkSpacingRatio ?: sConfig.cjkSpacingRatio + if (excludePatterns.isNotEmpty()) { sConfig.excludePatterns.clear() sConfig.excludePatterns.addAll(excludePatterns) - }; sConfig = configCopy + } + + sConfig = configCopy } } + when (instance.javaClass.name) { // Specialize those components because loading "hint" style after [doOnAttachRepeatable] causes problems. "com.google.android.material.textfield.TextInputEditText", @@ -137,12 +152,15 @@ internal object PanguWidget { else -> instance.doOnAttachRepeatable(sConfig) { it.injectRealTimePanguText(config = sConfig) } - }; return instance + } + + return instance } /** Copied from [View.doOnAttach]. */ private inline fun V.doOnAttachRepeatable(config: PanguTextConfig, crossinline action: (view: V) -> Unit) { if (!config.isEnabled) return + if (isAttachedToWindow) action(this) addOnAttachStateChangeListener( object : View.OnAttachStateChangeListener {