Layout

The layout is a container of elements that define how the elements it contains are placed. There are different types of layouts and each one of them places its elements in different ways (ex: by rows and columns). The elements that the layout contains are called children.

To create a layout type, you first need to call the canvasUI.layout.newType(name) function passing as argument the name of the type, like so:

const rowsType = canvasUI.layout.newType("rows")

This will return an object that you will use to specify all the data about the type.

Properties

After creating the layout type, you will be able to define what are the properties that it will have using the .set(property, value) method, like so:

rowsType.set("numRows", 3)
rowsType.set("rowSize", 100)

Layout Params

You can also define what we call layout parameters. These are parameters that its children can use to define how they have to be placed (ex: in a specific row and column). To define these layout parameters, you have to use the method .layoutParams.set(param, value), like so:

rowsType.layoutParams.set("row", 0)

Lifecycle

The layout element goes through different lifecycle states, and it performs different tasks in each one of them:

  • Create: It may perform some initial tasks and store some data if needed.

  • Start: It may compute and store some data that may be used for the next states. After that, it iterates over every child and calls the .start() method on each child.

  • Measure: It iterates over every child and for each one of them it computes the maximum size that the child is allowed to be and calls its .measure(maxSize) method. Then, it computes its size taking into consideration the maximum size that was passed in its .measure(maxSize) method and assigns it to its size property.

  • Locate: It iterates over every child and for each one of them it computes the coords in which the child has to be located and calls its .locate(coords) method. Then, it assigns the coords that were passed in its .locate(coords) method to its coords property.

  • Draw: It draws itself on the canvas using the canvas context that was passed in its .draw(ctx) method. Then, it iterates over every child and for each one of them it calls its .draw(ctx) method.

  • End: It may perform some final tasks that may be needed. Then, it iterates over every child and for each one of them it calls its end() method.

To perform these tasks, the layout will call its lifecycle functions. These functions are implemented differently by every layout type and to implement them you have to use the .lifecycle.set(name, func) method, like so:

layoutType.lifecycle.set("onCreate", function (layout) {
  // Code
})

Notice how the first parameter of the function is the layout itself. That happens with every lifecycle function.

The lifecycle functions of the layout type that we can use are:

  • onCreate(layout): It is called when the layout enters the create state.

  • onStart(layout): It is called when the layout enters the start state.

  • onMeasure(layout, maxSize): It is called when the layout enters the measure state. The second parameter is the maximum size that was passed in its .measure(maxSize) method.

  • sortChildrenToSetSizes(layout, maxSize): It is called in the measure state. In this function, the layout returns the children sorted by the order in which their sizes will be computed. If you don't implement this function, the order of the children will be the order in which they were in inserted in the layout.

  • getChildMaxSize(layout, maxSize, child, childrenWithSizes): It is called in the measure state for every child. This function needs to be implemented and in it the layout computes and returns the maximum size of the child. The third parameter is the child and the fourth parameter is the sorted children that have their sizes defined.

  • getSize(layout, maxSize): It is called in the measure state. This lifecycle function needs to be implemented and in it the layout has to compute and return its size.

  • onLocate(layout, coords): It is called when the layout enters the locate state. The second parameter is the coords that were passed in its .locate(coords) method.

  • sortChildrenToSetCoords(layout, coords): It is called in the locate state. In this function, the layout returns the children sorted by the order in which their coords will be computed. If you don't implement this function, the order of the children will be the order in which they were in inserted in the layout.

  • getChildCoords(layout, coords, child, childrenWithCoords): It is called in the locate state for every child. This function needs to be implemented and in it the layout computes and returns the coords of the child. The third parameter is the child and the fourth parameter is the sorted children that have their coords defined.

  • onDraw(layout): It is called when the layout enters the draw state.

  • drawItself(layout, ctx): It is called in the draw state. This lifecycle function may not need to be implemented, and in it the layout makes all the drawings. The second parameter is the canvas context that was passed in its .draw(ctx) method.

  • sortChildrenToDraw(layout): It is called in the draw state. In this function, the layout returns the children sorted by the order in which they will be drawn. If you don't implement this function, the order of the children will be the order in which they were in inserted in the layout. This function is only useful when there are children that are overlapping on the user interface.

  • onEnd(view): It is called when the layout enters the end state.

Example

To give you a solid understanding about how to create a layout type, I will show you how to create a type that places its children one next to the other horizontally.

We first need to create the layout type and give it some properties:

const horizontalType = canvasUI.layout.newType("horizontal")

horizontalType.set("size", { width: 100, height: 100 })
horizontalType.set("gap", 20)
horizontalType.set("background", "rgba(0,0,0,0)")

The size property defines the size of the layout in terms of percentages. The gap property defines the horizontal gap between children, and it is defined in px. The background property defines the background color of the layout.

Now, we can define the layout parameters that the children will be able to use:

horizontalType.layoutParams.set("position", 0)

