How do I implement this cool tree node chart custom control step by step

This paper introduces the principle and some effect demonstration of my custom tree node diagram control. Welcome to the exchange
Tree View; Mind map; Think map; tree map; Dendrogram; Mind mapping;

brief introduction

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

No good Android Dendrogram Open Source Controls were found, so I decided to write an open source control and compare the apps on the market for mind mapping or tree display (such as xMind, mind master, etc.). This open source framework is not inferior. In the process of implementing this tree graph, a number of key knowledge points of custom controls are applied, such as the steps of customizing ViewGroup, the handling of touch events, the use of animation, Scroller and inertial sliding, the use of ViewDragHelper, and so on. The following functional points are mainly implemented.

  • Silky follow finger zooming, dragging, and inertial sliding

  • Auto Animation Return to Screen Center

  • Supports child node complex layout customization, and node layout click events do not conflict with sliding

  • Connection line customization between nodes

  • Deletable Dynamic Nodes

  • Can dynamically add nodes

  • Supports dragging to adjust node relationships

  • Add or delete, move structures to add animation effects

Effect Display

Foundation - Connectors, Layout, Custom Node View

Add to

delete

Drag a node to edit the book tree structure

Zoom Drag Does Not Affect Clicks

Zoom Drag and Fit Window

Use steps

The Animal class in the illustration below is a bean for example only

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

Follow these four steps to use the open source control

1 Bind node data to node view by inheriting 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 Configure LayoutManager. Set the layout style (right or vertical down), the gap between parent and child nodes, the gap between child nodes, the connection between nodes (straight lines, smooth curves, dashed lines, root lines, or your own through 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 Set the Adapter and LayoutManager to your tree

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

4 Set node data

//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);

Implement basic layout processes

This involves the basic Trilogy onMeasure, onLayout, onDraw, or onDispatchDraw customized by View, in which I handled the onMeasure and onLayout layouts to a specific class LayoutManager and handled the generation and binding of a node's subViews to the Adapter, as well as drawing the node's connections in onDispatchDraw to the Adapter. This makes it very easy for users to customize the connection and node View, or even the LayoutManager. Also, record the size of the control in onSizeChange.

The process for these key points is onMeasure->onLayout->onSizeChanged->onDraw or onDispatchDraw

    private TreeViewHolder<?> createHolder(NodeModel<?> node) {
        int type = adapter.getHolderType(node);
		...
        //NodeSub View creation to adapter
        return adapter.onCreateViewHolder(this, (NodeModel)node);
    }
	/**
    * Initialize Add NodeView
    **/
	private void addNodeViewToGroup(NodeModel<?> node) {
        TreeViewHolder<?> treeViewHolder = createHolder(node);
        //NodeSub View bind to 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);
            //Deliver to LayoutManager for measurement
            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) {
            //Give LayoutManager Layout
            mTreeLayoutManager.performLayout(this);
        }
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //Record initial size
        viewWidth = w;
        viewHeight = h;
        drawInfo.setWindowWidth(w);
        drawInfo.setWindowHeight(h);
        //Record scale of fit window
        fixWindow();
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        if (mTreeModel != null) {
            drawInfo.setCanvas(canvas);
            drawTreeLine(mTreeModel.getRootNode());
        }
    }
    /**
     * Draw a tree-shaped line
     * @param root root node
     */
    private void drawTreeLine(NodeModel<?> root) {
        LinkedList<? extends NodeModel<?>> childNodes = root.getChildNodes();
        for (NodeModel<?> node : childNodes) {
			...
            //Draw wiring to adapter or mTreeLayoutManager
            BaseLine adapterDrawLine = adapter.onDrawLine(drawInfo);
            if(adapterDrawLine!=null){
                adapterDrawLine.draw(drawInfo);
            }else{
                mTreeLayoutManager.performDrawLine(drawInfo);
            }
            drawTreeLine(node);
        }
    }

Free zoom and drag

This part is the core point. At first glance, it's very simple. Is it OK to process dispaTouchEvent, onInterceptTouchEvent and onTouchEvent? Yes, they are all handled in these functions, but you have to know the following difficulties:

  1. This custom control uses MotionEvent in onTouchEvent to zoom or move. Touch events that getX() gets are also where the zoomed-in contact is relative to the parent View, and getRaw is not supported by all SDK versions, because stable contact data cannot be obtained, zooming can cause vibration.
  2. This tree chart custom control child node View is also a ViewGroup, at least drag zoom does not affect control click events in child node View
  3. Also, consider returning to screen center control, adding or deleting nodes to stabilize the target node's View display, inverse transformation to get the relative screen position of View, etc. to achieve contact following when zooming and dragging

