I’ve been reading a few different blog posts on creating a custom list view and re-ordering. I’ve decided to consolidate what I’ve learnt into this tutorial.
Get the code here.
See it in action here.
This sample app allows you to:
- View a list with custom row views.
- Reorder items in the list by way of drag and drop.
- Select and delete by pressing the DELETE key.
This sample app demonstrates:
- How to implement a custom SC.ListView row.
- How to hi-light an item when hovering with a mouse.
- How to implement drag-and-drop re ordering.
1. Prerequisite Reading
- ToDo tutorial on the Sproutcore wiki
- My CRUD tutorial
2. Model
The app simulate my NBA power ranking. Guess which team I like!
The data model is really simple. There is only a single team record containing the following attributes:
- Ranking: numeric ranking – 1 is the best team.
- Name: name of team.
- Reason: why the team is ranked so.
The team record is defined in reorder_sample/models/team_record.js.
ReorderSample.TeamRecord = SC.Record.extend(
/** @scope ReorderSample.Team.prototype */ {
ranking: SC.Record.attr(Number, { isRequired: YES }),
name: SC.Record.attr(String, { isRequired: YES }),
reason: SC.Record.attr(String, { isRequired: YES })
});
Fixtures for 5 teams is defined in reorder_sample/fixtures/team_record.js.
sc_require('models/team_record');
ReorderSample.TeamRecord.FIXTURES = [
{
guid: 1,
ranking: 1,
name: 'LA Lakers',
reason: 'KB24. Enough said.'
},
{
guid: 2,
ranking: 2,
name: 'Boston Celtics',
reason: "Aren't these guys getting a bit old?"
},
{
guid: 3,
ranking: 3,
name: 'Miami Heat',
reason: 'The three headed monster has moved to South Beach.'
},
{
guid: 4,
ranking: 4,
name: 'OKC Thunder',
reason: 'The Durantula is hungry.'
},
{
guid: 5,
ranking: 5,
name: 'Dallas Mavericks',
reason: "Mark Cuban's cheering must be worth some points."
}
];
3. Controller
This is where the majority of the work gets done.
- The teamArrayController instances SC.ArrayController with the SC.CollectionViewDelegate mixin.
- The SC.CollectionViewDelegate mixin provides the functionality we need to handle delete and reordering
- Delete is handled in the collectionViewDeleteContent() method. Details are provided in the Sproutcore wiki.
- Reordering is handled by collectionViewDragDataTypes(), collectionViewDragDataForType() and collectionViewPerformDragOperation().
- collectionViewDragDataTypes() is called when drag is started to check if the row being dragged is “draggable”. The type of record contained in the row must be in the array passed back by this method.
- collectionViewDragDataForType() is called next if the above check was passed. This method allows us to return some contextual data associated with the drag event. In this case, we return a SC.SelectionSet containing the selected team record. Because we have disallowed multiple selections, we should only ever have 1 team record in the selection set.
- Finally collectionViewPerformDragOperation() is called when the user drops the row. We retrieve our selection set saved in collectionViewDragDataForType() and extract the record to reorder. We then change the rankings accordingly. Note that we turn off property change notifications during the reorder to improve performance.
The code is located in reorder_sample/controllers/team_array.js.
ReorderSample.teamArrayController = SC.ArrayController.create(SC.CollectionViewDelegate, {
/**
* Only allow 1 row to be selected at anyone time.
* SC.CollectionView looks for this variable in it's content (which is this array)
*/
allowsMultipleSelection: NO,
/**
* Allows this controller to properly respond to ListView delete
* See http://wiki.sproutcore.com/Todos+05-Finishing+the+UI
* @param view View calling the delete
* @param content
* @param indexes Indexes of the item to be deleted
*/
collectionViewDeleteContent: function(view, content, indexes) {
// destroy the records
var records = indexes.map(function(idx) {
return this.objectAt(idx);
}, this);
records.invoke('destroy');
var selIndex = indexes.get('min') - 1;
if (selIndex < 0) {
selIndex = 0;
}
this.selectObject(this.objectAt(selIndex));
},
/**
* Specifies that we are allowed to drag teams
*/
collectionViewDragDataTypes: function(view) {
return [ReorderSample.TeamRecord];
},
/**
Called by a collection view when a drag concludes to give you the option
to provide the drag data for the drop.
This method should be implemented essentially as you would implement the
dragDataForType() if you were a drag data source. You will never be asked
to provide drag data for a reorder event, only for other types of data.
The default implementation returns null.
@param view {SC.CollectionView}
the collection view that initiated the drag
@param dataType {String} the data type to provide
@param drag {SC.Drag} the drag object
@returns {Object} the data object or null if the data could not be provided.
*/
collectionViewDragDataForType: function(view, drag, dataType) {
var ret = null;
if (dataType === ReorderSample.TeamRecord) {
return view.get('selection');
}
return ret;
},
/**
Called by the collection view to actually accept a drop. This method will
only be invoked AFTER your validateDrop method has been called to
determine if you want to even allow the drag operation to go through.
You should actually make changes to the data model if needed here and
then return the actual drag operation that was performed. If you return
SC.DRAG_NONE and the dragOperation was SC.DRAG_REORDER, then the default
reorder behavior will be provided by the collection view.
@param view {SC.CollectionView}
@param drag {SC.Drag} the current drag object
@param op {Number} proposed logical OR of allowed drag operations.
@param proposedInsertionIndex {Number} an index into the content array representing the proposed insertion point.
@param proposedDropOperation {String} the proposed drop operation. Will be one of SC.DROP_ON, SC.DROP_BEFORE, or SC.DROP_ANY.
@returns the allowed drag operation. Defaults to proposedDragOperation
*/
collectionViewPerformDragOperation: function(view, drag, op, proposedInsertionIndex, proposedDropOperation) {
// content is just a reference to this object.
var content = view.get('content');
var ret = SC.DRAG_NONE;
// Continue only if data is available from drag
var selectionSet = drag.dataForType(ReorderSample.TeamRecord);
if (!selectionSet) {
return ret;
}
// Get our record - there should only be 1 selection
var record = selectionSet.firstObject();
// Suspend notifications for bulk changes to properties
content.beginPropertyChanges();
// Re ordering
var oldIndex = record.get('ranking') - 1; // -1 to convert from ranking # to index
if (proposedInsertionIndex < oldIndex) {
// Move up list
for (var i = proposedInsertionIndex; i < oldIndex; i++) {
this.objectAt(i).set('ranking', i + 1 + 1); // add 1 to convert from ranking to sequence #
}
} else {
// Move down list
for (var i = oldIndex + 1; i <= proposedInsertionIndex; i++) {
this.objectAt(i).set('ranking', i - 1 + 1); // add 1 to convert from ranking to sequence #
}
}
record.set('ranking', proposedInsertionIndex + 1);
// Restart notifications
content.endPropertyChanges();
// Return the requested op, usually SC.DRAG_REORDER, to flag that the event has been handled
return op;
},
/**
* Denotes if the data source is ready
*/
isReady: function() {
var status = this.get('status');
return status & SC.Record.READY;
}.property('status').cacheable(),
/**
* Provides a summary of the status of the controller.
*/
summary: function() {
var ret = '';
var status = this.get('status');
if (status & SC.Record.READY) {
var len = this.get('length');
if (len && len > 0) {
ret = len === 1 ? "1 team" : "%@ teams".fmt(len);
} else {
ret = "No teams";
}
}
if (status & SC.Record.BUSY) {
ret = "Loading..."
}
if (status & SC.Record.ERROR) {
ret = "Error"
}
return ret;
}.property('length', 'status').cacheable()
});
4. View
4.1 Main Page and ListView
The main page and pane is setup as per the ToDo example in the Sproutcore wiki.
- The contentView is a SC.ListView that is bound to our teamArrayController.
- The rowHeight is set to 91. This is 90px for the custom row view and 1 px in order for the bottom border of each row to show.
- The exampleView is set to our custom row view class: ReorderSample.TeamView
- The canReorderContent and isEditable is set to YES to enable reorder functionality in the SC.CollectionView from which SC.ListView is derived.
The code can be found at reorder_sample/resources/main_page.js.
ReorderSample.mainPage = SC.Page.design({
// The main pane is made visible on screen as soon as your app is loaded.
// Add childViews to this pane for views to display immediately on page
// load.
mainPane: SC.MainPane.design({
childViews: 'middleView topView bottomView'.w(),
topView: SC.ToolbarView.design({
layout: { top: 0, left: 0, right: 0, height: 36 },
childViews: 'titleLabel'.w(),
anchorLocation: SC.ANCHOR_TOP,
titleLabel: SC.LabelView.design({
layout: { centerY: 0, height: 24, left: 8, width: 300 },
controlSize: SC.LARGE_CONTROL_SIZE,
fontWeight: SC.BOLD_WEIGHT,
value: 'Veeb\'s Power Rankings'
})
}),
middleView: SC.ScrollView.design({
hasHorizontalScroller: NO,
layout: { top: 36, bottom: 32, left: 0, right: 0 },
backgroundColor: 'white',
contentView: SC.ListView.design({
layout: { left: 15, right: 15, top: 15, bottom: 15 },
contentBinding: 'ReorderSample.teamArrayController.arrangedObjects',
selectionBinding: 'ReorderSample.teamArrayController.selection',
selectOnMouseDown: YES,
canDeleteContent: YES,
rowHeight: 91,
exampleView: ReorderSample.TeamView,
recordType: ReorderSample.TeamRecord,
canReorderContent: YES,
isEditable: YES
})
}),
bottomView: SC.ToolbarView.design({
layout: { bottom: 0, left: 0, right: 0, height: 32 },
childViews: 'summaryLabel'.w(),
anchorLocation: SC.ANCHOR_BOTTOM,
summaryLabel: SC.LabelView.design({
layout: { centerY: 0, height: 18, left: 20, right: 20 },
valueBinding: 'ReorderSample.teamArrayController.summary'
})
})
})
});
4.2 Custom ListView Row
ReorderSample.TeamView is where we implement our custom row view.
- contentDisplayProperties lists all the properties in our content (TeamRecord) that we are going to display in our custom view. It allows the row to be re-rendered if any of the listed properties changes.
- displayProperties lists the properties of this custom view that requires the row the be re-rendered if the property is changed. We’ve listed the isHovering property because if that changes, we will have to change the background of this row to show/not show hi-lighting.
- Hovering is triggered when the user moves the mouse over/out of the row. As such, we handle the mouseEnteredand mouseExited events and set the isHovering property accordingly. Because isHovering is listed in displayProperties, it will cause the render() method to be called.
- The render() method gets the data for the row from the content property (set by the ListView). The context is used to render the html.
- Other useful properties set by ListView are: contentIndex, layerId, isEnabled, isSelected, outlineLevel, disclosureState, isVisibleInWindow, isGroupView, page and parentView. See SC.CollectionView.itemViewForContentIndex().
The code can be found at reorder_sample/views/team.js.
ReorderSample.TeamView = SC.View.extend(SC.ContentDisplay, {
layout: { left: 10 },
classNames: ['team-view'],
/**
* List of content (TeamRecord) properties that needs to trigger a re-rendering when the property is changed
*
* Add an array with the names of any property on the content object that
* should trigger an update of the display for your view. Changes to the
* content object will only invoke your display method once per runloop.
*
* @property {Array}
*/
contentDisplayProperties: 'ranking name reason'.w(),
/**
* Flag so that we know if the mouse if hovering over this item or not
*/
isHovering: NO,
/**
* List of view properties that needs to trigger a re-rendering when the property is changed.
* We want to re-render when hovering so that we can hi-light the row
*/
displayProperties: 'isHovering isSelected'.w(),
/**
* When the mouse is over this view, set the isHovering flag in order to trigger hi-lighting
*/
mouseEntered: function() {
this.set('isHovering', YES);
},
/**
* When the mouse leaves this view, set the isHovering flag in order to turn off hi-lighting
*/
mouseExited: function() {
this.set('isHovering', NO);
},
/**
* Render our row
*
* @param context
* @param firstTime
*/
render: function(context, firstTime) {
var ranking = '';
var name = '';
var reason = '';
var content = this.get('content');
if (content != null) {
ranking = content.get('ranking') + '.';
name = content.get('name');
reason = content.get('reason');
}
// If hovering, add the hovering CSS class to the DIV that is this view
if (this.get('isSelected')) {
context.setClass('team-view-selected', this.get('isSelected'));
} else {
context.setClass('team-view-hover', this.get('isHovering'));
}
// Output HTML. Create inner DIV to show our data
context = context.begin('div').addClass('team-view-topfiller').push(' ').end();
context = context.begin('div').addClass('team-view-line1').push(ranking + ' ' + name).end();
context = context.begin('div').addClass('team-view-line2').push(reason).end();
context = context.begin('div').addClass('team-view-bottomfiller').push(' ').end();
sc_super();
}
});
5. Starting Up
The last thing we need to do is to hook up the data to our controller in reorder_sample/main.js.
Note that we order by ranking. When the ranking of a record is changed during a reorder, the ListView will automatically re-render each row because the order of each item in the SC.RecordArray returned by the query would have changed.
ReorderSample.main = function main() {
// Step 1: Instantiate Your Views
// The default code here will make the mainPane for your application visible
// on screen. If you app gets any level of complexity, you will probably
// create multiple pages and panes.
ReorderSample.getPath('mainPage.mainPane').append();
// Step 2. Set the content property on your primary controller.
// This will make your app come alive!
var query = SC.Query.local(ReorderSample.TeamRecord, {orderBy: 'ranking'});
var teamRecords = ReorderSample.store.find(query);
ReorderSample.teamArrayController.set('content', teamRecords);
};
function main() {
ReorderSample.main();
}

Hi,
I wonder if you’ve ever experience the error “exampleClass” is undefined when implementing a custom list. If I remove the exampleView property and use a regular contentValueKey instead it works fine
Seems very odd to me, since I have another custom list on the same page which is nearly identical, and that one works fine.
I’ve had a problem once with exampleView where I accidentally put the name of the class as a string. exampleView expects the reference to the class rather than the name of the class as a string.
Correct
exampleView: ReorderSample.TeamView,
Incorrect
exampleView: “ReorderSample.TeamView”,
Could that be your problem?
Veebs
Just edited this article. SC has fixed the mouseOver/mouseOut issue.
https://github.com/sproutcore/sproutcore/commit/42c4e5dcaecb800dad1ec824cdc3672a1edc8f79
It is now standardised as mouseEntered/mouseExited.
The text:
The code can be found at reorder_sample/views.team.js.
Should be changed to:
The code can be found at reorder_sample/views/team.js.
Jeff
Thanks Jeff. Fixed typo.