Android自定义HorizontalScrollView实现仿微信侧滑删除(多触点拉出侧滑菜单)

前言

在现有的开源库中,多数侧滑删除组件仅支持单一触点拉出菜单选项。然而,iOS版的微信消息界面提供了一种多触点侧滑菜单的实现。为了模仿这一交互模式,采用了HorizontalScrollView来满足多触点拉出侧滑菜单的需求。下文将详细介绍该组件的实现过程和效果。

一、目标与分析

1. 目标

效果图:

参考微信消息界面的用户交互:

css 复制代码
支持多指同时拉出侧滑菜单。
点击非菜单区域,其他展开的菜单将收回。
当多个菜单同时展开时,触碰到的菜单能够随手指移动,同时其他菜单会自动收回。
当新菜单展开时,之前展开的菜单需要自动收回。
点击content的事件
点击menu事件

2. 基本实现思路

为RecyclerView的每个项目(item)添加一个HorizontalScrollView容器以实现多触点滑动功能。值得注意的是,ScrollView和RecyclerView的滑动事件不会产生冲突,因为ScrollView会拦截触摸事件而不继续向下分发。

在XML布局中,使用match_parent来设置内容布局的宽度是无效的。这是因为ScrollView会将所有项目填充在其可用长度内。因此,我们需要在代码中动态地调整内容布局的宽度以解决这一问题。

难点主要集中在何时收回侧滑菜单,这涉及多个状态的判断。后续部分将详细阐述该组件的具体实现思路。

二、实现原理解析

本部分将结合之前的目标来逐步分析

1.动态设置content_layout的大小

xml部分很简单正常设置即可

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.george.SlideMenuScrollView.SlideMenuScrollView
    android:id="@+id/scroll_view"
    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="wrap_content"
    app:menu_id="@+id/menu_text"
    app:content_layout_id="@+id/content_layout"
    android:scrollbars="none">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <LinearLayout
            android:id="@+id/content_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <TextView
                android:id="@+id/content_text"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:textSize="16sp"
                android:padding="16dp"
                android:text="Content" />
        </LinearLayout>


        <LinearLayout
            android:id="@+id/menu_layout"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content">
            <TextView
                android:id="@+id/menu_text"
                android:layout_width="105dp"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:textSize="16sp"
                android:padding="16dp"
                android:text="@string/delete"
                android:gravity="center"
                android:background="#FF0000" />
        </LinearLayout>

    </LinearLayout>

</com.george.SlideMenuScrollView.SlideMenuScrollView>
java 复制代码
 protected void onFinishInflate() {
        super.onFinishInflate();

        validateViewId(menuId, "SlideToDeleteScrollView_menu_id");
        menuText = findViewById(menuId);
        validateViewId(contentLayoutId, "SlideToDeleteScrollView_content_layout_id");
        contentLayout = findViewById(contentLayoutId);
        
		//布局加载后将content_layout的宽度设为屏幕宽度
        DisplayMetrics displayMetrics = new DisplayMetrics();
        ((Activity) getContext()).getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
        int screenWidth = displayMetrics.widthPixels;
        ViewGroup.LayoutParams layoutParams = contentLayout.getLayoutParams();
        layoutParams.width = screenWidth;
        contentLayout.setLayoutParams(layoutParams);
        
		//textview的默认宽度为滑动阈值
        menuText.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                mScrollThreshold = menuText.getWidth();
                menuDefaultWidth = mScrollThreshold;
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    menuText.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                } else {
                    menuText.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                }
            }
        });

2、3、4、目标将放在一起分析

在分析第二个目标(点击非菜单区域,其他展开的菜单应自动收回)时,直观的解决方案是在触摸的down事件中将所有展开的菜单收回。

然而,当我们考虑到第三个目标(触碰的菜单应能随手指移动,其他菜单则需自动收回)时,仅仅依赖于down事件来处理这个动作是不足够的。我们还需要判断用户是否正在移动当前菜单,并据此决定是否收回其他菜单。

针对第四个目标(当新菜单展开时,先前展开的菜单应自动收回),一些人可能会质疑这是否与第二个目标相同。仔细分析后,由于支持多个菜单同时展开,我们需要维护一个列表(list)来追踪每个菜单的状态。决定何时将菜单加入此列表成为一个关键考虑因素。如果我们在触摸开始即刻加入列表,那么在down事件中收回菜单的逻辑就会干扰到多个菜单同时展开的操作。因此,合理的做法是仅在菜单完全展开后将其加入列表。这样,在多个菜单同时展开但尚未完全展开的情况下,由于列表数量为0,down事件自然不会影响这一操作。

代码如下:

java 复制代码
@Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
        if (isFullyOpened() && oldl < mScrollThreshold) {
            notifyMenuFullyOpened(); //完全展开将菜单加入list
        } else if (l == 0) {
            notifyMenuClosed(); //关闭时移除
        }
    }
    
private boolean isFullyOpened() {
        return getScrollX() >= mScrollThreshold;
    }
    
