一步步实现这个炫酷的树状节点图自定义控件

Posted 怪兽N

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一步步实现这个炫酷的树状节点图自定义控件相关的知识,希望对你有一定的参考价值。

本文介绍了我实现自定义树状节点图控件的原理及一些效果展示,欢迎交流
Tree View; Mind map; Think map; tree map; 树状图;思维导图;

简介

github连接: https://github.com/guaishouN/android-tree-view.git

目前没发现比较好的android树状图开源控件,于是决定自己写一个开源控件,对比了一下市面上关于思维导图或者树状图显示(如xMind,mind master等)的app,本文开源框架并不逊色。实现这个树状图过程中主要综合应用了很多自定义控件关键知识点,比如自定义ViewGroup的步骤、触摸事件的处理、动画使用、Scroller及惯性滑动、ViewDragHelper的使用等等。主要实现了下面几个功能点。

  • 丝滑的跟随手指放缩,拖动,及惯性滑动

  • 自动动画回归屏幕中心

  • 支持子节点复杂布局自定义,并且节点布局点击事件与滑动不冲突

  • 节点间的连接线自定义

  • 可删除动态节点

  • 可动态添加节点

  • 支持拖动调整节点关系

  • 增删、移动结构添加动画效果

效果展示

基础----连接线, 布局, 自定义节点View

添加

删除

拖动节点编辑书树状图结构

放缩拖动不影响点击

放缩拖动及适应窗口

使用步骤

下面说明中Animal类是仅仅用于举例的bean

public class Animal {
    public int headId;
    public String name;
}

按照以下四个步骤使用该开源控件

1 通过继承 TreeViewAdapter实现节点数据与节点视图的绑定

public class AnimalTreeViewAdapter extends TreeViewAdapter<Animal> {
    private DashLine dashLine =  new DashLine(Color.parseColor("#F06292"),6);
    @Override
    public TreeViewHolder<Animal> onCreateViewHolder(@NonNull ViewGroup viewGroup, NodeModel<Animal> node) {
        //TODO in inflate item view
        NodeBaseLayoutBinding nodeBinding = NodeBaseLayoutBinding.inflate(LayoutInflater.from(viewGroup.getContext()),viewGroup,false);
        return new TreeViewHolder<>(nodeBinding.getRoot(),node);
    }

    @Override
    public void onBindViewHolder(@NonNull TreeViewHolder<Animal> holder) {
        //TODO get view and node from holder, and then control your item view
        View itemView = holder.getView();
        NodeModel<Animal> node = holder.getNode();
		...
    }

    @Override
    public Baseline onDrawLine(DrawInfo drawInfo) {
        // TODO If you return an BaseLine, line will be draw by the return one instead of TreeViewLayoutManager's
		// if(...){
        //   ...
        // 	 return dashLine;
   		// }
        return null;
    }
}

2 配置LayoutManager。主要设置布局风格(向右展开或垂直向下展开)、父节点与子节点的间隙、子节点间的间隙、节点间的连线(已经实现了直线、光滑曲线、虚线、根状线,也可通过BaseLine实现你自己的连线)

int space_50dp = 50;
int space_20dp = 20;
//choose a demo line or a customs line. StraightLine, PointedLine, DashLine, SmoothLine are available.
Baseline line =  new DashLine(Color.parseColor("#4DB6AC"),8);
//choose layoout manager. VerticalTreeLayoutManager,RightTreeLayoutManager are available.
TreeLayoutManager treeLayoutManager = new RightTreeLayoutManager(this,space_50dp,space_20dp,line);

3 把Adapter和LayoutManager设置到你的树状图

...
treeView = findViewById(R.id.tree_view);   
TreeViewAdapter adapter = new AnimlTreeViewAdapter();
treeView.setAdapter(adapter);
treeView.setTreeLayoutManager(treeLayoutManager);
...

4 设置节点数据

//Create a TreeModel by using a root node.
NodeModel<Animal> node0 = new NodeModel<>(new Animal(R.drawable.ic_01,"root"));
TreeModel<Animal> treeModel = new TreeModel<>(root);

//Other nodes.
NodeModel<Animal> node1 = new NodeModel<>(new Animal(R.drawable.ic_02,"sub0"));
NodeModel<Animal> node2 = new NodeModel<>(new Animal(R.drawable.ic_03,"sub1"));
NodeModel<Animal> node3 = new NodeModel<>(new Animal(R.drawable.ic_04,"sub2"));
NodeModel<Animal> node4 = new NodeModel<>(new Animal(R.drawable.ic_05,"sub3"));
NodeModel<Animal> node5 = new NodeModel<>(new Animal(R.drawable.ic_06,"sub4"));


//Build the relationship between parent node and childs,like:
//treeModel.add(parent, child1, child2, ...., childN);
treeModel.add(node0, node1, node2);
treeModel.add(node1, node3, node4);
treeModel.add(node2, node5);

