Friday, May 18, 2012

My Own Button Bar!

As a bit of fun, I decided to create my own flex button bar component. This button bar component is designed for implementation with any type of Flex button; but not only that, it can accept images or other types of IVisualElements as buttons. The following explains the implementation of the code. This button bar can be used in web, desktop, and mobile Flex applications.

The Interfaces

Two interfaces are defined for this little library project. IButtonBar should be clear enough to understand. It defines the underlying characters of my bar. I give the user the ability to set the height and width of the buttons, as well as the skin class for the buttons.
package com.bars.interfaces {
    import mx.collections.ListCollectionView;

    /**
     * Interface for ButtonBars. This defines a generic set of properties that are expected.
     * @author Charles
     */
    public interface IButtonBar {
        /**
         * Used for button rendering. At a minimum, this should contain some sort of data object that
         * has a label property or a property defined in <code>IButtonBar::labelField</code> for a label
         * and an option skinClass property for button skin rendering.
         * @param list
         */
        function set dataProvider(list:ListCollectionView):void;
        /**
         * Get a reference to the dataProvider.
         * @return
         */
        function get dataProvider():ListCollectionView;
        /**
         * Sets the selected index of the last button.
         * @param index
         */
        function set selectedIndex(index:int):void;
        /**
         * Index of the last clicked button or set index.
         * @return
         */
        function get selectedIndex():int;
        /**
         * Return the content associated to the button.
         * @return
         */
        function get selectedItem():Object;
        /**
         * The object property in the data provider that is to be label for the button.
         * Implementors of this interface should have the default value be "label"
         * @param value
         */
        function set labelField(value:String):void;
        /**
         * The object property designated for the button label.
         * @return
         */
        function get labelField():String;
        /**
         * The height for each of the buttons.
         * @param value
         */
        function set buttonHeight(value:Number):void;
        /**
         * The height for each of the buttons.
         * @param return
         */
        function get buttonHeight():Number;
        /**
         * The width for each of the buttons.
         * @param value
         */
        function set buttonWidth(value:Number):void;
        /**
         * The width for each of the buttons.
         * @param return
         */
        function get buttonWidth():Number;
        /**
         * Add the class reference for the button skins.
         * @param value
         * @return
         */
        function set skinClass(value:Class):void;
        /**
         * Get the class reference for the skin.
         * @return
         */
        function get skinClass():Class;

    }
}

The second interface is IButtonFactory. This interface defines a single method to that is intended for the creation of IVisualElements. Buttons implement the IVisualElement interface, but many other components implement that interface. The importance of this factory interface will be reveal later.

Button Factories

IButtonFactory components are used in the implementation of the IButtonBar; thus, I will cover the two implementations I have created for this demonstration. These factories are simple enough and won't require much explanation:

ButtonFactory

package com.bars.implementations {
    import com.bars.interfaces.IButtonFactory;

    import mx.core.IVisualElement;

    import spark.components.Button;

    public class ButtonFactory implements IButtonFactory {
        public static const instance:IButtonFactory = new ButtonFactory();

        public function ButtonFactory() {
            if (instance) {
                throw(new Error("[ButtonFactory] is singleton"));
            }
        }

        public static function getInstance():IButtonFactory {
            return instance;
        }

        public function makeButton(label:String, height:Number, width:Number, skinClass:Class):IVisualElement {
            var button:Button = new Button();
            button.label = label;

            if (skinClass != null) {
                button.setStyle("skinClass", skinClass);
            }

            if (height < 0) {
                button.percentHeight = 100;
            } else {
                button.height = height;
            }

            if (width < 0) {
                button.percentWidth = 100;
            } else {
                button.width = width;
            }

            return button;
        }
    }
}

RadioButtonFactory

package com.bars.implementations {
    import com.bars.interfaces.IButtonFactory;

    import mx.core.IVisualElement;

    import spark.components.RadioButton;

    public class RadioButtonFactory implements IButtonFactory {
        public static const instance:RadioButtonFactory = new RadioButtonFactory();

        public function RadioButtonFactory() {
            if (instance) {
                throw(new Error("[RadioButtonFactory] is singleton"));
            }
        }

        public static function getInstance():IButtonFactory {
            return instance;
        }

        public function makeButton(label:String, height:Number, width:Number, skinClass:Class):IVisualElement {
            var button:RadioButton = new RadioButton();
            button.label = label;

            if (skinClass != null) {
                button.setStyle("skinClass", skinClass);
            }

            if (height < 0) {
                button.percentHeight = 100;
            } else {
                button.height = height;
            }

            if (width < 0) {
                button.percentWidth = 100;
            } else {
                button.width = width;
            }

            return button;
        }
    }
}
Both classes make buttons, the first a typical spark button and the latter a radio button. Properties such as height, width, and label as well as the style skinClass are set. The key about this factory is that it returns an IVisualElement. Therefore, this factory interface can be used to create non-button components. Also notice that these button factories are singletons. There is no need to create multiple instances of this class, so I chose to make these factory immutable singletons that produce buttons. If the bar that uses this factory is implemented all over an application, there will only be one ButtonFactory and/or RadioButtonFactory used.

