Android自定义树状图控件 GysoTreeView组织结构树形图

Tree View; Mind map; Think map; tree map; 树状图;思维导图;组织结构图;层次图

文章目录

[1 简介](# 简介)

[2 效果展示](# 效果展示)

[3 使用步骤](# 使用步骤)

[4 实现基本布局流程](# 实现基本布局流程)

[5 实现自由放缩及拖动](# 实现自由放缩及拖动)

[6 实现添加删除及节点动画](# 实现添加删除及节点动画)

[7 实现树状图的回归适应屏幕](# 实现树状图的回归适应屏幕)

[8 实现拖到编辑树状图结构](# 实现拖到编辑树状图结构)

[9 写在最后](# 写在最后)

简介

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

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

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

  • 自动动画回归屏幕中心

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

  • 节点间的连接线自定义

  • 可删除动态节点

  • 可动态添加节点

  • 支持拖动调整节点关系

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

效果展示

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

new.jpg
fs.png

添加

add.gif

删除

add.gif

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

add.gif

放缩拖动不影响点击

add.gif

放缩拖动及适应窗口

add.gif

使用步骤

下面说明中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) {
                        scaleFactor = finalMinScale;
                        scaleState = TreeViewControlListener.MIN_SCALE;
                    }
                    if(controlListener!=null){
                        int current = (int)(scaleFactor*100);
                        //just make it no so frequently callback
                        if(scalePercentOnlyForControlListener!=current){
                            scalePercentOnlyForControlListener = current;
                            controlListener.onScaling(scaleState,scalePercentOnlyForControlListener);
                        }
                    }
                    mView.setPivotX(0);
                    mView.setPivotY(0);
                    mView.setScaleX(scaleFactor);
                    mView.setScaleY(scaleFactor);
                    float tx = postFocusCenter.x-(preFocusCenter.x-preTranslate.x)*scaleFactor / preScaleFactor;
                    float ty = postFocusCenter.y-(preFocusCenter.y-preTranslate.y)*scaleFactor / preScaleFactor;
                    mView.setTranslationX(tx);
                    mView.setTranslationY(ty);
                    keepWithinBoundaries();
                } else if (mode == TOUCH_MODE_SINGLE) {
                    float deltaX = event.getRawX() - preMovingTouchEvent.getRawX();
                    float deltaY = event.getRawY() - preMovingTouchEvent.getRawY();
                    onSinglePointMoving(deltaX, deltaY);
                }
                break;
            case MotionEvent.ACTION_OUTSIDE:
                TreeViewLog.e(TAG, "onTouchEvent: touch out side" );
                break;
        }
        preMovingTouchEvent = MotionEvent.obtain(event);
        return true;
    }

对于问题2,为了不影响节点View的点击事件,我们不能使用Canvas去移送或放缩,否则点击位置会错乱。另外,也不能使用Sroller去控制,因为scrollTo滚动控制不会记录在View变换Matrix中,为了方便控制不使用scrollTo, 而是使用setTranslationYsetScaleY, 这样可以很方便根据变换矩阵来控制整个树状图。

对于问题3,控制变换及反变换, setPivotX(0)这样你可以很方便的通过x0*scale+translate = x1确定变换关系

mView.setPivotX(0);
mView.setPivotY(0);
mView.setScaleX(scaleFactor);
mView.setScaleY(scaleFactor);
//触点跟随
float tx = postFocusCenter.x-(preFocusCenter.x-preTranslate.x)*scaleFactor / preScaleFactor;
float ty = postFocusCenter.y-(preFocusCenter.y-preTranslate.y)*scaleFactor / preScaleFactor;
mView.setTranslationX(tx);
mView.setTranslationY(ty);

实现添加删除节点动画

实现思路很简单,保存当前相对目标节点位置信息,增删节点后,把重新测量布局的位置作为最新位置,位置变化进度用0->1间的百分比表示

首先,保存当前相对目标节点位置信息,如果是删除则选其父节点作为目标节点,如果是添加节点,那么选添加子节点的父节点作为目标节点,记录这个节点相对屏幕的位置,及这时的放缩比例,并且记录所有其他节点View相对这个目标节点的位置。写代码过程中,使用View.setTag记录数据

    /**
     * Prepare moving, adding or removing nodes, record the last one node as an anchor node on view port, so that make it looks smooth change
     * Note:The last one will been choose as target node.
     *  @param nodeModels nodes[nodes.length-1] as the target one
     */
    private void recordAnchorLocationOnViewPort(boolean isRemove, NodeModel<?>... nodeModels) {
        if(nodeModels==null || nodeModels.length==0){
            return;
        }
        NodeModel<?> targetNode = nodeModels[nodeModels.length-1];
        if(targetNode!=null && isRemove){
            //if remove, parent will be the target node
            Map<NodeModel<?>,View> removeNodeMap = new HashMap<>();
            targetNode.selfTraverse(node -> {
                removeNodeMap.put(node,getTreeViewHolder(node).getView());
            });
            setTag(R.id.mark_remove_views,removeNodeMap);
            targetNode = targetNode.getParentNode();
        }
        if(targetNode!=null){
            TreeViewHolder<?> targetHolder = getTreeViewHolder(targetNode);
            if(targetHolder!=null){
                View targetHolderView = targetHolder.getView();
                targetHolderView.setElevation(Z_SELECT);
                ViewBox targetBox = ViewBox.getViewBox(targetHolderView);
                //get target location on view port 相对窗口的位置记录
                ViewBox targetBoxOnViewport = targetBox.convert(getMatrix());

                setTag(R.id.target_node,targetNode);
                setTag(R.id.target_location_on_viewport,targetBoxOnViewport);

                //The relative locations of other nodes 相对位置记录
                Map<NodeModel<?>,ViewBox> relativeLocationMap = new HashMap<>();
                mTreeModel.doTraversalNodes(node->{
                    TreeViewHolder<?> oneHolder = getTreeViewHolder(node);
                    ViewBox relativeBox =
                            oneHolder!=null?
                            ViewBox.getViewBox(oneHolder.getView()).subtract(targetBox):
                            new ViewBox();
                    relativeLocationMap.put(node,relativeBox);
                });
                setTag(R.id.relative_locations,relativeLocationMap);
            }
        }
    }

然后按正常流程触发重新测量、布局。但是这时不要急着画到屏幕,先根据目标节点原来在屏幕的位置,及放缩大小,反变换使目标节点不至于产生跳动的感觉。

                ...
                if(targetLocationOnViewPortTag instanceof ViewBox){
                    ViewBox targetLocationOnViewPort=(ViewBox)targetLocationOnViewPortTag;

                    //fix pre size and location 根据目标节点在手机中屏幕的位置重新移动,避免跳动
                    float scale = targetLocationOnViewPort.getWidth() * 1f / finalLocation.getWidth();
                    treeViewContainer.setPivotX(0);
                    treeViewContainer.setPivotY(0);
                    treeViewContainer.setScaleX(scale);
                    treeViewContainer.setScaleY(scale);
                    float dx = targetLocationOnViewPort.left-finalLocation.left*scale;
                    float dy = targetLocationOnViewPort.top-finalLocation.top*scale;
                    treeViewContainer.setTranslationX(dx);
                    treeViewContainer.setTranslationY(dy);
                    return true;
                }
                ...

最后在Animate的start中根据相对位置还原添加删除前的位置,0->1变换到最终最新位置

    @Override
    public void performLayout(final TreeViewContainer treeViewContainer) {
        final TreeModel<?> mTreeModel = treeViewContainer.getTreeModel();
        if (mTreeModel != null) {
            mTreeModel.doTraversalNodes(new ITraversal<NodeModel<?>>() {
                @Override
                public void next(NodeModel<?> next) {
                    layoutNodes(next, treeViewContainer);
                }

                @Override
                public void finish() {
                    //布局位置确定完后,开始通过动画从相对位置移动到最终位置
                    layoutAnimate(treeViewContainer);
                }
            });
        }
    }

    /**
     * For layout animator
     * @param treeViewContainer container
     */
    protected void layoutAnimate(TreeViewContainer treeViewContainer) {
        TreeModel<?> mTreeModel = treeViewContainer.getTreeModel();
        //means that smooth move from preLocation to curLocation
        Object nodeTag = treeViewContainer.getTag(R.id.target_node);
        Object targetNodeLocationTag = treeViewContainer.getTag(R.id.target_node_final_location);
        Object relativeLocationMapTag = treeViewContainer.getTag(R.id.relative_locations);
        Object animatorTag = treeViewContainer.getTag(R.id.node_trans_animator);
        if(animatorTag instanceof ValueAnimator){
            ((ValueAnimator)animatorTag).end();
        }
        if (nodeTag instanceof NodeModel
                && targetNodeLocationTag instanceof ViewBox
                && relativeLocationMapTag instanceof Map) {
            ViewBox targetNodeLocation = (ViewBox) targetNodeLocationTag;
            Map<NodeModel<?>,ViewBox> relativeLocationMap = (Map<NodeModel<?>,ViewBox>)relativeLocationMapTag;

            AccelerateDecelerateInterpolator interpolator = new AccelerateDecelerateInterpolator();
            ValueAnimator valueAnimator = ValueAnimator.ofFloat(0f, 1f);
            valueAnimator.setDuration(TreeViewContainer.DEFAULT_FOCUS_DURATION);
            valueAnimator.setInterpolator(interpolator);
            valueAnimator.addUpdateListener(value -> {
                //先根据相对位置画出原来的位置
                float ratio = (float) value.getAnimatedValue();
                TreeViewLog.e(TAG, "valueAnimator update ratio[" + ratio + "]");
                mTreeModel.doTraversalNodes(node -> {
                    TreeViewHolder<?> treeViewHolder = treeViewContainer.getTreeViewHolder(node);
                    if (treeViewHolder != null) {
                        View view = treeViewHolder.getView();
                        ViewBox preLocation = (ViewBox) view.getTag(R.id.node_pre_location);
                        ViewBox deltaLocation = (ViewBox) view.getTag(R.id.node_delta_location);
                        if(preLocation !=null && deltaLocation!=null){
                            //calculate current location 计算渐变位置 并 布局
                            ViewBox currentLocation = preLocation.add(deltaLocation.multiply(ratio));
                            view.layout(currentLocation.left,
                                    currentLocation.top,
                                    currentLocation.left+view.getMeasuredWidth(),
                                    currentLocation.top+view.getMeasuredHeight());
                        }
                    }
                });
            });

            valueAnimator.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationStart(Animator animation, boolean isReverse) {
                    TreeViewLog.e(TAG, "onAnimationStart ");
                    //calculate and layout on preLocation  位置变换过程
                    mTreeModel.doTraversalNodes(node -> {
                        TreeViewHolder<?> treeViewHolder = treeViewContainer.getTreeViewHolder(node);
                        if (treeViewHolder != null) {
                            View view = treeViewHolder.getView();
                            ViewBox relativeLocation = relativeLocationMap.get(treeViewHolder.getNode());

                            //calculate location info 计算位置
                            ViewBox preLocation = targetNodeLocation.add(relativeLocation);
                            ViewBox finalLocation = (ViewBox) view.getTag(R.id.node_final_location);
                            if(preLocation==null || finalLocation==null){
                                return;
                            }

                            ViewBox deltaLocation = finalLocation.subtract(preLocation);

                            //save as tag
                            view.setTag(R.id.node_pre_location, preLocation);
                            view.setTag(R.id.node_delta_location, deltaLocation);

                            //layout on preLocation 更新布局
                            view.layout(preLocation.left, preLocation.top, preLocation.left+view.getMeasuredWidth(), preLocation.top+view.getMeasuredHeight());
                        }
                    });

                }

                @Override
                public void onAnimationEnd(Animator animation, boolean isReverse) {
                    ...
                    //layout on finalLocation 在布局最终位置
                    mTreeModel.doTraversalNodes(node -> {
                        TreeViewHolder<?> treeViewHolder = treeViewContainer.getTreeViewHolder(node);
                        if (treeViewHolder != null) {
                            View view = treeViewHolder.getView();
                            ViewBox finalLocation = (ViewBox) view.getTag(R.id.node_final_location);
                            if(finalLocation!=null){
                                view.layout(finalLocation.left, finalLocation.top, finalLocation.right, finalLocation.bottom);
                            }
                            view.setTag(R.id.node_pre_location,null);
                            view.setTag(R.id.node_delta_location,null);
                            view.setTag(R.id.node_final_location, null);
                            view.setElevation(TreeViewContainer.Z_NOR);
                        }
                    });
                }
            });
            treeViewContainer.setTag(R.id.node_trans_animator,valueAnimator);
            valueAnimator.start();
        }
    }

实现树状图的回归适应屏幕

这个功能点相对简单,前提是TreeViewContainer放缩一定要以(0,0)为中心点,并且TreeViewContainer的移动放缩不是使用Canas或srollTo操作,这样在onSizeChange中,我们记录适配屏幕的scale就行了。

/**
*记录
*/
@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        TreeViewLog.e(TAG,"onSizeChanged w["+w+"]h["+h+"]oldw["+oldw+"]oldh["+oldh+"]");
        viewWidth = w;
        viewHeight = h;
        drawInfo.setWindowWidth(w);
        drawInfo.setWindowHeight(h);
        fixWindow();
    }
    /**
     * fix view tree
     */
    private void fixWindow() {
        float scale;
        float hr = 1f*viewHeight/winHeight;
        float wr = 1f*viewWidth/winWidth;
        scale = Math.max(hr, wr);
        minScale = 1f/scale;
        if(Math.abs(scale-1)>0.01f){
            //setPivotX((winWidth*scale-viewWidth)/(2*(scale-1)));
            //setPivotY((winHeight*scale-viewHeight)/(2*(scale-1)));
            setPivotX(0);
            setPivotY(0);
            setScaleX(1f/scale);
            setScaleY(1f/scale);
        }
        //when first init
        if(centerMatrix==null){
            centerMatrix = new Matrix();
        }
        centerMatrix.set(getMatrix());
        float[] values = new float[9];
        centerMatrix.getValues(values);
        values[Matrix.MTRANS_X]=0f;
        values[Matrix.MTRANS_Y]=0f;
        centerMatrix.setValues(values);
        setTouchDelegate();
    }

    /**
    *恢复
    */
   public void focusMidLocation() {
        TreeViewLog.e(TAG, "focusMidLocation: "+getMatrix());
        float[] centerM = new float[9];
        if(centerMatrix==null){
            TreeViewLog.e(TAG, "no centerMatrix!!!");
            return;
        }
        centerMatrix.getValues(centerM);
        float[] now = new float[9];
        getMatrix().getValues(now);
        if(now[Matrix.MSCALE_X]>0&&now[Matrix.MSCALE_Y]>0){
            animate().scaleX(centerM[Matrix.MSCALE_X])
                    .translationX(centerM[Matrix.MTRANS_X])
                    .scaleY(centerM[Matrix.MSCALE_Y])
                    .translationY(centerM[Matrix.MTRANS_Y])
                    .setDuration(DEFAULT_FOCUS_DURATION)
                    .start();
        }
    }

拖动编辑树状图结构

想要拖动编辑树状图结构要有如下几个步骤:

  1. 请求父View不要拦截触摸事件

  2. 在TreeViewContainer中使用ViewDragHelper实现捕获View,以目标Node的所有Node一并记录原始位置

  3. 拖动目标View组

  4. 在移动过程中,计算跟是不是碰撞到某个节点View了,如果是那么记录碰撞的节点

  5. 在释放时,如果有碰撞节点,那么走添加删除节点流程即可

  6. 在释放时,如果没有碰撞点,则使用Scroller回滚到初始位置

请求父View不要拦截触摸事件, 这个不要搞混了,是parent.requestDisallowInterceptTouchEvent(isEditMode);而不是直接requestDisallowInterceptTouchEvent

    protected void requestMoveNodeByDragging(boolean isEditMode) {
        this.isDraggingNodeMode = isEditMode;
        ViewParent parent = getParent();
        if (parent instanceof View) {
            parent.requestDisallowInterceptTouchEvent(isEditMode);
        }
    }

这里简单说一下ViewDragHelper的使用, 官方说ViewDragHelper是在自定义ViewGroup时非常有用的工具类。它提供了一系列有用的操作及状态跟踪使用户可以在父类的中拖动或改变子View的位置。注重, 限于拖动及改变位置,对于放缩那就无能为力了, 不过刚好拖动编辑节点这个功能不使用放缩。它的原理也是,判断有没滑动一定距离,或者是否到达了边界来拦截触摸事件。

//1 初始化
dragHelper = ViewDragHelper.create(this, dragCallback);
//2 判断拦截及处理onTouchEvent
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercept = dragHelper.shouldInterceptTouchEvent(event);
    TreeViewLog.e(TAG, "onInterceptTouchEvent: "+MotionEvent.actionToString(event.getAction())+" intercept:"+intercept);
    return isDraggingNodeMode && intercept;
}

@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
    TreeViewLog.e(TAG, "onTouchEvent: "+MotionEvent.actionToString(event.getAction()));
    if(isDraggingNodeMode) {
        dragHelper.processTouchEvent(event);
    }
    return isDraggingNodeMode;
}
//3 实现Callback
private final ViewDragHelper.Callback dragCallback = new ViewDragHelper.Callback(){
    @Override
    public boolean tryCaptureView(@NonNull View child, int pointerId) {
        //是否捕获拖动的View
        return false;
    }

    @Override
    public int getViewHorizontalDragRange(@NonNull  View child) {
        //在判断是否拦截时,判断是否超出水平移动范围
        return Integer.MAX_VALUE;
    }

    @Override
    public int getViewVerticalDragRange(@NonNull  View child) {
        //在判断是否拦截时,判断是否超出垂直移动范围
        return Integer.MAX_VALUE;
    }

    @Override
    public int clampViewPositionHorizontal(@NonNull  View child, int left, int dx) {
        //水平移动位置差,返回希望移动后的位置
        //特别注意在拦截阶段 返回left与原来一样,说明到达边界,不拦截
        return left;
    }

    @Override
    public int clampViewPositionVertical(@NonNull  View child, int top, int dy) {
        //垂直移动位置差,返回希望移动后的位置
        //特别注意在拦截阶段 返回left与原来一样,说明到达边界,不拦截
        return top;
    }

    @Override
    public void onViewReleased(@NonNull  View releasedChild, float xvel, float yvel) {
        //释放捕获的View
    }
};