For Question 1, you can add another layer of the same size ViewGroup (actually the GysoTreeView, which is a shell) to receive touch events, so that the ViewGroup receiving touch events is stable in size, so the blocked touch is stable. The treeViewContainer inside is the real tree view group container.

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

The TouchEventHandler handles touch events, somewhat like the SDK's ViewDragHelper, which determines whether a touch event needs to be intercepted and handles zooming, dragging, and inertial sliding. Judging if you've slipped a little distance and how you've intercepted it

    /**
     * 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;
        }
        //If the slide is larger than mTouchSlop, the intercept is triggered
        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;
    }

For Question 2, we cannot use Canvas to move or zoom out in order not to affect the click event of the node View, otherwise the click location will be confused. Also, Sroller cannot be used for control, since scrollTo scrolling control is not recorded in the View Transform Matrix. To facilitate control, instead of scrollTo, setTranslationY and setScaleY are used, which makes it easy to control the entire dendrogram based on the transformation matrix.

For Question 3, control transformations and inverse transformations, setPivotX(0) so that you can easily determine the transformation relationship by x0*scale+translate=x1

mView.setPivotX(0);
mView.setPivotY(0);
mView.setScaleX(scaleFactor);
mView.setScaleY(scaleFactor);
//Contact Following
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);

Implement add-delete node animation

The idea is simple. Save the current relative target node location information, add or delete nodes, use the position of the re-measured layout as the latest location, and the progress of the location change is expressed as a percentage between 0 - > 1

First, save the current relative target node location information, select its parent node as the target node if it is deleted, select the parent node that adds a child node as the target node, record the relative screen position of this node, and the scaling ratio at that time, and record the position of all other node Views relative to this target node. Use View when writing code. SetTag records data

    /**
     * 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 position record relative to window
                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 relative position record
                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);
            }
        }
    }

Then trigger the re-measurement and layout according to the normal process. However, do not rush to draw to the screen at this time. First, according to the location of the target node on the screen and the size of the scaling, the inverse transformation will not make the target node feel jumpy.

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

                    //Fix pre-size and location moves again based on the location of the target node's screen in the phone to avoid jumping
                    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;
                }
				...

Finally, in Animate's start, restore the position before adding and deleting based on the relative position, and transform 0->1 to the final, up-to-date location

    @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() {
                    //Once the layout position is determined, the animation begins to move from the relative position to the final position
                    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 -> {
              	//Draw the original position according to the relative position first
                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 calculates the gradient position and layout
                            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 location transformation process
                    mTreeModel.doTraversalNodes(node -> {
                        TreeViewHolder<?> treeViewHolder = treeViewContainer.getTreeViewHolder(node);
                        if (treeViewHolder != null) {
                            View view = treeViewHolder.getView();
                            ViewBox relativeLocation = relativeLocationMap.get(treeViewHolder.getNode());

                            //calculate location info calculation location
                            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 update layout
                            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 at the end of the layout
                    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();
        }
    }

Implementing regression fit screen for dendrogram

This feature point is relatively simple, provided that the TreeViewContainer zoom must be centered on (0,0), and that the TreeViewContainer's move zoom is not Canas or srollTo, so in onSizeChange, we record the scale of the adapted screen.

/**
*Record
*/
@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();
    }

	/**
	*recovery
	*/
   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();
        }
    }

Drag to edit tree structure

There are several steps to drag and edit the tree structure:

  1. Request parent View not to intercept touch events

  2. Capture View in TreeViewContainer using ViewDragHelper implementation to record the original location along with all Nodes of the target Node

  3. Drag Target View Group

  4. During the movement, it calculates whether or not a node is collided with View, and if so, records the colliding node

  5. When releasing, if there are colliding nodes, then the process of adding and deleting nodes is sufficient

  6. When released, use Scroller to roll back to the initial position if there is no collision point

Ask the parent View not to intercept touch events. Don't get confused. It's parent.requestDisallowInterceptTouchEvent(isEditMode); Instead of requestDisallowInterceptTouchEvent directly

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

Here's a brief introduction to the use of ViewDragHelper, which is officially said to be a useful tool class for customizing ViewGroup s. It provides a useful set of operations and state tracking that allow users to drag or change the position of child views within the parent class. Attention, limited to dragging and changing the position, there is no way to zoom in and out, but just dragging the edit node does not use zooming. It also works by determining if you have slid a certain distance or if you have reached a boundary to intercept touch events.

//1 Initialization
dragHelper = ViewDragHelper.create(this, dragCallback);
//2 Judging interception and handling of 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 Implement Callback
private final ViewDragHelper.Callback dragCallback = new ViewDragHelper.Callback(){
    @Override
    public boolean tryCaptureView(@NonNull View child, int pointerId) {
        //Whether to capture the dragged View
        return false;
    }

    @Override
    public int getViewHorizontalDragRange(@NonNull  View child) {
        //When deciding whether to intercept or not, determine whether to move beyond the horizontal range
        return Integer.MAX_VALUE;
    }

    @Override
    public int getViewVerticalDragRange(@NonNull  View child) {
        //When deciding whether to intercept, determine whether to move beyond the vertical range
        return Integer.MAX_VALUE;
    }

    @Override
    public int clampViewPositionHorizontal(@NonNull  View child, int left, int dx) {
        //Move horizontally to return to the position you want to move
        //Special attention should be paid to returning to the left during the interception phase as before, indicating that the boundary is reached without interception
        return left;
    }

    @Override
    public int clampViewPositionVertical(@NonNull  View child, int top, int dy) {
        //Move vertically, return to the position you want to move
        //Special attention should be paid to returning to the left during the interception phase as before, indicating that the boundary is reached without interception
        return top;
    }

    @Override
    public void onViewReleased(@NonNull  View releasedChild, float xvel, float yvel) {
        //Release captured View s
    }
};

So when capturing, start recording location

        @Override
        public boolean tryCaptureView(@NonNull View child, int pointerId) {
            //If dragging editing, use the block to which the record is to be moved
            if(isDraggingNodeMode && dragBlock.load(child)){
                child.setTag(R.id.edit_and_dragging,IS_EDIT_DRAGGING);
                child.setElevation(Z_SELECT);
                return true;
            }
            return false;
        }

When dragging a group of View s, because their relative positions are constant, you can use the same dx both vertically and horizontally.

    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 changes the layout, not the transformation matrix. And dragging here doesn't affect container's Matrix
            view.offsetLeftAndRight(dx);
            view.offsetTopAndBottom(dy);
        }
    }

To calculate whether to collide with another View during dragging

@Override
public int clampViewPositionHorizontal(@NonNull  View child, int left, int dx) {
    //Returning to left before interception means no border can be intercepted, and returning to the original location after interception means we don't need dragHelper to help move, let's go to the target View together
    if(dragHelper.getViewDragState()==ViewDragHelper.STATE_DRAGGING){
        final int oldLeft = child.getLeft();
        dragBlock.drag(dx,0);
        //Continuously determine collision during dragging
        estimateToHitTarget(child);
        invalidate();
        return oldLeft;
    }else{
        return left;
    }
}

@Override
public int clampViewPositionVertical(@NonNull  View child, int top, int dy) {
    //Consistent with the code above
    ...
}

//If it crashes, invalidate, draw a crash reminder
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);
    }
}

When released, delete and add if there is a target, and go through the delete and add process; If not, use Scroller to help rollback

//release
@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 and record the impact point, delete and add, go through the delete and add process
    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{
        //If recover does not, use Scroller to help rollback
        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();
}

//Note that the container computeScroll is overridden for updates
@Override
public void computeScroll() {
    if(dragBlock.computeScroll()){
        invalidate();
    }
}

Write at the end

At this point, the implementation principles of the functions of dragging and zooming, adding and deleting nodes, dragging and editing the entire tree node graph are described. Of course, there are many implementation details. You can use this article as a guide for viewing the source code, and there is still much to improve on the details. Later this open source should continue to be updated, you can also discuss it together, fork out to change together. Give a star if you think it's good.

This project will continue to be updated if used. Like a compliment, thank you.

Keywords: Android Design Pattern

Added by mjm7867 on Sun, 30 Jan 2022 10:53:51 +0200