Implementation of the Button Bar

ButtonBarBase is, as its name-stake indicates, a base class implementation of the IButtonBar interface. The intent of this class is to be generic enough for sub-classes to use, and if needed, strategically override methods. Notice that this class extends Group; this is for layout support as well as IViewPort support. If a background is needed for this bar, it can be wrapped in a SkinnableContainer, which DOESN'T have IViewPort support. Below is the code in its entirety:
package com.bars.implementations {
    import com.bars.events.ButtonBarEvent;
    import com.bars.interfaces.IButtonBar;
    import com.bars.interfaces.IButtonFactory;
    
    import flash.events.Event;
    import flash.events.MouseEvent;
    import flash.utils.getDefinitionByName;
    
    import mx.collections.ArrayCollection;
    import mx.collections.IList;
    import mx.collections.ListCollectionView;
    import mx.collections.XMLListCollection;
    import mx.core.IVisualElement;
    import mx.events.CollectionEvent;
    import mx.events.FlexEvent;
    
    import spark.components.Group;

    [Event(name="buttonBarChange", type="com.bars.events.ButtonBarEvent")]
    [Event(name="buttonBarClick", type="com.bars.events.ButtonBarEvent")]
    [Event(name="widthUpdated", type="com.bars.events.ButtonBarEvent")]
    [Event(name="heightUpdated", type="com.bars.events.ButtonBarEvent")]
    /**
     * Base class implementation of the ButtonBarBase class.
     * @author Charles
     */
    public class ButtonBarBase extends Group implements IButtonBar {
        protected var _dataProvider:ListCollectionView;
        protected var _selectedIndex:int = -1;
        protected var _selectedItem:Object;
        protected var _labelField:String = "label";
        protected var _buttonHeight:Number = -1;
        protected var _buttonWidth:Number = -1;
        protected var _skinClass:Class;

        protected var buttonFactory:IButtonFactory;
        protected var internalDataProvider:ArrayCollection;
        
        private var isCreated:Boolean = false;

        public function ButtonBarBase(buttonFactory:IButtonFactory) {
            this.buttonFactory = buttonFactory;
            internalDataProvider = new ArrayCollection();
            addEventListener(FlexEvent.CREATION_COMPLETE, renderComponent);
            super();
        }

        /**
         * Implementation adds a CollectionEvent.COLLECTION_CHANGE event to the IList parameter. This will allow the component to dynamically change
         * when the data provider is changed.
         * @param list
         */
        public function set dataProvider(list:ListCollectionView):void {
            if (_dataProvider != null && _dataProvider != list) {
                _dataProvider.removeEventListener(CollectionEvent.COLLECTION_CHANGE, redrawComponentButtons);
            }

            if (_dataProvider == list) {
                return;
            }

            _dataProvider = list;
            
            if(isCreated){
                redrawComponentButtons();
            }
                
            _dataProvider.addEventListener(CollectionEvent.COLLECTION_CHANGE, redrawComponentButtons);
        }

        public function get dataProvider():ListCollectionView {
            return _dataProvider;
        }

        public function set selectedIndex(index:int):void {
            if (_selectedItem != index && index < internalDataProvider.length && index > -1) {
                _selectedIndex = index;
                _selectedItem = internalDataProvider[_selectedIndex].content;
                dispatchEvent(new ButtonBarEvent(ButtonBarEvent.CHANGE));
            } else if (index == -1) {
                _selectedIndex = index;
                _selectedItem = null;
                dispatchEvent(new ButtonBarEvent(ButtonBarEvent.CHANGE));
            }
        }

        [Bindable("buttonBarChange")]
        public function get selectedIndex():int {
            return _selectedIndex;
        }

        [Bindable("buttonBarChange")]
        public function get selectedItem():Object {
            return _selectedItem;
        }

        public function set labelField(value:String):void {
            _labelField = value;
        }

        public function get labelField():String {
            return _labelField;
        }

        public function set buttonHeight(value:Number):void {
            if (value >= 0 && value != _buttonHeight) {
                _buttonHeight = value;
                setButtonProperty("height", value);
                dispatchEvent(new ButtonBarEvent("heightUpdated"));
            }
        }

        [Bindable("heightUpdated")]
        public function get buttonHeight():Number {
            return _buttonHeight;
        }

        public function set buttonWidth(value:Number):void {
            if (value >= 0 && _buttonWidth != value) {
                _buttonWidth = value;
                setButtonProperty("width", value);
                dispatchEvent(new ButtonBarEvent("widthUpdated"));
            }
        }

        [Bindable("widthUpdated")]
        public function get buttonWidth():Number {
            return _buttonWidth;
        }

        public function set skinClass(value:Class):void {
            _skinClass = value;
        }

        public function get skinClass():Class {
            return _skinClass;
        }
        
        protected function setButtonProperty(propertyName:String, value:Number):void{
            for each(var object:Object in internalDataProvider){
                object.button[propertyName] = value;
            }
            
            invalidateSize();
        }

        protected function redrawComponentButtons(event:Event=null):void {
            for each (var object:Object in internalDataProvider) {
                object.button.removeEventListener(MouseEvent.CLICK, clickHandler);
            }

            internalDataProvider.removeAll();
            constructView();
        }

        protected function constructView():void {
            var button:IVisualElement;

            if (dataProvider == null) {
                return;
            }

            var actualLabel:String = "";

            for each (var object:Object in dataProvider) {
                if (object != null && object is XML) {
                    var labelContent:Object = (object as XML).elements(labelField)[0];
                    actualLabel = labelContent ? labelContent.toString() : "";
                } else if (object != null && dataProvider is ArrayCollection) {
                    actualLabel = object[labelField];
                }

                button = buttonFactory.makeButton(actualLabel, buttonHeight, buttonWidth, skinClass);
                button.addEventListener(MouseEvent.CLICK, clickHandler);
                internalDataProvider.addItem({ button: button, content: object });
            }

            addComponentsAndRedraw();
        }

        protected function addComponentsAndRedraw():void {
            this.removeAllElements();

            for each (var object:Object in internalDataProvider) {
                addElement(object.button);
            }

            invalidateSize();
            invalidateDisplayList();
        }

        protected function clickHandler(event:MouseEvent):void {
            var button:IVisualElement = event.currentTarget as IVisualElement;
            if (button != null) {
                for (var index:int = 0; index < internalDataProvider.length; index++) {
                    if (internalDataProvider[index].button == button) {
                        selectedIndex = index;
                        break;
                    }
                }
            }

            dispatchEvent(new ButtonBarEvent(ButtonBarEvent.CLICK));
        }
        
        private function renderComponent(event:FlexEvent):void{
            removeEventListener(FlexEvent.CREATION_COMPLETE, renderComponent);
            isCreated = true;
            constructView();
        }
    }
}

The class does the following:
  • The constructor accepts an argument of type IButtonFactory; this is the principal component used for button creation. It also initializes an internal data provider array collection as well as add a FlexEvent.CREATION_COMPLETE event to the class. The reason why this creation complete event is added is to prevent component drawing until all of the component parameters had a chance to be set.
  • The dataProvider method relies on the isCreated state. This state exists so that the component is not needlessly redrawn (an optimization). The dataProvider is set into a protected class variable, and if the component is created, the redrawComponentButtons method will be called so that the component can be reconstructed. Also notice that an event listener is added to the passed in ListCollectionView; this event will notify this object that dataProvider has changed, which will also order the redrawing of this component.
  • Setting the selected index will do one of three things:
    1. If the index set in is the current index and not -1, the function does nothing.
    2. If the index set in is not -1 and different, then the selectedItem is updated and a change event is dispatched.
    3. If the index set in is -1 and different, then the selectedItem is set to null and a change event is dispatched.
    The change event does two things: objects composing this view component will be notified that there is a change, and the selectedItem getter method will be called to update components that bind to the selectedItem getter. The selectedIndex getter also behaves identically, except instead of returning the content associated to the button, it returns the index of that content.
  • Button height and width setting is a bindable feature. Both the buttonHeight setter and the buttonWidth setter will update each button's width and height, dispatch an event that causes a binding to the height and width getters, and can be listened for in objects using this object. I use a generic method setButtonProperty to change the height and width properties..
  • redrawComponentButtons is used as both a listener function and something to be called by the class. This function tears down the internal dataprovider by removing the created buttons events. This is done to prevent memory leaks. This method calls constructView.
  • constructView builds the component. This is called when the data provider value is set when the component's creation complete event has already been called, or when the component's creation complete event is called. This method processes either an XMLListCollection or an ArrayCollection of objects. The labelField property is used to fetch the appropriate label from the content provided, defaulting to "label" for the label field. Also notice the use of the button factory in this component. This button factory will actually create the components, eliminating the need for subclasses to have to override this method for their own buttons.
  • addComponentsAndRedraw adds the elements to this object, and invalidated the size of the component and the display list.
  • The clickHandler handles button clicking. It uses the internal data provider to match the button that was clicked; and if there is a match, the selectedItem and selectedIndex are updated. Appropriate events are dispatched because of this. This is the only method is a genuine candidate for override in subclasses.
The beauty of this base class is that is does all of the work. This will be shown below...

MobileButtonBar

