In the last article, we used the document flow layout in canvas to quickly realize the generation of friends circle sharing graph

background

The last article introduced the simple use of easyCanvas. This article will introduce the implementation idea.

DEMO

Project address easy-Canvas

Vue component version vUE – Easy-Canvas

design

EasyCanvas is similar. It will generate an Element tree according to the render function, which is equivalent to directly generating a DOM tree. Layer merges and inherits the style of this tree, and then initializes the width and height according to the style, and then calculates the position of the drawing. Once the layout is complete, hand it over to Render for drawing. The EventManager class receives user actions, finds the corresponding Element, modifies it, and then updates it in the view.

Style preprocessing

Before you can do a layout, you need to do some pre-processing of the style, similar to a CSS Tree merge

  1. Adding default Styles

There are some styles that the user may not have written, but the convention is, such as textAlign. This step will fill in the default style 2. Some style attributes need to be inherited from the parent, such as fontSize color, etc. The completion style user might have some shorthand like padding, which internally converts this property to a single property like paddingLeft. In addition, properties like width:100% are also resolved.

Post some code:

    constrenderStyles = { ... this.styles }const parentWidth = this._getContainerLayout().contentWidth
    const parentHeight = this._getContainerLayout().contentHeight

    if (isAuto(renderStyles.width)) {
      renderStyles.width = 0
    } else if (isOuter(renderStyles.width)) {
      renderStyles.width = parseOuter(renderStyles.width) * parentWidth
    }

    if (isAuto(renderStyles.height)) {
      renderStyles.height = 0
    } else if (isOuter(renderStyles.height)) {
      renderStyles.height = parseOuter(renderStyles.height) * parentHeight
    }

    // Initialize contentWidth
    renderStyles.contentWidth = renderStyles.width - renderStyles.paddingLeft - renderStyles.paddingRight - renderStyles.marginLeft - renderStyles.marginRight - this._getTotalBorderWidth(renderStyles)
    renderStyles.contentHeight = renderStyles.height - renderStyles.paddingTop - renderStyles.paddingBottom - renderStyles.marginTop - renderStyles.marginBottom - this._getTotalBorderHeight(renderStyles)
    this.renderStyles = renderStyles
Copy the code

There is no special requirement for traversal mode in this step, only traversal from parent level to child level

If you look closely, you’ll see that our elements are just squares, of different sizes, arranged in different places in a certain order. So basically we just need to calculate the size and position of the elements, and we can lay them out. So the question is, should we calculate the size first or the position first? If we think about it carefully, we find that we calculate the size first, because the position of the next element in the document flow layout is affected by the previous element, and the size is not affected by the position.

Computing size

Our width and height in the previous step have been processed one layer, so we are either auto or already a number.

So let’s go through… So the question is, what is the traversal here, parent to child? Depth first or breadth first?

The answer is breadth first, because as for the size, auto is affected by the size of the child elements, so it is necessary to calculate the child elements first, to ensure that when calculating the width and height of the parent, all the child elements have been calculated. We also need to be sure to evaluate the child element on the left first, because for inline-block elements, we need to evaluate the newline.

There are two cases of automatic width and height. One is for container elements, such as View, whose width and height need to be calculated by traversing the width and height of all child elements, and the other is for text and image, whose width and height need to be calculated by calculating their own size. Text needs to take into account the maximum number of newlines and so on, so I’m not going to expand here, and there’s a lot of articles in the community.

In particular, there are inline-block and flex elements, which introduce the concept of line and flexBox. Each inline-block element is bound to line, which records the width of the parent element. If line is wide enough, It is bound to the line of the previous inline-block element, otherwise it is bound to a new line, like flexBox.

We also need to determine the width and height of the child element when iterating through it. In the case of inline-block, we only consider the size of the line and filter out elements that are not in the document stream.

    if (isAuto(width) || isAuto(height)) {
      // The height of a container is computed by its child elements. The height of a container is computed by its child elements
      const layout = this._measureLayout()
      // Initialize the width height
      if (isAuto(width)) {
        this.renderStyles.contentWidth = layout.width
      }

      if (isAuto(height)) {
        // Auto
        this.renderStyles.contentHeight = layout.height
      }
    }

    this._refreshLayoutWithContent()

    if (this._InFlexBox()) {
      this.line.refreshWidthHeight(this)}else if (display === STYLES.DISPLAY.INLINE_BLOCK) {
      // If inline-block is used, only height is calculated
      this._bindLine()
    }