那么捕获时,开始记录位置

        @Override
        public boolean tryCaptureView(@NonNull View child, int pointerId) {
            //如果是拖动编辑功能,那么使用记录要移动的块
            if(isDraggingNodeMode && dragBlock.load(child)){
                child.setTag(R.id.edit_and_dragging,IS_EDIT_DRAGGING);
                child.setElevation(Z_SELECT);
                return true;
            }
            return false;
        }

拖动一组View时,因为这组View的相对位置是不变的,所以可以都是无论是垂直方向还是水平方向都使用同一个dx,dy

    public void drag(int dx, int dy){
        if(!mScroller.isFinished()){
            return;
        }
        this.isDragging = true;
        for (int i = 0; i < tmp.size(); i++) {
            View view = tmp.get(i);
            //offset变化的是布局,不是变换矩阵。而这里拖动没有影响container的Matrix
            view.offsetLeftAndRight(dx);
            view.offsetTopAndBottom(dy);
        }
    }

拖动过程中,要计算是否碰撞到其他View

@Override
public int clampViewPositionHorizontal(@NonNull  View child, int left, int dx) {
    //拦截前返回left说明没有到边界可以拦截, 拦截后返回原来位置,说明不用dragHelper来帮忙移动,我们自己来一共目标View
    if(dragHelper.getViewDragState()==ViewDragHelper.STATE_DRAGGING){
        final int oldLeft = child.getLeft();
        dragBlock.drag(dx,0);
        //拖动过程中不断判断是否碰撞
        estimateToHitTarget(child);
        invalidate();
        return oldLeft;
    }else{
        return left;
    }
}

