A
第 7 章JAVA32 分钟

常用控件: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:在默认行距基础上额外加这么多 dp
  • lineSpacingMultiplier:行距倍数,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/count
  • onTextChanged:变化中(实际是新文本即将渲染时),能拿到新文本片段
  • 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。这里为简洁不展开。

常见坑与最佳实践

  1. 文字单位用 px 而不是 sp:用 px 写文字,开了「大字号」系统设置后文字不会跟着变大,老年人体验极差。sp 是适配无障碍的关键。
  2. selector 默认 item 顺序写错:把默认 item 写在最前面,后面所有状态都不生效(命中即停)。记住「具体到通用」从上到下排。
  3. textColor 用 drawable selector 但路径放错:文字颜色的 selector 必须放 res/color/ 目录、用 @color/xxx 引用;放 res/drawable/ 用 @drawable/xxx 引用会编译通过但运行时颜色不变。
  4. scaleType=fitXY 滥用:会让图片变形。绝大多数场景该用 centerCrop 或 fitCenter。只在背景拉伸(如顶部渐变条)时才考虑 fitXY。
  5. contentDescription 必填:纯装饰图也写 @null 或 contentDescription="",读屏 App 才不会朗读「未命名图片」。无障碍是上线前 checklist 的硬指标。
  6. 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 真正有「上下滑动」「左右切页」的现代体验。