作者:传道士链接:https://juejin.cn/post/7080519395163766791

文章目录

  • 1、简介
  • 2、效果展示
  • 3、使用步骤
  • 4、实现基本布局流程
  • 5、实现自由放缩及拖动
  • 6、实现添加删除及节点动画
  • 7、实现树状图的回归适应屏幕
  • 8、实现拖到编辑树状图结构
  • 9、写在最后

简介

github连接: https://links.jianshu.com/go?to=https://github.com/guaishouN/android-tree-view.git
目前没发现比较好的Android树状图开源控件,于是决定自己写一个开源控件,对比了一下市面上关于思维导图或者树状图显示(如xMind,mind master等)的app,本文开源框架并不逊色。实现这个树状图过程中主要综合应用了很多自定义控件关键知识点,比如自定义ViewGroup的步骤、触摸事件的处理、动画使用、Scroller及惯性滑动、ViewDragHelper的使用等等。主要实现了下面几个功能点。
  • 丝滑的跟随手指放缩,拖动,及惯性滑动
  • 自动动画回归屏幕中心
  • 支持子节点复杂布局自定义,并且节点布局点击事件与滑动不冲突
  • 节点间的连接线自定义
  • 可删除动态节点
  • 可动态添加节点
  • 支持拖动调整节点关系
  • 增删、移动结构添加动画效果

效果展示

基础--连接线, 布局, 自定义节点View
添加
删除
拖动节点编辑书树状图结构
放缩拖动不影响点击
放缩拖动及适应窗口

使用步骤

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

publicint
 headId;

public
 String name;

}

按照以下四个步骤使用该开源控件
1、 通过继承 TreeViewAdapter实现节点数据与节点视图的绑定
publicclassAnimalTreeViewAdapterextendsTreeViewAdapter<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
);

returnnew
 TreeViewHolder<>(nodeBinding.getRoot(),node);

    }


@Override
publicvoidonBindViewHolder(@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;
// }
returnnull
;

    }

}

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、 把AdapterLayoutManager设置到你的树状图
...

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

    **/

privatevoidaddNodeViewToGroup(NodeModel<?> node)
{

        TreeViewHolder<?> treeViewHolder = createHolder(node);

//node 子View绑定交给adapter
        adapter.onBindViewHolder((TreeViewHolder)treeViewHolder);

        ...

    }

    ...

