在Android应用开发中,用户协议和隐私政策的展示是每个应用都绕不开的功能点。传统的实现方式往往存在以下痛点:
这次要实现的复合控件将TextView、Span和CheckBox三者有机结合,解决以下核心需求:
采用组合式自定义View方案,核心包含三个层次:
code复制+-----------------------+
| AgreementCheckView | <-- 对外暴露的复合控件
+-----------------------+
| - TextView | <-- 处理富文本展示与点击
| - CheckBox/RadioButton| <-- 选择状态控制
+-----------------------+
| - AgreementSpan | <-- 自定义Span处理点击事件
| - TextMovementMethod | <-- 替换默认的LinkMovementMethod
+-----------------------+
富文本处理:
点击事件处理:
状态管理:
kotlin复制fun buildAgreementText(): SpannableStringBuilder {
val builder = SpannableStringBuilder()
val prefix = "我已阅读并同意"
val agreement = "《用户协议》"
val and = "和"
val privacy = "《隐私政策》"
builder.append(prefix)
.append(agreement)
.append(and)
.append(privacy)
// 设置协议点击范围
val agreementStart = prefix.length
val agreementEnd = agreementStart + agreement.length
builder.setSpan(
AgreementSpan(CLICK_TYPE_AGREEMENT),
agreementStart,
agreementEnd,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
// 设置隐私政策点击范围
val privacyStart = agreementEnd + and.length
val privacyEnd = privacyStart + privacy.length
builder.setSpan(
AgreementSpan(CLICK_TYPE_PRIVACY),
privacyStart,
privacyEnd,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
return builder
}
重写自定义Span的点击处理:
kotlin复制class AgreementSpan(private val clickType: Int) : ClickableSpan() {
override fun onClick(widget: View) {
if (widget is TextView) {
(widget.parent as? AgreementCheckView)?.handleSpanClick(clickType)
}
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false // 取消默认的下划线样式
}
}
kotlin复制private val checkChangeListener = CompoundButton.OnCheckedChangeListener { button, isChecked ->
if (button.isPressed) { // 确保是用户操作触发
currentState = isChecked
agreementListener?.onAgreementChanged(isChecked)
}
}
fun setChecked(checked: Boolean) {
checkBox?.apply {
if (isChecked != checked) {
isChecked = checked // 会自动触发监听器
}
}
}
通过RadioGroup实现单选逻辑:
xml复制<RadioGroup
android:id="@+id/agreementGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.example.AgreementCheckView
android:id="@+id/agreement1"
style="@style/AgreementCheckStyle"
app:agreementText="@array/agreement_text_1"/>
<com.example.AgreementCheckView
android:id="@+id/agreement2"
style="@style/AgreementCheckStyle"
app:agreementText="@array/agreement_text_2"/>
</RadioGroup>
支持运行时更新协议内容:
kotlin复制fun updateAgreementText(
text: CharSequence,
clickableParts: List<Pair<IntRange, Int>> // 可点击范围及类型
) {
val spannable = SpannableStringBuilder(text)
clickableParts.forEach { (range, type) ->
spannable.setSpan(
AgreementSpan(type),
range.first,
range.last,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
agreementTextView.text = spannable
}
Span复用:
kotlin复制private val spanPool = SparseArray<AgreementSpan>()
fun getSpan(type: Int): AgreementSpan {
return spanPool.get(type) ?: AgreementSpan(type).also {
spanPool.put(type, it)
}
}
避免频繁文本测量:
kotlin复制override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (textWidth == 0) {
// 首次测量文本宽度
textWidth = agreementTextView.paint.measureText(fullText)
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
kotlin复制override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 检查是否点击在Span区域
val span = findClickedSpan(event.x, event.y)
if (span != null) {
isSpanClick = true
return true
}
}
MotionEvent.ACTION_UP -> {
if (isSpanClick) {
handleSpanClick()
isSpanClick = false
return true
}
}
}
return super.onTouchEvent(event)
}
典型布局实现:
xml复制<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.example.AgreementCheckView
android:id="@+id/agreementCheck"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:agreementText="@array/register_agreement"
app:checkBoxDrawable="@drawable/selector_checkbox"/>
<Button
android:id="@+id/registerButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="24dp"
android:enabled="false"
android:text="立即注册"/>
</LinearLayout>
状态绑定逻辑:
kotlin复制agreementCheck.setAgreementListener { isChecked ->
registerButton.isEnabled = isChecked
}
kotlin复制val agreements = listOf(agreement1, agreement2, agreement3)
val submitButton: Button = findViewById(R.id.submitButton)
fun checkAllAgreements(): Boolean {
return agreements.all { it.isChecked }
}
agreements.forEach { view ->
view.setAgreementListener { updateSubmitState() }
}
private fun updateSubmitState() {
submitButton.isEnabled = checkAllAgreements()
}
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 点击无响应 | MovementMethod未设置 | 调用setMovementMethod |
| 文字显示不全 | 行间距设置过大 | 调整lineSpacingExtra |
| 点击区域偏移 | 包含padding未处理 | 重写点击坐标计算 |
| 状态不同步 | 监听器被覆盖 | 使用addOnCheckedChangeListener |
文本测量卡顿:
内存泄漏排查:
kotlin复制override fun onDetachedFromWindow() {
agreementListener = null
checkBox.setOnCheckedChangeListener(null)
super.onDetachedFromWindow()
}
kotlin复制fun showAgreementWithAnimation() {
val animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 300
addUpdateListener { anim ->
val value = anim.animatedValue as Float
agreementTextView.alpha = value
checkBox.scaleX = 0.8f + 0.2f * value
checkBox.scaleY = 0.8f + 0.2f * value
}
}
animator.start()
}
资源文件配置:
xml复制<string-array name="agreement_text">
<item>I have read and agree to the</item>
<item>User Agreement</item>
<item>and</item>
<item>Privacy Policy</item>
</string-array>
动态构建逻辑:
kotlin复制fun buildText(resources: Resources): SpannableStringBuilder {
val parts = resources.getStringArray(R.array.agreement_text)
// ...构建逻辑与之前类似
}
kotlin复制@Test
fun testAgreementClick() {
val view = AgreementCheckView(context)
view.setAgreementText("Test [click] me", listOf(5..10 to 1))
val touchEvent = MotionEvent.obtain(
0, 0, MotionEvent.ACTION_DOWN,
view.width / 2f, view.height / 2f, 0
)
view.dispatchTouchEvent(touchEvent)
// 验证点击回调触发
verify(mockListener).onAgreementClicked(1)
}
kotlin复制@RunWith(AndroidJUnit4::class)
class AgreementCheckTest {
@Test
fun checkBoxToggleTest() {
val scenario = launchFragment<TestFragment>()
onView(withId(R.id.agreementCheck))
.perform(click()) // 点击CheckBox
.check(matches(isChecked()))
onView(withText("用户协议"))
.perform(click()) // 点击协议文本
.check(doesNotExist()) // 验证跳转成功
}
}
样式统一方案:
xml复制<style name="AgreementCheckStyle">
<item name="android:textSize">14sp</item>
<item name="android:lineSpacingExtra">4dp</item>
<item name="spanColor">@color/colorPrimary</item>
<item name="checkBoxTint">@color/selector_checkbox</item>
</style>
大型项目集成建议:
可访问性优化:
kotlin复制agreementTextView.contentDescription = buildAccessibilityDescription()
checkBox.contentDescription = "同意协议选择框"
通过这种复合控件的实现,我们不仅解决了协议展示的交互问题,还创建了一个可复用的组件库基础元素。在实际项目中,这类控件通常会进一步抽象为Design System的一部分,成为跨团队共享的UI资产。