SweetCircularView循环滑动组件

在项目开发中,经常有首页轮播展示的需求,通常我们使用ViewPager就能满足需求。但经常随着需求变动,样式或者动画的修改,这时ViewPager往往改起来有点复杂了,并且要循环滑动时是最麻烦的。今天在这里为大家推荐一个专门为Banner设计的组件SweetCircularView,并且耦合度相当低,一个类可以直接提取出来使用。

详细介绍一下这个组件强大的功能支持,弥补了ViewPager在作为Banner时的功能缺陷。天生支持以下两个重要的特性:

  • 循环滑动
    手势/定时自动循环滑动啦,使用BaseAdapter实现内部视图复用,减少内存消耗滑动卡顿等问题。
    可配置属性:自定义滑动动画,手势快速滑动,滑动方向垂直或水平。
  • Item缩进
    缩进中心视图,并且展示左右视图,类似与PC网易云音乐首页Banner的样式。
    image.png

    当然除了以上两个属性意外,基本的Banner空间特性肯定是有的,比如弹性归位,惯性滑动,点击选中等等。附上组件源码:agility2/SweetCircularView,好用的话别忘了加颗闪亮的星星哦✨✨✨✨✨

下面来简单的介绍一下组件实现的基本原理,首先基本思路是给予Adapter的视图复用机制减少内存开销,然后重写onTouchEvent,onDispatchEvent,onInterceptTouchEvent,实现手势滑动相关逻辑,在滑动的时候将动画组件分离结偶,方便以后定义滑动动画,最后收尾的是视图滑动之后的停靠逻辑。

  • Adapter的视图复用,先贴关键代码:
    public SweetCircularView setAdapter(BaseAdapter cycleAdapter) {
        if (adapter != null) {
            adapter.unregisterDataSetObserver(dataSetObserver);
        }
        if (cycleAdapter != null) {
            dataSetObserver = new AdapterDataSetObserver();
            cycleAdapter.registerDataSetObserver(dataSetObserver);
        }
        adapter = cycleAdapter;
        if (null != adapter) {
            adapter.notifyDataSetChanged();
        }
        return this;
    }

        void updateView() {
            if (adapter != null && dataIndex >= 0 && dataIndex < adapter.getCount() && state == NONE) {
                state = USING;
                View convertView = adapter.getView(dataIndex, view, SweetCircularView.this);
                if (convertView == view) {
                    // nothing to do
                } else {
                    // remove old view
                    removeView();
                    // add new view
                    if (convertView != null) {
                        if (convertView.getParent() != SweetCircularView.this) {
                            addView(convertView);
                        }
                    }
                }
                //  ...
            }
        }

在新设置或调用adapter.notifyDataSetChanged时触发requestLayout使视图重新布局,在重新布局时先去出对应已经被添加的子视图使用adapter.getView进行视图刷新或创建,流程与ListView.setAdapter相同。

  • 重写onTouchEvent,onDispatchTouchEvent,onInterceptTouchEvent
    这三个方法是手势滑动的关键,主要思想是:首先在dispatch中判断事件是否需要进行拦截,在通过intercept返回true进行拦截,使事件进入onTouch完成移动。
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
        boolean superState = super.dispatchTouchEvent(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                needIntercept = false;
                lastPoint.set(event.getX(), event.getY());
                // 禁止父视图中的触摸事件,使事件派发到当前视图中
                // 处理ListView,ScrollView 嵌套的手势事件派发问题
                getParent().requestDisallowInterceptTouchEvent(true);
                return true;// can not return superState.
            case MotionEvent.ACTION_MOVE:
                float absXDiff = Math.abs(event.getX() - lastPoint.x);
                float absYDiff = Math.abs(event.getY() - lastPoint.y);
                if (orientation == LinearLayout.HORIZONTAL) {
                    if (absXDiff > absYDiff && absXDiff > MOVE_SLOP) {
                        // 当手指垂直或水平移动距离大于移动阀值时,确定为需要拦截处理
                        needIntercept = true;
                    } else if (absYDiff > absXDiff && absYDiff > MOVE_SLOP) {
                        // restore touch event in parent
                        getParent().requestDisallowInterceptTouchEvent(false);
                    }
                } else if (orientation == LinearLayout.VERTICAL) {
                    // 垂直滑动模式下 使用Y值
                    if (absYDiff > absXDiff && absYDiff > MOVE_SLOP) {
                        needIntercept = true;
                    } else if (absXDiff > absYDiff && absXDiff > MOVE_SLOP) {
                        // restore touch event in parent
                        getParent().requestDisallowInterceptTouchEvent(false);
                    }
                }
                // pause auto switch
                interceptAutoCycle();
                return superState;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                // ......
        }
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean superState = super.onInterceptTouchEvent(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                // 返回是否进行拦截,拦截的事件进入onTouchEvent 
                return needIntercept;
                // ......  其它 case 不拦截
        }
}
@Override
public boolean onTouchEvent(MotionEvent event) {
        boolean superState = super.onTouchEvent(event);
        velocityTracker.addMovement(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
               // ......
            case MotionEvent.ACTION_MOVE:
                // .......
                // 最终调用 move方法进行移动
                    if (orientation == LinearLayout.HORIZONTAL && absXDiff > absYDiff) {
                        move((int) -xDiff);
                    } else if (orientation == LinearLayout.VERTICAL && absYDiff > absXDiff) {
                        move((int) -yDiff);
                    }
                }
               // ......
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                if (isMoving) {
                    // 使用VelocityTracker获取手指离开时的滑动速度
                    if (LinearLayout.HORIZONTAL == orientation) {
                        offset = getScrollX();
                        velocity = velocityTracker.getXVelocity();
                    } else {
                        offset = getScrollY();
                        velocity = velocityTracker.getYVelocity();
                    }
                    // 根据手指离开视图时的速度计算惯性距离
                    int inertialDis = -(int) (velocity * durationOnInertial * inertialRatio);
                    if (Math.abs(inertialDis) + Math.abs(offset) <= maxOffset) {
                        inertialDis = 0;
                    }
                    // 开始自动滑动惯性距离
                    autoMove(inertialDis, durationOnInertial, new Runnable() {
                        @Override
                        public void run() {
                            // 自动滑动(惯性)之后停靠
                            autoPacking();
                        }
                    });
                }
                velocityTracker.clear();
                break;
            default:
                return superState;
        }
        return true;
}

