Saturday 3 October 2009

Correctly measuring element dimensions

Obtaining an Elements correct width and height

Should be easy shouldn't it? You could check the elements style.width and style.height property but if they haven't been set by an inline style or with Javascript this won't help. You can use the browser specific style functions to return the current value but there are big differences between IE's currentStyle and the standard getComputedStyle functions.

If you want the height and width in pixels because you need the values for further computations such as positioning an element in the window then you will probably go for offsetWidth and offsetHeight and in fact most of the frameworks will resort to this method within their CSS functions if those dimensions are required. This is because even if you have specified a CSS style for the width and height in pt, em, % or any other unit the offsets will return a value in px. This is very useful as its a very easy method to get an accurate measurement that works cross browser.

Now I was working with some absolutely positioned floating DIV's earlier today and I required the dimensions of an inner DIV for use in a positioning calculation. The issue was that the floating DIV contained numerous inner DIV's only one of which was actually visible at one time. To make one of these sections appear the outer DIV was made visible and all of the inner DIVs apart from the one required to be shown were hidden. When the DIV was closed both the outer and inner DIVs were set to display:none;

I won't go into the reasons for this method apart from saying that the DIV's contained links and the reason I did it this way was for SEO so that all the links were available in the DOM server-side for bots to spider. Too many people nowadays create too much content on the fly with Javascript and forget about the fact that anything created this way is hidden from crawlers.


The problem - Measuring a DIV hidden from the flow

If you want to obtain the dimensions of an element that is currently hidden from the flow using offsetWidth and offsetHeight you must first put that element back into the DOM so that a measurement can be made. The usual method which frameworks like jQuery and Prototype use is to store the existing values for visibility, display and position, set those values to hidden, block and absolute, take a measurement and then revert the styles back to their original values e.g from Prototypes getDimensions function:
getDimensions: function(element) {
element = $(element);
var display = $(element).getStyle('display');
if (display != 'none' && display != null) // Safari bug
return {width: element.offsetWidth, height: element.offsetHeight};

// All *Width and *Height properties give 0 on elements with display none,
// so enable the element temporarily
var els = element.style;
var originalVisibility = els.visibility;
var originalPosition = els.position;
var originalDisplay = els.display;
els.visibility = 'hidden';
els.position = 'absolute';
els.display = 'block';
var originalWidth = element.clientWidth;
var originalHeight = element.clientHeight;
els.display = originalDisplay;
els.position = originalPosition;
els.visibility = originalVisibility;
return {width: originalWidth, height: originalHeight};
},
Now this is fine if the element you are trying to measure is hiding itself from the flow but if a parent is hiding it or even multiple parents then this won't work.

For example if you have a DIV that is set to display:none that contains another DIV also set to display:none; and then this DIV also contains a DIV set to display:none; which is the one you want to measure you actually have to toggle all 3 into the flow and out again to get an accurate offset measurement.

You can see this in action on the following example page I created: http://www.strictly-software.com/testGetdim.htm

View the source and run the test which will take measurements of the DIV on the left which is always in the flow and then the 3 nested DIVs that are hidden from the flow which will only be made visible once the test has run.

As you can see from the debug JQuery has no problem at all getting the measurements of the visible DIV and the outer most one from the 3 nested DIV's. However the two inner DIV's that require parents to be toggled in and out of the flow for their offsets to be measured do not return an accurate value.


Functions to measure offsets correctly

There are 3 main functions used in this test page that return accurate offset values for the nested DIV elements. The others are just helper functions to return a computed/current/style value and output debug or return an element by id.

// returns an array of elements that need to be made visible to carry out a measurement of an element
function getVisibleObj(elem){
arrEls = []; // holds array of elements we need to make visible to measure X

while(elem && elem!==document){
var es = getStyle(elem,"display"); // method returns current/computed/style value

if(es == 'none'){
arrEls.push(elem);
}

elem = elem.parentNode;
}
return arrEls; //null;
}

// swap styles in and out for an accurate measurment. Taken from jQuery and tweaked by myself to
// handle multiple elements.
function Swap(elem, els, styles, callback){

var obj;

for(var x=0,l=els.length;x<l;x++){
// create hash on element to hold old styles so we can revert later
obj = els[x];
obj.old = {};

// Remember the old values, and insert the new ones
for ( var name in styles ) {
obj.old[ name ] = obj.style[ name ];
obj.style[ name ] = styles[ name ];
}
}

// call the function passing in any element that needs scope
callback.call( elem );


for(var x=0,l=els.length;x<l;x++){
obj = els[x];

// Revert the old values
for ( var name in styles ){
obj.style[ name ] = obj.old[ name ];
}
// delete the hash from the element
try{ delete obj.old; }catch(e){ obj.old=null}
}

}

// offsetWidth/Height is element.width/height +border+padding (standard box model)
// clientWidth/Height is element.width/height +padding (if overflow:scroll then -16px)
function getElementDimensions(el){

el = (typeof(el)=="string")?G(el):el; //return a reference to the element

var w=0,h=0,cw=0,ch=0;

// if element is currently hidden we won't be able to get measurements so we need to find out whether this or
// any other parent objects are hiding this element from the flow
var arrEls = getVisibleObj(el); //returns array of objects we need to show to meaure our element

// create function to do the measuring
function getElDim(){

// get style object
var els = el.style;

// get dimensions
w = el.offsetWidth, h = el.offsetHeight, cw = el.clientWidth, ch = el.clientHeight;

}

// do we need to toggle other objects before getting our dimensions
if(arrEls && arrEls.length>0){
// call function to swap over properties so we can accuratley measure this element
var styles = {visibility: "hidden",display:"block"};
Swap(el, arrEls, styles, getElDim);
}else{
getElDim();
}


// create object
var ret = {
"width":w, //total width (element+border+padding)
"height":h,
"clientWidth":cw, //element+padding
"clientHeight":ch
}

return ret;
}

1. getVisibleObj

This function takes an element as its parameter and then loops up any parent elements storing in an array any that have a style.display set to none. Similar function on the web stop at the first element it comes across that is hidden but if you have multiple parents all hiding their children then only a full list will do.

2. Swap

This function was taken from jQuery and tweaked by myself to handle multiple elements instead of just one. The array of elements from getVisibleObj is passed to this function along with the element that requires measuring. This is then passed to the callback function so that correct scope can be utilised not that I require it here. The first loop stores the current style properties of the elements that require toggling in a hash on the object itself. After the callback is run the element styles are toggled back to their original state and this hash is removed from the element.

3. getElementDimensions

This is the function that actually is called by a user and returns an object with the height and width obtained from the elements offset values after any toggling has been carried out.

The reason I have not toggled position:absolute on elements that need toggling is that it seems to work without me doing this plus on the actual code I was working with earlier it was actually causing an incorrect offsetWidth value to be returned in certain situations. The test page seems to work with or without this property so if you have any issues pass this into the Swap function along with display:block and visibility:hidden.

No comments: