小明开发Android APP时发现继承Button后样式消失的坑 自定义Button继承TextView的正确姿势解决样式丢失问题
小明是个刚入行的Android开发,那天他想给自己项目里一个按钮加点特殊效果——按下时微微发光,字体也跟着变化。他心想这还不简单?继承Button重写几个方法不就行了?
结果编译运行之后,小明整个人都不好了。按钮变成了纯文字,圆角没了,阴影没了,连原本的颜色都变了,就像被人扒光了衣服站在屏幕中央一样尴尬。
他盯着那个”裸奔”的按钮看了整整十分钟,开始怀疑人生。
为什么继承Button样式会丢失?
在讲解决方案之前,咱们得先把这个坑挖清楚。不然下次换个场景,可能又掉进去了。
Button在Android里本质上就是一个TextView,它继承了TextView,然后在构造函数里做了一些特殊处理。但问题恰恰出在这个继承链上。
当一个View被创建时,Android系统会通过obtainStyledAttributes来读取XML里定义的样式。这个过程中,它会查找当前类名对应的样式定义。
Key点来了:当你新建一个类MyButton extends Button,然后在XML里使用这个类时,系统会尝试查找com.example.mypackage.MyButton这个类名对应的样式。但你的styles.xml里只定义了Button的样式,根本没有MyButton的样式!
更糟糕的是,Button的构造函数里有一段这样的逻辑:
public Button(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
// 这里会用默认样式重新初始化
TypedArray a = context.obtainStyledAttributes(
attrs,
R.styleable.Button,
defStyleAttr,
R.style.Widget_Button
);
// 然后用这个样式覆盖掉之前设置的属性
// 问题就在这里!R.style.Widget_Button是系统默认样式
// 它不包含你在themes里自定义的任何样式
}
看到没?Button的构造函数里用的是系统默认的Widget_Button样式,这个样式里只有最基础的外观定义,你theme里自定义的background、textColor、size全都喂了狗。
正确的姿势:继承TextView而不是Button
小明在Stack Overflow上翻了三页,终于找到了解决方案——别继承Button,继承TextView!
原因很简单
Button本身就是一个TextView。你继承TextView,完全拥有Button的所有能力,包括点击事件、长按事件,还能完整保留你定义在style里的所有外观属性。
第一步:创建自定义TextView类
package com.example.mypackage;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Typeface;
import android.util.AttributeSet;
import android.view.MotionEvent;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
public class GlowButton extends AppCompatTextView {
// 按下时的发光颜色
private int pressGlowColor;
// 默认颜色
private int normalColor;
// 动画时长
private long animDuration;
// 记录是否处于按下状态
private boolean isPressed = false;
// 淡入淡出动画
private android.animation.ObjectAnimator glowAnimator;
public GlowButton(Context context) {
this(context, null);
}
public GlowButton(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, android.R.attr.buttonStyle);
}
public GlowButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 从XML中读取自定义属性
initAttrs(context, attrs);
// 初始化按钮状态
init();
}
private void initAttrs(Context context, AttributeSet attrs) {
if (attrs == null) {
return;
}
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.GlowButton);
try {
// 读取自定义属性,有默认值兜底
pressGlowColor = array.getColor(
R.styleable.GlowButton_glowColor,
0x400000FF // 默认蓝色半透明
);
normalColor = array.getColor(
R.styleable.GlowButton_normalColor,
0xFF6200EE // 默认紫色
);
animDuration = array.getInt(
R.styleable.GlowButton_animDuration,
150
);
} finally {
array.recycle();
}
}
private void init() {
// 设置初始背景
setBackgroundResource(R.drawable.bg_glow_button);
// 设置默认文字颜色
setTextColor(normalColor);
// 初始化动画
glowAnimator = android.animation.ObjectAnimator.ofFloat(this, "alpha", 1f, 0.7f, 1f);
glowAnimator.setDuration(animDuration);
glowAnimator.setRepeatCount(0);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isPressed = true;
// 按下效果:发光
setAlpha(0.7f);
startGlowAnimation();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
isPressed = false;
setAlpha(1f);
break;
}
// 必须调用父类,否则点击事件无法正确传递
return super.onTouchEvent(event);
}
private void startGlowAnimation() {
if (glowAnimator != null && !glowAnimator.isRunning()) {
glowAnimator.start();
}
}
// 设置发光颜色,比如让确认按钮绿色,取消按钮红色
public void setGlowColor(int color) {
this.pressGlowColor = color;
invalidate();
}
// 设置普通颜色
public void setNormalColor(int color) {
this.normalColor = color;
setTextColor(color);
}
}
第二步:定义自定义属性
在res/values/attrs.xml里:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="GlowButton">
<!-- 按下时的发光颜色 -->
<attr name="glowColor" format="color" />
<!-- 普通状态颜色 -->
<attr name="normalColor" format="color" />
<!-- 动画持续时间(ms) -->
<attr name="animDuration" format="integer" />
</declare-styleable>
</resources>
第三步:创建圆角背景drawable
res/drawable/bg_glow_button.xml:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 圆角 -->
<corners android:radius="24dp" />
<!-- 渐变背景,让按钮更有质感 -->
<gradient
android:angle="135"
android:startColor="#7B1FA2"
android:endColor="#4A148C"
android:type="linear" />
<!-- 内边距产生的阴影效果 -->
<stroke
android:width="1dp"
android:color="#FFFFFF33" />
<!-- 内边距 -->
<padding
android:left="16dp"
android:top="12dp"
android:right="16dp"
android:bottom="12dp" />
</shape>
第四步:在布局中使用
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:background="#F5F5F5">
<com.example.mypackage.GlowButton
android:id="@+id/btn_confirm"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="确认提交"
android:textSize="16sp"
android:textColor="#FFFFFF"
android:gravity="center"
android:layout_marginBottom="12dp"
app:glowColor="#4CAF50"
app:normalColor="#2E7D32"
app:animDuration="200" />
<com.example.mypackage.GlowButton
android:id="@+id/btn_cancel"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="取消"
android:textSize="16sp"
android:textColor="#FFFFFF"
android:gravity="center"
app:glowColor="#F44336"
app:normalColor="#D32F2F"
app:animDuration="150" />
</LinearLayout>
为什么这样就不会有样式丢失的问题?
因为TextView的构造函数里没有那段”多管闲事”的代码。
Button构造函数里那行R.style.Widget_Button是罪魁祸首。它强制用系统默认样式覆盖了你从theme里继承的所有样式。
而AppCompatTextView(或者直接用TextView)的构造函数只做了最简单的事情:读取你传入的AttributeSet,应用你明确指定的样式,不会偷偷摸摸地用另一个样式覆盖掉你的东西。
还有一个好处是,继承TextView你可以完全掌控样式的继承链:
// 如果你希望按钮默认有圆角、有阴影
// 你可以在theme里定义一个style,然后在构造函数里引用它
public GlowButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr, R.style.MyGlowButtonStyle);
// ^ 这里你明确指定了样式,不会被系统偷偷覆盖
}
进阶:如何处理点击事件和状态监听
很多人继承View之后,发现setOnClickListener不好使,或者状态变化监听不到。这里给小明整理了一份完整的方案:
package com.example.mypackage;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;
public class SmartButton extends View {
// 文字相关
private String buttonText = "按钮";
private Paint textPaint;
private float textSize = 18f;
// 背景相关
private Paint backgroundPaint;
private RectF bounds = new RectF();
private int cornerRadius = 24f;
private int normalBgColor = 0xFF6200EE;
private int pressedBgColor = 0xFF3700B3;
// 状态
private boolean isPressed = false;
private boolean isEnabled = true;
// 点击监听
private OnClickListener clickListener;
// 用于记录按下时的触摸点
private float touchX, touchY;
public SmartButton(Context context) {
this(context, null);
}
public SmartButton(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SmartButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 读取自定义属性
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SmartButton);
buttonText = a.getString(R.styleable.SmartButton_buttonText);
textSize = a.getDimension(R.styleable.SmartButton_textSize, 18f);
normalBgColor = a.getColor(R.styleable.SmartButton_normalBgColor, normalBgColor);
cornerRadius = a.getDimension(R.styleable.SmartButton_cornerRadius, cornerRadius).intValue();
a.recycle();
// 初始化画笔
textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(textSize * getResources().getDisplayMetrics().density);
textPaint.setTextAlign(Paint.Align.CENTER);
backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
backgroundPaint.setColor(normalBgColor);
// 启用点击事件
setClickable(true);
setFocusable(true);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
bounds.set(0, 0, w, h);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制圆角矩形背景
backgroundPaint.setColor(isPressed ? pressedBgColor : normalBgColor);
canvas.drawRoundRect(bounds, cornerRadius, cornerRadius, backgroundPaint);
// 绘制文字
if (buttonText != null) {
float textY = bounds.centerY() - textPaint.ascent() / 2;
canvas.drawText(buttonText, bounds.centerX(), textY, textPaint);
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!isEnabled) return false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isPressed = true;
touchX = event.getX();
touchY = event.getY();
invalidate(); // 重绘
return true;
case MotionEvent.ACTION_MOVE:
// 如果手指移出按钮范围,取消按下状态
if (touchX < 0 || touchX > getWidth() ||
touchY < 0 || touchY > getHeight()) {
isPressed = false;
invalidate();
}
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
boolean wasPressed = isPressed;
isPressed = false;
invalidate();
// 只有在按钮范围内抬起才算有效点击
if (wasPressed && clickListener != null) {
clickListener.onClick(this);
}
return true;
}
return super.onTouchEvent(event);
}
// 暴露点击监听接口
public void setOnClickListener(OnClickListener l) {
this.clickListener = l;
}
public void setButtonText(String text) {
this.buttonText = text;
invalidate();
}
public void setNormalColor(int color) {
this.normalBgColor = color;
if (!isPressed) invalidate();
}
}
这个版本的SmartButton是完全自绘的View,没有任何样式依赖问题,因为:
- 它不依赖任何系统样式
- 所有外观都在代码里明确定义
- 点击事件完全由自己掌控
如果一定要继承Button,怎么补救?
有时候你就是需要继承Button,比如要复用Button的一些内部逻辑。这种情况下,有几种补救方案:
方案一:在构造里强制应用正确的样式
public class MyButton extends Button {
public MyButton(Context context, AttributeSet attrs) {
// 关键:在调用super之前先设置样式
// 或者在super之后重新设置背景
super(context, attrs, R.attr.buttonStyle);
// 强制从当前theme读取样式
applyThemeStyle(context);
}
private void applyThemeStyle(Context context) {
// 获取当前theme里的buttonStyle
TypedArray ta = context.getTheme().obtainStyledAttributes(
new int[] { android.R.attr.buttonStyle }
);
int defaultButtonStyle = ta.getResourceId(0, 0);
ta.recycle();
// 重新用正确的样式初始化
TypedArray styledAttrs = context.obtainStyledAttributes(
null,
R.styleable.Button,
0,
defaultButtonStyle
);
// ... 用这些属性重新配置按钮
styledAttrs.recycle();
}
}
但这个方案比较绕,容易出错。
方案二:在XML里指定样式(最简单)
<com.example.mypackage.MyButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?attr/buttonStyle"
android:text="点击我" />
在XML里显式加上style="?attr/buttonStyle",告诉系统用当前theme的button样式来渲染你的自定义Button。这个方法最简单,但每次使用都要记得加,容易忘。
方案三:在代码里手动恢复样式
public class MyButton extends Button {
public MyButton(Context context, AttributeSet attrs) {
super(context, attrs);
// 手动设置背景,覆盖掉构造函数里的默认值
TypedArray ta = context.obtainStyledAttributes(
attrs,
R.styleable.MyButton,
0,
R.style.MyButtonStyle // 你的自定义style
);
int bgRes = ta.getResourceId(R.styleable.MyButton_android_background, 0);
if (bgRes != 0) {
setBackgroundResource(bgRes);
}
int textColor = ta.getColor(R.styleable.MyButton_android_textColor, 0);
if (textColor != 0) {
setTextColor(textColor);
}
ta.recycle();
}
}
给小明的最终建议
小明把这篇文章看完之后,总结了三条经验:
第一条:能继承TextView就别继承Button
Button是个”有个性”的类,它的构造函数会偷偷用系统默认样式覆盖你的东西。TextView没有这个毛病,它老老实实把AttributeSet里的内容应用上去,不会搞小动作。
第二条:如果一定要继承Button,记得在XML里加上style属性
style="?attr/buttonStyle" 或者 style="@style/YourCustomButtonStyle",显式指定样式,不要指望系统会自动帮你继承。
第三条:善用AppCompat系列的控件
AppCompatTextView、AppCompatButton这些控件做了很多兼容处理,样式继承也做得更好。如果你用的是Material Design风格的按钮,直接用MaterialButton继承或者自定义,它比原生Button稳定多了。
小明照着做了之后,按钮终于恢复了漂亮的圆角和阴影效果,按下时还会微微发光。他站在屏幕前看了好久,终于松了一口气,打开了IDE,准备把这个坑写进团队的技术文档里,让后来的同学别再踩了。
开发这条路就是这样,踩过的坑越多,走的路就越稳。