Banner
基本上所有的App首页都包含一个轮播器,一般称之为Banner。通过这一个组件可以实现以下几个功能:
- 图片循环播放。
- 可以添加标题文字。
- 播放动画可以是自动的也可以是用户手动触发的。
基本原理
轮播器组件循环播放主要是横向的,所有第一时间想到了ViewPager来实现,并且利用自定义View的创建符合控件的方法。
这个思路有个小问题,当ViewPager滚动到最后一个item时,不会自动重新回到第一个item,或者回到第一个item时效果非常差。经过Google后发现两种解决思路:
- 通过给PagerAdapter.getCount()返回一个很大的数字来实现循环播放。
- 通过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的制作
效果
方案二
自定义复合控件容器
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_DRAGGING
和ViewPager.SCROLL_STATE_SETTLING
状态和方案一种的实现一样。主要在ViewPager.SCROLL_STATE_IDLE
状态的实现。这里首先判断当前滚动到的item位置,分为以下几种情况:
- 当滑动到itemRight时,采用无动画方式切换到position=1的item,开启自动滚动。
- 当滑动到itemLeft时,采用无动画方式切换到itemLast,开启自动滚动。
- 正常情况,item完全显示,停止动画后,开启自动滚动。
在onPageSelected()
方法中要将ViewPager选择完全显示的item索引给类变量currentItem
,这样方便在onPageScrollStateChanged()
中去判断现在处于上述情况中的哪一种,并作出相应处理。
注意调用setCurrentItem(pos, false)时,ViewPager状态并不会改变,所以currentItem一定要在
onPageSelected()
获取,同时开启自动滚动一定要在ViewPager.SCROLL_STATE_IDLE
状态下开启。相反调用setCurrentItem(pos)
方法状态会从ViewPager.SCROLL_STATE_SETTLING
到ViewPager.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的边界。所以有强迫症的话可以选择方案一。
参考
布局优化
布局优化工具HierarchyViewer
不合理的布局,会导致整个应用程序UI启动慢,给用户一种“卡顿”的错觉。如果直接看代码来分析这种“卡顿”现象,很难找到问题出现在哪里。Android SDK给我们提供了一个很好的工具-HierarchyViewer。它能够可视化的直接获取UI布局设计结构和整个ViewTree中View 的属性,堪称UI优化的利器。
打开方式
官方教程建议从Motion中打开HierarchyViewer。
基本使用
- 连接模拟器,真机连接时可能无法显示。
- 在Window选项卡中选择当前模拟许显示的应用包名(一般为粗体)。
- 在TreeView窗口可以看到整个Application的ViewTree。
- 在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。
示意图
初始化界面
Window选项卡
指定View信息窗口
连接真机
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,有两种方法:
- 直接刷一个开发版本的Android固件;
- 如果只需要查看自己开发的应用的UI层级,可以用Github上的一个项目ViewServer。
手机端查看布局层次
打开设置—>开发者选项->显示布局边界,可以查看布局结构。
参考
Android UI 优化——使用HierarchyViewer工具
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是按需加载布局,那么一定是在代码中满足某些特殊情况才加载的,例如进度布局、网络失败显示的刷新布局、信息出错出现的提示布局等。那么在代码中怎么使用呢?
- 通过ViewStub标签的android:id属性获取到ViewStub实例。
- 然后调用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的测量速度。