丝滑~Android自定义树状图控件!

发表于 2年以前  | 总阅读数:5008 次

文章目录

  • 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

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、 把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
    **/
    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就可以了吗?没错是都是在这几个函数中处理,但是要知道以下这几个难点:

  • 这个自定义控件要放缩或移动过程中,通过onTouchEvent中MotionEvent.getX()拿到的触摸事件也是放缩后触点相对父View的位置,而getRaw又不是所有SDK版本都支持的,因为不能获取稳定的触点数据,所以可能放缩会出现震动的现象
  • 这个树状图自定义控件子节点View也是ViewGroup,至少拖动放缩不能影响子节点View里的控件点击事件
  • 另外还要考虑,回归屏幕中心控制、增删节点要稳定目标节点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();
        }
    }

拖动编辑树状图结构

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

  • 请求父View不要拦截触摸事件
  • TreeViewContainer中使用ViewDragHelper实现捕获View,以目标Node的所有Node一并记录原始位置
  • 拖动目标View组
  • 在移动过程中,计算跟是不是碰撞到某个节点View了,如果是那么记录碰撞的节点
  • 在释放时,如果有碰撞节点,那么走添加删除节点流程即可
  • 在释放时,如果没有碰撞点,则使用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的相对位置是不变的,所以可以都是无论是垂直方向还是水平方向都使用同一个dxdy

    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出来一起改。如果觉得不错请给个星呢。

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/GkcowlG5q0_IyxEQoDyQSg

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
简化Android的UI开发 5年以前  |  521236次阅读
Android 深色模式适配原理分析 4年以前  |  29628次阅读
Android阴影实现的几种方案 2年以前  |  12211次阅读
Android 样式系统 | 主题背景覆盖 4年以前  |  10289次阅读
 目录