Android 自定義 View 主要四類:
onDraw,配合 Paint/Canvas 繪製(如圖表、遊戲畫面)。ViewGroup (如 LinearLayout, FrameLayout),在構造函數中 inflate 佈局,封裝子 View 邏輯(如標題欄、複雜列表項)。TextView、ImageView、Button,擴展特定行為。onMeasure/onLayout,實現特殊佈局(如流式佈局、標籤雲)。本文重點演示組合控件模式:FrameLayout 封裝 Button+ImageView 實現 ImageButton,以及封裝 ViewPager+指示器的複雜組合控件。
<!-- res/layout/view_image_button.xml -->
<FrameLayout xmlns:android="https://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/btn_base"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground" />
<ImageView
android:id="@+id/img_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerInside"
android:clickable="false"
android:focusable="false" />
</FrameLayout>
<!-- res/values/attrs.xml -->
<resources>
<declare-styleable name="CustomImageButton">
<attr name="iconSrc" format="reference" />
<attr name="buttonText" format="string" />
<attr name="textColor" format="color" />
</declare-styleable>
</resources>
public class CustomImageButton extends FrameLayout {
private ImageView ivIcon;
private Button btnBase;
public CustomImageButton(Context context) {
this(context, null);
}
public CustomImageButton(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CustomImageButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
// 1. 加載佈局
LayoutInflater.from(context).inflate(R.layout.view_image_button, this, true);
ivIcon = findViewById(R.id.img_icon);
btnBase = findViewById(R.id.btn_base);
// 2. 解析自定義屬性
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomImageButton);
try {
int iconRes = a.getResourceId(R.styleable.CustomImageButton_iconSrc, 0);
if (iconRes != 0) ivIcon.setImageResource(iconRes);
String text = a.getString(R.styleable.CustomImageButton_buttonText);
if (text != null) btnBase.setText(text);
int textColor = a.getColor(R.styleable.CustomImageButton_textColor, 0);
if (textColor != 0) btnBase.setTextColor(textColor);
} finally {
a.recycle(); // 必須回收
}
}
// 對外暴露設置方法
public void setIcon(int resId) { ivIcon.setImageResource(resId); }
public void setText(CharSequence text) { btnBase.setText(text); }
public void setOnClickListener(OnClickListener l) { btnBase.setOnClickListener(l); }
}
<com.example.widget.CustomImageButton
xmlns:app="https://schemas.android.com/apk/res-auto"
android:layout_width="100dp"
android:layout_height="100dp"
app:iconSrc="@drawable/ic_star"
app:buttonText="收藏"
app:textColor="@color/white" />
<!-- res/layout/view_pager_indicator.xml -->
<LinearLayout xmlns:android="https://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/gray_bg">
<!-- 指示器欄 -->
<LinearLayout
android:id="@+id/ll_tab_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<!-- 動態添加 TextView + View(底線) -->
</LinearLayout>
<androidx.viewpager.widget.ViewPager
android:id="@+id/vp_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
public class TabViewPagerIndicator extends LinearLayout {
private LinearLayout llTabContainer;
private ViewPager viewPager;
private List tabTexts = new ArrayList<>();
private List tabIndicators = new ArrayList<>();
private OnPageChangeListener externalListener;
public TabViewPagerIndicator(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(Context context) {
LayoutInflater.from(context).inflate(R.layout.view_pager_indicator, this, true);
llTabContainer = findViewById(R.id.ll_tab_container);
viewPager = findViewById(R.id.vp_content);
viewPager.addOnPageChangeListener(new InternalPageChangeListener());
}
/** 設置標題並綁定 Fragment */
public void setup(List fragments, String[] titles, FragmentManager fm) {
if (fragments.size() != titles.length) throw new IllegalArgumentException();
llTabContainer.removeAllViews();
tabTexts.clear();
tabIndicators.clear();
for (int i = 0; i < titles.length; i++) {
// 動態創建 Tab View:TextView + 底線 View
View tabView = LayoutInflater.from(getContext()).inflate(R.layout.item_tab, llTabContainer, false);
TextView tv = tabView.findViewById(R.id.tv_title);
View indicator = tabView.findViewById(R.id.v_indicator);
tv.setText(titles[i]);
tv.setTag(i);
tv.setOnClickListener(v -> viewPager.setCurrentItem((int) v.getTag()));
llTabContainer.addView(tabView, new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f));
tabTexts.add(tv);
tabIndicators.add(indicator);
}
// 設置適配器
viewPager.setAdapter(new FragmentStateAdapter(fm, getLifecycle()) {
@NonNull @Override public Fragment createFragment(int position) { return fragments.get(position); }
@Override public int getItemCount() { return fragments.size(); }
});
viewPager.setOffscreenPageLimit(fragments.size() - 1);
updateTabState(0); // 默認第一個
}
private void updateTabState(int position) {
for (int i = 0; i < tabTexts.size(); i++) {
boolean selected = (i == position);
tabTexts[i].setTextColor(selected ? 0xFFFF0000 : 0xFF333333); // 紅/黑
tabIndicators[i].setVisibility(selected ? VISIBLE : INVISIBLE);
}
}
private class InternalPageChangeListener implements ViewPager.OnPageChangeListener {
@Override public void onPageSelected(int position) {
updateTabState(position);
if (externalListener != null) externalListener.onPageSelected(position);
}
@Override public void onPageScrollStateChanged(int state) {
if (externalListener != null) externalListener.onPageScrollStateChanged(state);
}
@Override public void onPageScrolled(int p, float o, int oPx) {}
}
public void setOnPageChangeListener(OnPageChangeListener l) { this.externalListener = l; }
public ViewPager getViewPager() { return viewPager; }
}
<LinearLayout xmlns:android="https://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:gravity="center">
<TextView android:id="@+id/tv_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="16sp"/>
<View android:id="@+id/v_indicator" android:layout_width="24dp" android:layout_height="3dp" android:background="@color/red" android:layout_marginTop="4dp" android:visibility="invisible"/>
</LinearLayout>
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
TabViewPagerIndicator tabPager = findViewById(R.id.tab_pager);
List fragments = Arrays.asList(
new Fragment1(), new Fragment2(), new Fragment3(), new Fragment4()
);
String[] titles = getResources().getStringArray(R.array.tab_titles);
tabPager.setup(fragments, titles, getSupportFragmentManager());
}
}
| 技術點 | 說明 |
|---|---|
| LayoutInflater.inflate(..., parent, true) | 將佈局直接附加到當前 ViewGroup (this),返回根 View |
| TypedArray obtainStyledAttributes | 解析 XML 中 app:xxx 自定義屬性,用完必須 recycle() |
| format="reference" | 屬性值為 @drawable/xxx 或 @color/xxx 等資源引用 |
| 動態生成子 View | LayoutInflater.inflate(item_layout, container, false) + container.addView() |
| FragmentStateAdapter | ViewPager2 適配器,自動管理 Fragment 生命週期,替換舊 FragmentPagerAdapter |
| 回調接口解耦 | 定義 OnPageChangeListener 接口,外部監聽不依賴具體實現類 |
(Context, AttributeSet),三參用於樣式支持。declare-styleable 名稱建議與類名一致,屬性名駝峰 iconSrc,R 文件自動生成 R.styleable.ClassName_iconSrc。@SuppressLint("CustomViewStyleable") 或重建項目。組合控件是 Android UI 復用的核心手段:FrameLayout 疊加實現圖文混排、LinearLayout 嵌套封裝指示器+ViewPager。掌握 inflate、自定義屬性、動態添加子 View、Fragment 適配器整合,即可構建高復用、易維護的 UI 組件庫。