Introduction
While the ScriptManager
and UpdatePanel
found in Microsoft AJAX do a good job of persisting your pages' scroll position during partial post back operations, you might be surprised to find out the same is not the case for scrollable child DIV
s contained within an UpdatePanel
.
The PersistentScrollPosition
control presented in this article seeks to remedy this issue, using a client-side behavior and ASP.NET server control implemented using Microsoft AJAX.
Background
While it is certainly not my intention to review the internals of the UpdatePanel
and PageRequestManager
or implementing any of the client-side components (Sys.Component
, Sys.UI,Behavior
, or Sys.UI.Control
), a quick understanding can go a long way into understanding and resolving this particular problem. There are two key items to keep in mind for this control:
- Client-side components are disposed of and recreated during the partial post back lifecycle so you can't use the control's instance to store any data you need to survive this.
- The HTML output of the
UpdatePanel
is completely replaced during a partial post back (assuming it was triggered) through the innerHTML
property, which is why the scroll position problem exists in the first place.
Using the code
For those who just want the solution, using the code is straightforward. The control has one property you need to set, named ControlToPersist
. This is a string
which takes the ID of the server-side container DIV (it must have runat="server"
).
<asp:UpdatePanel runat="server" ID="UpdatePanel" UpdateMode="always">
<ContentTemplate>
<asp:Button runat="server" ID="btnPostBack" Text="Post Back" OnClick="btnPostBack_Click" />
<br />
<div style="width:590px;height:400px;overflow-y:scroll;overflow-x:hidden;"
runat="server" id="persistMe">
<p>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit...</p>
</div>
<mbc:PersistentScrollPosition runat="server" ID="psf1" ControlToPersist="persistMe" />
</ContentTemplate>
</asp:UpdatePanel>
Building the control
The control consists of two parts, both of which, for the most part, are very "cookie cutter". On the server side, we inherit from Control
, and implement IScriptControl
and INamingContainer
, and create a HiddenField
during initialization to store our scroll position in between partial post backs.
public class PersistentScrollPosition : Control, IScriptControl, INamingContainer
{
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
storage = new HiddenField();
storage.ID = "storage";
Controls.Add(storage);
}
}
When creating the script descriptors for the client-side initiation, we pass through the scrollable DIV
's ClientID
, the control's ElementID
, and we pass in a reference to the HiddenField
's DOM element using the AddElementProprety
method of the ScriptComponentDescriptor
class.
public IEnumerable<scriptdescriptor /> GetScriptDescriptors()
{
ScriptComponentDescriptor scd =
new ScriptBehaviorDescriptor("Mbccs.WebControls.PersistentScrollPosition",
Control.ClientID);
scd.AddElementProperty("storage", storage.ClientID);
yield return scd;
}
On the client-side, the control inherits from the Sys.UI.Behavior
base class. Upon control initialization, it hooks into two events: the scroll
DOM event of the DIV
, and the EndRequest
event of the Sys.WebForms.PageRequestManager
class. The EndRequest
event is when the scroll state is restored, but I'll get to that shortly.
initialize : function() {
Mbccs.WebControls.PersistentScrollPosition.callBaseMethod(this, 'initialize');
this._scrollDelegate = Function.createDelegate(this, this._onScroll);
this._endRequestDelegate = Function.createDelegate(this, this._onEndRequest);
var prm = Sys.WebForms.PageRequestManager.getInstance();
prm.add_endRequest(this._endRequestDelegate);
$addHandler(this.get_element(), 'scroll', this._scrollDelegate);
}
When the DIV
is scrolled, the x,y scroll position is serialized and stored in the HiddenField
server-side control we created earlier.
_onScroll : function(e) {
this._storage.value =
Sys.Serialization.JavaScriptSerializer.serialize(this._getScrollPosition());
},
_getScrollPosition : function() {
var el = this.get_element();
if (el) {
return {
x: el.scrollLeft || 0,
y: el.scrollTop || 0
};
}
}
To prevent null's from floating around, the x,y coordinates are coerced into 0's if either the scrollLeft
or scrollTop
properties are null.
The EndRequest
event is fired when "an asynchronous post-back is finished and the control has been returned to the browser", so it's a perfect time to restore our scroll state.
_onEndRequest : function(sender, args) {
var o = null;
if(this._storage.value !== '')
o = Sys.Serialization.JavaScriptSerializer.deserialize(this._storage.value);
if (o) {
var el = this.get_element();
el.scrollLeft = o.x;
el.scrollTop = o.y;
this._storage.value = '';
}
}
The dispose
method isn't usually a place of any particular interest, but its worthy of noting that it you need to unhook the DIV
's scroll
event prior to calling dispose
on the base class.
dispose : function() {
$removeHandler(this.get_element(), 'scroll', this._scrollDelegate);
Mbccs.WebControls.PersistentScrollPosition.callBaseMethod(this, 'dispose');
var prm = Sys.WebForms.PageRequestManager.getInstance();
prm.remove_endRequest(this._endRequestDelegate);
delete this._endRequestDelegate;
delete this._scrollDelegate;
}