进行真是移动的move方法,由于是视图复用机制,所以需要在滑动的同时去更新视图的信息,更新视图基于中心视图向左右或上下两边延伸。

protected final void move(final int offset) {
        isMoving = true;
        int scrolled, maxOffset;
        if (orientation == LinearLayout.VERTICAL) {
            scrollBy(0, offset);
            scrolled = getScrollY();
            maxOffset = getItemHeight() + spaceBetweenItems;
        } else { // HORIZONTAL
            scrollBy(offset, 0);
            scrolled = getScrollX();
            maxOffset = getItemWidth() + spaceBetweenItems;
        }
        notifyOnItemScrolled(offset);
        final int overOffset = Math.abs(scrolled) - maxOffset;
        if (overOffset >= 0) {
            final int size = getRecycleItemSize();
            ItemWrapper item;
            if (scrolled > 0) {
                // 右/下滑动,复用视图下标逐个-1
                for (int i = 0; i < size; i++) {
                    item = findItem(i);
                    item.itemIndex -= 1;
                }
            } else if (scrolled < 0) {
                for (int i = size - 1; i >= 0; i--) {
                    item = findItem(i);
                    item.itemIndex += 1;
                }
            }
            // cycleItemIndex:使视图展示内容与adapter中的数据下标进行绑定,形成循环
            for (ItemWrapper tmp : items) {
                tmp.itemIndex = cycleItemIndex(tmp.itemIndex);
            }
            if (orientation == LinearLayout.VERTICAL) {
                scrollTo(0, scrolled > 0 ? overOffset : -overOffset);
            } else { // HORIZONTAL
                scrollTo(scrolled > 0 ? overOffset : -overOffset, 0);
            }
            // 已中心视图作为参考点,向左右/上下两个方向更新视图
            updateAllItemView(getCurrentIndex());
            // 根据参数对齐视图位置和更新大小
            alignAllItemPosition();
        }
}

组件本身将动画效果实现结偶,自定义动画可以使用AnimationAdapter很方便精准的控制,以上大概讲述了组件的核心原理,最后付上链式调用方式,看上去十分简洁,最终的效果就是文章开头截图的效果啦~~

    private final BaseAdapter adapter = new ArrayAdapter() {
        @Override
        public View getView(int i, View view, ViewGroup parent) {
            if (null == view) {
                view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_item, null);
            }
            view.setOnClickListener(v -> logout(TAG, "onClick: [" + i + "]"));
            // TODO ......
            return view;
        }
    };

    private void initGallery(SweetCircularView circular) {
        circular.setAdapter(adapter)
                .setClick2Selected(false)                   // 点击视图选中(将点击的非中心视图移动到中心)
                .setDurationOnInertial(1000)                // 自动滑动到下一视图的动画时间
                .setDurationOnPacking(500)                  // 惯性停靠动画时间
                .setOverRatio(0.2f)                         // 手指滑动停止之后视图归位的越界系数(>20%为滑动到下一个视图)
                .setInertialRatio(0.01f)                    // 惯性滑动速度
                .setAutoCycle(true, true)                   // 自动滑动
                .setIntervalOnAutoCycle(4000)               // 自动滑动间隔
                .setIndent(320, 220, 320, 220)              // 设置中心视图参考与父视图的缩进边距(默认铺满父视图)
                .setAnimationAdapter(new SimpleCircularAnimator().setRotation(20)) // 设置动画适配器
                .setRecycleItemSize(gallery.getRecycleItemSize() + 2) // 设置可复用的视图个数
                .setOrientation(LinearLayout.HORIZONTAL)    // 设置滑动方向(垂直/水平)
                .setSpaceBetweenItems(gallery.getSpaceBetweenItems() - 20) // 设置相邻视图之间的间隙
                // .setIndicator(<T extends IIndicator> T);    // 绑定滑动指示器
                .setOnItemScrolledListener((v, dataIndex, offset) -> logout(TAG, "scrolled: [" + dataIndex + ", " + offset + "]"))
                .setOnItemSelectedListener((v, dataIndex) -> logout(TAG, "selected: [" + dataIndex + "]"));
    }

最后推荐Android快速开发的工具库,Github:agility2
agility2/CommonTools 基础工具类:压缩图片,渲染文字...
agility2/DynamicProxy 动态代理
agility2/FieldUtils 反射
agility2/IOUtils 流处理:对接,写文件...

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,254评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,875评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,682评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,896评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,015评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,152评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,208评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,962评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,388评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,700评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,867评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,551评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,186评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,901评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,689评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,757评论 2 351