字体选择 自定义view

This commit is contained in:
renhaoting 2025-10-30 18:00:41 +08:00
parent 3c8b119a3a
commit 656826c41b
6 changed files with 530 additions and 55 deletions

View File

@ -0,0 +1,302 @@
package com.remax.visualnovel.ui.chat.ui
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.remax.visualnovel.R
import com.remax.visualnovel.utils.ResUtil
import com.remax.visualnovel.utils.spannablex.utils.dp
class LevelSeekBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var totalLevels = 5
private var currentLevel = 2
// 尺寸
private var trackHeight = ResUtil.getPixelSize(R.dimen.dp_5).toFloat()
private var trackEndRadius = ResUtil.getPixelSize(R.dimen.dp_8).toFloat()
private var thumbRadius = ResUtil.getPixelSize(R.dimen.dp_9).toFloat()
private var nodeRadius = ResUtil.getPixelSize(R.dimen.dp_3).toFloat()
private val nodeWidth = ResUtil.getPixelSize(R.dimen.dp_5)
private val nodeHeight= ResUtil.getPixelSize(R.dimen.dp_11)
// 颜色
private var trackColor = ResUtil.getColor(R.color.seekbar_color)
private var thumbColor = ResUtil.getColor(R.color.white)
private var activeTrackColor = trackColor
private var thumbBorderColor = trackColor
private var nodeColor = trackColor
private var activeNodeColor = trackColor
// 画笔
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val thumbBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG)
// 监听器
private var onLevelChangeListener: OnLevelChangeListener? = null
private var isDragging = false
// 触摸相关
private var lastTouchX = 0f
interface OnLevelChangeListener {
fun onLevelChanged(seekBar: LevelSeekBar, level: Int, fromUser: Boolean)
fun onStartTrackingTouch(seekBar: LevelSeekBar)
fun onStopTrackingTouch(seekBar: LevelSeekBar)
}
init {
setupAttributes(attrs)
setupPaints()
}
private fun setupAttributes(attrs: AttributeSet?) {
attrs?.let {
val typedArray = context.obtainStyledAttributes(it, R.styleable.CustomLevelSeekBar)
totalLevels = typedArray.getInt(R.styleable.CustomLevelSeekBar_totalLevels, totalLevels)
currentLevel = typedArray.getInt(R.styleable.CustomLevelSeekBar_currentLevel, currentLevel)
.coerceIn(0, totalLevels - 1)
trackColor = typedArray.getColor(R.styleable.CustomLevelSeekBar_trackColor, trackColor)
activeTrackColor = typedArray.getColor(R.styleable.CustomLevelSeekBar_activeTrackColor, activeTrackColor)
thumbColor = typedArray.getColor(R.styleable.CustomLevelSeekBar_thumbColor, thumbColor)
thumbBorderColor = typedArray.getColor(R.styleable.CustomLevelSeekBar_thumbBorderColor, thumbBorderColor)
nodeColor = typedArray.getColor(R.styleable.CustomLevelSeekBar_nodeColor, nodeColor)
activeNodeColor = typedArray.getColor(R.styleable.CustomLevelSeekBar_activeNodeColor, activeNodeColor)
trackHeight = typedArray.getDimension(R.styleable.CustomLevelSeekBar_trackHeight, trackHeight)
trackEndRadius = typedArray.getDimension(R.styleable.CustomLevelSeekBar_trackEndRadius, trackEndRadius)
thumbRadius = typedArray.getDimension(R.styleable.CustomLevelSeekBar_thumbRadius, thumbRadius)
nodeRadius = typedArray.getDimension(R.styleable.CustomLevelSeekBar_nodeRadius, nodeRadius)
typedArray.recycle()
}
}
private fun setupPaints() {
trackPaint.style = Paint.Style.FILL
thumbBorderPaint.style = Paint.Style.STROKE
//thumbBorderPaint.strokeWidth = 1F.dp.toFloat()
thumbBorderPaint.color = thumbBorderColor
thumbPaint.style = Paint.Style.FILL
thumbPaint.color = thumbColor
nodePaint.style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawTrack(canvas)
drawNodes(canvas)
drawThumb(canvas)
}
private fun drawTrack(canvas: Canvas) {
val centerY = height / 2f
val trackTop = centerY - trackHeight / 2
val trackBottom = centerY + trackHeight / 2
trackPaint.color = trackColor
val trackRect = RectF(0f + nodeWidth/2, trackTop, width.toFloat(), trackBottom - nodeWidth/2)
canvas.drawRoundRect(trackRect, trackEndRadius, trackEndRadius, trackPaint)
}
private fun drawNodes(canvas: Canvas) {
if (totalLevels <= 1) return
val centerY = height / 2f
for (i in 0 until totalLevels) {
val x = calculatePositionForLevel(i)
nodePaint.color = if (i <= currentLevel) activeNodeColor else nodeColor
//canvas.drawCircle(x, centerY, nodeRadius, nodePaint)
val trackRect = RectF(x - thumbRadius/2, centerY - nodeHeight/2 + 6, x + thumbRadius/2, centerY + nodeHeight/2)
canvas.drawRoundRect(trackRect, trackEndRadius, trackEndRadius, nodePaint)
}
}
private fun drawThumb(canvas: Canvas) {
if (totalLevels <= 1) return
val centerY = height / 2f
val thumbX = calculatePositionForLevel(currentLevel)
canvas.drawCircle(thumbX, centerY, thumbRadius, thumbBorderPaint)
canvas.drawCircle(thumbX, centerY, thumbRadius - 1F.dp.toFloat(), thumbPaint)
}
private fun calculatePositionForLevel(level: Int): Float {
if (totalLevels <= 1) return width / 2f
val availableWidth = width - 2 * thumbRadius
return thumbRadius + (availableWidth * level.toFloat() / (totalLevels - 1))
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (isPointInThumb(event.x, event.y) || isPointInTrack(event.x, event.y)) {
isDragging = true
lastTouchX = event.x
onLevelChangeListener?.onStartTrackingTouch(this)
handleTouch(event.x)
parent?.requestDisallowInterceptTouchEvent(true)
return true
}
}
MotionEvent.ACTION_MOVE -> {
if (isDragging) {
lastTouchX = event.x
handleTouch(event.x)
return true
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (isDragging) {
isDragging = false
snapToNearestLevel(lastTouchX)
onLevelChangeListener?.onStopTrackingTouch(this)
parent?.requestDisallowInterceptTouchEvent(false)
return true
}
}
}
return super.onTouchEvent(event)
}
private fun isPointInThumb(x: Float, y: Float): Boolean {
val thumbX = calculatePositionForLevel(currentLevel)
val centerY = height / 2f
val distance = Math.sqrt(
(x - thumbX) * (x - thumbX) + (y - centerY) * (y - centerY).toDouble()
)
return distance <= thumbRadius
}
private fun isPointInTrack(x: Float, y: Float): Boolean {
val centerY = height / 2f
val trackTop = centerY - trackHeight / 2 - thumbRadius // 扩大触摸区域
val trackBottom = centerY + trackHeight / 2 + thumbRadius
return x in 0f..width.toFloat() && y in trackTop..trackBottom
}
private fun handleTouch(x: Float) {
if (totalLevels <= 1) return
val newLevel = calculateLevelForPosition(x)
if (newLevel != currentLevel) {
currentLevel = newLevel
invalidate()
onLevelChangeListener?.onLevelChanged(this, currentLevel, true)
}
}
private fun snapToNearestLevel(x: Float) {
if (totalLevels <= 1) return
val exactLevel = calculateExactLevelForPosition(x)
val newLevel = (exactLevel + 0.5f).toInt().coerceIn(0, totalLevels - 1)
if (newLevel != currentLevel) {
currentLevel = newLevel
invalidate()
onLevelChangeListener?.onLevelChanged(this, currentLevel, true)
}
}
private fun calculateLevelForPosition(x: Float): Int {
if (totalLevels <= 1) return 0
val availableWidth = width - 2 * thumbRadius
val progress = ((x - thumbRadius) / availableWidth).coerceIn(0f, 1f)
return (progress * (totalLevels - 1)).toInt().coerceIn(0, totalLevels - 1)
}
private fun calculateExactLevelForPosition(x: Float): Float {
if (totalLevels <= 1) return 0f
val availableWidth = width - 2 * thumbRadius
val progress = ((x - thumbRadius) / availableWidth).coerceIn(0f, 1f)
return progress * (totalLevels - 1)
}
//---------------------------- public 设置方法 ---------------------------------//
fun setLevel(level: Int, fromUser: Boolean = false) {
val newLevel = level.coerceIn(0, totalLevels - 1)
if (newLevel != currentLevel) {
currentLevel = newLevel
invalidate()
onLevelChangeListener?.onLevelChanged(this, currentLevel, fromUser)
}
}
fun getLevel(): Int = currentLevel
fun setTotalLevels(levels: Int) {
if (levels > 0 && levels != totalLevels) {
totalLevels = levels
currentLevel = currentLevel.coerceIn(0, totalLevels - 1)
invalidate()
}
}
fun getTotalLevels(): Int = totalLevels
fun setOnLevelChangeListener(listener: OnLevelChangeListener) {
this.onLevelChangeListener = listener
}
fun setOnLevelChangeListener(
onLevelChanged: (LevelSeekBar, Int, Boolean) -> Unit = { _, _, _ -> },
onStartTrackingTouch: (LevelSeekBar) -> Unit = {},
onStopTrackingTouch: (LevelSeekBar) -> Unit = {}
) {
this.onLevelChangeListener = object : OnLevelChangeListener {
override fun onLevelChanged(seekBar: LevelSeekBar, level: Int, fromUser: Boolean) {
onLevelChanged(seekBar, level, fromUser)
}
override fun onStartTrackingTouch(seekBar: LevelSeekBar) {
onStartTrackingTouch(seekBar)
}
override fun onStopTrackingTouch(seekBar: LevelSeekBar) {
onStopTrackingTouch(seekBar)
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val minWidth = suggestedMinimumWidth + paddingLeft + paddingRight
val minHeight = (thumbRadius * 2 + paddingTop + paddingBottom).toInt()
val width = resolveSizeAndState(minWidth, widthMeasureSpec, 1)
val height = resolveSizeAndState(minHeight, heightMeasureSpec, 1)
setMeasuredDimension(width, height)
}
}

View File

@ -0,0 +1,138 @@
package com.remax.visualnovel.utils
import android.graphics.Color
import android.util.TypedValue
import androidx.annotation.*
import androidx.core.content.ContextCompat
import com.remax.visualnovel.configs.NovelApplication
object ResUtil {
private val appContext = NovelApplication.appContext()
// ==================== Dimen相关 ====================
/**
* 获取dp值对应的像素值
*/
fun dp(dpValue: Float): Float {
return dpValue * appContext.resources.displayMetrics.density
}
fun dp(dpValue: Int): Float {
return dp(dpValue.toFloat())
}
/**
* 获取dp值对应的像素值取整
*/
fun dpToPx(dpValue: Float): Int {
return (dp(dpValue) + 0.5f).toInt()
}
fun dpToPx(dpValue: Int): Int {
return dpToPx(dpValue.toFloat())
}
/**
* 获取sp值对应的像素值
*/
fun sp(spValue: Float): Float {
return spValue * appContext.resources.displayMetrics.scaledDensity
}
fun sp(spValue: Int): Float {
return sp(spValue.toFloat())
}
/**
* 从dimen资源获取像素值
*/
fun getPixelSize(@DimenRes dimenRes: Int): Int {
return appContext.resources.getDimensionPixelSize(dimenRes)
}
fun getDimension(@DimenRes dimenRes: Int): Float {
return appContext.resources.getDimension(dimenRes)
}
// ==================== 颜色相关 ====================
/**
* 从颜色资源获取颜色值
*/
fun getColor(@ColorRes colorRes: Int): Int {
return ContextCompat.getColor(appContext, colorRes)
}
/**
* 从颜色资源获取颜色值带透明度
*/
fun getColor(@ColorRes colorRes: Int, alpha: Float): Int {
val color = getColor(colorRes)
return applyAlphaToColor(color, alpha)
}
/**
* 解析颜色字符串
*/
fun parseColor(colorString: String): Int {
return try {
Color.parseColor(colorString)
} catch (e: IllegalArgumentException) {
Color.BLACK // 默认颜色
}
}
/**
* 给颜色应用透明度
*/
fun applyAlphaToColor(color: Int, alpha: Float): Int {
val alphaValue = (alpha.coerceIn(0f, 1f) * 255).toInt()
return color and 0x00FFFFFF or (alphaValue shl 24)
}
/**
* 获取主题颜色属性
*/
fun getColorAttr(@AttrRes attrRes: Int): Int {
val typedValue = TypedValue()
appContext.theme.resolveAttribute(attrRes, typedValue, true)
return typedValue.data
}
//==================== 扩展函数 ====================
/**
* Float的扩展函数转换为dp像素值
*/
val Float.dp: Float
get() = ResUtil.dp(this)
val Float.dpToPx: Int
get() = ResUtil.dpToPx(this)
/**
* Int的扩展函数转换为dp像素值
*/
val Int.dp: Float
get() = ResUtil.dp(this)
val Int.dpToPx: Int
get() = ResUtil.dpToPx(this)
/**
* Float的扩展函数转换为sp像素值
*/
val Float.sp: Float
get() = ResUtil.sp(this)
/**
* 字符串的扩展函数解析颜色
*/
val String.colorInt: Int
get() = ResUtil.parseColor(this)
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
<androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
@ -233,6 +233,7 @@
android:id="@+id/font_set_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/dp_5"
/>
<com.remax.visualnovel.ui.chat.ui.expandableSelector.ExpandChatModeSelectView
@ -298,14 +299,15 @@
<!-- Delete related -->
<com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout
android:id="@+id/ll_delete"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="28dp"
app:strokeColorToken="@string/color_outline_normal"
app:strokeWidthToken="@string/border_s"
app:backgroundColorToken="@string/color_background_specialmap"
app:radiusToken="@string/radius_round" >
app:advStrokeWidth="@dimen/dp_3"
app:advStrokeColor="@color/red_ff3b30"
app:advRadius="@dimen/dp_25"
>
<com.remax.visualnovel.widget.uitoken.view.UITokenTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -316,7 +318,7 @@
android:drawablePadding="@dimen/dp_10"
app:drawableLeftCompat="@mipmap/setting_delete"
android:textSize="@dimen/sp_26"
android:textColor="@color/glo_color_red_40"
android:textColor="@color/red_ff3b30"
/>
</com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout>
@ -324,4 +326,4 @@
</com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout>
</ScrollView>
</androidx.core.widget.NestedScrollView>

View File

@ -1,75 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<com.remax.visualnovel.widget.uitoken.view.UITokenRelativeLayout
<com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/titleLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:backgroundColorToken="@string/color_chat_setting_item_bg"
android:orientation="vertical"
app:radiusToken="@string/radius_m"
android:paddingVertical="@dimen/dp_12"
android:paddingHorizontal="@dimen/dp_17"
>
<com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout
android:id="@+id/left_container"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.remax.visualnovel.widget.uitoken.view.UITokenImageView
android:id="@+id/iv_left_icon"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerVertical="true"
android:src="@mipmap/setting_font_icon"/>
<com.remax.visualnovel.widget.uitoken.view.UITokenTextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_10"
android:layout_marginEnd="@dimen/dp_50"
android:textSize="@dimen/sp_14"
android:textColor="@color/gray6"
android:gravity="center"
android:textStyle="bold"
android:text="@string/font_size"
/>
</com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout>
<RelativeLayout
<com.remax.visualnovel.widget.uitoken.view.UITokenRelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_toEndOf="@+id/left_container">
<com.remax.visualnovel.widget.uitoken.view.UITokenImageView
android:id="@+id/iv_font_plus"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerVertical="true"
android:src="@mipmap/setting_font_plus"/>
android:layout_height="wrap_content" >
<com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout
android:id="@+id/left_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.remax.visualnovel.widget.uitoken.view.UITokenImageView
android:id="@+id/iv_left_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:src="@mipmap/setting_font_icon"/>
<com.remax.visualnovel.widget.uitoken.view.UITokenTextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_10"
android:layout_marginEnd="@dimen/dp_50"
android:textSize="@dimen/sp_14"
android:textColor="@color/gray6"
android:gravity="center"
android:textStyle="bold"
android:text="@string/font_size"
/>
</com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout>
<com.remax.visualnovel.widget.uitoken.view.UITokenTextView
android:id="@+id/tv_font_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/iv_font_plus"
android:layout_toStartOf="@+id/iv_font_add"
android:layout_centerVertical="true"
android:layout_alignParentEnd="true"
android:textSize="@dimen/sp_14"
android:textColor="@color/gray6"
android:textColor="@color/gray9"
android:gravity="center"
android:textStyle="bold"
android:text="20"
/>
</com.remax.visualnovel.widget.uitoken.view.UITokenRelativeLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toEndOf="@+id/left_container"
android:layout_marginTop="@dimen/dp_10">
<com.remax.visualnovel.widget.uitoken.view.UITokenImageView
android:id="@+id/iv_font_plus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/dp_10"
android:src="@mipmap/setting_font_plus"/>
<com.remax.visualnovel.ui.chat.ui.LevelSeekBar
android:layout_width="match_parent"
android:layout_height="@dimen/dp_20"
android:layout_centerVertical="true"
android:layout_toEndOf="@id/iv_font_plus"
android:layout_toStartOf="@+id/iv_font_add"
android:layout_marginHorizontal="@dimen/dp_10"
/>
<com.remax.visualnovel.widget.uitoken.view.UITokenImageView
android:id="@+id/iv_font_add"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginEnd="@dimen/dp_10"
android:layout_centerVertical="true"
android:src="@mipmap/setting_font_add"/>
</RelativeLayout>
</com.remax.visualnovel.widget.uitoken.view.UITokenRelativeLayout>
</com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout>

View File

@ -1530,23 +1530,29 @@
<attr name="dividerColor" format="color"/>
</declare-styleable>
<declare-styleable name="LevelSeekBar">
<declare-styleable name="CustomLevelSeekBar">
<!-- 档位数量 -->
<attr name="totalLevels" format="integer" />
<!-- 当前档位 -->
<attr name="currentLevel" format="integer" />
<!-- 轨道颜色 -->
<attr name="trackColor" format="color" />
<attr name="activeTrackColor" format="color" />
<!-- 拇指颜色 -->
<!-- 滑块颜色 -->
<attr name="thumbColor" format="color" />
<!-- 档位标记颜色 -->
<attr name="levelMarkerColor" format="color" />
<attr name="activeLevelMarkerColor" format="color" />
<attr name="thumbBorderColor" format="color" />
<!-- 节点颜色 -->
<attr name="nodeColor" format="color" />
<attr name="activeNodeColor" format="color" />
<!-- 尺寸 -->
<attr name="trackHeight" format="dimension" />
<attr name="trackEndRadius" format="dimension" />
<attr name="thumbRadius" format="dimension" />
<attr name="levelMarkerRadius" format="dimension" />
<attr name="nodeRadius" format="dimension" />
</declare-styleable>

View File

@ -194,6 +194,8 @@
<color name="grayf6">#fff6f6f6</color>
<color name="gray28">#ff282828</color>
<color name="red_ff3b30">#ffff3b30</color>
<!-- chat settings -->
<color name="glo_color_chat_setting_item_bg">#F6F6F6</color>
<color name="glo_color_switchview_normal">#FFFFFF</color>
@ -211,6 +213,10 @@
<color name="male_bg">#ffc7dbff</color>
<color name="chat_call_voice_text_color">#66eaeeff</color>
<!-- Font seekbar -->
<color name="seekbar_color">#ffa4a8b7</color>