Banner

作者: 狮_子歌歌 | 来源:发表于2016-12-26 23:52 被阅读456次

    Banner

    基本上所有的App首页都包含一个轮播器,一般称之为Banner。通过这一个组件可以实现以下几个功能:

    • 图片循环播放。
    • 可以添加标题文字。
    • 播放动画可以是自动的也可以是用户手动触发的。

    基本原理

    轮播器组件循环播放主要是横向的,所有第一时间想到了ViewPager来实现,并且利用自定义View的创建符合控件的方法。

    这个思路有个小问题,当ViewPager滚动到最后一个item时,不会自动重新回到第一个item,或者回到第一个item时效果非常差。经过Google后发现两种解决思路:

    1. 通过给PagerAdapter.getCount()返回一个很大的数字来实现循环播放。
    2. 通过ViewPager.setCurrentItem(pos, false)来取消最后一个到第一个的动画。

    实现

    实现上述两种方案之前,先做一些准备工作,编写一下复用布局文件:

    Banner布局

    <merge
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    
        <android.support.v4.view.ViewPager
            android:id="@+id/id_viewpager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
        <!-- 指示器容器 -->
        <LinearLayout
            android:id="@+id/id_dots_container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom"
            android:gravity="center"
            android:orientation="horizontal"
            android:padding="8dp"/>
    </merge>
    

    Banner ViewPager Item布局

    <FrameLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    
        <ImageView
            android:id="@+id/id_img_banner_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="centerCrop"/>
    
        <TextView
            android:id="@+id/id_tv_banner_content"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|center"
            android:layout_marginBottom="25dp"
            android:padding="8dp"
            android:textColor="@android:color/white"
            android:textSize="20sp"
            tools:text="Test"/>
    </FrameLayout>
    

    Activity Content 布局

    <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.support.v7.widget.Toolbar
            android:id="@+id/id_toolbar_banner_max"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:title="Banner with Max"
            android:background="?attr/colorPrimaryDark"/>
    
        <ListView
            android:id="@+id/id_listview_banner_max"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
    </LinearLayout>
    

    方案一

    自定义复合控件容器

    public class BannerMax extends FrameLayout implements View.OnClickListener{
    
    }
    

    构建复合组件布局

        private void initUI() {
            View banner = mInflater.inflate(R.layout.layout_banner, this, true);
            mViewPager = (ViewPager) banner.findViewById(R.id.id_viewpager);
            mDotsContainer = (LinearLayout) banner.findViewById(R.id.id_dots_container);
    
            int count = imgResourcesIds.length;
    
            for(int i = 0; i < count; i++) {
                ImageView dot = new ImageView(mContext);
                LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
                        LinearLayout.LayoutParams.WRAP_CONTENT,
                        LinearLayout.LayoutParams.WRAP_CONTENT
                );
                params.leftMargin = 5;
                params.rightMargin = 5;
                dot.setImageResource(R.drawable.bg_point_selector);
                if(i == 0)
                    dot.setEnabled(true);
                else
                    dot.setEnabled(false);
                mDotsContainer.addView(dot, params);
                dots.add(dot);
            }
    
            for(int i = 0; i < count; i++) {
                View content = mInflater.inflate(R.layout.layout_banner_content, mViewPager, false);
                ImageView img = (ImageView) content.findViewById(R.id.id_img_banner_content);
                TextView tv = (TextView) content.findViewById(R.id.id_tv_banner_content);
                img.setImageResource(imgResourcesIds[i]);
                contents.add(content);
                content.setOnClickListener(this);
            }
    
            mViewPager.setAdapter(new BannerAdapter());
            int resetIndex = (Integer.MAX_VALUE / 2) - (Integer.MAX_VALUE / 2) % count;
            mViewPager.setCurrentItem(resetIndex);
            mViewPager.addOnPageChangeListener(new BannerPagerChangeListener());
            startShow();
        }
    

    代码分析:首先将Banner布局文件添加到BannerMax这个容器中去。imgResourcesIds是存放了图片资源id的数组,用做Banner的数据源。根据数据源的数量,来创建指示器(dot),添加到Banner布局中的dot容器中;创建ViewPager Item(content),作为ViewPager的数据源。

    注意由于Banner布局文件将merge作为根标签,所以使用LayoutInflate加载布局时inflate()方法第三个参数一定为true。

    最后设置ViewPager的适配器,滑动监听(OnPageChangeListener),初始化第一item位置,以及开启自动循环动画。

    初始化第一个显示的item时,并不是简单的定位到0,而是选择Integer.MAX_VALUE / 2为基点的数据源中第一个item作为第一显示的item。这样做的好处就是当用户打开App后直接向右滑动,仍然可以显示更多的item。如果设置为0,则不可以向右滑动。

    自定义Adapter

    class BannerAdapter extends PagerAdapter {
            private int dataCount = imgResourcesIds.length;
            @Override
            public int getCount() {
                return Integer.MAX_VALUE;
            }
    
            @Override
            public boolean isViewFromObject(View view, Object object) {
    
                return view == object;
            }
    
            @Override
            public Object instantiateItem(ViewGroup container, int position) {
                container.addView(contents.get(position % dataCount));
                return contents.get(position % dataCount);
            }
    
            @Override
            public void destroyItem(ViewGroup container, int position, Object object) {
                container.removeView(contents.get(position % dataCount));
            }
        }
    

    代码分析:采用第一种方案后,在getCount()返回了一个很大的值,给用户造成无限循环的错觉。这样一来后面两个方法instantiateItem()destroyItem()中的参数position使用起来需要注意,一定要取除以数据源数量的余数,否则将抛出索引越界异常。虽然在getCount()中指定了可用item数量很多,但是通过取模的操作,保证在instantiateItem()中获取到相应的item view 添加到container中。

    实现监听器

    class BannerPagerChangeListener implements ViewPager.OnPageChangeListener {
            private int dataCount = imgResourcesIds.length;
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    
            }
    
            @Override
            public void onPageSelected(int position) {
                int newPosition = position % dataCount;
                dots.get(newPosition).setEnabled(true);
                dots.get(prePoint).setEnabled(false);
                prePoint = newPosition;
            }
    
            @Override
            public void onPageScrollStateChanged(int state) {
                switch(state) {
                    case ViewPager.SCROLL_STATE_DRAGGING:
                        isAutoPlay = false;
                        break;
                    case ViewPager.SCROLL_STATE_IDLE:
                        isAutoPlay = true;
                        break;
                    case ViewPager.SCROLL_STATE_SETTLING:
                        isAutoPlay = false;
                        break;
                }
            }
        }
    

    代码分析:可以看到一个类变量isAutoPlay用于决定是否自动滚动item。当ViewPager的状态发生改变时回调onPageScrollStateChanged()方法。用户操作(ViewPager.SCROLL_STATE_DRAGGING)时,将自动滚动关闭;当ViewPager自动校准item完全显示时(ViewPager.SCROLL_STATE_SETTLING),关闭自动滚动;
    当ViewPager item停止动画,完全显示后(ViewPager.SCROLL_STATE_IDLE),开启自动滚动。

    无论是手动滚动,还是自动滚动,ViewPager在没有结束item动画之前就可以确定要完全显示的item,会调用onPageSelected()方法。在这个方法里面设置指示器,制造跟随图片滚动的效果。

    自动循环播放

    private Runnable task = new Runnable() {
            @Override
            public void run() {
                int currentItem = mViewPager.getCurrentItem();
                if(isAutoPlay) {
                    mViewPager.setCurrentItem(currentItem + 1);
                }
                mHandler.postDelayed(task, delayTime);
            }
    };
    
    private void startShow() {
            isAutoPlay = true;
            mHandler.postDelayed(task, delayTime);
    }
    

    代码分析:采用Handler机制,利用postDelayed()向队列发送任务,并且延迟执行。由于isAutoPlay的限制,可以很好的解决自动滚动与手动滚动的冲突。

    暴露事件处理接口

    @Override
        public void onClick(View v) {
            if(mListener != null){
                Info entity = new Info();
                mListener.click(v, entity);
            }
        }
    
        public interface OnItemClickListener {
            void click(View view, Info entity);
        }
    
        public void setOnItemClickListener(OnItemClickListener listener) {
            mListener = listener;
        }
    

    代码分析:在BannerMax中创建了一个接口,用于给调用者实现点击Banner Item事件。

    参考

    Android自定义控件——仿淘宝、网易、彩票等广告条、Banner的制作

    效果

    BannerMax.gif

    方案二

    自定义复合控件容器

    public class Banner extends FrameLayout implements View.OnClickListener {
    }
    

    构建复合控件布局

    private void initUI() {
            //获取Banner布局,并将其添加到FrameLayout中.
            //注意merge标签定义的布局用LayoutInflate加载时第三个参数必须为true
            View view = mInflater.inflate(R.layout.layout_banner, this, true);
            mViewPager = (ViewPager) view.findViewById(R.id.id_viewpager);
            dotsContainer = (LinearLayout) view.findViewById(R.id.id_dots_container);
            dotsContainer.removeAllViews();
            //获取到数据源的数量
            int count = 0;
            if(mEntity != null)
                count = mEntity.size();
            else
                count = imgResourcesId.length;
    
            /**
             * 根据图片的个数,创建指示器
             */
            for (int i = 0; i < count; i++) {
                ImageView dot = new ImageView(mContext);
                LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
                        LinearLayout.LayoutParams.WRAP_CONTENT,
                        LinearLayout.LayoutParams.WRAP_CONTENT
                );
                params.leftMargin = 5;
                params.rightMargin = 5;
                dot.setImageResource(R.drawable.bg_point_selector);
                if (i == 0) {
                    dot.setEnabled(true);
                } else {
                    dot.setEnabled(false);
                }
                dotsContainer.addView(dot, params);
                dots.add(dot);
            }
    
            /**
             * 根据图片的个数,创建ViewPager数据源
             */
            for (int i = 0; i <= count + 1; i++) {
                View content = mInflater.inflate(R.layout.layout_banner_content, null);
                ImageView img = (ImageView) content.findViewById(R.id.id_img_banner_content);
                TextView tv = (TextView) content.findViewById(R.id.id_tv_banner_content);
    
                if (i == 0) {
                    img.setImageResource(imgResourcesId[count - 1]);
                } else if (i == count + 1) {
                    img.setImageResource(imgResourcesId[0]);
                } else {
                    img.setImageResource(imgResourcesId[i - 1]);
                }
                contents.add(content);
                content.setOnClickListener(this);
            }
            mViewPager.setAdapter(new BannerPagerAdapter());
            mViewPager.setCurrentItem(1);
            currentItem = 1;
            mViewPager.addOnPageChangeListener(new BannerPagerChangeListener());
            startPlay();
        }
    

    代码分析:主要的步骤和方案一类似,但是在创建ViewPager item时还是不一样的。实现方案二原理,在第一个item1的左边放置一个内容和最后一个itemLast相同的itemLeft,在最后一个itemLast右边放置一个内容和第一个item1相同的itemRight。滚动轮回:item1->...->itemLast->itemRight->item1。当从itemRight切换回item1时调用ViewPager.setCurrentItem(1, false)。反之从itemLeft->itemLast一样。

    最后和方案一一样设置适配器,监听器,初始化以及开启自动滚动。这里需要注意由于在itemLeft的存在,ViewPager初始化时要定位到position为1的item。

    自定义适配器

    class BannerPagerAdapter extends PagerAdapter {
            @Override
            public int getCount() {
                return contents.size();
            }
    
            @Override
            public boolean isViewFromObject(View view, Object object) {
                View content = contents.get((Integer) object);
                return view == content;
            }
    
            @Override
            public Object instantiateItem(ViewGroup container, int position) {
                container.addView(contents.get(position));
                return position;
            }
    
            @Override
            public void destroyItem(ViewGroup container, int position, Object object) {
                container.removeView(contents.get(position));
            }
        }
    

    代码分析:和一般实现适配器的方法相同。

    自定义监听器

    class BannerPagerChangeListener implements ViewPager.OnPageChangeListener {
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    
            }
    
            @Override
            public void onPageSelected(int position) {
                currentItem = position;
                if(position == imgResourcesId.length + 1) {
                    resetDots();
                    dots.get(0).setEnabled(true);
                }else if(position == 0) {
                    resetDots();
                    dots.get(imgResourcesId.length - 1).setEnabled(true);
                }else {
                    resetDots();
                    dots.get(position - 1).setEnabled(true);
                }
            }
    
            @Override
            public void onPageScrollStateChanged(int state) {
                switch(state) {
                    case ViewPager.SCROLL_STATE_DRAGGING:
                        isAutoPlay = false;
                        break;
                    case ViewPager.SCROLL_STATE_IDLE:
                        if(currentItem == imgResourcesId.length + 1) {
                            mViewPager.setCurrentItem(1, false);
                        }else if(currentItem == 0) {
                            mViewPager.setCurrentItem(imgResourcesId.length, false);
                        }
                        isAutoPlay = true;
                        break;
                    case ViewPager.SCROLL_STATE_SETTLING:
                        isAutoPlay = false;
                        break;
                }
            }
        }
    

    代码分析:这里同样使用一个boolean类变量isAutoPlay来解决手动滑动和自动滚动的冲突。ViewPager.SCROLL_STATE_DRAGGINGViewPager.SCROLL_STATE_SETTLING状态和方案一种的实现一样。主要在ViewPager.SCROLL_STATE_IDLE状态的实现。这里首先判断当前滚动到的item位置,分为以下几种情况:

    1. 当滑动到itemRight时,采用无动画方式切换到position=1的item,开启自动滚动。
    2. 当滑动到itemLeft时,采用无动画方式切换到itemLast,开启自动滚动。
    3. 正常情况,item完全显示,停止动画后,开启自动滚动。

    onPageSelected()方法中要将ViewPager选择完全显示的item索引给类变量currentItem,这样方便在onPageScrollStateChanged()中去判断现在处于上述情况中的哪一种,并作出相应处理。

    注意调用setCurrentItem(pos, false)时,ViewPager状态并不会改变,所以currentItem一定要在onPageSelected()获取,同时开启自动滚动一定要在ViewPager.SCROLL_STATE_IDLE状态下开启。相反调用setCurrentItem(pos)方法状态会从ViewPager.SCROLL_STATE_SETTLINGViewPager.SCROLL_STATE_IDLE改变。

    onPageSelected()还要设置指示器的显示,同样是根据上面三种情况,作出不同的设置。resetDots()方法重置所有dot状态。

    /**
     * 初始化所有指示器
     */
    private void resetDots() {
        for(int i = 0; i < dots.size(); i++) {
            dots.get(i).setEnabled(false);
        }
    }
    

    自动循环播放

        /**
         * 开启自动轮播
         */
        private void startPlay() {
            isAutoPlay = true;
            mHandler.postDelayed(task, delayTime);
        }
    
        /**
         * 利用Handler处理机制,实现循环轮播
         */
        private final Runnable task = new Runnable() {
            @Override
            public void run() {
                if(isAutoPlay) {
                    currentItem += 1;
                    mViewPager.setCurrentItem(currentItem);
                    mHandler.postDelayed(task,delayTime);
                }else {
                    mHandler.postDelayed(task, delayTime);
                }
            }
        };
    
    

    代码分析:利用Handler不断的向队列发送任务。这里不断的给currentItem增加1,难道不会索引越界吗?当然不会,因为setCurrentItem(pos)方法会改变ViewPager状态,接着调用ViewPager.SCROLL_STATE_IDLE状态实现的方法,最后在onPageSelected()中currentItem重新赋值。

    暴露事件处理接口

    这个和方案一完全一样...

    效果问题

    当用户快速的从itemLeft向右滑动,或者从itemRight快速滑动到item1时,会出现些许的卡顿,就像ViewPager滑动到了items的边界。所以有强迫症的话可以选择方案一。

    Banner.gif

    参考

    iKrelve/Kanner

    布局优化

    布局优化工具HierarchyViewer

    不合理的布局,会导致整个应用程序UI启动慢,给用户一种“卡顿”的错觉。如果直接看代码来分析这种“卡顿”现象,很难找到问题出现在哪里。Android SDK给我们提供了一个很好的工具-HierarchyViewer。它能够可视化的直接获取UI布局设计结构和整个ViewTree中View 的属性,堪称UI优化的利器。

    打开方式

    官方教程建议从Motion中打开HierarchyViewer。

    基本使用

    1. 连接模拟器,真机连接时可能无法显示。
    2. 在Window选项卡中选择当前模拟许显示的应用包名(一般为粗体)。
    3. 在TreeView窗口可以看到整个Application的ViewTree。
    4. 在TreeView中选择一个View,会显示一个窗口,详细记录了一些信息:节点的类名,View的id以及它的id名,该节点的Measure,Layout,Draw消耗的时间(包含View以及它的子View)和Measure、Layout、Draw指示器等。同时在左边的View Properties中显示了一些属性。

    这里可能遇到一个问题,选择一个View后,Measure、Layout、Draw时间会显示N/A。只需要选择TreeView窗口左上角的Profile Node的选项就可以。记住一定要选择View后在点击Profile Node。

    示意图

    初始化界面

    home_page.png

    Window选项卡

    window.png

    指定View信息窗口

    select_iew.png hv_treeview_screenshot.png

    连接真机

    Hierarchy Viewer只能连接搭载Android开发版系统的手机或模拟器。 Hierarchy Viewer在连接手机时,手机会启动View Server与其进行Socket通信;但在我们平常用的商业机上,是无法开启View Server的。

    检查一台手机是否开启了View Server的方法为:

    adb shell service call window 3

    • 若返回值是:Result: Parcel(00000000 00000000 '........'),说明View Server处于关闭状态;
    • 若返回值是:Result: Parcel(00000000 00000001 '........'),说明View Server处于开启状态。

    如果要在自己的手机上正常地使用Hierarchy Viewer,有两种方法:

    1. 直接刷一个开发版本的Android固件;
    2. 如果只需要查看自己开发的应用的UI层级,可以用Github上的一个项目ViewServer。

    手机端查看布局层次

    打开设置—>开发者选项->显示布局边界,可以查看布局结构。

    参考

    Android UI 优化——使用HierarchyViewer工具

    Hierarchy Viewer显示视图性能指标

    Optimizing Your UI

    include标签

    作用:达到布局的复用,方便修改。同时还可以定制特殊要求(修改android:layout_xxx属性)。
    使用方法

    <include layout="@layout/layout_name"/>
    
    

    如果需要修改,只需要在layout_name.xml中修改即可,不需要到使用的布局中修改。

    include标签中可以覆写所有android:layout_xxx属性。而且会覆盖掉layout_name.xml中根标签中的同名属性。同时还需要注意覆写android:layout_xxx属性时,一定要写android:layout_width&height这两个属性。否则无效。

    有一个使用技巧,在include标签中可以定义android:id属性,用于指定复用的布局的根View的id(会覆盖掉复用布局中根View设置的id)。这样在同一个布局中多次使用include复用同一个布局,可以通过include制定不同的id,在代码中获取对应的实例进行操作。如果只使用复用布局的根View的id来获取实例,永远只能获取布局中第一个include标签的实例。

    merge标签

    作用:防止在引用布局时产生多余的布局嵌套。辅助性扩展include标签。
    使用方法

    <merge xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
    
        <android.support.v4.view.ViewPager
            android:id="@+id/vp"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    
        <LinearLayout
            android:id="@+id/ll_dot"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom"
            android:gravity="right"
            android:orientation="horizontal"
            android:padding="8dp" >
        </LinearLayout>
    
    </merge>
    

    好处:Android系统去解析和展示一个布局是需要消耗时间的,布局嵌套的越多,那么解析起来就越耗时,性能也就越差。merge标签的作用就是减少多余的嵌套,可以加快解析,优化性能。

    当使用include标签复用一个布局时,该布局最外层的ViewGroup有时候是多余的。有无该ViewGroup都不会影响UI。所以使用merge标签作为改布局文件的根标签,来包含多个View。

    当使用LayoutInflate加载以merge为根标签的布局文件时,inflate()的第三个参数必须为true。

    这里有一个概念要区分,解析布局和View的绘制是两个操作。解析布局指的是setContentView(),LayoutInflate.inflate()等方法。而View绘制是Measure,Layout,Draw过程。

    ViewStub标签

    作为初级开发小白的我,很喜欢根据需求,通过设置View的visibility属性来显示和隐藏布局。但是在解析布局文件时,性能很差。因为在初始化解析布局时,也会把那些隐藏的View一一解析出来。
    作用:在需要时加载(解析)布局文件,节省初始化解析布局时cpu和内存,提高性能。
    使用,在布局文件中添加ViewStub节点

    <ViewStub
        android:id="@+id/stub_import"
        android:inflatedId="@+id/panel_import"
        android:layout="@layout/progress_overlay"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom" />
    

    这里看到两个属性:

    • android:layout,用于指定按需加载的布局。
    • android:inflatedId,用于指定按需加载的布局的id。

    使用ViewStub标签时,需要设置android:layout_width&height属性,以及android:layout,否则运行报错。

    ViewStub优点,它属于View的一种,但是没有大小,没有绘制功能,不参与布局,资源消耗很低。

    由于ViewStub是按需加载布局,那么一定是在代码中满足某些特殊情况才加载的,例如进度布局、网络失败显示的刷新布局、信息出错出现的提示布局等。那么在代码中怎么使用呢?

    1. 通过ViewStub标签的android:id属性获取到ViewStub实例。
    2. 然后调用setVisibility(View.VISIBLE)来解析隐藏的布局。或者使用ViewStub.inflate()方法解析,此方法有一个好处就是会返回一个解析布局的实例,方便再次隐藏,获取其childView实例等操作。

    再次隐藏可以设置inflate()返回的对象的Visibility属性来实现。一旦被隐藏的布局解析出来后,ViewStub标签中android:id属性就不可用了,并且ViewStub也不存在在当前的ViewTree中。所以如果要保留隐藏布局的实例可以通过inflate()返回的实力保存,或者后面通过android:inflatedId指定的id来获取实例。

    ViewStub属性android:layout指定的布局文件不可以是merge为根标签的布局。

    优化经验

    LinearLayout or RelativeLayout

    虽然建议使用LinearLayout,但是那是在布局层次相同的时候。如果实现同一个布局,使用RelativeLayout比使用LinearLayout布局层次少,那么应该使用RelativeLayout。

    多了解SDK中的控件

    例如实现一个文字和图片结合的布局,可以使用TextView的drawableLeft/Right属性,而不是使用ViewGroup来包含TextView和ImageView来实现。

    少使用layout_weight

    使用该属性会减慢View的测量速度。

    参考

    Android最佳性能实践(四)——布局优化技巧

    ANDROID 布局优化

    三招优化Android布局

    相关文章

      网友评论

          本文标题:Banner

          本文链接:https://www.haomeiwen.com/subject/osssvttx.html