//finally set this treeModel to the adapter
adapter.setTreeModel(treeModel);

实现基本的布局流程

这里涉及View自定义的基本三部曲onMeasureonLayoutonDrawonDispatchDraw, 其中我把onMeasureonLayout布局的交给了一个特定的类LayoutManager处理,并且把节点的子View生成及绑定交给Adapter处理,在onDispatchDraw中画节点的连线也交给Adapter处理。这样可以极大地方便使用者自定义连线及节点View,甚至是自定义LayoutManager。另外在onSizeChange中记录控件的大小。

这几个关键点的流程是onMeasure->onLayout->onSizeChanged->onDrawonDispatchDraw

    private TreeViewHolder<?> createHolder(NodeModel<?> node) {
        int type = adapter.getHolderType(node);
		...
        //node 子View创建交给adapter
        return adapter.onCreateViewHolder(this, (NodeModel)node);
    }
	/**
    * 初始化添加NodeView
    **/
	private void addNodeViewToGroup(NodeModel<?> node) {
        TreeViewHolder<?> treeViewHolder = createHolder(node);
        //node 子View绑定交给adapter
        adapter.onBindViewHolder((TreeViewHolder)treeViewHolder);
		...
    }
	...
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        TreeViewLog.e(TAG,"onMeasure");
        final int size = getChildCount();
        for (int i = 0; i < size; i++) {
            measureChild(getChildAt(i), widthMeasureSpec, heightMeasureSpec);
        }
        if(MeasureSpec.getSize(widthMeasureSpec)>0 && MeasureSpec.getSize(heightMeasureSpec)>0){
            winWidth  = MeasureSpec.getSize(widthMeasureSpec);
            winHeight = MeasureSpec.getSize(heightMeasureSpec);
        }
        if (mTreeLayoutManager != null && mTreeModel != null) {
            mTreeLayoutManager.setViewport(winHeight,winWidth);
            //交给LayoutManager测量
            mTreeLayoutManager.performMeasure(this);
            ViewBox viewBox = mTreeLayoutManager.getTreeLayoutBox();
            drawInfo.setSpace(mTreeLayoutManager.getSpacePeerToPeer(),mTreeLayoutManager.getSpaceParentToChild());
            int specWidth = MeasureSpec.makeMeasureSpec(Math.max(winWidth, viewBox.getWidth()), MeasureSpec.EXACTLY);
            int specHeight = MeasureSpec.makeMeasureSpec(Math.max(winHeight,viewBox.getHeight()),MeasureSpec.EXACTLY);
            setMeasuredDimension(specWidth,specHeight);
        }else{
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }


    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        TreeViewLog.e(TAG,"onLayout");
        if (mTreeLayoutManager != null && mTreeModel != null) {
            //交给LayoutManager布局
            mTreeLayoutManager.performLayout(this);
        }
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //记录初始大小
        viewWidth = w;
        viewHeight = h;
        drawInfo.setWindowWidth(w);
        drawInfo.setWindowHeight(h);
        //记录适应窗口的scale
        fixWindow();
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        if (mTreeModel != null) {
            drawInfo.setCanvas(canvas);
            drawTreeLine(mTreeModel.getRootNode());
        }
    }
    /**
     * 绘制树形的连线
     * @param root root node
     */
    private void drawTreeLine(NodeModel<?> root) {
        LinkedList<? extends NodeModel<?>> childNodes = root.getChildNodes();
        for (NodeModel<?> node : childNodes) {
			...
            //画连线交给adapter或mTreeLayoutManager处理
            BaseLine adapterDrawLine = adapter.onDrawLine(drawInfo);
            if(adapterDrawLine!=null){
                adapterDrawLine.draw(drawInfo);
            }else{
                mTreeLayoutManager.performDrawLine(drawInfo);
            }
            drawTreeLine(node);
        }
    }

实现自由放缩及拖动

这部分是核心点,乍一看很简单,不就是处理下dispaTouchEventonInterceptTouchEventonTouchEvent就可以了吗?没错是都是在这几个函数中处理,但是要知道以下这几个难点:

  1. 这个自定义控件要放缩或移动过程中,通过onTouchEvent中MotionEvent.getX()拿到的触摸事件也是放缩后触点相对父View的位置,而getRaw又不是所有SDK版本都支持的,因为不能获取稳定的触点数据,所以可能放缩会出现震动的现象
  2. 这个树状图自定义控件子节点View也是ViewGroup,至少拖动放缩不能影响子节点View里的控件点击事件
  3. 另外还要考虑,回归屏幕中心控制、增删节点要稳定目标节点View显示、反变换获取View相对屏幕位置等, 实现放缩及拖动时的触点跟随

