Create custom layouts for Feathers UI containers

Feathers UI provides a number of built-in layouts for containers, including HorizontalLayout, VerticalLayout and AnchorLayout. However, if these layouts don't quite fit a project's needs, a developer can create a custom layout by implementing the ILayout interface. This interface provides the most basic API required to use a custom layout with a Feathers UI container, such as LayoutGroup or ScrollContainer.

A Simple Example Layout

The complete source code for a custom layout appears below. This SimpleVerticalLayout class is similar to VerticalLayout, but it doesn't offer as many options as the built-in version. It positions items from top to bottom, aligned to the top and left. It has one customizable property, the gap between items, measured in pixels.

class SimpleVerticalLayout extends EventDispatcher implements ILayout {
    public function new() {
        super();
    }

    public var gap(default, set):Float = 0.0;

    private function set_gap(value:Float):Float {
        if (gap == value) {
            return gap;
        }
        gap = value;
        dispatchEvent(new Event(Event.CHANGE));
        return gap;
    }

    public function layout(items:Array<DisplayObject>, measurements:Measurements, ?result:LayoutBoundsResult):LayoutBoundsResult {
        // the starting y position
        var positionY = 0.0;
        // the max width of the items will be used to calculate bounds
        var maxItemWidth = 0.0;

        // loop through the items and position them
        for (item in items) {
            // skip items that aren't included in the layout
            if (Std.isOfType(item, ILayoutObject)) {
                var layoutItem = cast(item, ILayoutObject);
                if (!layoutItem.includeInLayout) {
                    continue;
                }
            }

            // validate Feathers UI components to get accurate measurements
            if (Std.isOfType(item, IValidating)) {
                var uiControl = cast(item, IValidating);
                uiControl.validateNow();
            }

            // position the item
            item.x = 0.0;
            item.y = positionY;

            // calculate the y position for the next item
            positionY += item.height + gap;

            // save the max width to calculate the bounds below
            maxItemWidth = Math.max(maxItemWidth, item.width);
        }

        // if the view port width or height is provided, use the provided value.
        // otherwise, calculate them based on the size of the content.
        var viewPortWidth = measurements.width;
        if (viewPortWidth == null) {
            var minWidth = measurements.minWidth;
            if (minWidth == null) {
                minWidth = 0.0;
            }
            var maxWidth = measurements.maxWidth;
            if (maxWidth == null) {
                maxWidth = Math.POSITIVE_INFINITY;
            }
            // the view port width is calculated from the maximum item width
            viewPortWidth = Math.max(minWidth, Math.min(maxWidth, maxItemWidth));
        }

        var totalContentHeight = positionY - gap;
        var viewPortHeight = measurements.height;
        if (viewPortHeight == null) {
            var minHeight = measurements.minHeight;
            if (minHeight == null) {
                minHeight = 0.0;
            }
            var maxHeight = measurements.maxHeight;
            if (maxHeight == null) {
                maxHeight = Math.POSITIVE_INFINITY;
            }
            // the view port height is calculated from the sum of all item
            // heights plus the gaps between items.
            viewPortHeight = Math.max(minHeight, Math.min(maxHeight, totalContentHeight));
        }

        // prepare the result object and return it
        if (result == null) {
            result = new LayoutBoundsResult();
        }
        result.viewPortWidth = viewPortWidth;
        result.viewPortHeight = viewPortHeight;
        // it's okay if the content width and height are larger than the
        // view port width and height. in some containers, that will simply
        // enable scrolling.
        result.contentWidth = Math.max(maxItemWidth, viewPortWidth);
        result.contentHeight = Math.max(totalContentHeight, viewPortHeight);
        return result;
    }
}

The ILayout interface

The SimpleVerticalLayout class implements the feathers.layout.ILayout interface. This interface is very simple, with a layout() method.

SimpleVerticalLayout also extends the standard openfl.events.EventDispatcher class because the ILayout interface specifies that layouts should dispatch Event.CHANGE when their properties change. This will allow containers that use layouts to know when they need to call layout() method again to reposition their children.

The layout() method

The layout() method has the following function signature:

function layout(items:Array<DisplayObject>, measurements:Measurements, ?result:LayoutBoundsResult):LayoutBoundsResult

This method's main purpose is to position and size the container's children. In SimpleVerticalLayout, it sets the y position of all children. Additionally, if the final dimensions of the container's view port aren't specified, the layout() method will also calculate those values and return them in a LayoutBoundsResult object.

Looping through children

The first argument is an Array<DisplayObject> that contains the children in the container. An ILayout implementation can loop through these items and transform them as needed. The loop from the SimpleVerticalLayout class appears again below.

for (item in items) {
    // skip items that aren't included in the layout
    if (Std.isOfType(item, ILayoutObject)) {
        var layoutItem = cast(item, ILayoutObject);
        if (!layoutItem.includeInLayout) {
            continue;
        }
    }

    // validate Feathers UI components to get accurate measurements
    if (Std.isOfType(item, IValidating)) {
        var uiControl = cast(item, IValidating);
        uiControl.validateNow();
    }

    // position the item
    item.x = 0.0;
    item.y = positionY;

    // calculate the y position for the next item
    positionY += item.height + gap;

    // save the max width to calculate the bounds below
    maxItemWidth = Math.max(maxItemWidth, item.width);
}

At the start of the loop, there are two checks for the data type of each child, using Std.isOfType().

The first check determines if the child implements the ILayoutObject interface. If the includeInLayout property of an ILayoutObject is false, that child won't affect the measurement of the parent container and the layout won't position, resize, or otherwise transform the display object. This property makes it possible to exclude a child from a layout to position it manually.

The second check determines if the child implements the IValidating interface. If the child is invalid, its width and height properties may not have been calculated yet, or they may return older values from a previous validation. Calling validateNow() will force an invalid child to validate immediately to ensure that the width and height properties return the correct measurements.

For more information about the Feathers UI validation system, please refer to UI Component Lifecycle.

After checking for these special cases, the main part of the loop is next. This is where SimpleVerticalLayout sets the y position of each child. If this were a more advanced layout, it might perform other transformations, like setting the width or height, or even rotating the child. It is also keeping track of maximum width of each child, which will be used later to calculate the final view port width, if necessary.

Finally, the loop checks if the curernt child's widht is larger than any of the child widths that we've already encountered. We'll use this maximum width value later.

Measurements

The second argument of the layout() method is a Measurements object containing any bounds of the container that were already set explicitly. This includes the container's width, height, minWidth, minHeight, maxWidth, and maxHeight. However, any of these properties may be null, which means that these values were not set explicitly, and they need to be calculated by the layout.

// if the view port width or height is provided, use the provided value.
// otherwise, calculate them based on the size of the content.
var viewPortWidth = measurements.width;
if (viewPortWidth == null) {
    var minWidth = measurements.minWidth;
    if (minWidth == null) {
        minWidth = 0.0;
    }
    var maxWidth = measurements.maxWidth;
    if (maxWidth == null) {
        maxWidth = Math.POSITIVE_INFINITY;
    }
    // the view port width is calculated from the maximum item width
    viewPortWidth = Math.max(minWidth, Math.min(maxWidth, maxItemWidth));
}

If the width of the view port needs to be calculated, use the maximum item width that was saved in the loop above. Be sure to account for minWidth and maxWidth, if they are not null.

var totalContentHeight = positionY - gap;
var viewPortHeight = measurements.height;
if (viewPortHeight == null) {
    var minHeight = measurements.minHeight;
    if (minHeight == null) {
        minHeight = 0.0;
    }
    var maxHeight = measurements.maxHeight;
    if (maxHeight == null) {
        maxHeight = Math.POSITIVE_INFINITY;
    }
    // the view port height is calculated from the sum of all item
    // heights plus the gaps between items.
    viewPortHeight = Math.max(minHeight, Math.min(maxHeight, totalContentHeight));
}

If the height of the view port needs to be calculated, use the last value of the positionY variable after the loop completes. Be sure to account for minHeight and maxHeight, if they are not null.

LayoutBoundsResult

The final argument is an optional LayoutBoundsResult object. This object is used to return the final view port dimensions, and the dimensions of the content within the view port, so that the container may use them. The content may be larger than the view port, and a component like ScrollContainer will use that to determine if it needs to scroll.

This LayoutBoundsResult argument actually becomes the return value of the layout() method. By passing in (and reusing) a pre-created LayoutBoundsResult object, Feathers UI can avoid unnecessary garbage collection. It is optional, though, so the layout is expected to create a new instance of LayoutBoundsResult, if the argument is null.

// prepare the result object and return it
if (result == null) {
    result = new LayoutBoundsResult();
}
result.viewPortWidth = viewPortWidth;
result.viewPortHeight = viewPortHeight;
// it's okay if the content width and height are larger than the
// view port width and height. in some containers, that will simply
// enable scrolling.
result.contentWidth = Math.max(maxItemWidth, viewPortWidth);
result.contentHeight = Math.max(totalContentHeight, viewPortHeight);
return result;

The viewPortWidth and viewPortHeight properties specify the calculated width and height of the container's view port. If the Measurements object specifies width and height values that are not null, those existing values should always be passed to viewPortWidth and viewPortHeight, without changes. If they are null, then these values must be calculated by the layout (which we did in the previous section).

The contentWidth and contentHeight properties specify the total bounds of the content. These values may be larger than viewPortWidth and viewPortHeight, which will cause some containers to enable scrolling.

Generally, developers should strive to ensure that contentWidth is smaller than viewPortWidth, and contentHeight is not smaller than viewPortHeight. It's not strictly required, but it's a good practice.