Mobile Data View Custom Control
I created a Custom Control that I can now reuse in any mobile application. It is used to display a Data View and has a couple neat features. The Custom Control is created through several pieces.Step 1 - Create a Java design element (Code | Java). Mine is in the "com.breakingpar" package and the name of the class is MobileDataViewBean. This is done to limit the actual Java on the Custom Control.
The code has one "real" method called getStartPos. One of the neat features I added was the ability to start the data view with any document. There's a "StartPos" property that can be added to the Mobile Data View that says what document to start on. I redirect from a data view and therefore I need a way to get back to the data view. When I go back to the data view, I include the UNID of the document I was on. So the data view will have that document at the top. That is passed in through a "k=" parameter on the URL. This Java code take a parameter of the URL and the underlying view name in the current database. It looks up the position number of that document in that view and returns that number so the "StartPos" value can be used. If the parameter isn't present or is invalid then "0" is returned. If the UNID is a valid UNID but doesn't point to a document in the view, then "0" will be returned.
Here's the Java Code:
package com.breakingpar;
import java.io.Serializable;
import lotus.domino.*;
import com.ibm.xsp.extlib.util.ExtLibUtil;
public class MobileDataViewBean implements Serializable {
private static final long serialVersionUID = 1L;
public int getStartPos(String url, String viewName) {
if (url.indexOf("k=") == -1) return 0;
String unid = url.substring(url.indexOf("k=")+2, url.length());
if (unid.indexOf("&") != -1) unid = unid.substring(0, unid.indexOf("&"));
if (unid.length() != 32) return 0;
Database currDb = null;
View vw = null;
Document doc = null;
try {
currDb = ExtLibUtil.getCurrentDatabase();
// Try to get the document to see if the UNID is at least valid
doc = currDb.getDocumentByUNID(unid);
vw = currDb.getView(viewName);
} catch (NotesException ne) {
return 0;
}
// If we get here, the document exists in the database (hopefully it exists
// in the view, but that's not easy to check). Run through the view and find
// the document and keep track of the position until the document is found.
int num = 0;
try {
doc = vw.getFirstDocument();
while (doc != null) {
if (doc.getUniversalID().equals(unid)) return num;
Document nextDoc = vw.getNextDocument(doc);
doc.recycle();
num++;
doc = nextDoc;
}
} catch (NotesException ne) {
return 0;
}
// If we got down here, the document wasn't found in the view.
return 0;
}
public MobileDataViewBean() {
}
}
Step 2: Use the MobileDataViewBean managed bean in your application. Go into faces-config.xml and add in the Java class so it will be available anywhere in this database. The managed bean name is the same as the class except that the very first letter is lower case instead of upper case (Java is case sensitive, remember). I didn't need this bean to be available everywhere, so I set the scope to view.
<managed-bean>
<managed-bean-name>mobileDataViewBean</managed-bean-name>
<managed-bean-class>com.breakingpar.MobileDataViewBean</managed-bean-class>
<managed-bean-scope>view</managed-bean-scope>
</managed-bean>
Step 3: Download jQuery and put it into your application. I have a File Resource called jquery-1.8.2.min.js which is the jQuery code. I do it as a file resource because it's still available and I haven't messed up any formatting like I would if I imported it into a JavaScript script library. Make sure to include jQuery on your XPage using this Custom Control. I don't include it in the Custom Control.
Step 4: Create the Custom Control named mobileDataView.
First, in the Property Definition area of the Custom Control, set up 7 properties. I set them all to be required. Here are the properties and what they ultimately mean:
1. ViewName (string). This is the alias of the view to be used as the data view.
2. ColumnName (string). This is the title of the column in that view which is the data to show in the data view. On mobile designs, it's best to include just 1 column of data. (See the "drawbacks" section at the bottom of the post for further information).
3. ColumnIsHTML (boolean). This will be "true" if the "ColumnName" column has HTML, or "false" if the column is regular text.
4. MobilePage (string). This is the name of the mobile page to jump to when a document is clicked. You'll need to make sure that the page is included in the <singlePageApp> on your XPage. If you want to jump to another XPage, then use my redirect to another XPage from a data view technique.
5. NumRows (integer). This is how many documents to show in the data view when it is first loaded up. Usually "10" is a good value for this, but it can be whatever you want.
6. PagerText (string). This is the text to show at the bottom of the data view (the "pager"). This will be described in a bit. Something like "Load More Documents" would be good for this.
7. NoMoreRowsText (string). When the user has scrolled all the way to the bottom of the data view, the "PagerText" value will be replaced with this text to indicate to the user that there are no more documents in the view.
In the Custom Control, a Data Context (global XPage variable) is set up to the starting position number. This is where the managed bean is called and value is computed.
<xp:this.dataContexts>
<!-- Compute the starting position of the data view (the first document to
show). If "&k=" is in the URL, the "k" value is the UNID of the first
document (compute its number). If "&k=" isn't in the URL, then this will
be the first document in the view (startPos = 0). -->
<xp:dataContext var="startPos"
value="#{javascript:mobileDataViewBean.getStartPos(facesContext.getExternalContext().getRequest().getQueryString(), compositeData.ViewName);}">
</xp:dataContext>
</xp:this.dataContexts>
The Query String is passed in as the first parameter, and the alias of the data view is passed in as the second parameter. The return value of the "getStartPos" method is stored in the "startPos" variable.
Next, we need to take two of the Property Definition values and put them into global JavaScript variables for use by JavaScript code that will be created later on. These are the "Load More Documents" and "No More Rows" text values that you can customize through the Custom Control properties. They are the text values of the links that appear below and possibly above the data view when there are more documents to load and then the "No More Rows" text to appear when all the rows have been loaded up.
<xp:text escape="false" disableTheme="true">
<xp:this.value><![CDATA[#{javascript:var html:string = "<script type=\"text/javascript\">\n";
html += "var mobileDataViewPagerText = \"";
html += compositeData.PagerText;
html += "\";\n";
html += "var mobileDataViewNoMoreRowsText = \"";
html += compositeData.NoMoreRowsText;
html += "\";\n";
html += "</script>\n";
return html;}]]></xp:this.value>
</xp:text>
Next, there's a panel that may or may not appear above the data view. If the "k=" parameter was in the URL (if "startPos" was computed to something besides "0") then this panel will contain a link that allows the user to reload the current XPage without the "k=" parameter present. Basically, it's a "jump back to the top of the data view" link. The appearance of the panel is going to be the same as the "load more rows" below the data view. This has to be styled in a specific manner to get it to show up in a way that makes sense to a mobile user. That's the reason for all the <ul> and <div> tags.
Note the way the URL is computed - it ends up being "whatever XPage you are on without any query string values".
<xp:panel>
<xp:this.rendered><![CDATA[#{javascript:return (startPos != 0);}]]></xp:this.rendered>
<ul dojotype="dojox.mobile.EdgeToEdgeList" class="mblEdgeToEdgeList mblDataView">
<div class="mblListItemWrapper mblDataRow">
<li class="mblVariableHeight mblListItem mblFooter mblDataView"
id="loadMoreRowsTop">
<div class="mblFooterText">
<a id="removeStartKey"
onclick="var href=window.location.href; if (href.indexOf('?') != -1) href = href.substring(0, href.indexOf('?')); window.location = href;">
<xp:text escape="true" id="computedField1"
value="#{javascript:compositeData.PagerText}">
</xp:text>
</a>
</div>
</li>
</div>
</ul>
</xp:panel>
Next comes the data view. Most of the values are computed from Custom Control properties or the "startPos" variable computed above.
<xe:dataView id="mobileDataView" openDocAsReadonly="true"
rows="#{javascript:compositeData.NumRows}"
pageName="#{javascript:compositeData.MobilePage}">
<xe:this.data>
<xp:dominoView
viewName="#{javascript:compositeData.ViewName}"
var="${javascript:compositeData.ViewName}">
</xp:dominoView>
</xe:this.data>
<xe:this.summaryColumn>
<xe:viewSummaryColumn
columnName="#{javascript:compositeData.ColumnName}">
<xe:this.contentType><![CDATA[#{javascript:if (compositeData.ColumnIsHTML) return "html"; else return "text";}]]></xe:this.contentType>
</xe:viewSummaryColumn>
</xe:this.summaryColumn>
<xe:this.first><![CDATA[#{javascript:return startPos;}]]></xe:this.first>
</xe:dataView>
Next comes the data view pager. 5 rows are added at a time - I didn't make this a parameter because of the way I do automatic loading when the user scrolls. (You'll see that later on). If there's no documents in the view, then the pager isn't needed.
<xe:pagerAddRows id="moreRows" for="mobileDataView"
disabledFormat="link" text="#{javascript:compositeData.PagerText}"
rowCount="5" styleClass="pagerAddRows">
<xe:this.rendered><![CDATA[#{javascript:try {
var db:NotesDatabase = session.getCurrentDatabase();
var vw:NotesView = db.getView(compositeData.ViewName);
var doc:NotesDocument = vw.getFirstDocument();
var retVal:boolean = false;
if (doc == null) retVal = false; else retVal = true;
vw.recycle();
return retVal;
} catch (e) {
return false;
}}]]></xe:this.rendered>
</xe:pagerAddRows>
The last part of the Custom Control is the jQuery script to handle loading of the data view. When the user scrolls to the bottom of the page, the "load more rows" link is "clicked" and more rows are loaded. When the end of the view is reached, the "no more rows" text is used to tell the user we're done loading rows. I honestly can't take credit for most of this code, but I did comment it fairly well.
<xp:scriptBlock>
<xp:this.value><![CDATA[$(function(){
// Hide the "<xe:pagerAddRows>" element
$('.pagerAddRows').css("display","none");
// Create a new element that will show to the browser instead
var html = '<li class="mblVariableHeight mblListItem mblFooter mblDataView" id="pagerAddRowsBox">';
html += '<div class="mblFooterText"><a id="pagerAddRowsBoxA">';
html += mobileDataViewPagerText + '</a></div>';
$('.pagerAddRows').after(html);
// When the "Load More Rows" link is clicked, run the "<xe:pagerAddRows>" event and then see if
// that event resulted in the <xe:pagerAddRows> being hidden, then change the "Load More Rows" link
$('#pagerAddRowsBoxA').click(function(){
$("#pagerAddRowsBox").find("a").html("Please wait... loading more rows...");
$("[id$='moreRows_ar']").click();
if($("[id$='moreRows_ar']").attr("class")=="disabled")
$("#pagerAddRowsBox").find("a").html(mobileDataViewNoMoreRowsText);
else
$("#pagerAddRowsBox").find("a").html(mobileDataViewPagerText);
});
// When the user scrolls to the bottom of the screen, automatically do the "Load More Rows" link.
$(window).scroll(function(){
if($(window).scrollTop() >= ($(document).height() - $(window).height() - 60)){
$("#pagerAddRowsBox").find("a").html("Please wait... loading more rows...");
$("[id$='moreRows_ar']").click();
if($("[id$='moreRows_ar']").attr("class")=="disabled")
$("#pagerAddRowsBox").find("a").html(mobileDataViewNoMoreRowsText);
else
$("#pagerAddRowsBox").find("a").html(mobileDataViewPagerText);
}
});
});]]></xp:this.value>
</xp:scriptBlock>
This works out great for me. There are a few drawbacks, though:
1. If the view is really big and the "k=" parameter either doesn't exist in the view or exists way at the bottom of the view, performance could be an issue. I'm dealing with views with a couple hundred documents at most and haven't had any problems.
2. The link above the data view jumps all the way back to the top of the data view. If "startPos" is something like 30, then it's jumping back 30 rows to the top. I didn't want to do additional computation to figure out what document is "x" back from the current document. One possible enhancement would be to allow a different parameter (instead of "k=", allow "n=" to be a number, for example). The link above the data view could compute "startPos" minus compositeData.NumRows and jump back a page at a time. I don't know if this would be better for the user experience or not. What I have seems to work out well enough for my purposes.
3. This won't work for a categorized view (or a view where you want to show multiple columns of information). In my experience, categorized views aren't the best idea on a mobile device, anyway. The best way to navigate a categorized view on a mobile device is to have a mobile page that shows all the categories and then go to another page that shows a single category of documents. So you could add an additional Property Name that corresponds to the "categoryFilter" attribute on the <xp:dominoView> tag. I didn't need that functionality in my Custom Control, so I didn't add it.