对于问题1,可以再加一层一样大小的ViewGroup(其实就是GysoTreeView,它是一个壳)用来接收触摸事件,这样因为这个接收触摸事件的ViewGroup是大小是稳定的,所以拦截的触摸要是稳定的。里面的treeViewContainer是真正的树状图ViewGroup容器。

    public GysoTreeView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT);
        setClipChildren(false);
        setClipToPadding(false);
        treeViewContainer = new TreeViewContainer(getContext());
        treeViewContainer.setLayoutParams(layoutParams);
        addView(treeViewContainer);
        treeViewGestureHandler = new TouchEventHandler(getContext(), treeViewContainer);
        treeViewGestureHandler.setKeepInViewport(false);

        //set animate default
        treeViewContainer.setAnimateAdd(true);
        treeViewContainer.setAnimateRemove(true);
        treeViewContainer.setAnimateMove(true);
    }

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        super.requestDisallowInterceptTouchEvent(disallowIntercept);
        this.disallowIntercept = disallowIntercept;
        TreeViewLog.e(TAG, "requestDisallowInterceptTouchEvent:"+disallowIntercept);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        TreeViewLog.e(TAG, "onInterceptTouchEvent: "+MotionEvent.actionToString(event.getAction()));
        return (!disallowIntercept && treeViewGestureHandler.detectInterceptTouchEvent(event)) || super.onInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        TreeViewLog.e(TAG, "onTouchEvent: "+MotionEvent.actionToString(event.getAction()));
        return !disallowIntercept && treeViewGestureHandler.onTouchEvent(event);
    }

TouchEventHandler用来处理触摸事件,有点像SDK提供的ViewDragHelper判断是否需要拦截触摸事件,并处理放缩、拖动及惯性滑动。判断是不是滑动了一小段距离,是那么拦截

    /**
     * to detect whether should intercept the touch event
     * @param event event
     * @return true for intercept
     */
    public boolean detectInterceptTouchEvent(MotionEvent event){
        final int action = event.getAction() & MotionEvent.ACTION_MASK;
        onTouchEvent(event);
        if (action == MotionEvent.ACTION_DOWN){
            preInterceptTouchEvent = MotionEvent.obtain(event);
            mIsMoving = false;
        }
        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
            mIsMoving = false;
        }
        //如果滑动大于mTouchSlop,则触发拦截
        if(action == MotionEvent.ACTION_MOVE && mTouchSlop < calculateMoveDistance(event, preInterceptTouchEvent)){
            mIsMoving = true;
        }
        return mIsMoving;
    }

    /**
     * handler the touch event, drag and scale
     * @param event touch event
     * @return true for has consume
     */
    public boolean onTouchEvent(MotionEvent event) {
        mGestureDetector.onTouchEvent(event);
        //Log.e(TAG, "onTouchEvent:"+event);
        int action =  event.getAction() & MotionEvent.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mode = TOUCH_MODE_SINGLE;
                preMovingTouchEvent = MotionEvent.obtain(event);
                if(mView instanceof TreeViewContainer){
                    minScale = ((TreeViewContainer)mView).getMinScale();
                }
                if(flingX!=null){
                    flingX.cancel();
                }
                if(flingY!=null){
                    flingY.cancel();
                }
                break;
            case MotionEvent.ACTION_UP:
                mode = TOUCH_MODE_RELEASE;
                break;
            case MotionEvent.ACTION_POINTER_UP:
            case MotionEvent.ACTION_CANCEL:
                mode = TOUCH_MODE_UNSET;
                break;
            case MotionEvent.ACTION_POINTER_DOWN:
                mode++;
                if (mode >= TOUCH_MODE_DOUBLE){
                    scaleFactor = preScaleFactor = mView.getScaleX();
                    preTranslate.set( mView.getTranslationX(),mView.getTranslationY());
                    scaleBaseR = (float) distanceBetweenFingers(event);
                    centerPointBetweenFingers(event,preFocusCenter);
                    centerPointBetweenFingers(event,postFocusCenter);
                }
                break;

            case MotionEvent.ACTION_MOVE:
                if (mode >= TOUCH_MODE_DOUBLE) {
                    float scaleNewR = (float) distanceBetweenFingers(event);
                    centerPointBetweenFingers(event,postFocusCenter);
                    if (scaleBaseR <= 0){
                        break;
                    }
                    scaleFactor = (scaleNewR / scaleBaseR) * preScaleFactor * 0.15f + scaleFactor * 0.85f;
                    int scaleState = TreeViewControlListener.FREE_SCALE;
                    float finalMinScale = isKeepInViewport?minScale:minScale*0.8f;
                    if (scaleFactor >= MAX_SCALE) {
                        scaleFactor = MAX_SCALE;
                        scaleState = TreeViewControlListener.MAX_SCALE;
                    }else if (scaleFactor <= finalMinScale) 一步步实现这个炫酷的树状节点图自定义控件

使用CoordinatorLayout打造各种炫酷的效果

Android自定义View(RollWeekView-炫酷的星期日期选择控件)

C# Winform实现炫酷的透明动画界面(转载)

翻翻git之---炫酷的自己定义翻滚View TagCloudView

翻翻git之---炫酷的自定义翻滚View TagCloudView