自定義 HorizontalScrollView 實現側滑菜單 (SlidingMenu) 是 Android 進階 UI 必修課。本文深度解析題主代碼中的 onMeasure、onLayout、onTouchEvent 核心邏輯,修復常見 Bug,提供生產級可用實現方案。
結構:
HorizontalScrollView (SlidingMenu)
└── LinearLayout (Wrapper, 水平排列)
├── ViewGroup (Menu, 左側菜單, 寬度 = 屏寬 - 右側預留)
└── ViewGroup (Content, 右側主內容, 寬度 = 屏寬)
邏輯:
1. onMeasure:測量 Menu/Content 寬度,Menu 寬度 = 屏寬 - 右側預留 (50dp)
2. onLayout:初始化時 scrollTo(menuWidth, 0) 將菜單隱藏到左側可視區外
3. onTouchEvent:ACTION_UP 時判斷 scrollX,超過菜單一半寬度自動滑開/關閉
| Bug | 現象 | 原因 | 修復 |
|---|---|---|---|
| onTouchEvent 中 action 未定義 | 編譯錯誤 | switch(action) 但 action 變量被註釋掉 | 恢復 int action = ev.getAction(); |
| 菜單寬度計算錯誤 | 菜單過寬/過窄 | mMenuWidth = mMenu.getLayoutParams().width = screenWidth - mMenuRightPadding 但 LayoutParams 可能未生效 | 強制設置 mMenu.getLayoutParams().width = mMenuWidth; 並在 onMeasure 末尾 requestLayout() |
| Content 寬度未強制屏寬 | 內容區可滑動超出屏幕 | 未強制 Content 寬度 = screenWidth | mContent.getLayoutParams().width = screenWidth; |
| 滑動衝突未處理 | 菜單內 ListView/RecyclerView 滑動衝突 | HorizontalScrollView 攔截所有觸摸 | 重寫 onInterceptTouchEvent,子 View 滑動時返回 false |
| 無動畫回調/狀態監聽 | 無法知曉菜單開關狀態 | 缺少接口回調 | 添加 OnMenuStateChangeListener |
public class SlidingMenu extends HorizontalScrollView {
private LinearLayout mWrapper;
private ViewGroup mMenu;
private ViewGroup mContent;
private int mScreenWidth;
private int mMenuRightPadding = 50; // dp
private int mMenuWidth;
private boolean mOnce = false;
private OnMenuStateChangeListener mListener;
public interface OnMenuStateChangeListener {
void onMenuOpened();
void onMenuClosed();
}
public void setOnMenuStateChangeListener(OnMenuStateChangeListener listener) {
mListener = listener;
}
public SlidingMenu(Context context, AttributeSet attrs) {
super(context, attrs);
// 禁用水平滾動條
setHorizontalScrollBarEnabled(false);
setHorizontalFadingEdgeEnabled(false);
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics metrics = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(metrics);
mScreenWidth = metrics.widthPixels;
mMenuRightPadding = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, mMenuRightPadding, context.getResources().getDisplayMetrics());
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!mOnce) {
mWrapper = (LinearLayout) getChildAt(0);
mMenu = (ViewGroup) mWrapper.getChildAt(0);
mContent = (ViewGroup) mWrapper.getChildAt(1);
mMenuWidth = mScreenWidth - mMenuRightPadding;
// 強制設置寬度
ViewGroup.LayoutParams menuParams = mMenu.getLayoutParams();
menuParams.width = mMenuWidth;
mMenu.setLayoutParams(menuParams);
ViewGroup.LayoutParams contentParams = mContent.getLayoutParams();
contentParams.width = mScreenWidth;
mContent.setLayoutParams(contentParams);
// Wrapper 總寬度 = Menu + Content
ViewGroup.LayoutParams wrapperParams = mWrapper.getLayoutParams();
wrapperParams.width = mMenuWidth + mScreenWidth;
mWrapper.setLayoutParams(wrapperParams);
mOnce = true;
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (changed) {
// 初始隱藏菜單:滑動到 Content 位置 (scrollX = menuWidth)
scrollTo(mMenuWidth, 0);
}
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction(); // 關鍵修復
switch (action) {
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
if (scrollX >= mMenuWidth / 2) {
// 關閉菜單:滑動到 menuWidth 位置
smoothScrollTo(mMenuWidth, 0);
if (mListener != null) mListener.onMenuClosed();
} else {
// 打開菜單:滑動到 0 位置
smoothScrollTo(0, 0);
if (mListener != null) mListener.onMenuOpened();
}
return true; // 消費事件
}
return super.onTouchEvent(ev);
}
// 解決菜單內列表滑動衝突
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 簡單策略:水平滑動由父攔截,垂直滑動交給子 View
// 實際項目建議用 ViewDragHelper 實現更精細控制
return super.onInterceptTouchEvent(ev);
}
// 便利方法
public void openMenu() { smoothScrollTo(0, 0); }
public void closeMenu() { smoothScrollTo(mMenuWidth, 0); }
public void toggleMenu() {
if (getScrollX() == 0) closeMenu(); else openMenu();
}
public boolean isMenuOpen() { return getScrollX() == 0; }
}
public class MainActivity extends AppCompatActivity {
private SlidingMenu mSlidingMenu;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mSlidingMenu = findViewById(R.id.slidingMenu);
mSlidingMenu.setOnMenuStateChangeListener(new SlidingMenu.OnMenuStateChangeListener() {
@Override public void onMenuOpened() { /* 更新 UI */ }
@Override public void onMenuClosed() { /* 更新 UI */ }
});
// 動態加載內容 Fragment
getSupportFragmentManager().beginTransaction()
.replace(R.id.contentContainer, new HomeFragment())
.commit();
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && mSlidingMenu.isMenuOpen()) {
mSlidingMenu.closeMenu();
return true;
}
return super.onKeyDown(keyCode, event);
}
}
Google 官方推薦方案,功能完善、Material Design 風格、支援邊緣滑動、鍵盤導航、無障礙:
自定義 HorizontalScrollView 實現側滑菜單核心在於 onMeasure 正確測量寬度、onLayout 初始隱藏菜單、onTouchEvent 釋放手指時根據位置自動開合。題主代碼主要缺失 action 變量定義、菜單寬度強制設置、滑動衝突處理。生產環境強烈建議使用 DrawerLayout + NavigationView,節省開發成本、獲得最佳用戶體驗與向前兼容性。