Creating Apps with Live UI Editing using Webix AbsLayout

Reading time: 7 mins

If you want to create applications that let your users build interfaces themselves, read on and find out how you can achieve this with Webix. Building dynamic user interfaces can be partially achieved with Webix Dashboard. In some cases, like building forms, you can use Form Builder. This service uses AbsLayout as the area for UI building. I will show you how to equip AbsLayout with Drag-n-Drop and use it for rearranging, adding, and deleting UI components.

Creating Apps with Live UI Editing using Webix AbsLayout

Dragging Controls

Let’s prepare the sandbox: AbsLayout with two buttons.

webix.ui({
    view:"abslayout", id:"abs1", cells:[
        { view:"button", value:"Save", width:100, height:40, left:100, top:100 },
        { view:"button", value:"Cancel", width:100, height:40, left:200, top:100 }
    ]
});

Now the task is to make the buttons draggable. For this, I will use webix.DragControl, the module for handling drag-n-drop events. To create a drag area, I will use the addDrag() method. In a simpler case, when you want to make some UI item draggable, you can use the method with the HTML node of the item as its parameter:

const view = $$("abs1");
webix.DragControl.addDrag(view.$view);

In this case, however, I want to make any UI item on AbsLayout draggable, so the solution is more complex. addDrag() receives extra parameters for controlling all the DnD stages. I will need these:

  • $dragCreate – a method that defines the start of dragging;
  • $dragPos – a method for positioning of the dragged item relative to the mouse pointer;
  • $dragDestroy – a method that defines what happens when the item is dropped.

By default, $dragCreate returns the HTML node of the item that is dragged. I need to return the node of the control on which the drag was initiated (i.e. where the pointer is when you press the mouse key to drag). $dragCreate receives two parameters, one of which is the mouse event from which we can get to the node of the Webix component with webix.$$(event). I will also ‘fix’ AbsLayout to prevent it from dragging:

$dragCreate:function(source, ev){
    const el = webix.$$(ev);
    if (el && el !== view){
        return el.$view;
    }
    return false;
}

Now the buttons vanish once they are dropped. The default behaviour is removing the dragged element from the drag area. I need to block this, so I will redefine $dragDestroy:

$dragDestroy:function(){
    return false;
}

By default, the dragged element is moved to the lower right of the pointer. I need to fix this, because if you try dragging the buttons now, it does not look good.

Webix Abslayout with DnD step 1

Live demo >>

DragControl has one more useful method getContext() that returns the context object of DnD. The context can store:

  • the ID of the target element (source),
  • the element from which it is dragged (from),
  • the offset between the pointer and the top-left corner of the top div of the dragged element (x_offset and y_offset).

First, I need to determine the position of the mouse pointer. There is a Webix helper for that, webix.html.pos(), it returns the object with coordinates. Next, I can calculate the offsets. Besides I need to set source and from – later I will use them to change the position of the button during dragging in $dragPos.

$dragCreate:function(source, ev){
    const el = webix.$$(ev);
    if (el && el !== view){
        var pos = webix.html.pos(ev);
        var context = webix.DragControl.getContext();
       
        context.source = [el.config.id];
        context.from = view;
        context.y_offset = el.config.top - pos.y;
        context.x_offset = el.config.left - pos.x;
       
        return el.$view;
    }
    return false;
}

Let’s redefine $dragPos. I will access the context object and adjust the position of the pointer, which is one of the parameters of $dragPos, and the position of the dragged button.

$dragPos:(pos) => {  
    var context =  webix.DragControl.getContext();
    let control = webix.$$(context.source[0]);

    control.config.left = pos.x + context.x_offset
    pos.x = control.config.left - webix.DragControl.left;
    control.config.top = pos.y+context.y_offset
    pos.y = control.config.top - webix.DragControl.top;
}

webix.DragControl stores the adjustments to the position (top and left) that are needed for correct drop of the element. In this case, I had to subtract them.

Finally, I will add a control to switch the demo between two modes: edit and read-only.

webix.ui({
    type:"space", rows:[
        { view:"checkbox", labelRight:"Allow drag-n-drop", id:"mode" },
        abslayout
    ]
});
...
$dragCreate:function(source, ev){
    if (!$$("mode").getValue()) return false;
    //...
}


View code >>

Saving and Restoring Positions

Let’s make the UI ‘reload-proof’: it will restore the positions of buttons.

I will create a new component on the base of AbsLayout. “s-abslayout” will have two methods for getting and setting the positions of buttons that can be used for saving and restoring the state of the UI.

webix.protoUI({
    name:"s-abslayout",
    //returns current positions of controls
    getState:function(){ },
    //sets positions of controls
    setState:function(obj){ }
},webix.ui.abslayout);

To get the state, I will go through all child views of AbsLayout with queryView() and save their coordinates:

getState:function(){
    var state = {};
    this.queryView(function(view){
        state[view.config.id] = {
            left:view.config.left,
            top:view.config.top
        };
    });
    return state;
}

setState() will accept an object returned by getState(), set the positions of buttons and repaint Abslayout:

setState:function(state){
    view.queryView(function(view){
        let id = view.config.id;
        if (state[id]) {
            view.config.left = state[id].left;
            view.config.top = state[id].top;
        }
    });
    view.resize();
}

I will also add a property that will serve as a flag to indicate whether the layout has been changed. I will later use it for saving state.