@Override
public int clampViewPositionVertical(@NonNull  View child, int top, int dy) {
    //与上面代码一致
    ...
}

//如果撞击了,那么invalidate,画撞击提醒
private void drawDragBackGround(View view){
    Object fTag = view.getTag(R.id.the_hit_target);
    boolean getHit = fTag != null;
    if(getHit){
        //draw
        .....
        mPaint.reset();
        mPaint.setColor(Color.parseColor("#4FF1286C"));
        mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        PointF centerPoint = getCenterPoint(view);
        drawInfo.getCanvas().drawCircle(centerPoint.x,centerPoint.y,(float)fR,mPaint);
        PointPool.free(centerPoint);
    }
}

释放时,如果有目标那么删除再添加,走删除添加流程;如果没有,那么使用Scroller协助回滚

//释放
@Override
public void onViewReleased(@NonNull  View releasedChild, float xvel, float yvel) {
    TreeViewLog.d(TAG, "onViewReleased: ");
    Object fTag = releasedChild.getTag(R.id.the_hit_target);
    boolean getHit = fTag != null;
    //如果及记录了撞击点,删除再添加,走删除添加流程
    if(getHit){
        TreeViewHolder<?> targetHolder = getTreeViewHolder((NodeModel)fTag);
        NodeModel<?> targetHolderNode = targetHolder.getNode();

        TreeViewHolder<?> releasedChildHolder = (TreeViewHolder<?>)releasedChild.getTag(R.id.item_holder);
        NodeModel<?> releasedChildHolderNode = releasedChildHolder.getNode();
        if(releasedChildHolderNode.getParentNode()!=null){
            mTreeModel.removeNode(releasedChildHolderNode.getParentNode(),releasedChildHolderNode);
        }
        mTreeModel.addNode(targetHolderNode,releasedChildHolderNode);
        mTreeModel.calculateTreeNodesDeep();
        if(isAnimateMove()){
            recordAnchorLocationOnViewPort(false,targetHolderNode);
        }
        requestLayout();
    }else{
        //recover 如果没有,那么使用Scroller协助回滚
        dragBlock.smoothRecover(releasedChild);
    }
    dragBlock.setDragging(false);
    releasedChild.setElevation(Z_NOR);
    releasedChild.setTag(R.id.edit_and_dragging,null);
    releasedChild.setTag(R.id.the_hit_target, null);
    invalidate();
}

//注意重写container的computeScroll,实现更新
@Override
public void computeScroll() {
    if(dragBlock.computeScroll()){
        invalidate();
    }
}

写在最后

到到这里就介绍完,整个树状节点图的拖动放缩,添加删除节点,拖动编辑等这几个功能的实现原理了,当然里面还有很多实现细节。你可以把这篇文章作为源码查看的引导,细节方面也还有很多待完善的地方。后面这个开源应该会继续更新,大家也可以一起探讨,fork出来一起改。如果觉得不错请给个星呢。

这个项目如果有人用就会持续更新下去。喜欢点个赞,谢谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容