先上效果图:
本文采用了两种方式实现了如上效果。
方案一:完全自定义的View填充。
自定义HorizontalBarChart的思路为:使该控件继承自RelativeLayout,然后根据传入的数据组装并填充item布局;声明一个线性布局,将这些item布局依次添加到线性布局中(还需要额外添加头布局和尾布局);然后声明一个ScrollView,将线性布局添加到ScrollView中;接着分别添加该ScrollView和竖直居中显示的红色的线布局到HorizontalBarChart中;最后在整个布局显示出来后,重新测量该布局的高度和宽度,重设头布局和尾布局的高度,使其分别达到整个布局高度的一半,以便可以充分滑动。最后需要着重处理的是ScrollView滑动时判断哪个item到达中线位置的监听事件(此应为该自定义控件的核心)。
下面进行详细介绍。首先定义实现item的布局,布局如下:
<?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="wrap_content"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginLeft="100dp"
android:background="#888888"/>
<View
android:layout_width="1dp"
android:layout_height="15dp"
android:layout_marginLeft="100dp"
android:background="#888888"/>
<LinearLayout
android:id="@+id/fill_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_title"
android:layout_width="100dp"
android:layout_height="match_parent"
android:paddingLeft="5dp"
android:paddingRight="5dp"
android:gravity="center"
android:textSize="15sp"/>
<TextView
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#888888"/>
<LinearLayout
android:id="@+id/layout_data"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
</LinearLayout>
</LinearLayout>
<View
android:layout_width="1dp"
android:layout_height="15dp"
android:layout_marginLeft="100dp"
android:background="#888888"/>
</LinearLayout>
数据格式:
/**
* View所需要的数据
*/
public class Data{
//标题(时间)
private CharSequence title;
//数据,key是颜色代码,value是值
private Map<Integer, Float> valueMap;
//附带标记,拓展用
private Object tag;
//...省略getter、setter方法
}
根据数据组装布局:
private void show(){
removeAllViews();
//红色的中间横线
View vLine = new View(getContext());
LayoutParams lineParams = new LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, dip2px(lineHeight));
lineParams.addRule(CENTER_VERTICAL);
vLine.setLayoutParams(lineParams);
vLine.setBackgroundColor(lineColor);
scrollView = new ScrollView(getContext()){
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
onScroll(t);
}
};
LayoutParams scrollParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
scrollView.setLayoutParams(scrollParams);
LinearLayout layout = new LinearLayout(getContext());
layout.setOrientation(LinearLayout.VERTICAL);
FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
layout.setLayoutParams(layoutParams);
//中间的数据部分
if(chartData != null){
maxValue = calculateMaxValue();
headView = getFillView();
layout.addView(headView); // ------
// |
int index = 0; // |
for(Data data : chartData){
index++;
ScrollLocation scrollLocation = new ScrollLocation();
View dataView = getDataView(data);
layout.addView(dataView);
scrollLocation.setData(data);
scrollLocation.setIndex(index);
scrollLocation.setSelectedView(dataView);
scrollLocationList.add(scrollLocation);
}
footView = getFillView();
layout.addView(footView); // ------
} // |
// |
scrollView.addView(layout);
addView(scrollView);
addView(vLine); //红色的中间横线
isCompleteShow = true;
}
其中为了方便进行滑动事件的判断,需要一个对象来保存相关数据:
//保存滑动所需要的对象
private class ScrollLocation{
private View selectedView;
private Data data;
private int index = 1;
public int getHeight(){
return selectedView.getHeight();
}
//...省略getter、setter方法
}
需要注意的是在组装布局画直方图的时候需要计算所有数据中的最大值,根据当前数据与最大值的比例来画不同长度的直方图:
/**
* 计算出最大值
* @return
*/
private float calculateMaxValue(){
float maxValue = -1;
for(Data data : chartData){
Collection<Float> values = data.getValueMap().values();
for(Float value : values){
if(maxValue <= value){
maxValue = value;
}
}
}
return maxValue;
}
布局组装完毕后需要测量其宽高并重设头布局和尾布局的高度:
private void init(){
//添加布局监听器,以获得view的真实高度
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if(isCompleteShow){
//因为布局监听器可能会被多次调用,所以需要及时注销
getViewTreeObserver().removeOnGlobalLayoutListener(this);
height = getHeight();
lineLocation = height / 2;
//让上方的填充线填满上半屏
View footFillView = footView.findViewById(R.id.fill_top);
LinearLayout.LayoutParams footFillLp = (LinearLayout.LayoutParams)footFillView.getLayoutParams();
footFillLp.height = lineLocation;
LinearLayout.LayoutParams footLp = (LinearLayout.LayoutParams) footView.getLayoutParams();
footLp.height = lineLocation;
footFillView.setLayoutParams(footFillLp);
footView.setLayoutParams(footLp);
//让下方的填充线填满下半屏
View headFillView = headView.findViewById(R.id.fill_top);
LinearLayout.LayoutParams headFillLp = (LinearLayout.LayoutParams)headFillView.getLayoutParams();
headFillLp.height = lineLocation;
LinearLayout.LayoutParams headLp = (LinearLayout.LayoutParams) headView.getLayoutParams();
headLp.height = lineLocation;
headFillView.setLayoutParams(headFillLp);
headView.setLayoutParams(headLp);
onScroll(scrollY);
return;
}
width = getWidth();
if(chartData != null){
show();
}
}
});
}
定义滚动到item时的监听器:
public interface OnSelectedChangeListener{
/**
* 当选中时执行
* @param barChart
* @param selectedView 选中的view
* @param selectedData 选中的数据
*/
void onSelectedChange(HorizontalBarChart barChart, View selectedView, Data selectedData);
/**
* 当取消选中时执行
* @param barChart
* @param unselectedView 取消选中的view
* @param unselectedData 取消选中的数据
*/
void onUnselectedChange(HorizontalBarChart barChart, View unselectedView, Data unselectedData);
}
当布局滚动时,首先遍历ScrollLocation对象,找到第一个累计高度>=滚动距离并且未选中的item,判断其索引与当前选中的索引是否相等,如果不同就表示这是一个新选中的item,调用相应的回调监听方法:
/**
* 滑动时执行,执行监听器,修改选中背景
* @param y 滑动距离
*/
private void onScroll(int y){
scrollY = y;
int height = 0;
boolean isSelected = false;
for(int i = 0; i < scrollLocationList.size(); i++ ){
ScrollLocation scrollLocation = scrollLocationList.get(i);
//找到第一个高度大于或等于滑动距离的
if(height + scrollLocation.getHeight() >= y && !isSelected){
if(mSelectedScrollLocation == -1 || mSelectedScrollLocation != scrollLocation.getIndex()){
scrollLocation.getSelectedView().setBackgroundColor(selectedColor);
if(mSelectedScrollLocation != -1) {
ScrollLocation unscrollLoaction = scrollLocationList.get(mSelectedScrollLocation - 1); //因为索引从1开始,所以这里-1
if (onSelectedChangeListener != null) {
onSelectedChangeListener.onUnselectedChange(this, unscrollLoaction.getSelectedView(), unscrollLoaction.getData());
}
}
if(onSelectedChangeListener != null){
onSelectedChangeListener.onSelectedChange(this, scrollLocation.getSelectedView(), scrollLocation.getData());
}
mSelectedScrollLocation = scrollLocation.getIndex(); //当前选中的索引
}
isSelected = true;
}else {
scrollLocation.getSelectedView().setBackgroundColor(unSelectedColor);
}
height += scrollLocation.getHeight();
}
}
方案二:ListView+自定义的View填充。
该方案的实现思路和第一种方案大同小异,主要区别在于数据填充与组装View时采用的ListView,然后滚动监听处理方面页不同。
数据格式:
/**
* View所需要的数据
*/
public class Data{
//标题(时间)
private CharSequence title;
//数据,key是颜色代码,value是值
private Map<Integer, Float> valueMap;
//附带标记,拓展用
private Object tag;
private boolean isSelected = false;
}
根据数据组装布局:
private void show(){
removeAllViews();
//红色的中间横线
View vLine = new View(getContext());
LayoutParams lineParams = new RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, dip2pix(lineHeight));
lineParams.addRule(CENTER_VERTICAL);
vLine.setLayoutParams(lineParams);
vLine.setBackgroundColor(lineColor);
//ListView的初始化
mListView = new ListView(mContext);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT);
mListView.setLayoutParams(params);
mListView.setDividerHeight(0);
headerView = getHeaderFillView();
footerView = getFooterFillView();
mListView.addHeaderView(headerView);
mListView.addFooterView(footerView);
adapter = new ListAdapter();
mListView.setAdapter(adapter);
addView(mListView);
addView(vLine);
isCompleteShow = true;
}
其中ListView的适配器:
private class ListAdapter extends BaseAdapter{
public ListAdapter(){
maxValue = calculateMaxValue();
}
@Override
public int getCount() {
return dataList == null ? 0 : dataList.size();
}
@Override
public Data getItem(int position) {
return dataList == null ? null : dataList.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
View view;
MyViewHolder holder;
if(convertView == null){
view = View.inflate(mContext, R.layout.item_horizontal_barchart, null);
holder = new MyViewHolder();
holder.llFillTop = view.findViewById(R.id.fill_top);
holder.tvTitle = view.findViewById(R.id.tv_title);
holder.llLayoutData = view.findViewById(R.id.layout_data);
view.setTag(holder);
}else{
view = convertView;
holder = (MyViewHolder) view.getTag();
}
Data data = dataList.get(position);
holder.tvTitle.setText(data.getTitle());
if(maxValue > 0){
float totalWidth = width - dip2pix(104);
Set<Integer> keySet = data.getValueMap().keySet();
holder.llLayoutData.removeAllViews();
for(Integer color : keySet){
float value = data.getValueMap().get(color);
View viewLine = new View(mContext);
int lineWidth = (int) ((value / maxValue) * totalWidth);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(lineWidth, dip2pix(10));
params.topMargin = dip2pix(5);
viewLine.setLayoutParams(params);
viewLine.setBackgroundColor(color);
holder.llLayoutData.addView(viewLine);
}
}
if(data.isSelected()){
view.setBackgroundColor(selectedColor);
}else{
view.setBackgroundColor(unSelectedColor);
}
return view;
}
}
布局填充完毕后测量布局的宽高,然后重设头布局和尾布局:
private void init(Context context){
this.mContext = context;
this.mHorizontalBarChart2 = this;
//添加布局监听器,以获得view的真实高度
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if(isCompleteShow){
if(!isHeaderChanged) {
isHeaderChanged = true;
//因为布局监听器可能会被多次调用,所以需要及时注销
getViewTreeObserver().removeOnGlobalLayoutListener(this);
height = getHeight();
int fillViewHeight = height / 2;
//重新设置header的高度--让上方的填充线填满上半屏
View fillDataView = headerView.findViewById(R.id.fill_top);
LinearLayout.LayoutParams paramsDataView = (LinearLayout.LayoutParams) fillDataView.getLayoutParams();
paramsDataView.height = fillViewHeight;
AbsListView.LayoutParams paramsItemView = (AbsListView.LayoutParams) headerView.getLayoutParams();
paramsItemView.height = fillViewHeight;
fillDataView.setLayoutParams(paramsDataView);
headerView.setLayoutParams(paramsItemView);
//重新设置footer的高度--//让下方的填充线填满下半屏
View fillDataView2 = footerView.findViewById(R.id.fill_top);
LinearLayout.LayoutParams paramsDataView2 = (LinearLayout.LayoutParams) fillDataView2.getLayoutParams();
paramsDataView2.height = fillViewHeight;
fillDataView2.setLayoutParams(paramsDataView2);
//测量普通子View的高度
mListView.setOnScrollListener(new MyOnScrollListener());
View listItem = adapter.getView(1, null, mListView); //获取第一个孩子的高度,因为第0个为header
listItem.measure(0, 0);
listItemHeight = listItem.getMeasuredHeight();
//初始化第一个item
dataList.get(0).setSelected(true);
adapter.notifyDataSetChanged();
}
return;
}
width = getWidth(); //获得本View的宽度
if(dataList != null && dataList.size() > 0){
show();
}
}
});
}
滚动监听器:
private class MyOnScrollListener implements AbsListView.OnScrollListener {
private int prePosition = 0;
private int curPosition = 0;
private View firstView;
private View preView;
private SparseArray recordSp = new SparseArray(0);
private int mCurrentFirstVisibleItem = 0;
private int scrollY;
boolean isSelected = false;
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
if(scrollState == SCROLL_STATE_IDLE){ //停止滑动
View firstView = view.getChildAt(0); //处理初始状态
firstView.getTop();
if(firstView.getTop() == 0){
dataList.get(0).setSelected(true);
if (!isSelected) {
isSelected = true;
adapter.notifyDataSetChanged();
if(onSelectedChangeListener != null){
onSelectedChangeListener.onSelectedChange(mHorizontalBarChart2, firstView, dataList.get(0));
}
}
}
}
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
mCurrentFirstVisibleItem = firstVisibleItem;
firstView = view.getChildAt(0); //获得第一个可见的子view(包括header)(非整个ListView所有数据的第一个)
if(firstView != null){
ItemRecord itemRecord = (ItemRecord) recordSp.get(mCurrentFirstVisibleItem);
if(itemRecord == null){
itemRecord = new ItemRecord();
}
itemRecord.height = firstView.getHeight();
itemRecord.top = firstView.getTop(); //第一个可见子View的Top距离ListView的Top的距离(通常<=0)
recordSp.append(mCurrentFirstVisibleItem, itemRecord);
}
if(listItemHeight > 0) {
scrollY = getScrollY();
prePosition = curPosition;
preView = firstView;
curPosition = scrollY / listItemHeight;
if(curPosition > dataList.size() -1){
curPosition = dataList.size() -1;
}
Log.i("tian", "totalItemCount:" + totalItemCount);
for (int i = 0; i < dataList.size(); i++) {
if (i == curPosition) {
dataList.get(i).setSelected(true);
} else {
dataList.get(i).setSelected(false);
}
}
if (!isSelected) {
isSelected = true;
adapter.notifyDataSetChanged();
if(onSelectedChangeListener != null){
onSelectedChangeListener.onSelectedChange(mHorizontalBarChart2, firstView, dataList.get(curPosition));
}
} else if (prePosition != curPosition) {
adapter.notifyDataSetChanged();
if(onSelectedChangeListener != null){
onSelectedChangeListener.onSelectedChange(mHorizontalBarChart2, firstView, dataList.get(curPosition));
onSelectedChangeListener.onUnselectedChange(mHorizontalBarChart2, preView, dataList.get(prePosition));
}
}
}
}
private int getScrollY(){ //计算整个ListView的滚动高度
int height = 0;
for(int i = 0; i < mCurrentFirstVisibleItem; i++){
ItemRecord itemRecord = (ItemRecord) recordSp.get(i);
height += itemRecord.height;
}
ItemRecord curRecord = (ItemRecord) recordSp.get(mCurrentFirstVisibleItem);
if(curRecord == null){
curRecord = new ItemRecord();
}
height -= curRecord.top;
return height;
}
class ItemRecord{
int height = 0;
int top = 0;
}
}
源码传送门:https://github.com/tianyalu/HorizontalBarChartDemo
如果觉得本文对您有帮助,欢迎给个star。