Any?.custom(
+ style: T,
+ replaceRule: Any? = null,
+ ) = singleSpan {
+ spanCustom(style, replaceRule)
+ }
+
+ companion object {
+ /**
+ * @see [SpanDsl]
+ */
+ fun create(text: CharSequence?, replaceRule: Any?): SpanDsl =
+ SpanDsl(text, replaceRule)
+ }
+}
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt
new file mode 100644
index 0000000..233b996
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt
@@ -0,0 +1,826 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:JvmName("SpanInternal")
+@file:Suppress("unused")
+
+package com.remax.visualnovel.utils.spannablex
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Bitmap
+import android.graphics.BlurMaskFilter
+import android.graphics.MaskFilter
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.os.Build
+import android.text.Layout
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.SpannableStringBuilder
+import android.text.style.*
+import android.widget.TextView
+import androidx.annotation.*
+import androidx.annotation.IntRange
+import com.bumptech.glide.request.RequestOptions
+import com.drake.spannable.replaceSpan
+import com.drake.spannable.setSpan
+import com.drake.spannable.span.CenterImageSpan
+import com.drake.spannable.span.GlideImageSpan
+import com.drake.spannable.span.MarginSpan
+import com.remax.visualnovel.utils.spannablex.annotation.TextStyle
+import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanClickListener
+import com.remax.visualnovel.utils.spannablex.span.LeadingMarginSpan
+import com.remax.visualnovel.utils.spannablex.span.ParagraphBitmapSpan
+import com.remax.visualnovel.utils.spannablex.span.ParagraphDrawableSpan
+import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig
+import com.remax.visualnovel.utils.spannablex.span.SimpleClickableSpan
+import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyBulletSpan
+import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyLineBackgroundSpan
+import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyLineHeightSpan
+import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyQuoteSpan
+import com.remax.visualnovel.utils.spannablex.utils.DrawableSize
+import com.remax.visualnovel.utils.spannablex.utils.drawableSize
+import com.remax.visualnovel.utils.spannablex.utils.textSizeInt
+import java.util.*
+
+//
+/**
+ * ImageSpan Text标识
+ */
+internal const val IMAGE_SPAN_TAG = " "
+
+private const val UNKNOWN_REPLACE_RULES =
+ "Unknown replace rules. please use `String(list/array)`, `Regex(list/array)`, `ReplaceRule(list/array)`."
+
+/**
+ * [CenterImageSpan] 适配 [Drawable] size
+ */
+private fun CenterImageSpan.setupSize(
+ useTextViewSize: TextView?,
+ size: DrawableSize?
+): CenterImageSpan = apply {
+ useTextViewSize?.textSizeInt?.let { textSize ->
+ setDrawableSize(textSize, textSize)
+ } ?: size?.let { drawableSize ->
+ setDrawableSize(drawableSize.width, drawableSize.height)
+ }
+}
+
+/**
+ * [CenterImageSpan] 适配 [Drawable] margin
+ * 这里多做判断,是防止[CenterImageSpan.setMarginHorizontal] 做多余的`drawableRef?.clear()`
+ */
+private fun CenterImageSpan.setupMarginHorizontal(
+ left: Int?,
+ right: Int?
+): CenterImageSpan = apply {
+ if (left != null || right != null) {
+ setMarginHorizontal(left ?: 0, right ?: 0)
+ }
+}
+
+/**
+ * [GlideImageSpan] 适配 [Drawable] size
+ */
+private fun GlideImageSpan.setupSize(
+ useTextViewSize: TextView?,
+ size: DrawableSize?
+): GlideImageSpan = apply {
+ useTextViewSize?.textSizeInt?.let { textSize ->
+ setDrawableSize(textSize, textSize)
+ } ?: size?.let { drawableSize ->
+ setDrawableSize(drawableSize.width, drawableSize.height)
+ }
+}
+
+/**
+ * [GlideImageSpan] 适配 [Drawable] margin
+ * 这里多做判断,是防止[GlideImageSpan.setMarginHorizontal] 做多余的`drawableRef?.set(null)`
+ */
+private fun GlideImageSpan.setupMarginHorizontal(
+ left: Int?,
+ right: Int?
+): GlideImageSpan = apply {
+ if (left != null || right != null) {
+ setMarginHorizontal(left ?: 0, right ?: 0)
+ }
+}
+
+/**
+ * 适配[setSpan] 的返回值为 [Spannable], 以便进行plus操作
+ */
+private fun CharSequence.span(what: Any?): Spannable = setSpan(what) as Spannable
+
+/**
+ * 适配[replaceSpan] 的返回值为 [Spannable], 以便进行plus操作
+ */
+private fun CharSequence.spanReplace(
+ regex: Regex,
+ quoteGroup: Boolean = false,
+ startIndex: Int = 0,
+ replacement: (MatchResult) -> Any?
+): Spannable {
+ return (replaceSpan(regex, quoteGroup, startIndex, replacement = replacement) as? Spannable) ?: SpannableStringBuilder(this)
+}
+
+/**
+ * 正则 [Regex] 列表替换
+ */
+private fun CharSequence.replaceRegexList(
+ ruleList: List,
+ createWhat: (matchText: String) -> Any
+): Spannable? {
+ var span: CharSequence? = null
+ ruleList.forEach { replace ->
+ span = (span ?: this).spanReplace(replace) {
+ createWhat.invoke(it.value)
+ }
+ }
+ return if (span is Spannable) span as Spannable else SpannableString.valueOf(span)
+}
+
+/**
+ * 组合替换规则 [ReplaceRule] 列表替换
+ */
+private fun CharSequence.replaceReplaceRuleList(
+ ruleList: List,
+ createWhat: (matchText: String) -> Any
+): Spannable? {
+ var span: CharSequence? = null
+ ruleList.forEach { replace ->
+ var currentMatchCount = 0
+ span = (span ?: this).spanReplace(replace.replaceRules) {
+ if (replace.matchRange == null || currentMatchCount++ in replace.matchRange) {
+ replace.replacementMatch?.onMatch(it)
+ val characterStyle = createWhat.invoke(it.value)
+ replace.newString?.span(characterStyle) ?: characterStyle
+ } else null
+ }
+ }
+
+ return if (span is Spannable) span as Spannable else SpannableString.valueOf(span)
+}
+
+/**
+ * [setSpan] or [replaceRule]
+ */
+@Suppress("UNCHECKED_CAST")
+private fun CharSequence.setOrReplaceSpan(
+ replaceRule: Any?,
+ createWhat: (matchText: String) -> Any
+): Spannable = replaceRule?.let { rule ->
+ when (rule) {
+ //
+ is String -> spanReplace(Regex.escape(rule).toRegex()) {
+ createWhat.invoke(it.value)
+ }
+
+ is Regex -> spanReplace(rule) {
+ createWhat.invoke(it.value)
+ }
+
+ is ReplaceRule -> replaceReplaceRuleList(listOf(rule), createWhat)
+ //
+
+ //
+ is Array<*> -> if (rule.isEmpty()) {
+ span(createWhat.invoke(this.toString()))
+ } else {
+ when (rule[0]) {
+ /* String */
+ is String -> replaceRegexList(
+ (rule as Array).map { Regex.escape(it).toRegex() },
+ createWhat
+ )
+ /* 正则 */
+ is Regex -> replaceRegexList(
+ (rule as Array).toList(),
+ createWhat
+ )
+
+ /* ReplaceRule */
+ is ReplaceRule -> replaceReplaceRuleList(
+ (rule as Array).toList(),
+ createWhat
+ )
+
+ else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES)
+ }
+ }
+ //
+
+ //
+ is List<*> -> if (rule.isEmpty()) {
+ span(createWhat.invoke(this.toString()))
+ } else {
+ when (rule[0]) {
+ /* String */
+ is String -> replaceRegexList(
+ (rule as List).map { Regex.escape(it).toRegex() },
+ createWhat
+ )
+ /* 正则 */
+ is Regex -> replaceRegexList(
+ (rule as List),
+ createWhat
+ )
+ /* ReplaceRule */
+ is ReplaceRule -> replaceReplaceRuleList(
+ (rule as List),
+ createWhat
+ )
+
+ else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES)
+ }
+ }
+ //
+ else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES)
+ }
+} ?: span(createWhat.invoke(this.toString()))
+
+//
+
+//
+/**
+ * [StyleSpan] 设置文本样式
+ *
+ * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanStyle(
+ @TextStyle style: Int,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ StyleSpan(style)
+}
+
+/**
+ * [TypefaceSpan] 设置字体样式
+ *
+ * @param typeface 字体(API>=28)
+ * @param family 字体集
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanTypeface(
+ typeface: Typeface?,
+ family: String?,
+ replaceRule: Any?
+): Spannable = (if (typeface != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ TypefaceSpan(typeface)
+} else TypefaceSpan(family)).let { typefaceSpan ->
+ setOrReplaceSpan(replaceRule) { typefaceSpan }
+}
+
+/**
+ * [TextAppearanceSpan] 设置字体效果
+ *
+ * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC]
+ * @param size 文本大小
+ * @param color 文本颜色
+ * @param family 字体集
+ * @param linkColor 链接颜色
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanTextAppearance(
+ @TextStyle style: Int = Typeface.NORMAL,
+ @Px size: Int = -1,
+ @ColorInt color: Int?,
+ family: String?,
+ linkColor: ColorStateList?,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ TextAppearanceSpan(family, style, size, color?.let(ColorStateList::valueOf), linkColor)
+}
+
+/**
+ * [ForegroundColorSpan] 文本颜色
+ *
+ * @param color 文本颜色
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanColor(
+ @ColorInt color: Int,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ ForegroundColorSpan(color)
+}
+
+/**
+ * [BackgroundColorSpan] 背景颜色
+ *
+ * @param color 背景颜色
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanBackground(
+ @ColorInt color: Int,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ BackgroundColorSpan(color)
+}
+
+/**
+ * [CenterImageSpan] 图片
+ *
+ * @param drawable [Drawable]
+ * @param source [Drawable] Uri
+ * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize]
+ * @param size 图片大小 [DrawableSize]
+ * @param marginLeft 图片左边距
+ * @param marginRight 图片右边距
+ * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanImage(
+ drawable: Drawable,
+ source: String?,
+ useTextViewSize: TextView?,
+ size: DrawableSize?,
+ @Px marginLeft: Int?,
+ @Px marginRight: Int?,
+ align: CenterImageSpan.Align,
+ replaceRule: Any?,
+): Spannable = setOrReplaceSpan(replaceRule) {
+ (source?.let {
+ CenterImageSpan(drawable, it)
+ } ?: CenterImageSpan(drawable)).setupSize(useTextViewSize, size)
+ .setupMarginHorizontal(marginLeft, marginRight)
+ .setAlign(align)
+}
+
+/**
+ * [CenterImageSpan] 图片
+ *
+ * @param context [Context]
+ * @param uri 图片 Uri
+ * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize]
+ * @param size 图片大小 [DrawableSize]
+ * @param marginLeft 图片左边距
+ * @param marginRight 图片右边距
+ * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanImage(
+ context: Context,
+ uri: Uri,
+ useTextViewSize: TextView?,
+ size: DrawableSize?,
+ @Px marginLeft: Int?,
+ @Px marginRight: Int?,
+ align: CenterImageSpan.Align,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ CenterImageSpan(context, uri).setupSize(useTextViewSize, size)
+ .setupMarginHorizontal(marginLeft, marginRight)
+ .setAlign(align)
+}
+
+/**
+ * [CenterImageSpan] 图片
+ *
+ * @param context [Context]
+ * @param resourceId 图片Id
+ * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize]
+ * @param size 图片大小 [DrawableSize]
+ * @param marginLeft 图片左边距
+ * @param marginRight 图片右边距
+ * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanImage(
+ context: Context,
+ @DrawableRes resourceId: Int,
+ useTextViewSize: TextView?,
+ size: DrawableSize?,
+ @Px marginLeft: Int?,
+ @Px marginRight: Int?,
+ align: CenterImageSpan.Align,
+ replaceRule: Any?,
+): Spannable = setOrReplaceSpan(replaceRule) {
+ CenterImageSpan(context, resourceId).setupSize(useTextViewSize, size)
+ .setupMarginHorizontal(marginLeft, marginRight)
+ .setAlign(align)
+}
+
+/**
+ * [CenterImageSpan] 图片
+ *
+ * @param context [Context]
+ * @param bitmap [Bitmap]
+ * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize]
+ * @param size 图片大小 [DrawableSize]
+ * @param marginLeft 图片左边距
+ * @param marginRight 图片右边距
+ * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanImage(
+ context: Context,
+ bitmap: Bitmap,
+ useTextViewSize: TextView?,
+ size: DrawableSize?,
+ @Px marginLeft: Int?,
+ @Px marginRight: Int?,
+ align: CenterImageSpan.Align,
+ replaceRule: Any?,
+): Spannable = setOrReplaceSpan(replaceRule) {
+ CenterImageSpan(context, bitmap).setupSize(useTextViewSize, size)
+ .setupMarginHorizontal(marginLeft, marginRight)
+ .setAlign(align)
+}
+
+/**
+ * [GlideImageSpan] 图片
+ *
+ * @param view 当前Span所在的[TextView], 用于异步加载完图片后通知[TextView]刷新
+ * @param url 图片地址参见 [Glide.with(view).load(url)]
+ * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize]
+ * @param size 图片大小 [DrawableSize]
+ * @param marginLeft 图片左边距
+ * @param marginRight 图片右边距
+ * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanGlide(
+ view: TextView,
+ url: Any,
+ useTextViewSize: TextView?,
+ size: DrawableSize?,
+ @Px marginLeft: Int?,
+ @Px marginRight: Int?,
+ align: GlideImageSpan.Align,
+ loopCount: Int?,
+ requestOption: RequestOptions?,
+ replaceRule: Any?,
+): Spannable = setOrReplaceSpan(replaceRule) {
+ GlideImageSpan(view, url).setupSize(useTextViewSize, size)
+ .setupMarginHorizontal(marginLeft, marginRight)
+ .setAlign(align)
+ .apply {
+ loopCount?.let(::setLoopCount)
+ requestOption?.let(::setRequestOption)
+ }
+}
+
+/**
+ * [ScaleXSpan] X轴文本缩放
+ *
+ * @param proportion 水平(X轴)缩放比例
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanScaleX(
+ @FloatRange(from = 0.0) proportion: Float,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ ScaleXSpan(proportion)
+}
+
+/**
+ * [MaskFilterSpan] 设置文本蒙版效果
+ *
+ * @param filter 蒙版效果 [MaskFilter]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanMaskFilter(
+ filter: MaskFilter,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ MaskFilterSpan(filter)
+}
+
+/**
+ * [BlurMaskFilter] 设置文本模糊滤镜蒙版效果
+ *
+ * @param radius 模糊半径
+ * @param style 模糊效果 [BlurMaskFilter.Blur]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanBlurMask(
+ @FloatRange(from = 0.0) radius: Float,
+ style: BlurMaskFilter.Blur?,
+ replaceRule: Any?
+): Spannable =
+ spanMaskFilter(BlurMaskFilter(radius, style ?: BlurMaskFilter.Blur.NORMAL), replaceRule)
+
+/**
+ * [SuperscriptSpan] 设置文本为上标
+ *
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanSuperscript(
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ SuperscriptSpan()
+}
+
+/**
+ * [SubscriptSpan] 设置文本为下标
+ *
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanSubscript(
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ SubscriptSpan()
+}
+
+/**
+ * [AbsoluteSizeSpan] 设置文本绝对大小
+ *
+ * @param size 文本大小
+ * @param dp true = [size] dp, false = [size] px
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanAbsoluteSize(
+ size: Int,
+ dp: Boolean,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ AbsoluteSizeSpan(size, dp)
+}
+
+/**
+ * [RelativeSizeSpan] 设置文本相对大小
+ *
+ * @param proportion 文本缩放比例
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanRelativeSize(
+ @FloatRange(from = 0.0) proportion: Float,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ RelativeSizeSpan(proportion)
+}
+
+/**
+ * [StrikethroughSpan] 设置文本删除线
+ *
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanStrikethrough(
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ StrikethroughSpan()
+}
+
+/**
+ * [UnderlineSpan] 设置文本下划线
+ *
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanUnderline(
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ UnderlineSpan()
+}
+
+/**
+ * [URLSpan] 设置文本超链接
+ *
+ * 需配合[TextView.activateClick]使用
+ * @param url 超链接地址
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanURL(
+ url: String,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ URLSpan(url)
+}
+
+/**
+ * [SuggestionSpan] 设置文本输入提示
+ *
+ * @param context [Context]
+ * @param suggestions 提示规则文本数组
+ * @param flags 提示规则 [SuggestionSpan.FLAG_EASY_CORRECT] [SuggestionSpan.FLAG_MISSPELLED] [SuggestionSpan.FLAG_AUTO_CORRECTION]
+ * @param locale 语言区域设置
+ * @param notificationTargetClass 通知目标. 基本已废弃, 只在API<29时生效
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanSuggestion(
+ context: Context,
+ suggestions: Array,
+ flags: Int,
+ locale: Locale?,
+ notificationTargetClass: Class<*>?,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ SuggestionSpan(context, locale, suggestions, flags, notificationTargetClass)
+}
+
+/**
+ * [SimpleClickableSpan] 设置文本点击效果
+ *
+ * @param color 文本颜色
+ * @param backgroundColor 背景颜色
+ * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC]
+ * @param config 附加配置 [SimpleClickableConfig]
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ * @param onClick [OnSpanClickListener] 点击回调
+ */
+internal fun CharSequence.spanClickable(
+ @ColorInt color: Int?,
+ @ColorInt backgroundColor: Int?,
+ @TextStyle style: Int?,
+ config: SimpleClickableConfig?,
+ replaceRule: Any?,
+ onClick: OnSpanClickListener?
+): Spannable = setOrReplaceSpan(replaceRule) { matchText ->
+ SimpleClickableSpan(color, backgroundColor, style, config) {
+ onClick?.onClick(it, matchText)
+ }
+}
+
+/**
+ * [MarginSpan] 设置文本间距
+ *
+ * @param width 文本间距
+ * @param color 间距填充颜色
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanMargin(
+ @Px width: Int,
+ @ColorInt color: Int,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ MarginSpan(width, color)
+}
+
+/**
+ * [QuoteSpan] 设置段落引用样式(段落前竖线标识)
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param color 竖线颜色
+ * @param stripeWidth 竖线宽度
+ * @param gapWidth 竖线与文本之间间隔宽度
+ */
+internal fun CharSequence.spanQuote(
+ @ColorInt color: Int,
+ @Px @IntRange(from = 0) stripeWidth: Int,
+ @Px @IntRange(from = 0) gapWidth: Int,
+): Spannable = setOrReplaceSpan(null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ QuoteSpan(color, stripeWidth, gapWidth)
+ } else {
+ LegacyQuoteSpan(color, stripeWidth, gapWidth)
+ }
+}
+
+/**
+ * [BulletSpan] 设置段落项目符号(段落前圆形标识)
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param color 圆形颜色
+ * @param bulletRadius 圆形半径
+ * @param gapWidth 竖线与文本之间间隔宽度
+ */
+internal fun CharSequence.spanBullet(
+ @ColorInt color: Int,
+ @Px @IntRange(from = 0) bulletRadius: Int,
+ @Px gapWidth: Int,
+): Spannable = setOrReplaceSpan(null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ BulletSpan(gapWidth, color, bulletRadius)
+ } else {
+ LegacyBulletSpan(color, bulletRadius, gapWidth)
+ }
+}
+
+/**
+ * [AlignmentSpan] 设置段落对齐方式
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param align [Layout.Alignment.ALIGN_NORMAL] [Layout.Alignment.ALIGN_CENTER] [Layout.Alignment.ALIGN_OPPOSITE]
+ */
+internal fun CharSequence.spanAlignment(
+ align: Layout.Alignment
+): Spannable = setOrReplaceSpan(null) {
+ AlignmentSpan.Standard(align)
+}
+
+/**
+ * [LineBackgroundSpan] 设置段落背景颜色
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param color 背景颜色
+ */
+internal fun CharSequence.spanLineBackground(
+ @ColorInt color: Int
+): Spannable = setOrReplaceSpan(null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ LineBackgroundSpan.Standard(color)
+ } else {
+ LegacyLineBackgroundSpan(color)
+ }
+}
+
+/**
+ * [LeadingMarginSpan] 设置段落文本缩进
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param firstLines 首行行数. 与[firstMargin]关联
+ * @param firstMargin 首行左边距(缩进)
+ * @param restMargin 剩余行(非首行)左边距(缩进)
+ */
+internal fun CharSequence.spanLeadingMargin(
+ @IntRange(from = 1L) firstLines: Int,
+ @Px firstMargin: Int,
+ @Px restMargin: Int
+): Spannable = setOrReplaceSpan(null) {
+ LeadingMarginSpan(firstLines, firstMargin, restMargin)
+}
+
+/**
+ * [LineHeightSpan] 设置段落行高
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param height 行高
+ */
+internal fun CharSequence.spanLineHeight(
+ @Px @IntRange(from = 1L) height: Int
+): Spannable = setOrReplaceSpan(null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ LineHeightSpan.Standard(height)
+ } else {
+ LegacyLineHeightSpan(height)
+ }
+}
+
+/**
+ * [ParagraphBitmapSpan] 设置段落图片
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param bitmap [Bitmap]
+ * @param padding 图片与文本的间距
+ * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize]
+ * @param size 图片大小 [DrawableSize]
+ */
+internal fun CharSequence.spanImageParagraph(
+ bitmap: Bitmap,
+ @Px padding: Int,
+ useTextViewSize: TextView?,
+ size: DrawableSize?
+): Spannable = setOrReplaceSpan(null) {
+ ParagraphBitmapSpan(bitmap, useTextViewSize?.textSizeInt?.drawableSize ?: size, padding)
+}
+
+/**
+ * [ParagraphDrawableSpan] 设置段落图片
+ *
+ * [ParagraphStyle] 段落Style不支持文本替换
+ * @param drawable [Drawable]
+ * @param padding 图片与文本的间距
+ * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize]
+ * @param size 图片大小 [DrawableSize]
+ */
+internal fun CharSequence.spanImageParagraph(
+ drawable: Drawable,
+ @Px padding: Int,
+ useTextViewSize: TextView?,
+ size: DrawableSize?
+): Spannable = setOrReplaceSpan(null) {
+ ParagraphDrawableSpan(drawable, useTextViewSize?.textSizeInt?.drawableSize ?: size, padding)
+}
+
+/**
+ * 自定义字符样式
+ *
+ * @param style 自定义样式. eg. spanCustom(ForegroundColorSpan(Color.RED))
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ */
+internal fun CharSequence.spanCustom(
+ style: T,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ style
+}
+
+/**
+ * 自定义段落样式
+ *
+ * @param style 自定义样式. eg. spanCustom(LineBackgroundSpan.Standard(Color.Red))
+ * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule]
+ * 由于段落样式的特殊性, [ParagraphStyle] 段落样式下 [replaceRule] 大部分情况并不会生效
+ */
+internal fun CharSequence.spanCustom(
+ style: T,
+ replaceRule: Any?
+): Spannable = setOrReplaceSpan(replaceRule) {
+ style
+}
+
+//
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt
new file mode 100644
index 0000000..cdb4865
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex
+
+import android.R
+import android.app.Activity
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.SpannedString
+import android.text.method.LinkMovementMethod
+import android.text.style.CharacterStyle
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.annotation.IdRes
+import androidx.core.view.children
+import androidx.fragment.app.Fragment
+import androidx.viewbinding.ViewBinding
+import com.drake.spannable.movement.ClickableMovementMethod
+
+/**
+ * 构建Spannable
+ * @see [SpanDsl]
+ */
+fun Any?.spannable(builderAction: SpanDsl.() -> Unit): SpannableStringBuilder =
+ SpanDsl.create(
+ text = if (this is CharSequence) this else null, replaceRule = null
+ ).apply(builderAction).spannable()
+
+//
+/**
+ * 删除指定Span
+ */
+inline fun CharSequence.removeSpans(): CharSequence =
+ (if (this is Spannable) this else SpannableString(this)).apply {
+ val allSpans = getSpans(0, length, T::class.java)
+ for (span in allSpans) {
+ removeSpan(span)
+ }
+ }
+
+/**
+ * 删除所有[CharacterStyle] Span
+ */
+fun CharSequence.removeAllSpans(): CharSequence =
+ (if (this is Spannable) this else SpannableString(this)).apply {
+ val allSpans = getSpans(0, length, CharacterStyle::class.java)
+ for (span in allSpans) {
+ removeSpan(span)
+ }
+ }
+
+//
+
+//
+/**
+ * 配置 [LinkMovementMethod] 或 [ClickableMovementMethod]
+ * @param background 是否显示点击背景
+ */
+fun TextView.activateClick(background: Boolean = true): TextView = apply {
+ movementMethod = if (background) LinkMovementMethod.getInstance() else ClickableMovementMethod.getInstance()
+}
+
+/**
+ * 循环获取控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod]
+ * @param background 是否显示点击背景
+ * @param ignoreId 忽略配置movementMethod的ViewId
+ */
+fun View?.autoActivateClick(background: Boolean, @IdRes vararg ignoreId: Int) {
+ when (this) {
+ is TextView -> {
+ if (!ignoreId.contains(id)) {
+ activateClick(background)
+ }
+ }
+
+ is ViewGroup -> {
+ children.forEach {
+ it.autoActivateClick(background, *ignoreId)
+ }
+ }
+ }
+}
+
+/**
+ * 循环 [ViewBinding] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod]
+ * @param background 是否显示点击背景
+ * @param ignoreId 忽略配置movementMethod的ViewId
+ */
+fun ViewBinding.activateAllTextViewClick(
+ background: Boolean = true,
+ @IdRes vararg ignoreId: Int
+) {
+ root.autoActivateClick(background, *ignoreId)
+}
+
+/**
+ * 循环 [Activity] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod]
+ * @param background 是否显示点击背景
+ * @param ignoreId 忽略配置movementMethod的ViewId
+ */
+fun Activity.activateAllTextViewClick(background: Boolean = true, @IdRes vararg ignoreId: Int) {
+ findViewById(R.id.content).children.first()
+ .autoActivateClick(background, *ignoreId)
+}
+
+/**
+ * 循环 [Fragment] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod]
+ * @param background 是否显示点击背景
+ * @param ignoreId 忽略配置movementMethod的ViewId
+ */
+fun Fragment.activateAllTextViewClick(background: Boolean = true, @IdRes vararg ignoreId: Int) {
+ view.autoActivateClick(background, *ignoreId)
+}
+//
+
+//
+/**
+ * [String] 转为 [Spannable], 以便进行plus操作
+ */
+val String.span: SpannedString
+ get() = SpannedString(this)
+
+/**
+ * 扩展Spanned +, 保留样式
+ * operator [Spannable] + [CharSequence]
+ * @return [Spannable]
+ */
+operator fun Spanned.plus(other: CharSequence): SpannableStringBuilder =
+ when (this) {
+ is SpannableStringBuilder -> append(other)
+ else -> SpannableStringBuilder(this).append(other)
+ }
+
+//
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt
new file mode 100644
index 0000000..ea8f6fc
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.annotation
+
+import androidx.annotation.IntDef
+
+@IntDef(value = [ConversionUnit.NOT_CONVERT, ConversionUnit.SP, ConversionUnit.DP])
+@Retention(AnnotationRetention.SOURCE)
+annotation class ConversionUnit {
+
+ companion object {
+ /**
+ * 不转换单位
+ */
+ const val NOT_CONVERT = 0
+
+ /**
+ * 转换为sp
+ */
+ const val SP = 1
+
+ /**
+ * 转换为dp
+ */
+ const val DP = 2
+ }
+}
+
+
+
+
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt
new file mode 100644
index 0000000..ec3aca6
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.annotation
+
+import android.graphics.Typeface
+import androidx.annotation.IntDef
+
+/**
+ * copy form [Typeface.Style], it's is SOURCE annotation
+ */
+@IntDef(value = [Typeface.NORMAL, Typeface.BOLD, Typeface.ITALIC, Typeface.BOLD_ITALIC])
+@Retention(AnnotationRetention.SOURCE)
+annotation class TextStyle
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java
new file mode 100644
index 0000000..3a6c844
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.interfaces;
+
+import android.view.View;
+
+import com.remax.visualnovel.utils.spannablex.span.SimpleClickableSpan;
+
+/**
+ * {@link SimpleClickableSpan} 点击回调
+ *
+ * {@link SpanInternal#spanClickable}
+ */
+public interface OnSpanClickListener {
+ /**
+ * {@link SimpleClickableSpan}被点击时回调
+ *
+ * @param v 点击的当前View
+ * @param matchText 点击时匹配上的文本
+ */
+ void onClick(View v, String matchText);
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java
new file mode 100644
index 0000000..29fdb99
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.interfaces;
+
+import com.remax.visualnovel.utils.spannablex.ReplaceRule;
+
+import kotlin.jvm.functions.Function1;
+import kotlin.text.MatchResult;
+import kotlin.text.Regex;
+
+/**
+ * 当 {@link ReplaceRule} 有匹配项时回调
+ * 详细说明: {@link com.drake.spannable.SpanUtilsKt#replaceSpan(CharSequence, Regex, Function1)}
+ */
+public interface OnSpanReplacementMatch {
+ /**
+ * @param result 当前 @{@link Regex} 匹配到的结果
+ */
+ void onMatch(MatchResult result);
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt
new file mode 100644
index 0000000..8e83ca8
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.text.Layout
+import android.text.style.LeadingMarginSpan
+import androidx.annotation.IntRange
+import androidx.annotation.Px
+
+class LeadingMarginSpan(
+ @IntRange(from = 1L) val firstLines: Int,
+ @Px val firstMargin: Int,
+ @Px val restMargin: Int,
+) : LeadingMarginSpan.LeadingMarginSpan2 {
+
+ override fun getLeadingMargin(first: Boolean): Int = if (first) firstMargin else restMargin
+
+ override fun getLeadingMarginLineCount(): Int = firstLines
+
+ override fun drawLeadingMargin(
+ c: Canvas?, p: Paint?, x: Int, dir: Int, top: Int,
+ baseline: Int, bottom: Int, text: CharSequence?, start: Int, end: Int,
+ first: Boolean, layout: Layout?
+ ) {
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt
new file mode 100644
index 0000000..89eb4fc
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Paint.FontMetricsInt
+import android.text.Layout
+import android.text.Spanned
+import android.text.style.LeadingMarginSpan
+import android.text.style.LineHeightSpan
+import androidx.annotation.Px
+import androidx.core.graphics.scale
+import com.remax.visualnovel.utils.spannablex.utils.DrawableSize
+
+class ParagraphBitmapSpan(
+ val bitmap: Bitmap,
+ private val drawableSize: DrawableSize?,
+ @Px val padding: Int
+) : LeadingMarginSpan, LineHeightSpan {
+ private val bitmapHeight: Int
+ get() = drawableSize?.height ?: bitmap.height
+
+ private val bitmapWidth: Int
+ get() = drawableSize?.width ?: bitmap.width
+
+ override fun getLeadingMargin(first: Boolean): Int {
+ return bitmapWidth + padding
+ }
+
+ override fun drawLeadingMargin(
+ c: Canvas, p: Paint, x: Int, dir: Int,
+ top: Int, baseline: Int, bottom: Int,
+ text: CharSequence, start: Int, end: Int,
+ first: Boolean, layout: Layout
+ ) {
+ val st = (text as Spanned).getSpanStart(this)
+ val lineTop = layout.getLineTop(layout.getLineForOffset(st))
+ val scaleBitmap = drawableSize?.let {
+ bitmap.scale(bitmapWidth, bitmapHeight, true)
+ } ?: bitmap
+
+ c.drawBitmap(
+ scaleBitmap,
+ (if (dir < 0) bitmapWidth - x else x).toFloat(),
+ lineTop.toFloat(),
+ p
+ )
+ }
+
+ override fun chooseHeight(
+ text: CharSequence, start: Int, end: Int,
+ istartv: Int, v: Int,
+ fm: FontMetricsInt
+ ) {
+ if (end == (text as Spanned).getSpanEnd(this)) {
+ val ht = bitmapHeight
+ var need = ht - (v + fm.descent - fm.ascent - istartv)
+ if (need > 0) {
+ fm.descent += need
+ }
+ need = ht - (v + fm.bottom - fm.top - istartv)
+ if (need > 0) {
+ fm.bottom += need
+ }
+ }
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt
new file mode 100644
index 0000000..a09ef2c
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Paint.FontMetricsInt
+import android.graphics.drawable.Drawable
+import android.text.Layout
+import android.text.Spanned
+import android.text.style.LeadingMarginSpan
+import android.text.style.LineHeightSpan
+import androidx.annotation.Px
+import com.remax.visualnovel.utils.spannablex.utils.DrawableSize
+
+class ParagraphDrawableSpan(
+ val drawable: Drawable,
+ private val drawableSize: DrawableSize?,
+ @Px val padding: Int
+) : LeadingMarginSpan, LineHeightSpan {
+ private val drawableHeight: Int
+ get() = drawableSize?.height ?: drawable.intrinsicHeight
+
+ private val drawableWidth: Int
+ get() = drawableSize?.width ?: drawable.intrinsicWidth
+
+ override fun getLeadingMargin(first: Boolean): Int {
+ return drawableWidth + padding
+ }
+
+ override fun drawLeadingMargin(
+ c: Canvas, p: Paint, x: Int, dir: Int,
+ top: Int, baseline: Int, bottom: Int,
+ text: CharSequence, start: Int, end: Int,
+ first: Boolean, layout: Layout
+ ) {
+ val st = (text as Spanned).getSpanStart(this)
+ val lineTop = layout.getLineTop(layout.getLineForOffset(st))
+ drawable.setBounds(x, lineTop, x + drawableWidth, lineTop + drawableHeight)
+ drawable.draw(c)
+ }
+
+ override fun chooseHeight(
+ text: CharSequence, start: Int, end: Int,
+ istartv: Int, v: Int,
+ fm: FontMetricsInt
+ ) {
+ if (end == (text as Spanned).getSpanEnd(this)) {
+ val ht = drawableHeight
+ var need = ht - (v + fm.descent - fm.ascent - istartv)
+ if (need > 0) {
+ fm.descent += need
+ }
+ need = ht - (v + fm.bottom - fm.top - istartv)
+ if (need > 0) {
+ fm.bottom += need
+ }
+ }
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt
new file mode 100644
index 0000000..7ddf581
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span
+
+import android.graphics.Color
+import android.graphics.Typeface
+import android.text.TextPaint
+import android.text.style.ClickableSpan
+import android.view.View
+import androidx.annotation.ColorInt
+import com.remax.visualnovel.utils.spannablex.annotation.TextStyle
+
+typealias OnSimpleClickListener = (widget: View) -> Unit
+
+data class SimpleClickableConfig(
+
+ /**
+ * 下划线
+ */
+ val underline: Boolean? = null,
+)
+
+class SimpleClickableSpan(
+ @ColorInt private val color: Int? = null,
+ @ColorInt private val backgroundColor: Int? = null,
+ @TextStyle private val typeStyle: Int? = null,
+ private val config: SimpleClickableConfig? = null,
+ private val onClick: OnSimpleClickListener? = null
+) : ClickableSpan() {
+
+ constructor(
+ colorString: String?,
+ backgroundColorString: String?,
+ @TextStyle typeStyle: Int? = null,
+ config: SimpleClickableConfig? = null,
+ onClick: OnSimpleClickListener? = null
+ ) : this(
+ colorString?.let(Color::parseColor),
+ backgroundColorString?.let(Color::parseColor),
+ typeStyle,
+ config,
+ onClick
+ )
+
+ override fun updateDrawState(ds: TextPaint) {
+ color?.let(ds::setColor)
+ backgroundColor?.let { ds.bgColor = backgroundColor }
+ typeStyle?.let(Typeface::defaultFromStyle)?.let(ds::setTypeface)
+
+ config?.run {
+ underline?.let(ds::setUnderlineText)
+ }
+ }
+
+ override fun onClick(widget: View) {
+ onClick?.invoke(widget)
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt
new file mode 100644
index 0000000..4ef9003
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span.legacy
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.text.Layout
+import android.text.Spanned
+import android.text.style.LeadingMarginSpan
+import androidx.annotation.ColorInt
+import androidx.annotation.IntRange
+
+/** Copy @RequiresApi(Build.VERSION_CODES.P) Type from [android.text.style.BulletSpan] */
+class LegacyBulletSpan(
+ @ColorInt val color: Int,
+ @IntRange(from = 0) val bulletRadius: Int,
+ val gapWidth: Int
+) : LeadingMarginSpan {
+
+ override fun getLeadingMargin(first: Boolean): Int {
+ return 2 * bulletRadius + gapWidth
+ }
+
+ override fun drawLeadingMargin(
+ canvas: Canvas, paint: Paint, x: Int, dir: Int,
+ top: Int, baseline: Int, bottom: Int,
+ text: CharSequence, start: Int, end: Int,
+ first: Boolean, layout: Layout?
+ ) {
+ if ((text as Spanned).getSpanStart(this) == start) {
+ val cacheStyle = paint.style
+ val cacheColor = paint.color
+ paint.color = color
+ paint.style = Paint.Style.FILL
+ val yPosition = (top + bottom) / 2f
+ val xPosition = (x + dir * bulletRadius).toFloat()
+ canvas.drawCircle(xPosition, yPosition, bulletRadius.toFloat(), paint)
+ paint.color = cacheColor
+ paint.style = cacheStyle
+ }
+ }
+}
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt
new file mode 100644
index 0000000..0a362f9
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span.legacy
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.text.style.LineBackgroundSpan
+import androidx.annotation.ColorInt
+import androidx.annotation.Px
+
+/** Copy @RequiresApi(Build.VERSION_CODES.Q) Type from [LineBackgroundSpan.Standard] */
+class LegacyLineBackgroundSpan(@ColorInt val color: Int) : LineBackgroundSpan {
+
+ override fun drawBackground(
+ canvas: Canvas, paint: Paint,
+ @Px left: Int, @Px right: Int,
+ @Px top: Int, @Px baseline: Int, @Px bottom: Int,
+ text: CharSequence, start: Int, end: Int,
+ lineNumber: Int
+ ) {
+ val originColor = paint.color
+ paint.color = color
+ canvas.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat(), paint)
+ paint.color = originColor
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt
new file mode 100644
index 0000000..eca76cf
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span.legacy
+
+import android.graphics.Paint.FontMetricsInt
+import android.text.style.LineHeightSpan
+import androidx.annotation.IntRange
+import androidx.annotation.Px
+import kotlin.math.roundToInt
+
+/** Copy @RequiresApi(Build.VERSION_CODES.Q) Type from [LineHeightSpan.Standard] */
+class LegacyLineHeightSpan(@Px @IntRange(from = 1) val height: Int) : LineHeightSpan {
+
+ override fun chooseHeight(
+ text: CharSequence, start: Int, end: Int,
+ spanstartv: Int, lineHeight: Int,
+ fm: FontMetricsInt
+ ) {
+ val originHeight = fm.descent - fm.ascent
+ if (originHeight <= 0) {
+ return
+ }
+ val ratio = height * 1.0f / originHeight
+ fm.descent = (fm.descent * ratio).roundToInt()
+ fm.ascent = fm.descent - height
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt
new file mode 100644
index 0000000..81e770a
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.span.legacy
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.text.Layout
+import android.text.style.LeadingMarginSpan
+import androidx.annotation.ColorInt
+import androidx.annotation.IntRange
+import androidx.annotation.Px
+
+/** Copy @RequiresApi(Build.VERSION_CODES.P) Type from [android.text.style.QuoteSpan] */
+class LegacyQuoteSpan(
+ @ColorInt val color: Int,
+ @Px @IntRange(from = 0) val stripeWidth: Int,
+ @Px @IntRange(from = 0) val gapWidth: Int
+) : LeadingMarginSpan {
+
+ override fun getLeadingMargin(first: Boolean): Int {
+ return stripeWidth + gapWidth
+ }
+
+ override fun drawLeadingMargin(
+ c: Canvas, p: Paint, x: Int, dir: Int,
+ top: Int, baseline: Int, bottom: Int,
+ text: CharSequence, start: Int, end: Int,
+ first: Boolean, layout: Layout
+ ) {
+ val cacheStyle = p.style
+ val cacheColor = p.color
+
+ p.style = Paint.Style.FILL
+ p.color = color
+ c.drawRect(
+ x.toFloat(),
+ top.toFloat(),
+ (x + dir * stripeWidth).toFloat(),
+ bottom.toFloat(),
+ p
+ )
+
+ p.style = cacheStyle
+ p.color = cacheColor
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt
new file mode 100644
index 0000000..f29527f
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.utils
+
+import android.graphics.drawable.Drawable
+import android.widget.EditText
+import android.widget.TextView
+import androidx.annotation.IntRange
+import androidx.annotation.Keep
+import com.drake.spannable.span.CenterImageSpan
+
+/**
+ * [CenterImageSpan] 的大小配置辅助类
+ */
+@Keep
+data class DrawableSize(
+ @IntRange(from = 0L) val width: Int,
+ @IntRange(from = 0L) val height: Int = width
+)
+
+/**
+ * 快速构建 [DrawableSize]
+ */
+val Int.drawableSize: DrawableSize
+ get() = DrawableSize(this, this)
+
+
+/**
+ * 设置[Drawable] 大小
+ */
+fun Drawable.drawableSize(width: Int, height: Int = width): Drawable = apply {
+ setBounds(0, 0, width, height)
+}
+
+/**
+ * 设置[Drawable] 大小为[TextView] [EditText] 的字体大小
+ * @param view 参考文字大小的textSize view
+ */
+fun Drawable.configTextViewSize(view: T?): Drawable = apply {
+ view?.textSizeInt?.let { size ->
+ drawableSize(size, size)
+ } ?: drawableSize(intrinsicWidth, intrinsicHeight)
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt
new file mode 100644
index 0000000..fe63676
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2022 TxcA, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.remax.visualnovel.utils.spannablex.utils
+
+import android.content.res.Resources
+import android.graphics.Color
+import android.widget.TextView
+import androidx.annotation.ColorInt
+import androidx.core.graphics.toColorInt
+import kotlin.math.roundToInt
+
+/**
+ * ColorString 2 [ColorInt]
+ * error default is [Color.RED]
+ */
+val String.color: Int
+ get() = try {
+ toColorInt()
+ } catch (e: Exception) {
+ Color.RED
+ }
+
+/**
+ * @receiver dp 2 px
+ */
+val Int.dp: Int
+ get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).roundToInt()
+
+/**
+ * @receiver dp 2 px
+ */
+val Float.dp: Int
+ get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).roundToInt()
+
+/**
+ * @receiver sp 2 px
+ */
+val Int.sp: Int
+ get() = (this * Resources.getSystem().displayMetrics.scaledDensity + 0.5f).roundToInt()
+
+/**
+ * 获取Int型[TextView.getTextSize]
+ */
+val TextView.textSizeInt: Int
+ get() = textSize.roundToInt()
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt
new file mode 100644
index 0000000..6f2df2c
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt
@@ -0,0 +1,63 @@
+package com.remax.visualnovel.widget
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.LinearLayout
+import androidx.annotation.StringRes
+import androidx.core.content.withStyledAttributes
+import com.remax.visualnovel.R
+import com.remax.visualnovel.databinding.WidgetPriceViewBinding
+import com.remax.visualnovel.extension.setMargin
+import com.remax.visualnovel.extension.setSize
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.uitoken.changeTextFont
+import com.dylanc.viewbinding.nonreflection.inflate
+
+/**
+ * Created by HJW on 2023/8/2
+ */
+@SuppressLint("SetTextI18n")
+class PriceView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
+ LinearLayout(context, attrs, defStyleAttr) {
+
+ private var binding: WidgetPriceViewBinding? = null
+
+ init {
+ binding = inflate(WidgetPriceViewBinding::inflate)
+ context.withStyledAttributes(attrs, R.styleable.PriceView) {
+ val iconSize = getDimensionPixelOffset(R.styleable.PriceView_priceIconSize, 16.dp)
+ val priceIconPadding = getDimensionPixelOffset(R.styleable.PriceView_priceIconPadding, 4.dp)
+
+ binding?.priceIcon?.run {
+ setMargin(marginEnd = priceIconPadding)
+ setSize(iconSize, iconSize)
+ }
+
+
+ getString(R.styleable.PriceView_priceTextToken)?.let { txtToken ->
+ binding?.priceTv?.changeTextFont {
+ textUITextToken = txtToken
+ }
+ }
+
+ val content = getString(R.styleable.PriceView_priceText)
+ binding?.priceTv?.text = content
+ }
+ }
+
+ fun getContentView() = binding?.priceTv
+
+ fun setSizeType(@StringRes txtToken: Int, iconSize: Int) {
+ binding?.run {
+ priceTv.changeTextFont {
+ textUITextToken = context.getString(txtToken)
+ }
+ priceIcon.setSize(iconSize, iconSize)
+ }
+ }
+
+ fun setPrice(content: String?) {
+ binding?.priceTv?.text = content
+ }
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt
new file mode 100644
index 0000000..752f589
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt
@@ -0,0 +1,333 @@
+package com.remax.visualnovel.widget.custom
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.GradientDrawable
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import com.remax.visualnovel.R
+import kotlin.math.max
+import kotlin.math.min
+
+class TagFlowLayout2 @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : ViewGroup(context, attrs, defStyleAttr) {
+
+ // 属性变量
+ private var horizontalSpacing = 20f.dpToPx()
+ private var verticalSpacing = 12f.dpToPx()
+ private var textSize = 14f.spToPx()
+ private var textColor = Color.WHITE
+ private var tagBackground: Drawable? = null
+ private var maxLines = Int.MAX_VALUE
+ private var expandIndicator: Drawable? = null
+ private var collapseIndicator: Drawable? = null
+ private var eachLineMaxTagNum = 2 //一行最大标签宽度数
+
+ // 状态变量
+ private var isExpanded = false
+ private var actualLineCount = 0
+ private var showExpandButton = false
+ private var eachLineAvailableWidth = 0 // 可用宽度
+
+ // 数据
+ private val tagItems = mutableListOf()
+ private val tagViews = mutableListOf()
+ private lateinit var expandButton: TextView
+
+ // 监听器
+ private var onTagClickListener: ((TagItem) -> Unit)? = null
+ private var onExpandStateChangeListener: ((Boolean) -> Unit)? = null
+
+ init {
+ initAttributes(attrs)
+ initExpandButton()
+ }
+
+ private fun initAttributes(attrs: AttributeSet?) {
+ val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TagFlowLayout2)
+
+ horizontalSpacing = typedArray.getDimension(
+ R.styleable.TagFlowLayout2_tag_horizontal_spacing, horizontalSpacing
+ )
+ verticalSpacing = typedArray.getDimension(
+ R.styleable.TagFlowLayout2_tag_vertical_spacing, verticalSpacing
+ )
+ textSize = typedArray.getDimension(
+ R.styleable.TagFlowLayout2_tag_text_size, textSize
+ )
+ textColor = typedArray.getColor(
+ R.styleable.TagFlowLayout2_tag_text_color, textColor
+ )
+ tagBackground = typedArray.getDrawable(R.styleable.TagFlowLayout2_tag_background)
+ maxLines = typedArray.getInt(R.styleable.TagFlowLayout2_tag_max_lines, Int.MAX_VALUE)
+ expandIndicator = typedArray.getDrawable(R.styleable.TagFlowLayout2_expand_indicator_drawable)
+ collapseIndicator = typedArray.getDrawable(R.styleable.TagFlowLayout2_collapse_indicator_drawable)
+ eachLineMaxTagNum = typedArray.getInt(R.styleable.TagFlowLayout2_each_line_max_num, 2)
+ typedArray.recycle()
+
+
+ if (tagBackground == null) {
+ tagBackground = createDefaultBackground()
+ }
+ if (expandIndicator == null) {
+ expandIndicator = ContextCompat.getDrawable(context, R.mipmap.tag_flow_expand)
+ }
+ if (collapseIndicator == null) {
+ collapseIndicator = ContextCompat.getDrawable(context, R.mipmap.tag_flow_shrink)
+ }
+ }
+
+ private fun createDefaultBackground(): Drawable {
+ val gradientDrawable = GradientDrawable()
+ gradientDrawable.cornerRadius = 16f.dpToPx()
+ gradientDrawable.setColor(Color.parseColor("#8A8A8E"))
+ return gradientDrawable
+ }
+
+ private fun initExpandButton() {
+ expandButton = TextView(context).apply {
+ text = "展开"
+ setCompoundDrawablesWithIntrinsicBounds(null, null, expandIndicator, null)
+ compoundDrawablePadding = 4.dpToPx()
+ setTextColor(Color.parseColor("#8A8A8E"))
+ textSize = 12f
+ setPadding(12.dpToPx(), 6.dpToPx(), 8.dpToPx(), 6.dpToPx())
+ background = createExpandButtonBackground()
+
+ setOnClickListener {
+ toggleExpandState()
+ }
+ }
+ addView(expandButton)
+ }
+
+ private fun createExpandButtonBackground(): Drawable {
+ val gradientDrawable = GradientDrawable()
+ gradientDrawable.cornerRadius = 16f.dpToPx()
+ gradientDrawable.setColor(Color.parseColor("#E5E5EA"))
+ gradientDrawable.setStroke(1.dpToPx(), Color.parseColor("#C6C6C8"))
+ return gradientDrawable
+ }
+
+ // 设置标签数据
+ fun setTags(tags: List) {
+ tagItems.clear()
+ tagViews.forEach { removeView(it) }
+ tagViews.clear()
+
+ tagItems.addAll(tags)
+
+ tags.forEach { tag ->
+ val textView = createTagView(tag)
+ tagViews.add(textView)
+ addView(textView)
+ }
+
+ requestLayout()
+ }
+
+ private fun createTagView(tag: TagItem): TextView {
+ return TextView(context).apply {
+ text = tag.text
+ setTextColor(textColor)
+ textSize = textSize / resources.displayMetrics.scaledDensity
+ setPadding(16.dpToPx(), 8.dpToPx(), 16.dpToPx(), 8.dpToPx())
+ setBackgroundResource(R.drawable.tag_flow_item_bg)
+ isSingleLine = true
+ ellipsize = TextUtils.TruncateAt.END
+ maxLines = 1
+ includeFontPadding = false
+
+ setOnClickListener {
+ onTagClickListener?.invoke(tag)
+ }
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val width = MeasureSpec.getSize(widthMeasureSpec)
+ eachLineAvailableWidth = width - paddingLeft - paddingRight
+
+ val oneTagMaxWidth = (eachLineAvailableWidth - (eachLineMaxTagNum-1) * horizontalSpacing) / eachLineMaxTagNum
+ var totalNeedHeight = 0
+ if (tagViews.isEmpty()) {
+ setMeasuredDimension(width, totalNeedHeight)
+ return
+ }
+
+ // 为所有标签应用最大宽度限制
+ tagViews.forEach { subView ->
+ val maxWidthSpec = MeasureSpec.makeMeasureSpec(oneTagMaxWidth.toInt(), MeasureSpec.AT_MOST)
+ val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
+ subView.measure(maxWidthSpec, heightSpec)
+ }
+
+
+ var curLineTotalWidth = 0
+ var curLineTotalHeight = 0
+ var lineCount = 0
+ val maxDisplayLines = if (isExpanded) Int.MAX_VALUE else maxLines
+
+ tagViews.forEach { view ->
+ val childWidth = view.measuredWidth
+ val childHeight = view.measuredHeight
+
+ // 检查是否需要换行(考虑水平间距)
+ val curLineNeedTotalWidth: Int = if (curLineTotalWidth == 0) childWidth
+ else curLineTotalWidth + horizontalSpacing.toInt() + childWidth
+
+ if (curLineNeedTotalWidth > eachLineAvailableWidth) {
+ // 换行处理
+ lineCount++
+ if (lineCount >= maxDisplayLines) {
+ return@forEach
+ }
+
+ totalNeedHeight += curLineTotalHeight + (if (lineCount >= 1) verticalSpacing.toInt() else 0)
+ curLineTotalWidth = childWidth
+ curLineTotalHeight = childHeight
+ } else {
+ curLineTotalWidth = curLineNeedTotalWidth
+ curLineTotalHeight = max(curLineTotalHeight, childHeight)
+ }
+ }
+
+ // 添加最后一行高度
+ if (lineCount < maxDisplayLines && tagViews.isNotEmpty()) {
+ totalNeedHeight += curLineTotalHeight
+ }
+
+ // 添加padding
+ totalNeedHeight += paddingTop + paddingBottom
+
+ actualLineCount = lineCount + 1
+ showExpandButton = actualLineCount > maxLines && !isExpanded
+
+ // 测量展开按钮
+ if (showExpandButton) {
+ val buttonWidthSpec = MeasureSpec.makeMeasureSpec(eachLineAvailableWidth, MeasureSpec.AT_MOST)
+ val buttonHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
+ expandButton.measure(buttonWidthSpec, buttonHeightSpec)
+ }
+
+ setMeasuredDimension(width, totalNeedHeight)
+ }
+
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
+ if (tagViews.isEmpty()) return
+
+ val width = r - l
+ val maxTagWidth = (eachLineAvailableWidth * eachLineMaxTagNum).toInt()
+
+ var currentLeft = paddingLeft
+ var currentTop = paddingTop
+ var currentLineHeight = 0
+ var lineCount = 0
+ val maxDisplayLines = if (isExpanded) Int.MAX_VALUE else maxLines
+
+ // 布局可见的标签
+ for (i in tagViews.indices) {
+ val view = tagViews[i]
+ val childWidth = min(view.measuredWidth, maxTagWidth)
+ val childHeight = view.measuredHeight
+
+ // 检查是否需要换行
+ if (currentLeft + childWidth > width - paddingRight) {
+ lineCount++
+ if (lineCount >= maxDisplayLines) {
+ view.visibility = GONE
+ continue
+ }
+
+ currentLeft = paddingLeft
+ currentTop += currentLineHeight + verticalSpacing.toInt()
+ currentLineHeight = 0
+ }
+
+ view.visibility = VISIBLE
+ view.layout(currentLeft, currentTop, currentLeft + childWidth, currentTop + childHeight)
+
+ currentLeft += childWidth + horizontalSpacing.toInt()
+ currentLineHeight = max(currentLineHeight, childHeight)
+ }
+
+ // 布局展开按钮
+ layoutExpandButton(width, currentTop, currentLineHeight)
+ }
+
+ private fun layoutExpandButton(parentWidth: Int, currentTop: Int, currentLineHeight: Int) {
+ if (showExpandButton) {
+ expandButton.visibility = VISIBLE
+ val buttonWidth = expandButton.measuredWidth
+ val buttonHeight = expandButton.measuredHeight
+
+ // 计算按钮位置(在当前行右侧)
+ val buttonLeft = parentWidth - paddingRight - buttonWidth
+ val buttonTop = currentTop + (currentLineHeight - buttonHeight) / 2
+
+ expandButton.layout(
+ buttonLeft,
+ buttonTop,
+ buttonLeft + buttonWidth,
+ buttonTop + buttonHeight
+ )
+ } else {
+ expandButton.visibility = GONE
+ }
+ }
+
+ private fun toggleExpandState() {
+ isExpanded = !isExpanded
+ updateExpandButton()
+ requestLayout()
+ onExpandStateChangeListener?.invoke(isExpanded)
+ }
+
+ private fun updateExpandButton() {
+ val indicator = if (isExpanded) collapseIndicator else expandIndicator
+ expandButton.setCompoundDrawablesWithIntrinsicBounds(null, null, indicator, null)
+ expandButton.text = if (isExpanded) "收起" else "展开"
+ }
+
+ // 公共方法
+ fun setOnTagClickListener(listener: (TagItem) -> Unit) {
+ onTagClickListener = listener
+ }
+
+ fun setOnExpandStateChangeListener(listener: (Boolean) -> Unit) {
+ onExpandStateChangeListener = listener
+ }
+
+ fun expand() {
+ if (!isExpanded) {
+ toggleExpandState()
+ }
+ }
+
+ fun collapse() {
+ if (isExpanded) {
+ toggleExpandState()
+ }
+ }
+
+ fun isExpanded(): Boolean = isExpanded
+
+ // 设置最大标签数
+ fun setMaxTagsNumEachLine(maxTagNumForEachLine: Int) {
+ require(maxTagNumForEachLine >= 1 && maxTagNumForEachLine <= 10) { "Ratio must be between 1 and 10" }
+ eachLineMaxTagNum = maxTagNumForEachLine
+ requestLayout()
+ }
+
+ // 扩展函数
+ private fun Float.dpToPx(): Float = this * resources.displayMetrics.density
+ private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
+ private fun Float.spToPx(): Float = this * resources.displayMetrics.scaledDensity
+}
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt
new file mode 100644
index 0000000..6afff85
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt
@@ -0,0 +1,7 @@
+package com.remax.visualnovel.widget.custom
+
+data class TagItem(
+ val id: String,
+ val text: String,
+ var isSelected: Boolean = false
+)
\ No newline at end of file
diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt
new file mode 100644
index 0000000..c558236
--- /dev/null
+++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt
@@ -0,0 +1,444 @@
+package com.remax.visualnovel.widget.dialoglib
+
+import android.app.Dialog
+import android.content.Context
+import android.content.DialogInterface
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.ShapeDrawable
+import android.graphics.drawable.shapes.RoundRectShape
+import android.os.Bundle
+import android.util.SparseArray
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.WindowManager
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.annotation.ColorInt
+import androidx.annotation.DrawableRes
+import androidx.annotation.IdRes
+import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.viewbinding.ViewBinding
+import com.remax.visualnovel.R
+import com.remax.visualnovel.utils.spannablex.utils.dp
+import com.remax.visualnovel.widget.uitoken.handleUIToken
+
+/**
+ * Created by HJW on 2021/10/20
+ */
+open class LBindingDialog(private val context: Context,
+ private val inflate: (LayoutInflater) -> VB,
+ themeResId: Int = R.style.LDialog) :
+ Dialog(context, themeResId), LifecycleEventObserver {
+ private val views = SparseArray()
+ private var width = 0
+ private var height = 0
+ private var bgRadius = 0 //背景圆角
+
+ private var leftTopRadius = 0
+ private var rightTopRadius = 0
+ private var leftBottomRadius = 0
+ private var rightBottomRadius = 0
+ private var bgColor = Color.TRANSPARENT //背景颜色
+
+ lateinit var binding: VB
+ var currEvent: Lifecycle.Event? = null
+
+ companion object {
+
+ fun getRoundRectDrawable(radius: Int, color: Int): ShapeDrawable {
+ return getRoundRectDrawable(radius, radius, radius, radius, color)
+ }
+
+ fun getRoundRectDrawable(leftTop: Int, rightTop: Int, rightBottom: Int, leftBottom: Int, color: Int): ShapeDrawable {
+ //左上、右上、右下、左下的圆角半径
+ val radius = floatArrayOf(
+ leftTop.toFloat(),
+ leftTop.toFloat(),
+ rightTop.toFloat(),
+ rightTop.toFloat(),
+ rightBottom.toFloat(),
+ rightBottom.toFloat(),
+ leftBottom.toFloat(),
+ leftBottom.toFloat()
+ )
+ val drawable = ShapeDrawable().apply {
+ shape = RoundRectShape(radius, null, null)
+ paint.color = color
+ }
+ return drawable
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = inflate(layoutInflater)
+ setContentView(binding.root)
+ init()
+ }
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ currEvent = event
+ if (Lifecycle.Event.ON_DESTROY == event && isShowing) {
+ dismiss()
+ }
+ }
+
+ fun init() {
+ if (context is ComponentActivity) {
+ context.lifecycle.addObserver(this)
+ }
+ setCanceledOnTouchOutside(true)
+ window?.setBackgroundDrawableResource(R.color.transparent)
+ width = (ScreenUtils.getScreenWidth() * 0.8).toInt()
+ height = WindowManager.LayoutParams.WRAP_CONTENT
+ setWidthHeight()
+ window?.setWindowAnimations(R.style.dialog_alpha)
+ }
+
+ fun with(): LBindingDialog {
+ create()
+ setBgColorToken(R.string.color_surface_base_normal)
+ return this
+ }
+
+ fun setAnimationsStyle(style: Int): LBindingDialog {
+ window?.setWindowAnimations(style)
+ return this
+ }
+
+ /**
+ * 设置位置
+ */
+ fun setGravity(gravity: Int, offX: Int, offY: Int): LBindingDialog {
+ setGravity(gravity)
+ val layoutParams = window?.attributes
+ layoutParams?.x = offX
+ layoutParams?.y = offY
+ window?.attributes = layoutParams
+ return this
+ }
+
+ fun setGravity(gravity: Int): LBindingDialog {
+ window?.setGravity(gravity)
+ return this
+ }
+
+
+ fun setBottom(): LBindingDialog {
+ setGravity(Gravity.BOTTOM)
+ setAnimationsStyle(R.style.dialog_translate)
+ setWidthRatio(1.0)
+ setDBgRadius(24, 24, 0, 0)
+ return this
+ }
+
+ fun setCenter(cancelable: Boolean = true): LBindingDialog {
+ setGravity(Gravity.CENTER)
+ setWidthRatio(0.8)
+ setBgRadius(16)
+ setAnimationsStyle(R.style.dialog_alpha)
+ setCanceledOnTouchOutside(cancelable)
+ setCancelable(cancelable)
+ return this
+ }
+
+ override fun show() {
+ if (!isShowing) {
+ super.show()
+ }
+ }
+
+ fun thisShow(): DialogInterface {
+ this.show()
+ return this
+ }
+
+ /**
+ * 遮罩透明度
+ *
+ * @param value 0-1f
+ */
+ fun setMaskValue(value: Float): LBindingDialog {
+ window?.setDimAmount(value)
+ return this
+ }
+
+ // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置背景>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+ open fun setBg(): LBindingDialog {
+ if (leftTopRadius != 0 || rightTopRadius != 0 || rightBottomRadius != 0 || leftBottomRadius != 0) {
+ window?.setBackgroundDrawable(getRoundRectDrawable(leftTopRadius, rightTopRadius, rightBottomRadius, leftBottomRadius, bgColor))
+ } else {
+ window?.setBackgroundDrawable(getRoundRectDrawable(bgRadius, bgColor))
+ }
+ return this
+ }
+
+ /**
+ * 设置背景颜色
+ */
+ fun setBgColor(@ColorInt color: Int): LBindingDialog {
+ bgColor = color
+ return setBg()
+ }
+
+ fun setBgColorToken(@StringRes colorToken: Int): LBindingDialog {
+ bgColor = context.handleUIToken(colorToken)?.color ?: 0
+ return setBg()
+ }
+
+ fun setBgColorRes(colorRes: Int): LBindingDialog {
+ bgColor = ContextCompat.getColor(context, colorRes)
+ return setBg()
+ }
+
+ /**
+ * 设置背景圆角
+ */
+ fun setBgRadius(bgRadius: Int): LBindingDialog {
+ this.bgRadius = bgRadius.dp
+ return setBg()
+ }
+
+ /**
+ * 设置背景不同圆角
+ */
+ fun setDBgRadius(leftTopRadius: Int, rightTopRadius: Int, rightBottomRadius: Int, leftBottomRadius: Int): LBindingDialog {
+ this.leftTopRadius = leftTopRadius.dp
+ this.rightTopRadius = rightTopRadius.dp
+ this.rightBottomRadius = rightBottomRadius.dp
+ this.leftBottomRadius = leftBottomRadius.dp
+ return setBg()
+ }
+
+ /**
+ * 设置背景圆角
+ */
+ fun setBgRadiusPX(bgRadius: Int): LBindingDialog {
+ this.bgRadius = bgRadius
+ return setBg()
+ }
+
+ // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置宽高>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+ /**
+ * 设置宽高
+ */
+ open fun setWidthHeight(): LBindingDialog {
+ val dialogWindow = window
+ val lp = dialogWindow?.attributes
+ lp?.width = width
+ lp?.height = height
+ dialogWindow?.attributes = lp
+ return this
+ }
+
+ fun setWidth(width: Int): LBindingDialog {
+ this.width = width.dp
+ return setWidthHeight()
+ }
+
+ fun setWidthPX(width: Int): LBindingDialog {
+ this.width = width
+ return setWidthHeight()
+ }
+
+ fun setHeight(height: Int): LBindingDialog {
+ this.height = height.dp
+ return setWidthHeight()
+ }
+
+ fun setHeightPX(height: Int): LBindingDialog {
+ this.height = height
+ return setWidthHeight()
+ }
+
+ /**
+ * 设置宽占屏幕的比例
+ */
+ fun setWidthRatio(widthRatio: Double): LBindingDialog {
+ width = (ScreenUtils.getScreenWidth() * widthRatio).toInt()
+ setWidthHeight()
+ return this
+ }
+
+ /**
+ * 设置高占屏幕的比例
+ */
+ fun setHeightRatio(heightRatio: Double): LBindingDialog {
+ height = (ScreenUtils.getHeightRealPixels() * heightRatio).toInt()
+ setWidthHeight()
+ return this
+ }
+
+ // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置监听>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+ /**
+ * 设置监听
+ */
+ open fun setOnClickListener(onClickListener: DialogOnClickListener, vararg viewIds: Int): LBindingDialog {
+ val lDialog: LBindingDialog = this
+ for (element in viewIds) {
+ getView(element).setOnClickListener { v -> onClickListener.onClick(v, lDialog) }
+ }
+ return this
+ }
+
+ interface DialogOnClickListener {
+ fun onClick(v: View, lDialog: LBindingDialog<*>)
+ }
+
+ /**
+ * 设置 关闭dialog的按钮
+ */
+ fun setCancelBtn(viewId: Int): LBindingDialog {
+ getView(viewId)?.setOnClickListener(View.OnClickListener { dismiss() })
+ return this
+ }
+
+ // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置常见属性>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+ @Suppress("UNCHECKED_CAST")
+ fun