refactor: merge to BetterAndroid new usage

This commit is contained in:
2025-08-03 23:11:55 +08:00
parent 34c1d660e9
commit 2f5c44bbf6
13 changed files with 106 additions and 22 deletions

View File

@@ -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)

View File

@@ -35,6 +35,7 @@ class ListActivity : BaseActivity<ActivityListBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.recyclerView.bindAdapter<String> {
onBindData { listData }
onBindItemView<AdapterListBinding> { binding, text, _ ->

View File

@@ -46,9 +46,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.root.handleOnWindowInsetsChanged(animated = true) { linearLayout, insetsWrapper ->
linearLayout.setInsetsPadding(insetsWrapper.safeDrawing)
}
listOf(
binding.textViewPanguText,
binding.textViewPanguTextCjkSpacingRatio,

View File

@@ -32,6 +32,7 @@ open class BaseActivity<VB : ViewBinding> : AppBindingActivity<VB>() {
override fun onPrepareContentView(savedInstanceState: Bundle?): LayoutInflater {
val inflater = super.onPrepareContentView(savedInstanceState)
PanguTextFactory2.inject(inflater)
return inflater
}
}

View File

@@ -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

View File

@@ -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<PanguMarginSpan.Placeholder>())
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<PanguMarginSpan>()) return this
getSpans(0, length, classOf<PanguMarginSpan>()).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 <reified T : CharacterStyle> CharSequence.hasSpan(): Boolean {
val spannable = this as? Spanned ?: return false
val spans = spannable.getSpans(0, spannable.length, classOf<T>())
return spans.isNotEmpty()
}
}

View File

@@ -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) }
}
/**

View File

@@ -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<TextWatcher>()
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) {}

View File

@@ -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<Boolean>(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)
}
}

View File

@@ -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<Pair<Int, Int>>()
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<Map<String, Int>>()
return namedGroups?.get(groupName) ?: -1
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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<TextView>()) 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 <reified V : View> V.doOnAttachRepeatable(config: PanguTextConfig, crossinline action: (view: V) -> Unit) {
if (!config.isEnabled) return
if (isAttachedToWindow) action(this)
addOnAttachStateChangeListener(
object : View.OnAttachStateChangeListener {