常用控件:TextView / Button / EditText / ImageView
学会 TextView 文字样式、Button 交互与 selector、EditText 输入校验、ImageView 加载与 scaleType。
学习目标
- 掌握 TextView 的文字、颜色、省略、阴影与 SpannableString 富文本
- 会用 selector 和 shape drawable 给按钮定制按压态、圆角、描边
- 了解 EditText 的输入类型与 TextWatcher 实时校验
- 掌握 ImageView 的 scaleType 与本地资源加载
- 能独立完成一个登录页的视觉与交互细节
学习目标
- 能用
TextView处理文字大小、颜色、行间距、省略号、阴影、富文本 - 能用 selector + shape drawable 让
Button按下变色、有圆角和描边 - 能用
EditText配合TextWatcher做实时输入校验 - 理解
ImageView各种scaleType的差异,能选对合适的缩放模式 - 把这些控件组合成一个有视觉细节、有交互反馈的登录页
常用控件:TextView / Button / EditText / ImageView
1. TextView:显示文字
TextView 是 Android 里最高频的控件,Button 甚至继承自它。最简单的写法:
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello Android"
android:textSize="16sp"
android:textColor="#333333" />
1.1 文字大小用 sp,尺寸用 dp
| 单位 | 含义 | 用途 |
|---|---|---|
sp |
scale-independent pixel,会跟随系统字号设置缩放 | 文字大小 |
dp |
density-independent pixel,跟随屏幕密度换算 | 尺寸、边距、控件宽高 |
px |
物理像素 | 几乎不用 |
永远用 sp 写文字、dp 写尺寸,让视觉在不同屏幕密度和系统字号下保持一致。px 只在极少数需要精确像素的场合(自定义绘制的描边宽)才用。
1.2 颜色
android:textColor="#FF333333" <!-- ARGB 八位,A 是透明度 -->
android:textColor="#333333" <!-- RGB 六位,不透明 -->
android:textColor="@color/text_primary" <!-- 引用资源 -->
推荐把颜色集中放在 res/values/colors.xml:
<resources>
<color name="text_primary">#333333</color>
<color name="text_secondary">#666666</color>
<color name="text_hint">#AAAAAA</color>
<color name="brand_primary">#1976D2</color>
</resources>
然后布局里直接 @color/text_primary 引用,全项目换主题色只改一处。
1.3 单行、多行与省略号
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="2"
android:ellipsize="end"
android:text="这是一段很长很长的文字,长到两行装不下时会在末尾显示一个省略号..." />
maxLines:最多显示几行ellipsize:超出时省略号位置,end(末尾)、start(开头)、middle(中间)、marquee(滚动)singleLine="true"(已废弃,但还能用)/android:maxLines="1"+android:inputType="text":单行显示
marquee(跑马灯) 必须配合 android:focusable="true" 和 android:focusableInTouchMode="true" 才会动起来,否则只显示静态省略号。
1.4 行间距与对齐
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lineSpacingExtra="4dp"
android:lineSpacingMultiplier="1.2"
android:gravity="center"
android:text="多行文字\n第二行\n第三行" />
lineSpacingExtra:在默认行距基础上额外加这么多 dplineSpacingMultiplier:行距倍数,1.2 即 1.2 倍行距
1.5 SpannableString:富文本
要让一段文字中部分变色、加粗、可点击,用 SpannableString 或 SpannableStringBuilder:
TextView tvLink = findViewById(R.id.tvLink);
SpannableStringBuilder ssb = new SpannableStringBuilder();
ssb.append("同意《");
int start = ssb.length();
ssb.append("用户协议");
ssb.setSpan(new ForegroundColorSpan(0xFF1976D2), start, ssb.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.setSpan(new StyleSpan(Typeface.BOLD), start, ssb.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
Toast.makeText(MainActivity.this, "点击了用户协议", Toast.LENGTH_SHORT).show();
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(true);
}
}, start, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.append("》");
tvLink.setText(ssb);
tvLink.setMovementMethod(LinkMovementMethod.getInstance()); // 必须设置,否则 ClickableSpan 不响应
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE 表示这段 span 在文本后续插入新字符时前后都不延伸。其他模式(INCLUSIVE)会让新字符继承样式,适合做输入框样式同步。
2. Button:点击与状态 selector
2.1 基础点击
<Button
android:id="@+id/btnSubmit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="提交" />
Button btnSubmit = findViewById(R.id.btnSubmit);
btnSubmit.setOnClickListener(v -> {
Toast.makeText(this, "点击了", Toast.LENGTH_SHORT).show();
});
注意 AndroidX 的 androidx.appcompat 主题下,<Button> 会被自动替换成 AppCompatButton,自带 Material 风格的按压效果。
2.2 selector:按状态切换外观
系统默认按钮按下会变浅一点,但要做出「按下变深色 + 描边变色」这种效果,得自己写 selector。新建 res/drawable/btn_primary_bg.xml:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 按下时 -->
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#FF1565C0" />
<corners android:radius="8dp" />
</shape>
</item>
<!-- 禁用时 -->
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="#FFBDBDBD" />
<corners android:radius="8dp" />
</shape>
</item>
<!-- 默认 -->
<item>
<shape android:shape="rectangle">
<solid android:color="#FF1976D2" />
<corners android:radius="8dp" />
</shape>
</item>
</selector>
使用:
<Button
android:id="@+id/btnSubmit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="提交"
android:textColor="#FFFFFF"
android:background="@drawable/btn_primary_bg" />
selector 中 item 的顺序很重要:从上到下匹配,命中即停止。所以更具体的状态(pressed、enabled=false)要写在前面,兜底的默认 item 写在最后。
shape 支持的元素:
| 标签 | 作用 |
|---|---|
<solid> |
填充色 |
<corners> |
圆角,可单独设 topLeftRadius 等 |
<stroke> |
描边,width + color + dashWidth(虚线) |
<gradient> |
渐变,startColor / centerColor / endColor + angle |
<padding> |
内边距 |
2.3 文字颜色也用 selector
按下时背景变深、文字也想变浅一点。再建一个 res/color/btn_primary_text.xml(注意是 res/color 目录,不是 drawable):
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:color="#FFCCCCCC" />
<item android:state_enabled="false" android:color="#FFEEEEEE" />
<item android:color="#FFFFFFFF" />
</selector>
引用:
android:textColor="@color/btn_primary_text"
2.4 用代码动态启用/禁用按钮
登录表单里,账号密码都填了才点亮按钮:
Button btnLogin = findViewById(R.id.btnLogin);
EditText etAccount = findViewById(R.id.etAccount);
EditText etPassword = findViewById(R.id.etPassword);
TextWatcher watcher = new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
@Override public void onTextChanged(CharSequence s, int start, int before, int count) { }
@Override
public void afterTextChanged(Editable s) {
boolean enabled = etAccount.getText().length() > 0
&& etPassword.getText().length() > 0;
btnLogin.setEnabled(enabled);
}
};
etAccount.addTextChangedListener(watcher);
etPassword.addTextChangedListener(watcher);
// 默认未填写,禁用
btnLogin.setEnabled(false);
按钮一被 setEnabled(false),selector 会自动切到 state_enabled="false" 的样式——这就是为什么把「禁用态」单独配色有意义。
3. EditText:输入与校验
3.1 常用 inputType
| inputType | 用途 |
|---|---|
text |
普通文本 |
textPassword |
密码,内容显示为圆点 |
textEmailAddress |
邮箱,键盘会显示 @ 符号 |
phone |
电话,弹数字键盘 |
number |
纯数字 |
numberDecimal |
带小数点的数字 |
textMultiLine |
多行(按回车换行而不是确定) |
<EditText
android:id="@+id/etPhone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入手机号"
android:inputType="phone"
android:maxLength="11" />
maxLength 限制字符数,配合 phone 实现 11 位手机号输入。
3.2 TextWatcher 实时校验
TextWatcher 的三个回调:
beforeTextChanged:内容变化前调用,能拿到旧文本的 start/countonTextChanged:变化中(实际是新文本即将渲染时),能拿到新文本片段afterTextChanged:内容已变化,参数Editable s就是新内容
实际开发最常用 afterTextChanged。一个手机号实时校验的完整例子:
public class FormActivity extends AppCompatActivity {
private EditText etPhone;
private TextView tvPhoneTip;
private static final Pattern PHONE_PATTERN =
Pattern.compile("^1[3-9]\\d{9}$");
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_form);
etPhone = findViewById(R.id.etPhone);
tvPhoneTip = findViewById(R.id.tvPhoneTip);
etPhone.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
@Override public void onTextChanged(CharSequence s, int start, int before, int count) { }
@Override
public void afterTextChanged(Editable s) {
String phone = s.toString().trim();
if (phone.isEmpty()) {
tvPhoneTip.setText("");
return;
}
if (PHONE_PATTERN.matcher(phone).matches()) {
tvPhoneTip.setText("手机号格式正确");
tvPhoneTip.setTextColor(0xFF388E3C);
} else {
tvPhoneTip.setText("请输入正确的手机号");
tvPhoneTip.setTextColor(0xFFD32F2F);
}
}
});
}
}
3.3 注意:TextWatcher 别递归
afterTextChanged 里如果再调用 setText(),会再次触发 TextWatcher,造成死循环。需要修改内容时,先 removeTextChangedListener、再 setText、最后再 addTextChangedListener,或者用 s.replace(0, s.length(), newText) 直接改 Editable 但配合一个静态 boolean 防止递归。
4. ImageView:图片显示
4.1 加载本地资源
<ImageView
android:id="@+id/ivAvatar"
android:layout_width="96dp"
android:layout_height="96dp"
android:src="@drawable/ic_avatar"
android:background="@drawable/bg_avatar_circle"
android:contentDescription="用户头像" />
android:src:图片内容,前景android:background:背景,会被 src 盖住部分contentDescription:无障碍读屏时朗读的描述,必填
4.2 scaleType 决定怎么缩放
scaleType 是 ImageView 最容易踩坑的属性,差异非常微妙:
| scaleType | 行为 |
|---|---|
fitCenter(默认) |
保持比例,让图片完整显示,居中,可能留白 |
centerCrop |
保持比例,让图片充满整个 ImageView,多的部分裁剪掉 |
centerInside |
保持比例,完整显示,比 fitCenter 更倾向于不放大 |
fitXY |
不保持比例,强行拉伸填满,会变形 |
center |
不缩放,按图片原始尺寸从左上角绘制 |
matrix |
用 setImageMatrix 自定义变换 |
最常用的是 fitCenter(默认,适合头像)和 centerCrop(适合封面图、Banner):
<ImageView
android:layout_width="match_parent"
android:layout_height="200dp"
android:scaleType="centerCrop"
android:src="@drawable/banner" />
4.3 圆形头像(不依赖第三方库)
用 shape drawable 模拟一个圆形容器,再让 ImageView 裁剪:
<!-- res/drawable/bg_avatar_circle.xml -->
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFCCCCCC" />
</shape>
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:background="@drawable/bg_avatar_circle"
android:src="@drawable/ic_avatar"
android:scaleType="centerCrop" />
这种做法的局限:背景圆是 oval 形状,但 src 是矩形 bitmap,没有真圆角剪裁。真正的圆形剪裁要自定义 View 或者用 androidx.core.graphics.drawable.RoundedBitmapDrawableFactory:
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
Bitmap bmp = BitmapFactory.decodeResource(getResources(), R.drawable.ic_avatar);
RoundedBitmapDrawable drawable = RoundedBitmapDrawableFactory.create(getResources(), bmp);
drawable.setCircular(true);
ImageView ivAvatar = findViewById(R.id.ivAvatar);
ivAvatar.setImageDrawable(drawable);
加载网络图片请等后续章节用 Glide / Coil,本章只覆盖本地。
5. 完整示例:登录页
把本章所有内容综合成一个登录页。布局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="欢迎登录"
android:textColor="@color/text_primary"
android:textSize="28sp"
android:textStyle="bold" />
<EditText
android:id="@+id/etAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入手机号"
android:inputType="phone"
android:maxLength="11" />
<EditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="请输入密码(至少 6 位)"
android:inputType="textPassword" />
<TextView
android:id="@+id/tvTip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="8dp"
android:textColor="@color/text_hint"
android:textSize="12sp" />
<Button
android:id="@+id/btnLogin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@drawable/btn_primary_bg"
android:text="登录"
android:textColor="@color/btn_primary_text" />
<TextView
android:id="@+id/tvLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textSize="12sp" />
</LinearLayout>
对应 Activity(带手机号 + 密码长度实时校验、按钮启用控制、富文本协议链接):
package com.example.commonviews;
import android.graphics.Typeface;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.text.TextWatcher;
import android.text.Editable;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import java.util.regex.Pattern;
public class LoginActivity extends AppCompatActivity {
private EditText etAccount;
private EditText etPassword;
private TextView tvTip;
private Button btnLogin;
private static final Pattern PHONE_PATTERN =
Pattern.compile("^1[3-9]\\d{9}$");
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
etAccount = findViewById(R.id.etAccount);
etPassword = findViewById(R.id.etPassword);
tvTip = findViewById(R.id.tvTip);
btnLogin = findViewById(R.id.btnLogin);
TextView tvLink = findViewById(R.id.tvLink);
// 富文本「同意《用户协议》和《隐私政策》」
setupAgreementText(tvLink);
// 实时校验
TextWatcher watcher = new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
@Override public void onTextChanged(CharSequence s, int start, int before, int count) { }
@Override
public void afterTextChanged(Editable s) {
validate();
}
};
etAccount.addTextChangedListener(watcher);
etPassword.addTextChangedListener(watcher);
validate(); // 初始化状态
btnLogin.setOnClickListener(v -> {
Toast.makeText(this, "登录:" + etAccount.getText(), Toast.LENGTH_SHORT).show();
});
}
private void validate() {
String phone = etAccount.getText().toString().trim();
String pwd = etPassword.getText().toString().trim();
boolean phoneOk = PHONE_PATTERN.matcher(phone).matches();
boolean pwdOk = pwd.length() >= 6;
if (!phoneOk && !phone.isEmpty()) {
tvTip.setText("手机号格式不正确");
tvTip.setTextColor(0xFFD32F2F);
} else if (!pwdOk && !pwd.isEmpty()) {
tvTip.setText("密码至少 6 位");
tvTip.setTextColor(0xFFD32F2F);
} else {
tvTip.setText("");
}
btnLogin.setEnabled(phoneOk && pwdOk);
}
private void setupAgreementText(TextView tv) {
SpannableStringBuilder ssb = new SpannableStringBuilder();
ssb.append("同意");
int p1Start = ssb.length();
ssb.append("《用户协议》");
int p1End = ssb.length();
ssb.append("和");
int p2Start = ssb.length();
ssb.append("《隐私政策》");
int p2End = ssb.length();
ssb.setSpan(new ForegroundColorSpan(0xFF1976D2),
p1Start, p1End, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.setSpan(new StyleSpan(Typeface.BOLD),
p1Start, p1End, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
Toast.makeText(LoginActivity.this, "打开用户协议", Toast.LENGTH_SHORT).show();
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(true);
}
}, p1Start, p1End, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.setSpan(new ForegroundColorSpan(0xFF1976D2),
p2Start, p2End, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.setSpan(new StyleSpan(Typeface.BOLD),
p2Start, p2End, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
ssb.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
Toast.makeText(LoginActivity.this, "打开隐私政策", Toast.LENGTH_SHORT).show();
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(true);
}
}, p2Start, p2End, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.setText(ssb);
tv.setMovementMethod(LinkMovementMethod.getInstance());
tv.setHighlightColor(0x00000000); // 去掉点击高亮背景
}
}
注意上面有一处编译细节:ClickableSpan 的 onClick 里用了 View 但没 import,实际项目里需要 import android.view.View;,或者直接写全限定名 android.view.View。这里为简洁不展开。
常见坑与最佳实践
- 文字单位用 px 而不是 sp:用 px 写文字,开了「大字号」系统设置后文字不会跟着变大,老年人体验极差。
sp是适配无障碍的关键。 - selector 默认 item 顺序写错:把默认 item 写在最前面,后面所有状态都不生效(命中即停)。记住「具体到通用」从上到下排。
textColor用 drawable selector 但路径放错:文字颜色的 selector 必须放res/color/目录、用@color/xxx引用;放res/drawable/用@drawable/xxx引用会编译通过但运行时颜色不变。scaleType=fitXY滥用:会让图片变形。绝大多数场景该用centerCrop或fitCenter。只在背景拉伸(如顶部渐变条)时才考虑fitXY。contentDescription必填:纯装饰图也写@null或contentDescription="",读屏 App 才不会朗读「未命名图片」。无障碍是上线前 checklist 的硬指标。- TextWatcher 递归:在
afterTextChanged里setText会再次触发自身。先removeTextChangedListener、再setText、最后addTextChangedListener是经典解法。
章节小结
TextView是基础控件之王,Button继承自它,富文本用SpannableString+ 各种 Span- 文字大小用
sp、尺寸用dp,颜色统一放colors.xml管理 selector控制按状态切换外观,具体状态在前、默认在后;shape画背景,solid/corners/stroke/gradient是核心元素EditText用inputType控制键盘和内容类型,TextWatcher.afterTextChanged做实时校验,注意别递归ImageView的scaleType差异要记牢,centerCrop适合封面、fitCenter适合头像- 一份登录页 = 富文本协议 + 实时校验 + selector 按钮启用态的综合应用
下一章预告
按钮和输入框能跑起来了,但只能显示一屏内容。下一章我们引入 ScrollView、ViewPager2 + TabLayout、BottomNavigationView 这些「能装一摞页面」的容器,并概览 CoordinatorLayout 与 AppBarLayout 的协调机制,让你的 App 真正有「上下滑动」「左右切页」的现代体验。