@Override
    public boolean onTouchEvent(MotionEvent ev) {
        super.onTouchEvent(ev);
        int action = ev.getAction();
        switch (action & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                initialX = ev.getX();
                initialY = ev.getY();
                isMoving = false;
                //将除当前触摸的菜单全部回收,down不能将全部菜单收回,
                //用户有可能想移动其中一个菜单
                mOnMenuStateChangeListener.onActionDown(this);
                break;
            case MotionEvent.ACTION_MOVE:
            //这里手动判断是否移动的原因是为了展开多个菜单时,用户移动的那个menu不能收回
                if (Math.abs(initialX - ev.getX()) > TOUCH_THRESHOLD) {
                    isMoving = true;
                }
                break;
            case MotionEvent.ACTION_UP:
                mScrollThreshold = menuText.getWidth();
                if (!isMoving) {
                	//将所有菜单收回
                    notifyAboutToOpen();
                    //这里点击事件判断用getScrollX(),用户如果点击空白区域,菜单
                    //菜单会全部收回,getScrollX就会为0,用户抬手后就会触发点击content的操作
                    if (getScrollX() == 0) {
                        mOnMenuStateChangeListener.onContentClick(this);
                    }
                } else {
                	//判断滑动阈值超过一半展开,这里可自行更改也可以添加手指滑动速度判断
                    if (getScrollX() > mScrollThreshold / 2) {
                        smoothScrollTo(mScrollThreshold, 0);
                    } else if (getScrollX() <= mScrollThreshold / 2) {
                        smoothScrollTo(0, 0);
                    }
                }
                break;
            default:
                break;
        }
        return super.onTouchEvent(ev);
    }


public interface OnMenuStateChangeListener {
        /**
         * @Scenario: When the slide menu is closed.
         * @Function: Removes the closed menu from a list of open menus.
         */
        void onMenuClosed(SlideMenuScrollView view);

        /**
         * @Scenario: When the slide menu is fully opened.
         * @Function: Adds the menu to a list of open menus.
         */
        void onMenuFullyOpened(SlideMenuScrollView view);

        /**
         * @Scenario: When the slide menu is about to open.
         * @Function: Closes any already opened menus.
         */
        void onMenuAboutToOpen(SlideMenuScrollView view);

        /**
         * @Scenario: When a finger is pressed down.
         * @Function: Closes all menus except the current one.
         */
        void onActionDown(SlideMenuScrollView view);

        /**
         * @Scenario: When the slide menu is confirmed.
         * @Function: Performs actions related to confirming the menu.
         */
        void onMenuConfirm(SlideMenuScrollView view);

        /**
         * @Scenario: When the content area is clicked.
         * @Function: Performs actions related to clicking on the content area.
         */
        void onContentClick(SlideMenuScrollView view);
    }

adapter

java 复制代码
holder.scrollView.setOnMenuStateChangeListener(new SlideMenuScrollView.OnMenuStateChangeListener() {
            @Override
            public void onMenuClosed(SlideMenuScrollView view) {
                openedMenus.remove(view);
            }

            @Override
            public void onMenuFullyOpened(SlideMenuScrollView view) {
                openedMenus.add(view);
            }

            @Override
            public void onMenuAboutToOpen(SlideMenuScrollView view) {
                for (SlideMenuScrollView openedMenu : new ArrayList<>(openedMenus)) {
                    openedMenu.scrollWithAnimation(0, 0,300);
                }
                openedMenus.clear();
            }

            @Override
            public void onActionDown(SlideMenuScrollView view) {
                for (SlideMenuScrollView openedMenu : new ArrayList<>(openedMenus)) {
                    if (openedMenu != view) {
                        openedMenu.scrollWithAnimation(0, 0,300);
                        openedMenus.remove(openedMenu);
                    }
                }
            }

            @Override
            public void onMenuConfirm(SlideMenuScrollView view) {
                data.remove(position);
                notifyDataSetChanged();
            }

            @Override
            public void onContentClick(SlideMenuScrollView view) {
                Toast.makeText(view.getContext(), "Menu confirm", Toast.LENGTH_SHORT).show();
            }
        });

5、6目标

这两个点击事件就很简单了,需要注意一下menu的touch事件后需要拦截,不能继续向下分发事件,不然会触发scroll的down事件

java 复制代码
//可以自行更改需求,我这里的需求是点击删除,menu长度会增加,再次点击删除菜单
menuText.setOnTouchListener(new OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        mOnMenuStateChangeListener.onActionDown(SlideMenuScrollView.this);
                        break;
                    case MotionEvent.ACTION_UP:
                        if (isMenuConfirm) {
                            mOnMenuStateChangeListener.onMenuConfirm(SlideMenuScrollView.this);
                        } else {
                            updateMenuState();
                        }
                        break;
                    default:
                        break;
                }
                return true; //拦截事件,自己处理
            }
        });
        
private void updateMenuState() {
        isMenuConfirm = true;
        menuText.setText(getResources().getText(R.string.confirm_delete));
        ViewGroup.LayoutParams params = menuText.getLayoutParams();
        int newWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 144, getResources().getDisplayMetrics());
        int difference = newWidth - params.width;
        params.width = newWidth;
        menuText.setLayoutParams(params);
        scrollWithAnimation(mScrollThreshold + difference, 0, 100);//移动到menu变长后的位置
    }

    private void resetMenuState() {
        ViewGroup.LayoutParams textParams = menuText.getLayoutParams();
        if (textParams.width != menuDefaultWidth) {
            isMenuConfirm = false;
            menuText.setText(getResources().getText(R.string.delete));
            textParams.width = menuDefaultWidth;
            menuText.setLayoutParams(textParams);
            mScrollThreshold = menuDefaultWidth;
        }
    }

三、demo与注意事项

demo地址:github demo地址

注意事项: xml中使用SlideMenuScrollView需要设置menu_id和content_layout_id,目前自定义view中给contentLayout设置的是线性布局,可自行更改为其他布局。

相关推荐
To_OC4 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic5 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy5 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志5 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble6 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯7 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜8 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司8 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
kaiking_g9 小时前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
江华森9 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http