webix.protoUI({
    name:"s-abslayout",
    defaults:{
        changed:false
    },
    ...
});
...
webix.DragControl.addDrag(view.$view, {
    $dragDestroy:function(){
        view.config.changed = true;
        return false;
    },
    ...
});

I can use getState() and setState() to save the positions of all buttons when the page is reloaded. For that, I will handle the onDestruct event of AbsLayout. Positions will be put to the local storage (or the session storage to restore them after a user closes the browser).

// saving positions of buttons
view.attachEvent("onDestruct", function(){
    var changed = this.config.changed;
    if (changed){    
        const state = this.getState();
        webix.storage.local.put("state", state);
    }
});

When the page is reloaded, AbsLayout will take the positions from the storage.

const state = webix.storage.local.get("state");
if (state){
    view.setState(state);
}

Live demo >>

Adding New Elements to UI

Users should also be able to add new controls. I will add a button that creates a new view to AbsLayout. New controls are added with abslayout.addView():

webix.ui({
    type:"space", rows:[
        {
            cols:[
                { view:"checkbox", labelRight:"Allow drag-n-drop", id:"mode" },
                { view:"button", value:"Add New", width:100, click:addNew }
            ]
        },
        //abslayout...
    ]
});

function addNew(){
    view.addView({
        view:"text", placeholder:"New input...",
        top:50, left:50, width:130
    });
};


View code >>

If you reload the page, the newly added controls do not appear. I will redefine getState() to include newly added views:

getState:function(){
    let state = [];
    this.queryView(function(view){
        let config = view.config;
        state.push(config);
    });
    return state;
}

I will also change restoring. I will use webix.ui() to rebuild AbsLayout:

setState:function(state){
    webix.ui(state,this);
}

Live demo >>

Removing Elements from UI

It is time to enable controls removal. I will do this with drag-n-drop. I will create a drop area in the corner of AbsLayout and remove controls when they are dropped there.

First, I will add an icon in the corner. As the icon will not be removed or dragged around, I will place it in another layout.

{
    view:"abslayout", cells:[
        {
            relative:true, view:"s-abslayout", id:"abs1", cells:[
                {
                    view:"button", value:"Save",
                    width:100, height:40, left:100, top:100
                },
                {
                    view:"button", value:"Cancel",
                    width:100, height:40, left:200, top:100
                }
            ]
        },
        {
                view:"icon", icon:"trash", id:"trash",
                right:60, bottom:60, width:60, height:60,
                hidden:true, css:"trash"
        }
    ]
}

The icon will be shown only in the edit mode. I will use the mode checkbox to show and hide the trash:

const trash = $$("trash");
...
{
    view:"checkbox", labelRight:"Allow drag-n-drop", id:"mode",
    on:{
        onChange(newv){
            newv ? trash.show() : trash.hide();
        }
    }
}

Controls will be removed when they are dropped on the trash. I will define a function that will check if a control is dropped over trash. webix.html.offset() will return the coordinates and sizes of the compared views. The function will return true for all controls, the borders of which overlap the borders of trash.

function intersect(aview, bview){
    const a = webix.html.offset(aview.$view);
    const b = webix.html.offset(bview.$view);
    if (a.x < b.x+b.width && a.x+a.width > b.x)
        if (a.y < b.y+b.height && a.y+a.height > b.y)
            return true;
    return false;
}

Next, I will modify $dragDestroy. If intersect() returns true, a confirmation dialogue will open and wait for permission to remove the control.

$dragDestroy:function(){
    view.config.changed = true;

    var context = webix.DragControl.getContext();
    var control = webix.$$(context.source[0]);

    //borders of dragged control
    if (intersect($$("trash"),control)){
        webix.confirm("Are you sure?",function(res){
            if (res) view.removeView(context.source[0]);
        });
    }
    return false;
}

Everything works fine, and the last thing I would like to do is to highlight the controls with red when they are over the trash. For that, I will define a function that will add a CSS class to the control if it is dragged over trash:

function markErase(control, mode){
    if (intersect($$("trash"),control) && mode !== false)
        control.$view.classList.add("will_erase");
    else
        control.$view.classList.remove("will_erase");
}

markErase will add CSS in $dragPos:

$dragPos:(pos, ev) => {  
    var context =  webix.DragControl.getContext();
    let control = webix.$$(context.source[0]);

    control.config.left = pos.x + context.x_offset
    pos.x = control.config.left - webix.DragControl.left;
    control.config.top = pos.y+context.y_offset
    pos.y = control.config.top - webix.DragControl.top;
   
    markErase(control);
}

and remove it in $dragDestroy if a user doesn’t want to remove the control:

$dragDestroy:function(){
    view.config.changed = true;

    var context = webix.DragControl.getContext();
    var control = webix.$$(context.source[0]);
   
    //borders of dragged control
    if (intersect($$("trash"),control)){
        webix.confirm("Are you sure?",function(res){
            if (res) view.removeView(context.source[0]);
            else markErase(control, false);
        });
    }
    return false;
}


View code >>

Conclusion

With Webix, you can develop apps with dynamic layouts that can be built by users themselves. You can do this with AbsLayout and DnD functionality. The same technique is used in Form Builder, which can be used not only as a service, but also as a part of an app.

For more details about custom DnD, you can read documentation articles: