[置顶] Android(安卓)自定义ViewGroup实现整个Item布局竖直跑马灯效果
16lz
2021-01-26
之前我也写过一篇关于Android竖直跑马灯效果的控件,不过这个控件是基于子Item是纯文本的情况,详情请移步:Android 自定义View实现竖直跑马灯效果,不过后面项目需求发生了变化,必须要整个Item包括图片啊文本啥的一起上下滚动,这个控件顿时就傻眼了,旧的设计架构是不行了,但是旧的思路依然可行。本文采取得思路和之前的是一样的,只是实现方式不同。放上效果图,DEMO在最下面
首先这里将大概的一些重点讲解一下:
- Item的布局采用XML的方式填充,这样更满足android的MVC开发模式
- Item的布局的数据填充,采用泛型+建造者模式实现
- 无线滚动的思路和上一篇文章一样,即在内存中只存在两个View,一个处于可见处,一个处于下方的不可见处。
- 重写ViewGroup的onMeasure和onLayout方法,在onMeasure方法中设置控件的高度和Item的高度一致,在onLayout方法进行初始化布局。
- 开启属性动画,动态得对子View的布局进行重设定,并且刷新UI。在动画完毕的监听中,对当前索引进行判断更新迭代
现在开始讲解,首先是一堆属性的声明和链式设置方法:
//滚动间隔时间 和滚动动画时间 public static final int DURATION_SCROLL = 3000; public static final int DURATION_ANIMATOR = 1000; //实体集合和子控件集合 private List<T> beans = new ArrayList<T>(); private List<View> views = new ArrayList<View>(2); private int itemLayoutId; private Handler handler = new Handler(); //宽度和高度(包括padding) private int width; private int height; //第一个子View的中点Y坐标 private int centerY; //是否结束滚动 private boolean isStopScroll = true; //当前的索引 private int current; private OnItemClickListener listener; private OnItemBuilder builder; public VerticalMarqueeLayout(Context context) { super(context); } public VerticalMarqueeLayout(Context context, AttributeSet attrs) { super(context, attrs); } public VerticalMarqueeLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public int getCurrentIndex(){ return current; } public VerticalMarqueeLayout listener(OnItemClickListener listener){ this.listener = listener; return this; } public VerticalMarqueeLayout builder(OnItemBuilder builder){ this.builder = builder; return this; } /** * 设置实体集合和item布局id */ public VerticalMarqueeLayout datas(List<T> beans, int itemLayoutId){ this.beans.clear(); this.beans.addAll(beans); this.itemLayoutId = itemLayoutId; return this; }
变量属性的申明已经有了足够的注释,这里就不累述了。主要是下面三个方法设置的方法,其中listener就是设置本类的一个监听器,datas方法就是将数据和所需要的Item布局id设置进来,这里的数据采用了泛型的模式。而这三个方法其中最重要的是builder方法,设置一个建造者,这个建造者是干嘛用的呢?我们接下来继续看:
public interface OnItemClickListener{ void onItemClick(int position); } public abstract class OnItemBuilder{ public abstract void assemble(View view, T t); private void measure(View view){ view.measure(MeasureSpec.makeMeasureSpec(width - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); } public void builder(View view, T t){ //先装配数据 assemble(view, t); //重新测量 measure(view); } }
点击监听器就不说了,主要是这个onItemBuilder基类,首先这个类会有一个装配方法assemble方法暴露出来给外界设置,然后又一个measure方法进行View的测量,其中的width变量是指本控件的宽度,后面会介绍。最后在builder方法中,将这两个方法进行组装。代码很少但是这的确就是建造者模式。好了这个类我们知道实现方式了,但是怎么来使用这个类呢,别急,慢慢来。
public void commit(){ if(builder == null){ throw new IllegalStateException("must invoke the method [builder(OnItemBuilder)]"); } this.views.clear(); if(beans != null && beans.size() != 0){ View view = View.inflate(getContext(), itemLayoutId, null); //在这里填充布局参数 if(builder != null){ builder.builder(view, beans.get(0)); } this.views.add(view); //这里通过手动设置全屏宽度的方式add addViewWidthMatchParent(view); //如果大于等于2个,初始化第二个View if(beans.size() > 1){ View view1 = View.inflate(getContext(), itemLayoutId, null); if(builder != null){ builder.builder(view1, beans.get(1)); } this.views.add(view1); addViewWidthMatchParent(view1); } //手动触发onMeasure和onDraw LayoutParams params = getLayoutParams(); if(params != null){ setLayoutParams(params); invalidate(); } current = 0; setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if(listener != null){ listener.onItemClick(current); } } }); } } private void addViewWidthMatchParent(View view){ LayoutParams params = new LayoutParams(view.getMeasuredWidth(), view.getMeasuredHeight()); addView(view, params); }
这个commit方法是必须调用的,首先这个方法对builder进行了非空判断,必须强制调用者进行数据填充,这样后来的高度计算才会正确。然后实例化第一个View,并且通过addViewWidthMatchParent方法添加到本控件。因为这个view已经measure过了,所以view.getMeasureWidth()的值就等于本控件的宽度值,view.getMeasureHeight就等于数据填充后控件的实际大小。然后判断当前数据源如果大于1的话,再同上一样初始化一个View。内存中一共就只存在两个View。然后通过getLayoutParams得到LayoutParams然后再直接设置进去,有人想了这不是多此一举的无用代码么。非也,这句代码主要是为了触发调用onMeasure方法。为什么要触发呢?因为beans的数据源可能发生变化,那么本控件的宽高也可能发生变化,这里主要是为了刷新作用。 接下来就是onMeasure方法的重写了:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if(beans.size() == 0 || views.size() == 0){ super.onMeasure(widthMeasureSpec, heightMeasureSpec); }else{ width = MeasureSpec.getSize(widthMeasureSpec); //这里必须再次measure一次,因为此时width才真正意义上有值 views.get(0).measure(MeasureSpec.makeMeasureSpec(width - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); height = views.get(0).getMeasuredHeight() + getPaddingTop() + getPaddingBottom(); centerY = height / 2; views.get(1).measure(MeasureSpec.makeMeasureSpec(width - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); } }
首先进行非空判断,首先得到本控件的宽度width的值,然后重新进行一次measure,这个步骤是必不可少的,因为width此时才被真正的赋值。然后将第一个View的高度加上padding值作为本控件的高度,centerY作为本控件的中点Y坐标,直接设置测量值。 控件的高度解决了,那么如何实现控件的滚动呢。首先给出第一部分的代码
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { //第一次布局时 if(changed){ change(l + getPaddingLeft(), t + getPaddingTop(), r - getPaddingRight(), b - getPaddingBottom()); } } private void change(int l, int t, int r, int b) { if(views.size() != 0){ //布局第一个View int dy = height / 2 - centerY; views.get(0).layout(l, t - dy, r, b - dy); //布局第二个View if(views.size() > 1){ views.get(1).layout(l, t + height - dy, r, b + height - dy); } } }
重写onLayout方法,并且只有在changed等于true的时候才进行重布局,也就是只有第一次初始化的时候才调用。这个change方法就是对内存中的两个View进行布局。首先通过本控件中点Y坐标减去第一个View的中点Y坐标得到偏移值,然后进行第一个View的布局。再然后,将第二个View紧紧地布局在第一个View的下方。这个change方法非常重要,下面的代码还会接着调用。
public void startScroll(){ stopScroll(); if(views.size() > 1){ isStopScroll = false; if(!isStopScroll){ handler.postDelayed(new Runnable() { @Override public void run() { scroll(); if(!isStopScroll){ handler.postDelayed(this, DURATION_SCROLL); } } }, DURATION_SCROLL); } } } private void scroll() { ValueAnimator animator = ValueAnimator.ofPropertyValuesHolder( PropertyValuesHolder.ofInt("centerY", height / 2, - height / 2)).setDuration(DURATION_ANIMATOR); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { centerY = (Integer) animation.getAnimatedValue("centerY"); //手动布局 change(getPaddingLeft(), getPaddingTop(), width - getPaddingRight(), height - getPaddingBottom()); invalidate(); } }); animator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) {} @Override public void onAnimationEnd(Animator animation) { //动画结束,进行迭代 current ++; //删除第一个View,然后加入到最后一个的位置 removeViewAt(0); View view = views.remove(0); views.add(view); addViewWidthMatchParent(view); //边界检查 if(current == beans.size() - 1){ if(builder != null){ builder.builder(views.get(0), beans.get(current)); builder.builder(view, beans.get(0)); } }else if(current == beans.size()){ current = 0; if(builder != null){ builder.builder(views.get(0), beans.get(current)); builder.builder(view, beans.get(current + 1)); } }else{ if(builder != null){ builder.builder(views.get(0), beans.get(current)); builder.builder(view, beans.get(current + 1)); } } //每一次动画结束,都触发onMeasure和onDraw,防止每一个Item的高度发生变化而出现错位 LayoutParams params = getLayoutParams(); setLayoutParams(params); invalidate(); } @Override public void onAnimationCancel(Animator animation) {} @Override public void onAnimationRepeat(Animator animation) {} }); animator.start(); } public void stopScroll(){ isStopScroll = true; handler.removeCallbacksAndMessages(null); }
滚动我采取的是Handler+ValueAnimator的方式实现,startScroll方法主要进行一个无限延迟执行的任务,在stopScroll方法中停止这个任务。主要是实现逻辑是在scroll方法中。首先用ValueAnimator属性动画对中点y坐标和中点y坐标的负值进行遍历,然后得到实际第一个View的中点y坐标,然后调用change方法进行重布局,并且invalidate方法进行UI的重绘。动画结束的监听器中,对当前索引值current进行自加,然后将第一个View从控件中移除,再加入到控件的最后面。然后对current进行边界判断,并且调用builder方法进行数据与界面的重绑定。每次动画结束时都通过LayoutParams的方式触发onMeasure,因为Item不同内容也不同,控件的高度也不一定相同,如果不重设置高度,也许会出现错位或者显示不全的问题。
代码的讲解就到这儿了,接下来放出整个控件的代码:
package cc.wxf.component;import android.animation.Animator;import android.animation.PropertyValuesHolder;import android.animation.ValueAnimator;import android.content.Context;import android.os.Handler;import android.util.AttributeSet;import android.view.View;import android.view.ViewGroup;import java.util.ArrayList;import java.util.List;/** * Created by ccwxf on 2016/8/1. */public class VerticalMarqueeLayout<T> extends ViewGroup { //滚动间隔时间 和滚动动画时间 public static final int DURATION_SCROLL = 3000; public static final int DURATION_ANIMATOR = 1000; //实体集合和子控件集合 private List<T> beans = new ArrayList<T>(); private List<View> views = new ArrayList<View>(2); private int itemLayoutId; private Handler handler = new Handler(); //宽度和高度(包括padding) private int width; private int height; //第一个子View的中点Y坐标 private int centerY; //是否结束滚动 private boolean isStopScroll = true; //当前的索引 private int current; private OnItemClickListener listener; private OnItemBuilder builder; public VerticalMarqueeLayout(Context context) { super(context); } public VerticalMarqueeLayout(Context context, AttributeSet attrs) { super(context, attrs); } public VerticalMarqueeLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public int getCurrentIndex(){ return current; } public VerticalMarqueeLayout listener(OnItemClickListener listener){ this.listener = listener; return this; } public VerticalMarqueeLayout builder(OnItemBuilder builder){ this.builder = builder; return this; } /** * 设置实体集合和item布局id */ public VerticalMarqueeLayout datas(List<T> beans, int itemLayoutId){ this.beans.clear(); this.beans.addAll(beans); this.itemLayoutId = itemLayoutId; return this; } public void commit(){ if(builder == null){ throw new IllegalStateException("must invoke the method [builder(OnItemBuilder)]"); } this.views.clear(); if(beans != null && beans.size() != 0){ View view = View.inflate(getContext(), itemLayoutId, null); //在这里填充布局参数 if(builder != null){ builder.builder(view, beans.get(0)); } this.views.add(view); //这里通过手动设置全屏宽度的方式add addViewWidthMatchParent(view); //如果大于等于2个,初始化第二个View if(beans.size() > 1){ View view1 = View.inflate(getContext(), itemLayoutId, null); if(builder != null){ builder.builder(view1, beans.get(1)); } this.views.add(view1); addViewWidthMatchParent(view1); } //手动触发onMeasure和onDraw LayoutParams params = getLayoutParams(); if(params != null){ setLayoutParams(params); invalidate(); } current = 0; setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if(listener != null){ listener.onItemClick(current); } } }); } } private void addViewWidthMatchParent(View view){ LayoutParams params = new LayoutParams(view.getMeasuredWidth(), view.getMeasuredHeight()); addView(view, params); } public void startScroll(){ stopScroll(); if(views.size() > 1){ isStopScroll = false; if(!isStopScroll){ handler.postDelayed(new Runnable() { @Override public void run() { scroll(); if(!isStopScroll){ handler.postDelayed(this, DURATION_SCROLL); } } }, DURATION_SCROLL); } } } private void scroll() { ValueAnimator animator = ValueAnimator.ofPropertyValuesHolder( PropertyValuesHolder.ofInt("centerY", height / 2, - height / 2)).setDuration(DURATION_ANIMATOR); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { centerY = (Integer) animation.getAnimatedValue("centerY"); //手动布局 change(getPaddingLeft(), getPaddingTop(), width - getPaddingRight(), height - getPaddingBottom()); invalidate(); } }); animator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) {} @Override public void onAnimationEnd(Animator animation) { //动画结束,进行迭代 current ++; //删除第一个View,然后加入到最后一个的位置 removeViewAt(0); View view = views.remove(0); views.add(view); addViewWidthMatchParent(view); //边界检查 if(current == beans.size() - 1){ if(builder != null){ builder.builder(views.get(0), beans.get(current)); builder.builder(view, beans.get(0)); } }else if(current == beans.size()){ current = 0; if(builder != null){ builder.builder(views.get(0), beans.get(current)); builder.builder(view, beans.get(current + 1)); } }else{ if(builder != null){ builder.builder(views.get(0), beans.get(current)); builder.builder(view, beans.get(current + 1)); } } //每一次动画结束,都触发onMeasure和onDraw,防止每一个Item的高度发生变化而出现错位 LayoutParams params = getLayoutParams(); setLayoutParams(params); invalidate(); } @Override public void onAnimationCancel(Animator animation) {} @Override public void onAnimationRepeat(Animator animation) {} }); animator.start(); } public void stopScroll(){ isStopScroll = true; handler.removeCallbacksAndMessages(null); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if(beans.size() == 0 || views.size() == 0){ super.onMeasure(widthMeasureSpec, heightMeasureSpec); }else{ width = MeasureSpec.getSize(widthMeasureSpec); //这里必须再次measure一次,因为此时width才真正意义上有值 views.get(0).measure(MeasureSpec.makeMeasureSpec(width - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); height = views.get(0).getMeasuredHeight() + getPaddingTop() + getPaddingBottom(); centerY = height / 2; views.get(1).measure(MeasureSpec.makeMeasureSpec(width - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { //第一次布局时 if(changed){ change(l + getPaddingLeft(), t + getPaddingTop(), r - getPaddingRight(), b - getPaddingBottom()); } } private void change(int l, int t, int r, int b) { if(views.size() != 0){ //布局第一个View int dy = height / 2 - centerY; views.get(0).layout(l, t - dy, r, b - dy); //布局第二个View if(views.size() > 1){ views.get(1).layout(l, t + height - dy, r, b + height - dy); } } } public interface OnItemClickListener{ void onItemClick(int position); } public abstract class OnItemBuilder{ public abstract void assemble(View view, T t); private void measure(View view){ view.measure(MeasureSpec.makeMeasureSpec(width - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); } public void builder(View view, T t){ //先装配数据 assemble(view, t); //重新测量 measure(view); } }}
然后是这个控件怎么使用,非常简单。首先是Item的实体类,标标准准的javabean
package cc.wxf.androiddemo;/** * Created by ccwxf on 2016/8/1. */public class Bean { private int icon; private String time; private String title; private String summary; public Bean() { } public Bean(int icon, String time, String title, String summary) { this.icon = icon; this.time = time; this.title = title; this.summary = summary; } public int getIcon() { return icon; } public void setIcon(int icon) { this.icon = icon; } public String getTime() { return time; } public void setTime(String time) { this.time = time; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getSummary() { return summary; } public void setSummary(String summary) { this.summary = summary; } @Override public String toString() { return "Bean{" + "icon=" + icon + ", time='" + time + '\'' + ", title='" + title + '\'' + ", summary='" + summary + '\'' + '}'; }}
然后是这个bean对应的item布局文件:
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@android:color/white" android:padding="5dp" > <ImageView android:id="@+id/icon" android:layout_width="50dp" android:layout_height="50dp" android:scaleType="centerCrop" android:src="@mipmap/ic_launcher" /> <TextView android:id="@+id/time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:text="2016-8-1 14:42" android:textColor="@android:color/holo_blue_light" android:textSize="13sp" /> <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="10dp" android:layout_marginRight="10dp" android:layout_toLeftOf="@id/time" android:layout_toRightOf="@id/icon" android:text="南海仲裁不过为一张废纸" android:textColor="@android:color/black" android:lines="1" android:ellipsize="end" android:textSize="15sp" /> <TextView android:id="@+id/summary" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignLeft="@id/title" android:layout_below="@id/title" android:layout_marginTop="5dp" android:ellipsize="end" android:lines="2" android:text="南海仲裁不过为一张废纸南海仲裁不过为一张废纸南海仲裁不过为一张废纸南海仲裁不过为一张废纸" android:textColor="@android:color/darker_gray" android:textSize="13sp" /></RelativeLayout>
然后是我们的activity_main.xml
<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#e3e3e3" android:orientation="vertical" > <cc.wxf.component.VerticalMarqueeLayout android:id="@+id/vmLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="10dp" /></LinearLayout>
最后是MainActivity类:
package cc.wxf.androiddemo;import android.app.Activity;import android.os.Bundle;import android.view.View;import android.widget.ImageView;import android.widget.TextView;import android.widget.Toast;import java.util.ArrayList;import java.util.List;import cc.wxf.component.VerticalMarqueeLayout;public class MainActivity extends Activity { private VerticalMarqueeLayout<Bean> vmLayout; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); List<Bean> beans = new ArrayList<Bean>(); for(int i = 0; i < 6; i++){ beans.add(new Bean(R.mipmap.ic_launcher, "2016/8/1 15:51" , "南海仲裁一张废纸" + i ,"南海仲裁不过为一张废纸南海仲裁不过为一张废纸南海仲裁不过为一张废纸南海仲裁不过为一张废纸" + i)); } vmLayout = (VerticalMarqueeLayout<Bean>) findViewById(R.id.vmLayout); vmLayout.datas(beans, R.layout.item).builder(vmLayout.new OnItemBuilder(){ @Override public void assemble(View view, Bean bean) { ImageView icon = (ImageView) view.findViewById(R.id.icon); TextView time = (TextView) view.findViewById(R.id.time); TextView title = (TextView) view.findViewById(R.id.title); TextView summary = (TextView) view.findViewById(R.id.summary); icon.setImageResource(bean.getIcon()); time.setText(bean.getTime()); title.setText(bean.getTitle()); summary.setText(bean.getSummary()); } }).listener(new VerticalMarqueeLayout.OnItemClickListener() { @Override public void onItemClick(int position) { Toast.makeText(MainActivity.this, "当前选择:" + position, Toast.LENGTH_SHORT).show(); } }).commit(); vmLayout.startScroll(); } @Override protected void onDestroy() { super.onDestroy(); if(vmLayout != null){ vmLayout.stopScroll(); } }}
好了,所有都搞定了。接下来就是照顾懒人了,我知道很多人看见字太多,都是直接翻最后找DEMO的: 点我去下载DEMO
更多相关文章
- Android(安卓)bugs——RecyclerView scrollToPosition不会触发sc
- Android坐标系统常用方法属性总结
- Android(安卓)ROM分析(1):刷机原理及方法
- android 游戏:俄罗斯方块的小结
- android 图片下面显示文字
- Android(安卓)RecyclerView网格布局示例解析
- ProgressView
- 调用百度语音SDK,简单的语音识别控件
- BigInteger类的使用方法