Monday 20 May 2013

Simplifying Dynamic Positioning with jQuery UI


When building web applications or interactive widgets, it's often necessary to position an element relative to something else. As a few examples, an autocomplete suggestion list may appear below the text field the user is typing into, a dialog may appear centered within the window, and a tooltip may appear offset from the cursor. All of these scenarios seem simple because we naturally think about layout in terms of objects and their relative position to each other. However, when you get down to the actual code involved in positioning elements, things get hairy pretty quickly.

The Problem

Let's take a look at what's involved in one of the more basic scenarios: centering an element in the window. At first glance, this is pretty simple. You set position: absolute on the element and do some simple calculations based on the dimensions of the window and the dimensions of the element in order to set the top and left appropriately.
var win = $( window );
var dialog = $( "#dialog" );
var top = (win.height() - dialog.height()) / 2;
var left = (win.width() - dialog.width()) / 2;
dialog.css({
    position: "absolute",
    top: top,
    left: left
});
This works great until we scroll the page, or put the dialog inside another positioned element, or decide that we want to support position: fixed or position: relative. Things get even worse if the dialog manages to become larger than the window in either direction. The number of scenarios that need to be accounted for and the math involved in properly positioning arbitrary elements quickly becomes daunting.

There's Gotta Be a Better Way

This is where jQuery UI's .position() method comes in to save the day. jQuery UI extends the.position() method from jQuery core in a way that lets you describe how you want to position an element the same way you would naturally describe it to another person. Instead of working with numbers and math, you work with meaningful words (such as left and right) and relationships. Let's take a look at how you would center a dialog in the window using jQuery UI:
var dialog = $( "#dialog" );
dialog.position({
    my: "center",
    at: "center",
    of: window
});
Notice how this reads almost as a full English sentence: "position my center at (the) center of (the) window." It really is that simple. In addition to removing all of the math involved, and having self-explanatory code, our positioning will now work even if the page is scrolled. Even better, the dialog can be positioned relativeabsolute, or fixed and it will still work properly. If the dialog hasstatic positioning, then the .position() method will automatically convert the position torelative so that it can be positioned.
In this example, we used a simplified form for the my and at properties. Both of these properties use syntax similar to CSS's background-position; that is to say they take the format "horizontal vertical" where the horizontal value can be leftcenter, or right and the vertical value can betopcenter, or bottom. If either value is omitted, then center is used for that value.
You won't always want the element to be positioned right up against the edge or center of another element though. Let's say you want to display a message in the top right corner of the window, but leave some padding so that the message stands out more. You can add an offset for the horizontal and vertical values of my or at.
$( "#message" ).position({
    my: "right-10 top+10",
    at: "right top",
    of: window
});
Positive offsets move right and down, and negative offsets move left and up. So in the above example, we're saying position the right top corner of the message with the right top corner of the window, then move 10 pixels to the left and 10 pixels down. In addition to specifying pixel offsets, you can use a percentage offset. Percents are based on the width of the element being position or the target (of) element, based on whether the percent is specified in my or at, respectively.
Let's take a look at the autocomplete example, where we want to position the suggestion list directly below the text field, with the left edge of each lined up:
$( "#suggestions" ).position({
    my: "left top",
    at: "left bottom",
    of: "#autocomplete"
});
You'll notice that in this example, the of value is a selector. jQuery UI's .position() method is actually quite flexible in what you can position against. The of value can be a selector, an element, a jQuery object, or an event object. If an event object is used, it is assumed to be an event associated with a pointer and should therefore have pageX and pageY properties. To see how this works, let's create a very simple popup that appears at the cursor when you click.
$( "body" ).on( "click", function( event ) {
    $( "#message" ).position({
        my: "left top",
        at: "left top",
        of: event
    });
});

Handling Collisions

One of the trickiest parts of dynamic positioning is handling collisions in an elegant way. In the previous example, we created a popup that appeared relative to the cursor. But what happens if the user clicks near the bottom right corner of the window? Depending on how close the user clicks to the edge, and how large the popup is, the element we're showing may not fit on the screen. Luckily, the .position() method handles this for us automatically. The code is smart enough to detect the collision (some portion of the element colliding with the edge of the window) and adjust the position so that the entire element is visible. In fact, there are multiple ways that a collision can be handled.
By default, the element will flip. That is to say that "left top" will become "right bottom" if the element collides in both directions. The code is smart enough to only adjust the positioning in one direction if necessary. For example, if the element collides on the bottom, but not on the right, then the element will only flip in the vertical direction. In the rare circumstance that a collision occurs, and flipping would cause even less of the element to be visible, the element will not flip.
Another option is to have the element shift away from the collision until it fits within the window. If the element collides on the right, then it would continue to shift left until the right edge no longer collides and the element is fully visible.
The collision handling is conrolled by two properties. The collision property, takes a value of"flip""fit", "flipfit", or "none". Using "flipfit" will apply both flip and fit logic, which may be useful if you do end up in the rare circumstance where flipping will not allow the full element to be visible. Setting collision: "none" disables collision handling completely. Just like the my and atproperties, you can specify different values for the horizontal and vertical direction, e.g., "fit none"would apply the fit logic in the horizontal direction, but no collision handling in the vertical direction.
The second property that controls collision handling is within. The within property allows you to specify which element to use as the bounding box for collision detection. This can come in handy if you need to contain the positioned element within a specific section of your page.

Getting Fancy

There's one more feature provided by the .position() method. You can ask for .position() to do all the hard work of calculating positions and handling collisions, without actually positioning the element. There are probably lots of interesting features that can be built with this information, but the simplest to show and understand is animating an element from its current position to its new position. Let's revisit the dialog that gets centered in the window. If we wanted to slide the dialog in from the left side of the screen, we could first position it offscreen to the left, then reposition it in the center of the window and provide a using callback which animates the position.
// Position the dialog offscreen to the left, but centered vertically
$( "#dialog" ).position({
    my: "right",
    at: "left",
    of: window,
    // Disable collision detection, since we're positioning offscreen
    collision: "none"
});

// Animate the dialog to the center of the window
$( "#dialog" ).position({
    my: "center",
    at: "center",
    of: window,
    using: function( position ) {
        $( this ).animate( position );
    }
});
The position argument that is passed to the using callback contains left and top properties, which can easily be passed off to .css() or .animate().
The using callback receives one more argument which provides information about how the element was positioned (since the requested position may change based on collision handling). This can be quite useful for situations where you want to style the element differently based on where it's positioned. For example, if you were creating a tooltip and wanted to add an arrow pointing to the target element. This is a rather advanced concept and is out of scope for this tutorial, but it's good to know about for when you need it.

No comments:

Post a Comment