Marc Hughes


Home
Blog
Twitter
LinkedIn
GitHub
about
I am a developer from a bit west of Boston.

Model Adapters - A binding pattern using an Adapter

17 Jul 2008

Binding in Flex is great. It's an ultra convienent way to get information from your data model to show up in your views. But it does have some limitations, and to work around those limitations I've been using the "Model Adapter" aka the "Wrapper" or just plain "Adapter" pattern (some info, and more).

The basic idea is you shouldn't have to modify your data model to use it in a specific view. If you need to filter, sort, or summarize the data for a view you can do that through an Adapter so your model doesn't need to understand that logic and you're view isn't reliant on a specific implementation of your model.

Example: If you had a list of books in an array, and you want to filter by some property of books (say publisher) you shouldn't apply the filter directly to the model. Instead, create an adapter that can watch that array, and have that adapter apply the filter (or sort, or whatever).

More Examples: Consider Timeliner XE, a product I've been working on at my day-job. The main data model is a list of events. There are several views for that data. We have a text based / grid view, and some graphical views. Here's a couple screenshots:

Each of those screenshots has 2 views active at a time, the grid, and then a seperate graphical view. That makes 3 views that all want to bind to our data model. But, notice the grid has 5 events in it, while the graphical views only have 3. This is because only 3 of those events are valid to plot (they have a date). It'd be nice if we only had to bind to a list of events that actually has the data we want.

Now take a look at these three screenshots from AgileAgenda, my project scheduling application.

In all of these the data we have is a list of tasks. The first two show one view with two different filters applied to the data. The third shows a large grid with all of our tasks, and a much shorter pulldown that only has the tasks that are also milestones. (A milestone is a specific type of task)

To create an adapter:

  • Create a new adapter class
  • Create a constructor for that class that takes in the "source" data model, and any options that might be specific to the adapter.
  • Add event listeners to the "source" model.
  • Write event handlers in the adapter to update the adapter's internal state when the source changes.
  • Write accessors in the adapter, so other components can get data from it.
A simple example...

Click here to run a simple example. View-source is enabled in that. Here's a screenshot of the example:

When you run the example, it creates a simple data model, populates that data model with 4 sample items, and then creates 4 panels. Each of those panels represents a view. The example also creates 4 different model adapters all from the same data model, but with different options set. Then each panel gets a different adapter.

As you add items to the data model, you can see that the 4 views update depending on whether or not they are filtered and sorted.

Our Data Model:

package
{
    public  DataItemExample
    {
        public var name:String;
        public var amount:Number;
        public var active:Boolean;
    }

}

package
{
    import mx.collections.ArrayCollection;

public DataModelExample { [Bindable] public var myDataItems:ArrayCollection = new ArrayCollection();

} }

As you can see, it's a pretty simple data model. There are items with a name, amount and active properties, and then there is DataModelExample class with an array of those. Notice that no view-specific data is in there.

Now, lets create our adapter and name it "AdapterExample"