Copy the code

Calculation of position

At this point, we have initialized the width and height of each element. We need to traverse the initialized element position from parent to child. The child element adds its height and determines whether or not to wrap with line.

    if (this.renderStyles.display === STYLES.DISPLAY.INLINE_BLOCK) {
      // inline-block is computed inline
      this.line.refreshElementPosition(this)}else {
      this.x = parentContentX
      this.y = this._getPreLayout().y + this._getPreLayout().height
    }
Copy the code

draw

Now that the layout is complete and each element has its width, height and position, it is ready to draw. Import the Element tree into Render to draw, which requires depth-first traversal, as explained below.

Drawing is divided into several steps:

  1. Draw the shadow because the shadow is on the outside and needs to be drawn before clipping
  2. Draw clipping and borders
  3. Draw the background
  4. Draw content, such as text and image
    walk(element, (element, callContinue, callNext) = > {
      if (element.isVisible()) {
        // Visible only render
        this.paint(element)
      } else {
        // Skip the entire child node
        callNext()
        this._helpParentRestoreCtx(element)
      }
    })
Copy the code

Walk through the tree, repeating the steps above for each element. A new problem arises. After we clip one element, the following elements can not be seen. What should we do? The first thought might be to do ctx.save() for each draw and ctx.restore() for each draw, but then the child element would not be wrapped by the parent element, and the overflow:hidden effect would not be implemented

Let’s look at this drawing tree

Overflow effects apply to child elements, i.e. the clip for element 1 should apply to all elements below, and the clip for element 2 should apply to element 10 after drawing, and this is why we need depth-first traversal.

And we need to know that the CTX state is stored as a stack, with each save() pushing the current state onto the stack and each restore() popping up using the previous state.

Therefore, we cannot immediately release the CTX stack after drawing an element. We need to judge whether to close the CTX stack according to whether there are child elements and whether it is the end of the tree.

    // The first step is to determine that there are no child elements
    // This time is restore itself
    if(! element.hasChildren()) {this.getCtx().restore()
    }

    if((element.isVisible() && ! isEndNode(element)) || (! element.isVisible() && element.next))return

    // restore above the parent level
    let cur = element.parent
    while(cur && ! cur.next) {// If the parent is also the last of the siblings, close the parent
      this.getCtx().restore()
      cur = cur.parent
    }

    // restore The first layer parent
    if (cur && cur.next) {
      this.getCtx().restore()
    }
Copy the code

As per the code, we are reviewing the tree above:

The following element numbers are based on the depth-first traversal diagram above

Draw element 1, save

.

Draw element 5, determine that it is the end element, restore 5, determine that 4 is not the last, restore 4

.

Draw 7, determine the end, restore 7, determine that 6 is not the last, restore 6

Draw 8, judge is terminal, restore 8, judge 8 is last, restore 2, judge 2 is not last, restore 8

.

In this way, the CTX stack is gradually released, and the clip of the parent element is applied to the child element

By now, I can draw the layout normally. Of course, there are a lot of twists and turns in the middle. Before refactoring, I used deep traversal, resulting in very low performance.

The event

This refers to events that the user operates on, such as Click

We all know that browser events are captured and bubbled, or in the example of the deep traversal above, clicking on element 2 will be caught by element 1 and then bubbled back through element 2 and then bubbled back through element 9 without being sensed.

Normal event management would add all the callback methods to an array and iterate to see if they fired, but that doesn’t work.

As we see, the view can be abstracted into a tree, think of the view of the event is also can be abstracted into a tree, the element of events according to the elements in the view hierarchy structure into an event tree, every time a user clicks the view, down from the top of the tree traversal, if the current element term capture method, and to continue to traverse the child nodes, Until there are no children under the node, the bubbling implementation simply pushes the hit callbacks onto a stack during the traverse and pops them up as the last element is reached.

Here’s the code:

// Build the event tree
addCallback(callback, element, tree, list, isCapture) {
    let parent = null
    let node = null
    // Find the parent node that should be mounted
    for (let i = list.length - 1; i >= 0; i--) {
      if (element === list[i].element) {
        / / the current
        parent = list[i - 1]
        node = list[i]
        break
      }
      walkParent(element, (p, callBreak) = > {
        if (p === list[i].element) {
          parent = list[i]
          callBreak()
        }
      })
      if (parent) {
        break}}// If the same element node does not exist
    if(! node) { node =new Callback(element, callback)
    }

    // Add a callback method
    if (isCapture) {
      node.addCapture(callback)
    } else {
      node.addCallback(callback)
    }

    // Mount the node
    if (parent) {
      parent.appendChild(node)
    } else {
      tree.appendChild(node)
    }

    // Cache to list
    list.push(node)
  }

  // Callback inherits from TreeNode and constructs a Callback tree based on element's hierarchy
  class Callback extends TreeNode {
    constructor(element) {
      super(a)this.element = element
      this.callbackList = []
      this.captureList = []
    }

    addCallback(callback) {
      this.callbackList.push(callback)
    }

    addCapture(callback) {
      this.captureList.push(callback)
    }

    runCallback(params) {
      this.callbackList.forEach(item= > item(params))
    }

    runCapture(params) {
      this.captureList.forEach(item= > item(params))
    }

  }

Copy the code
    // Perform capture and bubble
    walk(tree, (node, callContinue, callBreak) = > {
      if (node.element) {
        if (this.isPointInElement(e.relativeX, e.relativeY, node.element)) {
          node.runCapture(e)
          callbackList.unshift(node)
        } else {
          // Skip the current child node and traverse the adjacent nodes
          callContinue()
        }
      }
    })

    /** * execute on callback from child to parent */
    for (let i = 0; i < callbackList.length; i++) {
      if(! e.currentTarget) e.currentTarget = callbackList[i].element callbackList[i].runCallback(e)if (e.cancelBubble) break
    }
Copy the code

With event support, we can implement an Scroll view. The basic idea is that an instance of an Scroll view returns an outer view with a fixed width and height, while the inner view is split according to the content. During initialization, the inner view registers events with the event manager. Scroll by controlling the draw translate value.

The process was smooth, but when I clicked the element in the Scroll View, I found a problem. Because the Scroll View translated, the coordinate point clicked did not coincide with the actual element. In other words, if you click a point in the Scroll view, translate will be required to determine the position of the inner element. Reviewing the process of event capture and bubbling, we can register an Scroll View capture event that internally converts coordinate values to scroll values. The code is as follows:

    this.getLayer().eventManager.onClick((e) = > {
        e.relativeY -= this.currentScrollY
        e.relativeX -= this.currentScrollX
    }, this._scrollView, true) // The last parameter controls whether to capture or bubble
Copy the code

Finally I can roll happily ~ ~

Adding and Deleting a Node

The implementation of adding and deleting nodes is very simple. You only need to add nodes to the tree, rearrange and redraw the corresponding nodes that need to be changed, and paste the code directly:

  // Add at the end
  appendChild(element) {
    super.appendChild(element)
    this.getLayer().onElementChange(element)
    return element
  }
Copy the code

performance

Here is an example of rendering a long table using easyCanvas:

Render 1000 rows of data, 10000 elements

In the first version of the design, there were many performance issues, so the code for the later layout was completely refaced.the second design ensured that there would be no internal loop calculations outside the main loop, reducing unnecessary layout calculations.

What the operator can really perceive is the smoothness of the scroll view when it is rolling. On the original console, there are implementations that only draw visible elements, namely virtual lists, which greatly improve drawing performance, and there are similar implementations on easyCanvas.

If renderOnDemand is enabled in the Scroll view, the visibility of all first-level child elements will be checked before the first rendering, and render will decide whether to draw the node and child nodes according to the visibility. When the scroll view is scrolled, it will check the viewability again. An optimization point here is that the internal record of the viewable boundary index will be recorded, and only the nodes before and after the boundary will be calculated each time, avoiding traversing all the child nodes each time.

There is an idea behind, if the node is marked as a static node, it will save the ImageData of the node after the first drawing, only putImageData is needed behind, there is no need to draw child nodes, but I tried to achieve some problems, the current performance is also sufficient, behind slowly study ~ ~

Thank you for reading