管灌系统巡查员智能手机App
1.添加数据更新功能,确保本地没有数据时再获取基础数据
2.地图界面上添加滚动功能的控件(部分功能)
11个文件已修改
2个文件已添加
583 ■■■■■ 已修改文件
app/build.gradle 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/dayu/pipirrapp/dao/CenterPointDao.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/dayu/pipirrapp/dao/DivideDao.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/dayu/pipirrapp/dao/MarkerDao.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/dayu/pipirrapp/fragment/MapFragment.java 165 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/dayu/pipirrapp/fragment/MyFragment.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/dayu/pipirrapp/net/Constants.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/dayu/pipirrapp/utils/CommonKeyName.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/res/layout/fragment_map.xml 19 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/res/layout/fragment_my.xml 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
expand_button/src/main/java/com/example/expand_button/ExpandButton.kt 307 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
expand_button/src/main/res/drawable/ic_triangle.xml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
expand_button/src/main/res/values/attrs.xml 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/build.gradle
@@ -89,7 +89,8 @@
}
dependencies {
    implementation project(':library')
    implementation project(':expand_button')
    implementation(project(':library'))
    implementation project(':date_time_picker')
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.8.0'
app/src/main/java/com/dayu/pipirrapp/dao/CenterPointDao.java
@@ -8,7 +8,9 @@
import androidx.room.Update;
import com.dayu.pipirrapp.bean.db.CenterPointBean;
import com.dayu.pipirrapp.bean.db.TagBean;
import io.reactivex.rxjava3.core.Maybe;
/**
 * author: zuo
@@ -18,8 +20,11 @@
 */
@Dao
public interface CenterPointDao {
    @Query("SELECT * FROM CenterPointBean LIMIT 1")
    Maybe<CenterPointBean> findFirst();
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insert(CenterPointBean adminData);
    void insert(CenterPointBean centerPointBean);
    @Update
    void update(CenterPointBean adminData);
@@ -27,6 +32,4 @@
    @Delete
    void delete(CenterPointBean adminData);
    @Query("select  * from CenterPointBean limit 1")
    CenterPointBean findFirst();
}
app/src/main/java/com/dayu/pipirrapp/dao/DivideDao.java
@@ -14,6 +14,7 @@
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.Maybe;
/**
 * DivideDao -
@@ -50,4 +51,7 @@
    @Query("select  * from DivideBean")
    Single<List<DivideBean>> findAllToSingle();
    @Query("SELECT * FROM divide")
    Maybe<List<DivideBean>> getAll();  // 改为返回Maybe<List<DivideBean>>
}
app/src/main/java/com/dayu/pipirrapp/dao/MarkerDao.java
@@ -13,6 +13,7 @@
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.Maybe;
/**
 * author: zuo
@@ -48,4 +49,8 @@
    @Query("select  * from MarkerBean")
    Single<List<MarkerBean>> findAllToSingle();
    @Query("SELECT * FROM MarkerBean")
    Maybe<List<MarkerBean>> getAll();
}
app/src/main/java/com/dayu/pipirrapp/fragment/MapFragment.java
@@ -78,6 +78,7 @@
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
@@ -116,6 +117,9 @@
    MarkerBean mMarkerBean;
    // 添加CompositeDisposable来管理所有订阅
    private CompositeDisposable compositeDisposable = new CompositeDisposable();
    @Override
    public void onAttach(@NonNull Context context) {
        super.onAttach(context);
@@ -130,6 +134,13 @@
        setRetainInstance(true);
        Log.i(TAG, "onCreate");
        mInspectionState = SharedPreferencesHelper.getInstance(this.getContext()).get(CommonKeyName.inspectionState, 0);
        // 添加刷新数据的监听
        LiveEventBus.get(CommonKeyName.refreshData).observe(this, o -> {
            getCenterPoint();
            getMarkerData();
            getDivideList();
        });
    }
    @Override
@@ -142,7 +153,6 @@
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        binding = FragmentMapBinding.inflate(inflater, container, false);
        mapFragmenObserver.setmWebView(binding.webView);
        Log.i("MapFragment", "onCreateView");
@@ -151,15 +161,87 @@
        MyWebViewInterface myWebViewInterface = new MyWebViewInterface(MapFragment.this);
        mWebView.addJavascriptInterface(myWebViewInterface, "Android");
        mWebView.loadUrl("file:///android_asset/index.html");
        getCenterPoint();
        // 异步加载本地数据
        loadLocalData();
        initView();
        initWeb();
        getMarkerData();
        getDivideList();
        chageInspecState(mInspectionState);
        return binding.getRoot();
    }
    /**
     * 异步加载本地数据
     */
    private void loadLocalData() {
        // 异步加载中心点数据
        compositeDisposable.add(
            DaoSingleton.getAsynchInstance(this.getContext()).centerPointDao().findFirst()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(result -> {
                    centerPointBean = result;
                    if (centerPointBean == null) {
                        getCenterPoint();
                    } else {
                        jumpCenterPoint();
                    }
                }, throwable -> {
                    Log.e(TAG, "Load centerPoint error: " + throwable);
                    getCenterPoint();
                }, () -> {
                    // 当Maybe为空时调用
                    getCenterPoint();
                })
        );
        // 异步加载取水口数据
        compositeDisposable.add(
            DaoSingleton.getAsynchInstance(this.getContext()).markerDao().getAll()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(markers -> {
                    if (markers == null || markers.isEmpty()) {
                        getMarkerData();
                    } else {
                        for (MarkerBean marker : markers) {
                            markerBeanSet.put(marker.getId(), marker);
                            setMapMarker(marker);
                        }
                    }
                }, throwable -> {
                    Log.e(TAG, "Load markers error: " + throwable.getMessage());
                    getMarkerData();
                }, () -> {
                    // 当Maybe为空时调用
                    getMarkerData();
                })
        );
        // 异步加载分水房数据
        compositeDisposable.add(
            DaoSingleton.getAsynchInstance(this.getContext()).divideDao().getAll()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(divides -> {
                    if (divides == null || divides.isEmpty()) {
                        getDivideList();
                    } else {
                        for (DivideBean divide : divides) {
                            divideBeanMap.put(divide.getId(), divide);
                            setMapDivide(divide);
                        }
                    }
                }, throwable -> {
                    Log.e(TAG, "Load divides error: " + throwable.getMessage());
                    getDivideList();
                }, () -> {
                    // 当Maybe为空时调用
                    getDivideList();
                })
        );
    }
    private void initWeb() {
@@ -232,9 +314,10 @@
     * web加载完初始化本地数据
     */
    public void webFinishInitLocalData() {
        //跳转中心点
        centerPointBean = DaoSingleton.getInstance(MapFragment.this.getContext()).centerPointDao().findFirst();
        jumpCenterPoint();
        // 只在本地没有数据时获取中心点
        if (centerPointBean != null) {
            jumpCenterPoint();
        }
        //添加因webview没有加载完成导致没有添加的地图标注
        if (!webNoFinishMarkerData.isEmpty()) {
            for (MarkerBean bean : webNoFinishMarkerData) {
@@ -285,18 +368,22 @@
                                })
                                .collect(Collectors.toList());
                        DaoSingleton.getInstance(MapFragment.this.getContext()).markerDao().deleteAll();
                        // 使用 RxJava 异步插入数据
                        DaoSingleton.getAsynchInstance(MapFragment.this.getContext()).markerDao().insertAll(markerBeans)
                                .subscribeOn(Schedulers.io()) // 在 IO 线程上执行
                                .observeOn(AndroidSchedulers.mainThread()) // 在主线程上观察
                                .subscribe(() -> {
                                    // 插入成功
                                    Log.i("mWebView", "数据插入成功");
                                }, throwable -> {
                                    // 插入失败
                                    Log.e("mWebView", "数据插入失败: " + throwable.getMessage());
                                });
                        // 使用 CompositeDisposable 管理数据库插入操作
                        compositeDisposable.add(
                            DaoSingleton.getAsynchInstance(MapFragment.this.getContext()).markerDao().insertAll(markerBeans)
                                .subscribeOn(Schedulers.io())
                                .observeOn(AndroidSchedulers.mainThread())
                                .subscribe(
                                    () -> {
                                        // 插入成功
                                        Log.i("mWebView", "数据插入成功");
                                    },
                                    throwable -> {
                                        // 插入失败
                                        Log.e("mWebView", "数据插入失败: " + throwable.getMessage());
                                    }
                                )
                        );
                    }
                } else {
@@ -629,7 +716,7 @@
    }
    /**
     * 添加取水口标注
     * 添加管网标注
     */
    public void setMapDivide(PipeNetworkBean pipeNetworkBean) {
//        if (divide != null) {
@@ -917,27 +1004,30 @@
                try {
                    if (t.isSuccess()) {
                        if (t.getContent().getObj() != null && !t.getContent().getObj().isEmpty()) {
                            List<DivideBean> divideBeans = new ArrayList<>();
                            for (DivideResult divideResult : t.getContent().getObj()) {
                                DivideBean divideBean = getDivideBean(divideResult);
                                setMapDivide(divideBean);
                                divideBeans.add(divideBean);
                            }
                            // 使用 RxJava 异步插入数据
                            DaoSingleton.getAsynchInstance(MapFragment.this.getContext()).divideDao().insertAll(divideBeans)
                                    .subscribeOn(Schedulers.io()) // 在 IO 线程上执行
                                    .observeOn(AndroidSchedulers.mainThread()) // 在主线程上观察
                                    .subscribe(() -> {
                                        // 插入成功
                                        Log.i("mWebView", "数据插入成功");
                                    }, throwable -> {
                                        // 插入失败
                                        Log.e("mWebView", "数据插入失败: " + throwable.getMessage());
                                    });
                            // 使用 CompositeDisposable 管理数据库插入操作
                            compositeDisposable.add(
                                DaoSingleton.getAsynchInstance(MapFragment.this.getContext()).divideDao().insertAll(divideBeans)
                                    .subscribeOn(Schedulers.io())
                                    .observeOn(AndroidSchedulers.mainThread())
                                    .subscribe(
                                        () -> {
                                            // 插入成功
                                            Log.i("mWebView", "数据插入成功");
                                        },
                                        throwable -> {
                                            // 插入失败
                                            Log.e("mWebView", "数据插入失败: " + throwable.getMessage());
                                        }
                                    )
                            );
                        }
                    } else {
                        ToastUtil.showToastLong(MapFragment.this.getContext(), t.getMsg());
                    }
@@ -946,7 +1036,6 @@
                    CrashReport.postCatchedException(e);
                }
            }
        });
    }