@Override
protectedvoidonMeasure(int widthMeasureSpec, int heightMeasureSpec)
{

        TreeViewLog.e(TAG,
"onMeasure"
);

finalint
 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
protectedvoidonLayout(boolean changed, int l, int t, int r, int b)
{

        TreeViewLog.e(TAG,
"onLayout"
);

if
 (mTreeLayoutManager != 
null
 && mTreeModel != 
null
) {

//交给LayoutManager布局
            mTreeLayoutManager.performLayout(
this
);

        }

    }


@Override
protectedvoidonSizeChanged(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
protectedvoiddispatchDraw(Canvas canvas)
{

super
.dispatchDraw(canvas);

if
 (mTreeModel != 
null
) {

            drawInfo.setCanvas(canvas);

            drawTreeLine(mTreeModel.getRootNode());

        }

    }

/**

     * 绘制树形的连线

     * 
@param
 root root node

     */

privatevoiddrawTreeLine(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就可以了吗?没错是都是在这几个函数中处理,但是要知道以下这几个难点:
  • 这个自定义控件要放缩或移动过程中,通过onTouchEvent中MotionEvent.getX()拿到的触摸事件也是放缩后触点相对父View的位置,而getRaw又不是所有SDK版本都支持的,因为不能获取稳定的触点数据,所以可能放缩会出现震动的现象
  • 这个树状图自定义控件子节点View也是ViewGroup,至少拖动放缩不能影响子节点View里的控件点击事件
  • 另外还要考虑,回归屏幕中心控制、增删节点要稳定目标节点View显示、反变换获取View相对屏幕位置等, 实现放缩及拖动时的触点跟随
对于问题1,可以再加一层一样大小的ViewGroup(其实就是GysoTreeView,它是一个壳)用来接收触摸事件,这样因为这个接收触摸事件的ViewGroup是大小是稳定的,所以拦截的触摸要是稳定的。里面的treeViewContainer是真正的树状图ViewGroup容器。
publicGysoTreeView(@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
publicvoidrequestDisallowInterceptTouchEvent(boolean disallowIntercept)
{

super
.requestDisallowInterceptTouchEvent(disallowIntercept);

this
.disallowIntercept = disallowIntercept;

        TreeViewLog.e(TAG, 
"requestDisallowInterceptTouchEvent:"
+disallowIntercept);

    }


@Override
publicbooleanonInterceptTouchEvent(MotionEvent event)
{

        TreeViewLog.e(TAG, 
"onInterceptTouchEvent: "
+MotionEvent.actionToString(event.getAction()));

return
 (!disallowIntercept && treeViewGestureHandler.detectInterceptTouchEvent(event)) || 
super
.onInterceptTouchEvent(event);

    }


@Override
publicbooleanonTouchEvent(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

     */

publicbooleandetectInterceptTouchEvent(MotionEvent event)
{

finalint
 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

     */

publicbooleanonTouchEvent(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;

                    }
elseif
 (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();

                } 
elseif
 (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);

returntrue
;

    }

对于问题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

     */

privatevoidrecordAnchorLocationOnViewPort(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);

returntrue
;

                }

                ...


最后在Animate的start中根据相对位置还原添加删除前的位置,0->1变换到最终最新位置
@Override
publicvoidperformLayout(final TreeViewContainer treeViewContainer)
{

final
 TreeModel<?> mTreeModel = treeViewContainer.getTreeModel();

if
 (mTreeModel != 
null
) {

            mTreeModel.doTraversalNodes(
new
 ITraversal<NodeModel<?>>() {

@Override
publicvoidnext(NodeModel<?> next)
{

                    layoutNodes(next, treeViewContainer);

                }


@Override
publicvoidfinish()
{

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

                }

            });

        }

    }


/**

     * For layout animator

     * 
@param
 treeViewContainer container

     */

protectedvoidlayoutAnimate(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
publicvoidonAnimationStart(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
publicvoidonAnimationEnd(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
protectedvoidonSizeChanged(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

     */

privatevoidfixWindow()
{

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 = 
newfloat
[
9
];

        centerMatrix.getValues(values);

        values[Matrix.MTRANS_X]=
0f
;

        values[Matrix.MTRANS_Y]=
0f
;

        centerMatrix.setValues(values);

        setTouchDelegate();

    }


/**

    *恢复

    */

publicvoidfocusMidLocation()
{

        TreeViewLog.e(TAG, 
"focusMidLocation: "
+getMatrix());

float
[] centerM = 
newfloat
[
9
];

if
(centerMatrix==
null
){

            TreeViewLog.e(TAG, 
"no centerMatrix!!!"
);

return
;

        }

        centerMatrix.getValues(centerM);

float
[] now = 
newfloat
[
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();

        }

    }


拖动编辑树状图结构

想要拖动编辑树状图结构要有如下几个步骤:
  • 请求父View不要拦截触摸事件
  • TreeViewContainer中使用ViewDragHelper实现捕获View,以目标Node的所有Node一并记录原始位置
  • 拖动目标View组
  • 在移动过程中,计算跟是不是碰撞到某个节点View了,如果是那么记录碰撞的节点
  • 在释放时,如果有碰撞节点,那么走添加删除节点流程即可
  • 在释放时,如果没有碰撞点,则使用Scroller回滚到初始位置
请求父View不要拦截触摸事件, 这个不要搞混了,是parent.requestDisallowInterceptTouchEvent(isEditMode);而不是直接requestDisallowInterceptTouchEvent
protectedvoidrequestMoveNodeByDragging(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
publicbooleanonInterceptTouchEvent(MotionEvent event)
{

boolean
 intercept = dragHelper.shouldInterceptTouchEvent(event);

    TreeViewLog.e(TAG, 
"onInterceptTouchEvent: "
+MotionEvent.actionToString(event.getAction())+
" intercept:"
+intercept);

return
 isDraggingNodeMode && intercept;

}


@SuppressLint
(
"ClickableViewAccessibility"
)

@Override
publicbooleanonTouchEvent(MotionEvent event)
{

    TreeViewLog.e(TAG, 
"onTouchEvent: "
+MotionEvent.actionToString(event.getAction()));

if
(isDraggingNodeMode) {

        dragHelper.processTouchEvent(event);

    }

return
 isDraggingNodeMode;

}

//3 实现Callback
privatefinal
 ViewDragHelper.Callback dragCallback = 
new
 ViewDragHelper.Callback(){

@Override
publicbooleantryCaptureView(@NonNull View child, int pointerId)
{

//是否捕获拖动的View
returnfalse
;

    }


@Override
publicintgetViewHorizontalDragRange(@NonNull  View child)
{

//在判断是否拦截时,判断是否超出水平移动范围
return
 Integer.MAX_VALUE;

    }


@Override
publicintgetViewVerticalDragRange(@NonNull  View child)
{

//在判断是否拦截时,判断是否超出垂直移动范围
return
 Integer.MAX_VALUE;

    }


@Override
publicintclampViewPositionHorizontal(@NonNull  View child, int left, int dx)
{

//水平移动位置差,返回希望移动后的位置
//特别注意在拦截阶段 返回left与原来一样,说明到达边界,不拦截
return
 left;

    }


@Override
publicintclampViewPositionVertical(@NonNull  View child, int top, int dy)
{

//垂直移动位置差,返回希望移动后的位置
//特别注意在拦截阶段 返回left与原来一样,说明到达边界,不拦截
return
 top;

    }


@Override
publicvoidonViewReleased(@NonNull  View releasedChild, float xvel, float yvel)
{

//释放捕获的View
    }

};


那么捕获时,开始记录位置
@Override
publicbooleantryCaptureView(@NonNull View child, int pointerId)
{

//如果是拖动编辑功能,那么使用记录要移动的块
if
(isDraggingNodeMode && dragBlock.load(child)){

                child.setTag(R.id.edit_and_dragging,IS_EDIT_DRAGGING);

                child.setElevation(Z_SELECT);

returntrue
;

            }

returnfalse
;

        }


拖动一组View时,因为这组View的相对位置是不变的,所以可以都是无论是垂直方向还是水平方向都使用同一个dxdy
publicvoiddrag(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
publicintclampViewPositionHorizontal(@NonNull  View child, int left, int dx)
{

//拦截前返回left说明没有到边界可以拦截, 拦截后返回原来位置,说明不用dragHelper来帮忙移动,我们自己来一共目标View
if
(dragHelper.getViewDragState()==ViewDragHelper.STATE_DRAGGING){

finalint
 oldLeft = child.getLeft();

        dragBlock.drag(dx,
0
);

//拖动过程中不断判断是否碰撞
        estimateToHitTarget(child);

        invalidate();

return
 oldLeft;

    }
else
{

return
 left;

    }

}


@Override
publicintclampViewPositionVertical(@NonNull  View child, int top, int dy)
{

//与上面代码一致
    ...

}


//如果撞击了,那么invalidate,画撞击提醒
privatevoiddrawDragBackGround(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
publicvoidonViewReleased(@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
publicvoidcomputeScroll()
{

if
(dragBlock.computeScroll()){

        invalidate();

    }

}

写在最后

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