The MobileButtonBar is a subclass of ButtonBarBase. The whole class is as follows:

package com.bars.implementations {

    public class MobileButtonBar extends ButtonBarBase {
        public function MobileButtonBar() {
            super(ButtonFactory.getInstance());
        }
    }
}

Notice that the constructor doesn't have any parameters, and that the ButtonFactory instance is passed in to the base class. This is a simple and clean example of the power of the base class implementation.

MobileRadioButtonBar

This implementation of the ButtonBarBase required an override of the clickHandler method; however, most of the implementation is left to the parent class. Notice that this uses the RadioButtonFactory instance.

package com.bars.implementations {
    import com.bars.events.ButtonBarEvent;

    import flash.events.MouseEvent;

    import spark.components.RadioButton;

    public class MobileRadioButtonBar extends ButtonBarBase {
        public function MobileRadioButtonBar() {
            super(RadioButtonFactory.getInstance());
        }

        override protected function clickHandler(event:MouseEvent):void {
            var button:RadioButton=event.currentTarget as RadioButton;
            if (button != null) {
                for (var index:int=0; index < internalDataProvider.length; index++) {
                    var radioButton:RadioButton=internalDataProvider[index].button as RadioButton;

                    if (radioButton == button) {
                        selectedIndex=index;
                    } else if (radioButton.selected) {
                        radioButton.selected=false;
                    }
                }
            }

            dispatchEvent(new ButtonBarEvent(ButtonBarEvent.CLICK));
        }
    }
}


Application of the Button Bar

Below is an application of the button bar in mxml:

<?xml version="1.0" encoding="utf-8"?>
<s:View xmlns:fx="http://ns.adobe.com/mxml/2009"
        xmlns:s="library://ns.adobe.com/flex/spark"
        xmlns:implementations="com.bars.implementations.*"
        title="HomeView">
    <fx:Declarations>
    </fx:Declarations>

    <fx:Script>
        <![CDATA[
            import avmplus.getQualifiedClassName;

            import flash.utils.getDefinitionByName;

            import mx.collections.ArrayCollection;
            import mx.collections.XMLListCollection;
            
            [Bindable]
            private var dataItems:ArrayCollection = new ArrayCollection(
                [{ label: "Hello", selection: "Red" }, { label: "This", selection: "Blue" }, { label: "Is", selection: "Yellow" }, { label: "A", selection: "Green" }, { label: "Button", selection: "Blue" }, { label: "Bar", selection: "Violate" }]
                );

            public function get dataItemsXML():XMLListCollection {
                var dataItems:XMLListCollection = new XMLListCollection();
                dataItems.addItem(<content><label>Test</label><something>George</something></content>);
                dataItems.addItem(<content><label>Two</label><something>Peter</something></content>);

                return dataItems;
            }

            [Bindable]
            private var heightValue:Number = 150;

            [Bindable]
            private var widthValue:Number = 150;

            public function change():void {
                dataItems.addItem({ label: "ThisWasAdded", selection: "indigo" });
            }

            public function resize():void {
                heightValue = 150;
                widthValue = 550;
            }
        ]]>
    </fx:Script>
    <s:Group width="100%"
             height="100%">
        <s:layout>
            <s:VerticalLayout/>
        </s:layout>

        <s:Group>
            <s:layout>
                <s:HorizontalLayout/>
            </s:layout>

            <s:Scroller width="300"
                        height="500">
                <implementations:MobileButtonBar id="firstBar"
                                                 buttonHeight="{heightValue}"
                                                 buttonWidth="{widthValue}"
                                                 dataProvider="{dataItemsXML}"
                                                 skinClass="{CobaltButtonSkin}">
                    <implementations:layout>
                        <s:VerticalLayout/>
                    </implementations:layout>
                </implementations:MobileButtonBar>
            </s:Scroller>
            <s:Scroller width="300"
                        height="500">
                <implementations:MobileRadioButtonBar id="secondBar"
                                                      buttonHeight="{heightValue}"
                                                      buttonWidth="{widthValue}"
                                                      dataProvider="{dataItems}">
                    <implementations:layout>
                        <s:VerticalLayout/>
                    </implementations:layout>
                </implementations:MobileRadioButtonBar>
            </s:Scroller>
        </s:Group>
        <s:Button label="change"
                  click="change()"/>
        <s:Button label="resize"
                  click="resize()"/>
        <s:HGroup width="100%">
            <s:Label text="first bar"/>
            <s:Label text="{firstBar.selectedItem.something}"/>
        </s:HGroup>
        <s:HGroup width="100%">
            <s:Label text="second bar"/>
            <s:Label text="{secondBar.selectedItem.selection}"/>
        </s:HGroup>
    </s:Group>
</s:View>

This implementation is in a mobile application. Below is an image from that running application:

No comments:

Post a Comment