@@ -1031,6 +1120,10 @@
    @Override
    public void onDestroy() {
        super.onDestroy();
        // 清理所有订阅
        if (compositeDisposable != null && !compositeDisposable.isDisposed()) {
            compositeDisposable.dispose();
        }
        LiveEventBus.get(CommonKeyName.locationData).removeObserver(locationObserver);
        if (mWebView != null) {
            mWebView.destroy();
app/src/main/java/com/dayu/pipirrapp/fragment/MyFragment.java
@@ -24,6 +24,7 @@
import com.dayu.pipirrapp.utils.ToastUtil;
import com.dayu.pipirrapp.view.ConfirmDialog;
import com.dayu.pipirrapp.view.TipUtil;
import com.jeremyliao.liveeventbus.LiveEventBus;
/**
 * author: zuo
@@ -94,6 +95,10 @@
            Intent intent = new Intent(MyFragment.this.getContext(), IssueListActivity.class);
            MyFragment.this.getActivity().startActivity(intent);
        });
        binding.refreshDataTV.setOnClickListener(v->{
            // 发送刷新事件通知MapFragment刷新数据
            LiveEventBus.get(CommonKeyName.refreshData).post(true);
        });
    }
    private void initData() {
app/src/main/java/com/dayu/pipirrapp/net/Constants.java
@@ -7,8 +7,8 @@
 */
public class Constants {
    //    public static final String BASE_URL = "http://192.168.10.52:8088";
//    public static final String BASE_URL = "https://no253541tf71.vicp.fun";
    public static final String BASE_URL = "http://192.168.40.166:54321";
    public static final String BASE_URL = "https://no253541tf71.vicp.fun";
//    public static final String BASE_URL = "http://192.168.40.166:54321";
    //public static final String BASE_URL = "http://fve2iz.natappfree.cc";
    public static final String BASE_UPLOAD_FILE_URL = BASE_URL;
    /**
app/src/main/java/com/dayu/pipirrapp/utils/CommonKeyName.java
@@ -29,4 +29,8 @@
    //创建通知
    public final static String CreateNotification="CreateNotification";
    /**
     * 刷新数据事件
     */
    public static final String refreshData = "refreshData";
}
app/src/main/res/layout/fragment_map.xml
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <!--    <com.github.lzyzsd.jsbridge.BridgeWebView-->
    <!--        android:id="@+id/webView"-->
@@ -105,7 +106,21 @@
        android:textColor="@color/white"
        android:textSize="18sp" />
    <com.example.expand_button.ExpandButton
        android:id="@+id/expandButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="10dp"
        android:background="@drawable/ic_green_bg"
        android:layout_alignParentRight="true"
        android:layout_marginTop="180dp"
        android:textSize="18sp"
        android:layout_marginRight="15dp"
        android:textColor="@color/white"
        app:letterSpacing="10dp"
        app:expandedText="点击展开"
        app:collapsedText="点"
        app:animDuration="300" />
    <RelativeLayout
        android:id="@+id/pointRL"
        android:layout_width="match_parent"
app/src/main/res/layout/fragment_my.xml
@@ -131,12 +131,37 @@
            android:layout_marginRight="15dp"
            android:src="@drawable/ic_right" />
    </RelativeLayout>
    <RelativeLayout
        android:id="@+id/refreshDataRL"
        android:layout_width="match_parent"
        android:layout_height="@dimen/item_height"
        android:layout_below="@+id/passwordRL"
        android:layout_marginTop="1dp">
        <TextView
            android:id="@+id/refreshDataTV"
            android:layout_width="match_parent"
            android:layout_height="@dimen/item_height"
            android:background="@color/white"
            android:gravity="center_vertical"
            android:paddingLeft="30dp"
            android:text="更新本地数据"
            android:textColor="@color/black"
            android:textSize="@dimen/my_item_text_size" />
        <ImageView
            android:layout_width="25dp"
            android:layout_height="25dp"
            android:layout_alignParentRight="true"
            android:layout_centerVertical="true"
            android:layout_marginRight="15dp"
            android:src="@drawable/ic_right" />
    </RelativeLayout>
    <RelativeLayout
        android:id="@+id/cleanDataRL"
        android:layout_width="match_parent"
        android:layout_height="@dimen/item_height"
        android:layout_below="@+id/passwordRL"
        android:layout_below="@+id/refreshDataRL"
        android:layout_marginTop="1dp">
        <TextView
expand_button/src/main/java/com/example/expand_button/ExpandButton.kt
@@ -1,14 +1,319 @@
package com.example.expand_button
class ExpandButton {
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ClickableSpan
import android.util.AttributeSet
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat
/**
 * 可展开的按钮控件
 * 初始状态显示单个字符,点击后展开显示完整文字
 * 展开后的每个字符都可以单独点击
 */
class ExpandButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
    // 展开时显示的完整文字
    private var expandedText: String = ""
    // 收起时显示的单个字符
    private var collapsedText: String = ""
    // 当前是否处于展开状态
    private var isExpanded: Boolean = false
    // 动画持续时间,默认300毫秒
    private var animationDuration: Long = 300
    // 字符点击事件监听器
    private var onCharClickListener: ((Char, Int) -> Unit)? = null
    // 字间距,默认为2dp
    private var customLetterSpacing: Float = context.resources.displayMetrics.density * 2
    // 三角形图标
    private val triangleDrawable: Drawable = ContextCompat.getDrawable(
        context,
        R.drawable.ic_triangle
    )!!.mutate()
    // 图标旋转角度
    private var triangleRotation = 0f
    // 三角形图标与文字的间距,默认为8dp
    private var triangleMargin: Float = 3 * context.resources.displayMetrics.density
    init {
        // 读取自定义属性
        context.theme.obtainStyledAttributes(
            attrs,
            R.styleable.ExpandButton,
            defStyleAttr,
            0
        ).apply {
            try {
                customLetterSpacing = getDimension(R.styleable.ExpandButton_letterSpacing, customLetterSpacing)
                expandedText = getString(R.styleable.ExpandButton_expandedText) ?: ""
                collapsedText = getString(R.styleable.ExpandButton_collapsedText) ?: ""
                animationDuration = getInteger(R.styleable.ExpandButton_animDuration, 300).toLong()
                triangleMargin = getDimension(R.styleable.ExpandButton_triangleMargin, triangleMargin)
            } finally {
                recycle()
            }
        }
        // 设置初始文本和宽度
        if (collapsedText.isNotEmpty()) {
            text = collapsedText
            post {
                // 确保初始宽度为收起状态的宽度
                layoutParams = layoutParams.apply {
                    width = paint.measureText(collapsedText).toInt() + paddingLeft + paddingRight
                }
            }
        }
        // 设置文本可点击,仅在收起状态时响应点击展开
        setOnClickListener {
            if (!isExpanded) {
                toggleExpand()
            }
        }
        // 添加触摸事件处理
        setOnTouchListener { _, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    // 检查点击是否在三角形图标区域内
                    if (isClickOnTriangle(event.x)) {
                        toggleExpand()
                        return@setOnTouchListener true
                    }
                }
            }
            false
        }
        // 设置左边距,为图标留出空间
        compoundDrawablePadding = triangleMargin.toInt()
        setPadding(
            (16 * context.resources.displayMetrics.density + triangleMargin).toInt(), // 左边距增加,为图标留空间
            paddingTop,
            paddingRight,
            paddingBottom
        )
        // 设置单行显示,防止高度变化
        maxLines = 1
        isSingleLine = true
        // 设置文字垂直居中
        gravity = Gravity.CENTER_VERTICAL
    }
    override fun onDraw(canvas: Canvas) {
        // 保存画布状态
        canvas.save()
        // 计算图标位置
        val iconSize = triangleDrawable.intrinsicWidth
        val iconLeft = paddingLeft - iconSize - compoundDrawablePadding
        val iconTop = (height - iconSize) / 2
        // 设置图标边界
        triangleDrawable.setBounds(
            iconLeft,
            iconTop,
            iconLeft + iconSize,
            iconTop + iconSize
        )
        // 旋转画布
        canvas.rotate(
            triangleRotation,
            (iconLeft + iconSize / 2).toFloat(),
            (iconTop + iconSize / 2).toFloat()
        )
        // 绘制图标
        triangleDrawable.draw(canvas)
        // 恢复画布状态
        canvas.restore()
        super.onDraw(canvas)
    }
    /**
     * 设置字间距
     * @param spacing 间距值(像素)
     */
    fun setCustomLetterSpacing(spacing: Float) {
        this.customLetterSpacing = spacing
        if (isExpanded) {
            setExpandedClickableText()
        }
    }
    /**
     * 设置展开和收起时显示的文字
     * @param expanded 展开时显示的完整文字
     * @param collapsed 收起时显示的单个字符
     */
    fun setExpandText(expanded: String, collapsed: String) {
        this.expandedText = expanded
        this.collapsedText = collapsed
        text = collapsedText
    }
    /**
     * 设置单个字符点击监听器
     * @param listener 点击回调,参数为被点击的字符和位置
     *                char: 被点击的字符
     *                position: 字符在文本中的位置(从0开始)
     */
    fun setOnCharClickListener(listener: (char: Char, position: Int) -> Unit) {
        this.onCharClickListener = listener
    }
    /**
     * 设置展开/收起动画的持续时间
     * @param duration 动画持续时间(毫秒)
     */
    fun setAnimationDuration(duration: Long) {
        this.animationDuration = duration
    }
    /**
     * 切换展开/收起状态
     * 使用ValueAnimator实现宽度动画和图标旋转
     */
    private fun toggleExpand() {
        isExpanded = !isExpanded
        // 计算收起和展开状态的宽度
        val collapsedWidth = paint.measureText(collapsedText).toInt() + paddingLeft + paddingRight
        val expandedWidth = calculateExpandedWidth()
        // 创建宽度动画
        ValueAnimator.ofInt(
            if (isExpanded) collapsedWidth else expandedWidth,
            if (isExpanded) expandedWidth else collapsedWidth
        ).apply {
            duration = animationDuration
            addUpdateListener { animator ->
                layoutParams = layoutParams.apply {
                    width = animator.animatedValue as Int
                }
                requestLayout()
            }
            start()
        }
        // 创建图标旋转动画
        ValueAnimator.ofFloat(
            if (isExpanded) 0f else 180f,
            if (isExpanded) 180f else 0f
        ).apply {
            duration = animationDuration
            addUpdateListener { animator ->
                triangleRotation = animator.animatedValue as Float
                invalidate() // 重绘以更新图标旋转
            }
            start()
        }
        // 更新文本
        if (isExpanded) {
            setExpandedClickableText()
        } else {
            text = collapsedText
        }
    }
    /**
     * 计算展开后的总宽度
     */
    private fun calculateExpandedWidth(): Int {
        val spaceWidth = paint.measureText(" ") * (customLetterSpacing / 10)
        // 计算所有字符的总宽度
        val textWidth = expandedText.fold(0f) { acc, char ->
            acc + paint.measureText(char.toString())
        }
        // 计算间距的总宽度(字符数量减1个间距)
        val spacesWidth = spaceWidth * (expandedText.length - 1)
        return (textWidth + spacesWidth).toInt() + paddingLeft + paddingRight
    }
    /**
     * 设置展开后的可点击文本
     */
    private fun setExpandedClickableText() {
        val builder = SpannableStringBuilder()
        expandedText.forEachIndexed { index, char ->
            // 添加字符
            builder.append(char)
            // 为字符设置点击事件
            val clickableSpan = object : ClickableSpan() {
                override fun onClick(view: View) {
                    onCharClickListener?.invoke(char, index)
                }
                override fun updateDrawState(ds: android.text.TextPaint) {
                    super.updateDrawState(ds)
                    // 移除下划线
                    ds.isUnderlineText = false
                    // 保持原始文字颜色
                    ds.color = currentTextColor
                }
            }
            builder.setSpan(
                clickableSpan,
                builder.length - 1,
                builder.length,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
            // 只在非最后一个字符后添加空格作为间距
            if (index < expandedText.length - 1) {
                builder.append(" ".repeat((customLetterSpacing / 10).toInt()))
            }
        }
        text = builder
        // 启用LinkMovementMethod以响应ClickableSpan的点击事件
        movementMethod = android.text.method.LinkMovementMethod.getInstance()
    }
    /**
     * 判断点击是否在三角形图标区域内
     */
    private fun isClickOnTriangle(x: Float): Boolean {
        val iconSize = triangleDrawable.intrinsicWidth
        val iconLeft = paddingLeft - iconSize - compoundDrawablePadding
        return x <= paddingLeft && x >= iconLeft
    }
    /**
     * 设置三角形图标与文字的间距
     * @param margin 间距值(像素)
     */
    fun setTriangleMargin(margin: Float) {
        this.triangleMargin = margin
        compoundDrawablePadding = margin.toInt()
        setPadding(
            (16 * context.resources.displayMetrics.density + margin).toInt(),
            paddingTop,
            paddingRight,
            paddingBottom
        )
    }
}
expand_button/src/main/res/drawable/ic_triangle.xml
New file
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="16dp"
    android:height="16dp"
    android:viewportWidth="48"
    android:viewportHeight="48">
    <path
        android:pathData="M19,12L31,24L19,36"
        android:strokeLineJoin="round"
        android:strokeWidth="4"
        android:fillColor="#00000000"
        android:strokeColor="#757575"
        android:strokeLineCap="round"/>
</vector>
expand_button/src/main/res/values/attrs.xml
New file
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ExpandButton">
        <!-- 字间距 -->
        <attr name="letterSpacing" format="dimension"/>
        <!-- 展开时显示的文字 -->
        <attr name="expandedText" format="string"/>
        <!-- 收起时显示的文字 -->
        <attr name="collapsedText" format="string"/>
        <!-- 动画时长 -->
        <attr name="animDuration" format="integer"/>
        <!-- 三角形图标与文字的间距 -->
        <attr name="triangleMargin" format="dimension"/>
    </declare-styleable>
</resources>