在Android应用开发中,处理文本交互是每个开发者都会遇到的场景。特别是登录注册页面,通常需要实现三个典型功能:协议勾选框与可点击文本的分离处理、富文本样式控制以及单选按钮组。这些看似简单的需求,实际开发中却藏着不少技术细节。
登录页面最常见的交互模式就是"同意用户协议"勾选框。很多初级开发者会直接使用CheckBox包含协议文本,但这会导致两个问题:一是勾选框的点击区域过大,二是无法单独控制协议文本的点击效果。更合理的做法是将这两个功能解耦:
xml复制<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<CheckBox
android:id="@+id/cb_agreement"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tv_agreement"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="《用户协议》和《隐私政策》"/>
</LinearLayout>
这种分离设计有几个优势:
要让TextView中的部分文字可点击,核心是使用ClickableSpan和SpannableString的组合。以下是具体实现步骤:
java复制// 获取原始文本
String agreementText = tvAgreement.getText().toString();
// 创建SpannableString包装文本
SpannableString spannable = new SpannableString(agreementText);
// 定义用户协议点击效果
ClickableSpan userAgreementSpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
// 跳转到用户协议页面
startActivity(new Intent(this, UserAgreementActivity.class));
// 点击后清除选中状态
widget.postDelayed(() -> {
if (tvAgreement.getText() instanceof Spannable) {
Selection.removeSelection((Spannable) tvAgreement.getText());
}
}, 1000);
}
};
// 定义隐私政策点击效果
ClickableSpan privacySpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
// 跳转到隐私政策页面
startActivity(new Intent(this, PrivacyPolicyActivity.class));
}
};
// 设置用户协议文本的点击范围
int userAgreementStart = agreementText.indexOf("《用户协议》");
int userAgreementEnd = userAgreementStart + "《用户协议》".length();
spannable.setSpan(userAgreementSpan, userAgreementStart, userAgreementEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 设置隐私政策文本的点击范围
int privacyStart = agreementText.indexOf("《隐私政策》");
int privacyEnd = privacyStart + "《隐私政策》".length();
spannable.setSpan(privacySpan, privacyStart, privacyEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 应用到TextView
tvAgreement.setText(spannable);
tvAgreement.setMovementMethod(LinkMovementMethod.getInstance());
这里有几个关键点需要注意:
在登录按钮的点击事件中,我们需要检查协议是否勾选:
java复制btnLogin.setOnClickListener(v -> {
// 检查协议是否同意
if (!cbAgreement.isChecked()) {
Toast.makeText(this, "请先同意用户协议", Toast.LENGTH_SHORT).show();
return;
}
// 其他登录逻辑...
});
这种实现方式既保持了良好的用户体验,又使代码结构清晰可维护。
Android的Span系统允许我们对文本的特定部分应用样式或行为,而无需拆分字符串或使用多个TextView。Span主要分为三类:
外观Span(CharacterStyle):影响文本绘制但不影响布局
度量Span(MetricAffectingSpan):影响文本测量和布局
段落Span(ParagraphStyle):影响整个段落样式
Android提供了三种主要的Span容器:
| 类型 | 文本可变 | Span可变 | 适用场景 |
|---|---|---|---|
| SpannedString | 否 | 否 | 完全静态的文本 |
| SpannableString | 否 | 是 | 文本固定但需要修改样式 |
| SpannableStringBuilder | 是 | 是 | 需要动态修改文本和样式 |
实际开发中最常用的是SpannableStringBuilder,因为它提供了最大的灵活性:
java复制SpannableStringBuilder builder = new SpannableStringBuilder();
// 添加普通文本
builder.append("欢迎使用");
// 添加带样式的文本
SpannableString styled = new SpannableString("超级应用");
styled.setSpan(new StyleSpan(Typeface.BOLD), 0, styled.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append(styled);
// 继续添加文本
builder.append("!点击");
// 添加可点击文本
ClickableSpan clickableSpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
// 处理点击
}
};
builder.append("这里");
builder.setSpan(clickableSpan, builder.length()-2, builder.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(builder);
textView.setMovementMethod(LinkMovementMethod.getInstance());
setSpan方法有四个参数:what(span对象), start(开始位置), end(结束位置), flags(标志位)。其中区间和标志位的使用需要特别注意:
区间规则:左闭右开[start, end)
常用Flag:
大多数情况下使用SPAN_EXCLUSIVE_EXCLUSIVE即可,只有在需要让新插入的文本自动继承span时才考虑其他flag。
下面是一个综合应用多种Span的示例:
java复制// 创建文本构建器
SpannableStringBuilder builder = new SpannableStringBuilder();
// 添加标题
String title = "重要通知\n";
SpannableString titleSpan = new SpannableString(title);
titleSpan.setSpan(new RelativeSizeSpan(1.2f), 0, title.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
titleSpan.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
titleSpan.setSpan(new ForegroundColorSpan(Color.RED), 0, title.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append(titleSpan);
// 添加正文
String content = "系统将于今晚24:00进行维护升级,预计耗时2小时。";
builder.append(content);
// 设置关键信息样式
int start = content.indexOf("24:00");
int end = start + "24:00".length();
builder.setSpan(new ForegroundColorSpan(Color.BLUE),
start + title.length(), end + title.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 设置段落样式
builder.setSpan(new QuoteSpan(Color.GRAY), 0, builder.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(builder);
这个示例展示了如何:
RadioGroup配合RadioButton可以实现单选功能,确保同一时间只有一个选项被选中:
xml复制<RadioGroup
android:id="@+id/radioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/option1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="选项1"/>
<RadioButton
android:id="@+id/option2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="选项2"/>
<RadioButton
android:id="@+id/option3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="选项3"/>
</RadioGroup>
在代码中获取选中的选项:
java复制radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.option1) {
// 选项1被选中
} else if (checkedId == R.id.option2) {
// 选项2被选中
}
});
// 或者通过按钮获取当前选择
int selectedId = radioGroup.getCheckedRadioButtonId();
RadioButton selectedButton = findViewById(selectedId);
String selectedText = selectedButton.getText().toString();
java复制RadioGroup radioGroup = findViewById(R.id.radioGroup);
radioGroup.removeAllViews(); // 清除现有选项
List<String> options = Arrays.asList("北京", "上海", "广州", "深圳");
for (int i = 0; i < options.size(); i++) {
RadioButton radioButton = new RadioButton(this);
radioButton.setId(View.generateViewId()); // 生成唯一ID
radioButton.setText(options.get(i));
// 设置第一个选项默认选中
if (i == 0) {
radioButton.setChecked(true);
}
radioGroup.addView(radioButton);
}
xml复制<!-- res/drawable/radio_button_selector.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/radio_checked" android:state_checked="true"/>
<item android:drawable="@drawable/radio_unchecked" android:state_checked="false"/>
</selector>
<!-- 在布局中使用 -->
<RadioButton
android:button="@drawable/radio_button_selector"
... />
xml复制<RadioGroup
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
...
</RadioGroup>
java复制public class OptionAdapter extends RecyclerView.Adapter<OptionAdapter.ViewHolder> {
private List<String> options;
private int selectedPosition = -1;
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_option, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.radioButton.setText(options.get(position));
holder.radioButton.setChecked(position == selectedPosition);
holder.radioButton.setOnClickListener(v -> {
selectedPosition = holder.getAdapterPosition();
notifyDataSetChanged();
});
}
static class ViewHolder extends RecyclerView.ViewHolder {
RadioButton radioButton;
ViewHolder(View view) {
super(view);
radioButton = view.findViewById(R.id.radioButton);
}
}
}
java复制@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt("SELECTED_ID", radioGroup.getCheckedRadioButtonId());
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
int selectedId = savedInstanceState.getInt("SELECTED_ID", -1);
if (selectedId != -1) {
radioGroup.check(selectedId);
}
}
结合前面介绍的技术,我们可以实现一个完整的登录页面:
xml复制<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<!-- 用户名输入 -->
<EditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="用户名"/>
<!-- 密码输入 -->
<EditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="密码"
android:inputType="textPassword"/>
<!-- 协议勾选 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<CheckBox
android:id="@+id/cbAgreement"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tvAgreement"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="同意《用户协议》和《隐私政策》"/>
</LinearLayout>
<!-- 登录按钮 -->
<Button
android:id="@+id/btnLogin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="登录"/>
</LinearLayout>
对应的Activity代码:
java复制public class LoginActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
// 设置协议文本可点击
setupAgreementText();
// 登录按钮点击事件
findViewById(R.id.btnLogin).setOnClickListener(v -> {
String username = ((EditText)findViewById(R.id.etUsername)).getText().toString();
String password = ((EditText)findViewById(R.id.etPassword)).getText().toString();
boolean agreed = ((CheckBox)findViewById(R.id.cbAgreement)).isChecked();
if (username.isEmpty()) {
Toast.makeText(this, "请输入用户名", Toast.LENGTH_SHORT).show();
return;
}
if (password.isEmpty()) {
Toast.makeText(this, "请输入密码", Toast.LENGTH_SHORT).show();
return;
}
if (!agreed) {
Toast.makeText(this, "请先同意用户协议", Toast.LENGTH_SHORT).show();
return;
}
// 执行登录逻辑...
});
}
private void setupAgreementText() {
TextView tvAgreement = findViewById(R.id.tvAgreement);
String text = tvAgreement.getText().toString();
SpannableString spannable = new SpannableString(text);
// 用户协议点击
int userAgreementStart = text.indexOf("《用户协议》");
int userAgreementEnd = userAgreementStart + "《用户协议》".length();
ClickableSpan userAgreementSpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
startActivity(new Intent(LoginActivity.this, UserAgreementActivity.class));
}
};
spannable.setSpan(userAgreementSpan, userAgreementStart, userAgreementEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 隐私政策点击
int privacyStart = text.indexOf("《隐私政策》");
int privacyEnd = privacyStart + "《隐私政策》".length();
ClickableSpan privacySpan = new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
startActivity(new Intent(LoginActivity.this, PrivacyPolicyActivity.class));
}
};
spannable.setSpan(privacySpan, privacyStart, privacyEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// 设置文本样式
spannable.setSpan(new ForegroundColorSpan(Color.BLUE),
userAgreementStart, userAgreementEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(new ForegroundColorSpan(Color.BLUE),
privacyStart, privacyEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(new UnderlineSpan(),
userAgreementStart, userAgreementEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(new UnderlineSpan(),
privacyStart, privacyEnd,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
tvAgreement.setText(spannable);
tvAgreement.setMovementMethod(LinkMovementMethod.getInstance());
}
}
Span使用优化:
RadioGroup优化:
内存泄漏预防:
java复制@Override
protected void onDestroy() {
radioGroup.setOnCheckedChangeListener(null);
super.onDestroy();
}
java复制spannable.setSpan(new BackgroundColorSpan(Color.parseColor("#33FF0000")),
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
java复制@Test
public void testRadioGroupSelection() {
// 模拟Activity创建
ActivityScenario<MyActivity> scenario = ActivityScenario.launch(MyActivity.class);
scenario.onActivity(activity -> {
RadioGroup radioGroup = activity.findViewById(R.id.radioGroup);
RadioButton option1 = activity.findViewById(R.id.option1);
// 验证初始状态
assertEquals(option1.getId(), radioGroup.getCheckedRadioButtonId());
// 模拟选择另一个选项
RadioButton option2 = activity.findViewById(R.id.option2);
option2.setChecked(true);
// 验证选择变化
assertEquals(option2.getId(), radioGroup.getCheckedRadioButtonId());
});
}
java复制@Test
public void testLoginFlow() {
// 输入用户名密码
onView(withId(R.id.etUsername)).perform(typeText("testuser"));
onView(withId(R.id.etPassword)).perform(typeText("password"));
// 点击协议文本
onView(withId(R.id.tvAgreement))
.perform(clickOnText("用户协议"));
// 返回登录页面
pressBack();
// 勾选协议
onView(withId(R.id.cbAgreement)).perform(click());
// 点击登录
onView(withId(R.id.btnLogin)).perform(click());
// 验证登录结果
onView(withText("登录成功")).check(matches(isDisplayed()));
}
点击不生效:
内存泄漏:
java复制private static class SafeClickableSpan extends ClickableSpan {
private WeakReference<Context> contextRef;
SafeClickableSpan(Context context) {
this.contextRef = new WeakReference<>(context);
}
@Override
public void onClick(@NonNull View widget) {
Context context = contextRef.get();
if (context != null) {
// 处理点击
}
}
}
xml复制<TextView
android:textColorLink="@color/your_link_color"
... />
区间计算错误:
性能问题:
Span类型混淆:
默认选中项:
动态选项更新:
java复制// 保存当前选中ID
int checkedId = radioGroup.getCheckedRadioButtonId();
// 清除并添加新选项
radioGroup.removeAllViews();
for (String option : newOptions) {
// 添加新选项...
}
// 恢复选中状态
if (checkedId != -1) {
radioGroup.check(checkedId);
}
ClickableSpan在旧版本上的问题:
Span在不同Android版本的表现差异:
RadioButton样式兼容:
除了系统提供的Span,我们还可以自定义Span实现更复杂的效果:
java复制public class RoundedBackgroundSpan extends ReplacementSpan {
private int backgroundColor;
private int textColor;
private float cornerRadius;
public RoundedBackgroundSpan(int bgColor, int textColor, float radius) {
this.backgroundColor = bgColor;
this.textColor = textColor;
this.cornerRadius = radius;
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text,
int start, int end, @Nullable Paint.FontMetricsInt fm) {
return Math.round(paint.measureText(text, start, end));
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text,
int start, int end, float x, int top, int y,
int bottom, @NonNull Paint paint) {
// 保存原始Paint属性
int oldColor = paint.getColor();
Paint.Style oldStyle = paint.getStyle();
// 绘制圆角背景
paint.setColor(backgroundColor);
paint.setStyle(Paint.Style.FILL);
float width = paint.measureText(text, start, end);
RectF rect = new RectF(x, top, x + width, bottom);
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint);
// 绘制文本
paint.setColor(textColor);
canvas.drawText(text, start, end, x, y, paint);
// 恢复Paint属性
paint.setColor(oldColor);
paint.setStyle(oldStyle);
}
}
使用示例:
java复制SpannableString spannable = new SpannableString("这是带圆角背景的文本");
spannable.setSpan(new RoundedBackgroundSpan(Color.BLUE, Color.WHITE, 8),
3, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(spannable);
可以结合MotionLayout为RadioGroup添加动画效果:
xml复制<androidx.constraintlayout.motion.widget.MotionScene
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">
<Transition
motion:constraintSetStart="@+id/start"
motion:constraintSetEnd="@+id/end"
motion:duration="300">
<OnClick motion:target="@+id/option1" motion:clickAction="toggle"/>
<OnClick motion:target="@+id/option2" motion:clickAction="toggle"/>
</Transition>
<ConstraintSet android:id="@+id/start">
<!-- 初始状态约束 -->
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
<!-- 选中状态约束 -->
</ConstraintSet>
</androidx.constraintlayout.motion.widget.MotionScene>
ViewBinding可以简化UI元素的引用:
java复制// 在Activity中使用
private ActivityLoginBinding binding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityLoginBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
binding.btnLogin.setOnClickListener(v -> {
String username = binding.etUsername.getText().toString();
// 其他逻辑...
});
setupAgreementText();
}
private void setupAgreementText() {
// 使用binding引用TextView
String text = binding.tvAgreement.getText().toString();
// Span设置逻辑...
}
在Kotlin项目中,可以定义扩展函数简化Span操作:
kotlin复制fun TextView.setClickableSpan(
text: String,
clickableText: String,
color: Int = Color.BLUE,
underline: Boolean = true,
onClick: () -> Unit
) {
val spannable = SpannableString(text)
val start = text.indexOf(clickableText)
val end = start + clickableText.length
spannable.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) = onClick()
}, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(ForegroundColorSpan(color), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (underline) {
spannable.setSpan(UnderlineSpan(), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
this.text = spannable
movementMethod = LinkMovementMethod.getInstance()
}
使用示例:
kotlin复制binding.tvAgreement.setClickableSpan(
text = "同意《用户协议》和《隐私政策》",
clickableText = "《用户协议》",
onClick = { showUserAgreement() }
)
binding.tvAgreement.setClickableSpan(
text = binding.tvAgreement.text.toString(),
clickableText = "《隐私政策》",
onClick = { showPrivacyPolicy() }
)
这种封装使代码更简洁,同时保持了良好的可读性。