The children will use this parameter to define the order in which they will be placed. The first child will be the one with the lowest value, and the last child the one with the highest value. If several children have the same value, they will be placed in the order in which they were inserted in the layout.

Now, we are ready to start implementing some lifecycle functions. To do that, we will start thinking about how we want to approach it.

The children need to be placed in the order they define using their position layout parameter. That means that we will need to implement some lifecycle functions that deal with the ordering of the children. In these functions, we don't want to sort the children several times, so it would be useful to store the sorted children in some place to avoid having to repeat this process again. We can do it like so:

horizontalType.lifecycle.set("onStart", function (layout) {
  const sortedChildren = [...layout.children].sort(
    (first, second) =>
      first.layoutParams.get("position") - second.layoutParams.get("position")
  )
  layout.inner.set("sortedChildren", sortedChildren)
})

In this lifecycle function, we have sorted the children and stored the result in an inner property. We will explain what inner properties are in another section. For now, you just need to understand that we have stored the result for later use.

We will need to compute the maximum available size of every child. To be able to do that, it may be useful to have the size of the layout already computed. We can compute and store the value in the onMeasure lifecycle function, like so:

horizontalType.lifecycle.set("onMeasure", function (layout, maxSize) {
  const size = layout.inner.call("getSize", maxSize)
  layout.inner.set("size", size)
})

horizontalType.inner.fun("getSize", function (layout, maxSize) {
  const width = (layout.get("size").width * maxSize.width) / 100
  const height = (layout.get("size").height * maxSize.height) / 100
  return { width, height }
})

In this lifecycle function, we have used an inner function. We will explain what inner functions are in another section. For now, you just need to understand that we have stored the size for later use.

Now, we can implement the sortChildrenToSetSizes lifecycle function and use the value we stored before:

horizontalType.lifecycle.set("sortChildrenToSetSizes", function (layout) {
  return layout.inner.get("sortedChildren")
})

After that, we define the maximum available size of every child, like so:

horizontalType.lifecycle.set(
  "getChildMaxSize",
  function (layout, maxSize, child, childrenWithSizes) {
    const width = layout.inner.call("getChildMaxWidth", childrenWithSizes)
    const height = layout.inner.get("size").height
    return { width, height }
  }
)

horizontalType.inner.fun(
  "getChildMaxWidth",
  function (layout, childrenWithSizes) {
    const usedWidth = childrenWithSizes.reduce((acc, child) => {
      return acc + child.size.width + layout.get("gap")
    }, 0)
    const childMaxWidth = layout.inner.get("size").width - usedWidth
    if (childMaxWidth < 0) return 0
    return childMaxWidth
  }
)

To compute the maximum available size of every child we have used the size of the layout we stored previously, and we have also used the sizes of the children in which their sizes have already been computed.

Since the elements are placed horizontally, the maximum available height of the child will be the height of the layout and the maximum available width of the child will be all the remaining width that has not been used.

Now, we have to implement the getSize function:

horizontalType.lifecycle.set("getSize", function (layout, maxSize) {
  return layout.inner.get("size")
})

We are now ready to implement the functions of the locate state.

We have to implement the sortChildrenToSetCoords lifecycle function, like so:

horizontalType.lifecycle.set(
  "sortChildrenToSetCoords",
  function (layout, coords) {
    return layout.inner.get("sortedChildren")
  }
)

Then, we will define the coords of every child by implementing the getChildCoords lifecycle function:

horizontalType.lifecycle.set(
  "getChildCoords",
  function (layout, coords, child, childrenWithCoords) {
    const x = layout.inner.call("getChildX", coords, childrenWithCoords)
    const y = coords.y
    return { x, y }
  }
)

horizontalType.inner.fun(
  "getChildX",
  function (layout, coords, childrenWithCoords) {
    if (childrenWithCoords.length === 0) return coords.x
    const lastChild = childrenWithCoords[childrenWithCoords.length - 1]
    return lastChild.coords.x + lastChild.size.width + layout.get("gap")
  }
)

The y coord will be the top of the layout, and the x coord will be computed to make the child be placed next to the last child that had its coords defined.

After doing that, we just need to implement the drawItself lifecycle function of the draw state:

horizontalType.lifecycle.set("drawItself", function (layout, ctx) {
  ctx.fillStyle = layout.get("background")
  ctx.fillRect(
    layout.coords.x,
    layout.coords.y,
    layout.size.width,
    layout.size.height
  )
})

The horizontal type has been created, and now we are ready to use it:

const horizontal = canvasUI.layout.new("horizontal-1", "horizontal")

horizontal.set("gap", 100)
horizontal.set("background", "#ffa234")

const text1 = canvasUI.view.new("text-1", "text")
text1.set("text", "Hello")

const text2 = canvasUI.view.new("text-2", "text")
text2.set("text", "Goodbye")

horizontal.insert(text1)
horizontal.insert(text2)

text1.layoutParams.set("position", 1)