First, create a constructor and some variables to hold some information about the adapter. We'll have 2 options. onlyActiveItems and sorted. For sorted, we'll also create a Sort object to actually do the sort for us. And we'll also create an array to hold our filtered/sorted list of items. Note that we add an event listener for the COLLECTIONCHANGE event. This is how we'll propogate changes from the data model to our adapter. We'll see the handler for that later.

 public  AdapterExample extends EventDispatcher
    {
        protected var model:DataModelExample;
        protected var filteredDataItems:ArrayCollection = new ArrayCollection();
        protected var _onlyActiveItems:Boolean;
        protected var _sorted:Boolean = false;
        protected var sort:Sort;        

public function AdapterExample(dataModel:DataModelExample, onlyActiveItems:Boolean, sorted:Boolean) { _sorted = sorted; _onlyActiveItems = onlyActiveItems;

model = dataModel; model.myDataItems.addEventListener(CollectionEvent.COLLECTIONCHANGE, onItemsChanged );

if(sorted) { sort = new Sort(); sort.fields = [new SortField("name",true)]; }

rebuildFilteredArray(); }

Notice that we called rebuildFilteredArray above. Lets write that next. All this method does is loop through our data model and grab all the items from it (respecting our filtering option) and adds them to our internal array. It also applies the sort if neccessary. At the end we dispatch two events which will be used for binding later.
  protected function rebuildFilteredArray() : void
        {
            var tmp:Array = [];
            for each ( var item:DataItemExample in model.myDataItems )
            {
                if( (! onlyActiveItems ) || (item.active) )
                {
                    tmp.push(item);
                }
            }                        

filteredDataItems = new ArrayCollection(tmp);

if( sort ) { filteredDataItems.sort = sort; filteredDataItems.refresh(); }

dispatchEvent(new Event("dataItemsUpdated") ); dispatchEvent(new Event("totalChanged") ); }

So now if we made an adapter it would start up, read in the source data model, and populate our internal array of items. But it wouldn't respond to changes in the source data model. So lets create the event handler that we set up in the constructor. We'll also create a couple helper methods
    protected function onItemsChanged(event:CollectionEvent):void
        {
            switch(event.kind)
            {
                case CollectionEventKind.ADD: addItems(event.items); break;
                case CollectionEventKind.REMOVE: removeItems(event.items); break;

case CollectionEventKind.MOVE: case CollectionEventKind.REFRESH: case CollectionEventKind.REPLACE: case CollectionEventKind.RESET: rebuildFilteredArray(); break;

case CollectionEventKind.UPDATE:

}

}

protected function addItems(items:Array):void { for each ( var item:DataItemExample in items ) { if( (! _onlyActiveItems ) || (item.active) ) { filteredDataItems.addItem(item); } } dispatchEvent(new Event("totalChanged") ); }

protected function removeItems(items:Array):void { for each ( var item:DataItemExample in items ) { var index:int = filteredDataItems.getItemIndex(item); if( index != -1 ) { filteredDataItems.removeItemAt(index); } } dispatchEvent(new Event("totalChanged") ); }

For adding/removing items we're going to our internal array and manually adding or removing items from it. We're making sure to account for filtered items, but the sort object is taking care of the sorting for us.

For the other types of events, we're kind of cheating. We only really care about adding / removing operations so we'll just rebuild our entire internal array on other types of events. If your application uses those types of events often, you should implement them in the adapter in a more efficient manner.

Exposing data from the Adapter

We now have the internal state of the adapter updating as the model changes. So the only thing left to do in there is expose some properties so we can get at that info from our view. Let's write two bindable getters. One of them will summarize the data (get total()) the other will give us our filtered list (get dataItems())

Note that we set the event="" property in the [Bindable] tags so our views can correctly know when these properties change.

  [Bindable(event="dataItemsUpdated")]
        public function get dataItems() : ArrayCollection
        {
            return filteredDataItems;
        }

[Bindable(event="totalChanged")] public function get total() : Number { var total:Number = 0; for each ( var item:DataItemExample in filteredDataItems ) { total += item.amount; }

return total; }

Using the Adapter

Once you've done all of that, you can actually use your adapter. So create your data model, create your adapter, and use it!

 [Bindable] protected var dataModel:DataModelExample = new DataModelExample();
 [Bindable] protected var example1:AdapterExample = new AdapterExample( dataModel, true ,true);
...
    <span class="MXMLComponentTag"><mx:Panel x="10" y="218" width="250" height="200" layout="absolute" title="Filtered, Sorted">
        <mx:Label x="10" y="132" text="Total:"/>
        <mx:Label x="56" y="132" text="{example1.total}"/>
        <mx:List x="10" y="4" width="210" height="120" dataProvider="{example1.dataItems}" labelField="name"></mx:List>
    </mx:Panel>
If you look at the source of the example, we actually create 4 adapters with varying options.

Beyond this basic example

If you want your adapter to respect changes to individual items, your items should implement the IPropertyChangeNotifier interface. So in our example if we edited an item so it's active flag changed, the views would not update. To make that work we would implement that IPropertyChangeNotifier interface, and then write some code for the CollectionEventKind.UPDATE event.

Often times only one or two views are visible at a time and it'd be nice if all the views in the background weren't madly updating themselves every change. To accomplish that I often write an enable() disable() method on the adapter. They usually look something like this:

protected function enable() : void

{

model.myDataItems.addEventListener(CollectionEvent.COLLECTIONCHANGE, onItemsChanged );

rebuildFilteredArray();

}

protected function disable() : void

{

model.myDataItems.removeEventListener(CollectionEvent.COLLECTIONCHANGE, onItemsChanged );

}