Pages

July 15, 2010

Flex DateChooser with Selectable Week / Month

Posted by Jeremy Mitchell
Recently, I was given the task of enhancing Flex's DateChooser to include the ability to select a week or a month. Currently, you can select a week or a month by highlighting multiple days using shift-click (if allowMultipleSelection = true), but we wanted to make it a little easier on the user.

Here is the final product (click on an arrow or the month name):



View Source is enabled


Implementation of this functionality included the following:
  1. Creating a class to represent the "week selection arrow"
  2. Extension of the CalendarLayout class
  3. Creation of a custom event to indicate when a week was selected and help us determine which week was selected
  4. Extension of the DateChooser class
  5. Creation of some utility methods to help us determine the start / end date of a selected week or month

In addition to providing the source code, this blog entry documents the implementation details and provides a brief explanation of the applied logic.


Creating a class to represent the "week selection arrow"


Let's start with creating the arrow used for selecting a week. This class will be primarily responsible for drawing an arrow. However, it will also hold a reference to the arrow's row number (this is important when determining which calendar days to programatically select when the arrow is clicked).
package components
{
    import flash.events.MouseEvent;
    import flash.geom.Point;

    import mx.core.UIComponent;

    public class WeekSelector extends UIComponent
    {
        public var weekRow:int;

        public function WeekSelector(weekRow:int)
        {
            super();
            this.weekRow = weekRow;
            height = 5;
            width = 5;
            buttonMode = true;
            useHandCursor = true;
            addEventListener(MouseEvent.MOUSE_UP, onMouseUp)
        }

        private function onMouseUp(event:MouseEvent):void
        {
            // we don't want to interfere with CalendarLayout's MOUSE_UP handler
            event.stopImmediatePropagation();
        }

        override protected function updateDisplayList(unscaledWidth:Number, unscaledHeight:Number):void
        {
            var vertices:Vector.<Number> = new Vector.<Number>;

            var top:Point = new Point(0, 0);
            var bottom:Point = new Point(0, height);
            var right:Point = new Point(width, height / 2);

            vertices.push(top.x, top.y, bottom.x, bottom.y, right.x, right.y);

            graphics.beginFill(0x000000, 1);
            graphics.drawTriangles(vertices);
            graphics.endFill();
        }
    }
}

Extension of the CalendarLayout class


Our new DateChooser's layout includes arrows for selecting a week. Flex's DateChooser delegates layout of its calendar to an instance of the CalendarLayout class (see DateChooser.dateGrid). Therefore, we will need to extend CalendarLayout and do the following:

  1. Create an instance of WeekSelector (the arrow) for each calendar row
  2. Position each arrow to the left of its corresponding calendar row
  3. Register click handlers on each arrow

Simple enough, right? Sure, but there's a slight snag. Positioning the arrows requires knowledge of each calendar row's position (the x & y values of the row). This information can be extracted from the CalendarLayout's dayBlocksArray property which is marked mx_internal. Fortunately, unlike private variables, mx_internal properties are accessible to subclasses so I simply assigned a property of my subclass to the CalendarLayout's dayBlocksArray property. Now I can access the contents of dayBlocksArray.
package components
{
    import events.WeekSelectionEvent;
    
    import flash.events.MouseEvent;
    import flash.geom.Point;
    
    import mx.controls.CalendarLayout;
    import mx.core.UITextField;
    import mx.core.mx_internal;

    public class CustomCalendarLayout extends CalendarLayout
    {
        private const ROWS_IN_CALENDAR_LAYOUT:int = 5;
        private var weekSelectors:Array = [];
        private var dayBlocksArray:Array = [];

        public function CustomCalendarLayout()
        {
            super();
        }

        override protected function createChildren():void
        {
            super.createChildren();
            createWeekSelectors();
        }

        override protected function updateDisplayList(unscaledWidth:Number, unscaledHeight:Number):void
        {
            super.updateDisplayList(unscaledWidth, unscaledHeight);
            // we need to get a hold of a reference to the superclass' dayBlocksArray property
            // it contains information needed to position each week selection arrow
            dayBlocksArray = mx_internal::dayBlocksArray 
            positionWeekSelectors();
        }

        private function createWeekSelectors():void
        {
            // row zero is [S, M, T, W, T, F, S], we don't need an arrow for that row
            // so let's start with row one
            var weekRowIndex:int = 1; 

            while (weekRowIndex <= ROWS_IN_CALENDAR_LAYOUT)
            {
                // create an arrow for each row
                weekSelectors.push(new WeekSelector(weekRowIndex));
                addChild(weekSelectors[weekRowIndex - 1]);
                weekSelectors[weekRowIndex - 1].addEventListener(MouseEvent.CLICK, onClick);
                weekRowIndex++;
            }
        }

        private function positionWeekSelectors():void
        {
            var weekRowPosition:Point;
            var weekSelector:WeekSelector;
            // again, let's start with row one (no need for the [S, M, T, W, T, F, S] row)
            var weekRowIndex:int = 1;

            while (weekRowIndex <= ROWS_IN_CALENDAR_LAYOUT)
            {
                weekRowPosition = getWeekRowPosition(weekRowIndex);
                weekSelector = weekSelectors[weekRowIndex - 1];
                weekSelector.x = weekRowPosition.x;
                weekSelector.y = weekRowPosition.y;
                setChildIndex(weekSelector, numChildren - 1);
                weekRowIndex++;
            }
        }

        private function getWeekRowPosition(row:int = 0):Point
        {
            // using dayBlocksArray, we can find a UITextField that represents the last day (column 6) in the row
            // and we can use that UITextField's X & Y properties to determine the positioning of the row
            // and ultimately where to position the arrow
            var column:int = 6;
            var day:UITextField = dayBlocksArray[column][row];
            var rowY:Number = day.y + day.height / 3;

            return new Point(3, rowY);
        }

        private function onClick(event:MouseEvent):void
        {
            var dayInWeek:int = getDayInSelectedWeek(event);
            dispatchWeekSelectionEvent(dayInWeek);
        }

        private function getDayInSelectedWeek(event:MouseEvent):int
        {
            var target:WeekSelector = event.target as WeekSelector; // target is the arrow that was clicked
            var row:int = target.weekRow; // the arrow knows which row it belongs to
            var day:UITextField; // the dateChooser calendar is a grid of UITextFields

            const FIRST_DAY_OF_MONTH:int = 1;
            
            // let's loop through each column in the arrow's row until we find a UITextField with text
            // this means - until we find a date. notice how the first row in a calendar sometimes 
            // doesn't have a date at the beginning of the week?

            for (var columnIndex:* in dayBlocksArray)
            {
                day = (dayBlocksArray[columnIndex][row] as UITextField)

                if (day.text && day.text != "")
                {
                    return parseInt(day.text);
                }
            }

            return FIRST_DAY_OF_MONTH;
        }

        private function dispatchWeekSelectionEvent(dayInWeek:int):void
        {
            var weekSelectionEvent:WeekSelectionEvent = new WeekSelectionEvent(WeekSelectionEvent.WEEK_SELECTED, true);
            weekSelectionEvent.dayInWeek = dayInWeek;
            // dispatch the bubbling event that will get caught by our custom datechooser
            // and select all the days for that week
            dispatchEvent(weekSelectionEvent);
        }
    } //EOClass
} //EOPackage

Creation of a custom event to indicate when a week was selected and help us determine which week was selected


There's nothing particularly interesting about this task. We need a custom event that bubbles to indicate when a week has been selected. This event's payload includes a day (any day really) that is part of the selected week. We'll use that day to figure out the start and end dates of the selected week.

If you need a refresher on creating custom events read Flex Examples - Creating a Custom Event.
package events
{
    import flash.events.Event;

    public class WeekSelectionEvent extends Event
    {
        public static const WEEK_SELECTED:String = "weekSelected";
        
        protected var _dayInWeek:int;

        public function WeekSelectionEvent(type:String, bubbles:Boolean = false, cancelable:Boolean = false)
        {
            super(type, bubbles, cancelable);
        }
        
        override public function clone():Event
        {
            var clone:WeekSelectionEvent = new WeekSelectionEvent(type, bubbles, cancelable);
            clone.dayInWeek = dayInWeek;
            return clone;
        }
        
        public function get dayInWeek():int
        {
            return _dayInWeek;
        }

        public function set dayInWeek(value:int):void
        {
            _dayInWeek = value;
        }
    } //EOClass
} //EOPackage

Extension of the DateChooser class


It's time to bring this all together. We'll need a modified version of DateChooser to utilize our custom calendar layout (CustomCalendarLayout) that includes our nifty little selection arrows. In addition, we'll add the ability to select the entire month by simply clicking on the month label.

Again, we're fortunate that the DateChooser properties needed to complete this undertaking (dateGrid and monthDisplay) are accessible via mx_internal. Otherwise, we'd be in a world of hurt.
package components
{    
    import events.WeekSelectionEvent;
    
    import flash.events.MouseEvent;
    
    import mx.controls.*;
    import mx.core.UITextField;
    import mx.core.UITextFormat;
    import mx.core.mx_internal;
    import mx.events.CalendarLayoutChangeEvent;
    import mx.events.DateChooserEvent;
    import mx.styles.StyleProxy;
    
    import utils.CustomDateChooserUtil;

    public class CustomDateChooser extends DateChooser
    {
        private const FIRST_DAY_OF_MONTH:int = 1;
        
        private var dateGrid:CalendarLayout;

        public function CustomDateChooser()
        {
            super();
            // dispatched from our CustomCalendarLayout when a "week arrow" is clicked
            addEventListener(WeekSelectionEvent.WEEK_SELECTED, selectWeek); 
        }

        override protected function createChildren():void
        {
            overrideAddDateGrid_in_superclass_createChildren();
            super.createChildren();
            addMonthDisplayHandlers();
            addDateGrid();
        }

        private function addMonthDisplayHandlers():void
        {
            // make the date chooser's month display function like a link
            mx_internal::monthDisplay.addEventListener(MouseEvent.CLICK, selectMonth);
            mx_internal::monthDisplay.addEventListener(MouseEvent.MOUSE_OVER, onMouseOverMonthDisplay);
            mx_internal::monthDisplay.addEventListener(MouseEvent.MOUSE_OUT, onMouseOutMonthDisplay);
        }

        private function addDateGrid():void
        {
            addChild(mx_internal::dateGrid);
        }

        private function selectWeek(event:WeekSelectionEvent):void
        {
            // catch the event dispatched when a week selection arrow is clicked
            // and set the DateChooser's selectedRanges property to a value representing the selected week
            var dayInWeek:int = event.dayInWeek as int;
            var selectedDate:Date = new Date(displayedYear, displayedMonth, dayInWeek);
            var weekRange:Object = CustomDateChooserUtil.getWeekOf(selectedDate);
            
            selectedRanges = [weekRange];
        }

        private function selectMonth(event:MouseEvent):void
        {
            // set the DateChooser's selectedRanges property to a value representing the entire displayed month
            var selectedDate:Date = new Date(displayedYear, displayedMonth, FIRST_DAY_OF_MONTH);
            var monthRange:Object = CustomDateChooserUtil.getMonthOf(selectedDate);
            
            selectedRanges = [monthRange];
        }

        private function onMouseOverMonthDisplay(event:MouseEvent):void
        {
            highlightMonthDisplay(event);
        }

        private function onMouseOutMonthDisplay(event:MouseEvent):void
        {
            unHighlightMonthDisplay(event);
        }

        private function highlightMonthDisplay(event:MouseEvent):void
        {
            // bold / underline on rollover (just like a link)
            var monthDisplay:UITextField = event.target as UITextField;
            var textFormat:UITextFormat = new UITextFormat(this.systemManager);
            textFormat.underline = true;
            textFormat.color = 0x0000FF;
            monthDisplay.setTextFormat(textFormat);
        }

        private function unHighlightMonthDisplay(event:MouseEvent):void
        {
            // unbold / remove underline on rollout (just like a link)
            var monthDisplay:UITextField = event.target as UITextField;
            var textFormat:UITextFormat = new UITextFormat(this.systemManager);
            textFormat.underline = false;
            textFormat.color = 0x000000;
            monthDisplay.setTextFormat(textFormat);
        }

        private function overrideAddDateGrid_in_superclass_createChildren():void
        {
            // we're going to assign the DateChooser's dateGrid property an instance of our CustomCalendarLayout
            // fortunately, super.createChildren will not override our assignment. our CustomCalendarLayout is in place.
            dateGrid = new CustomCalendarLayout(); // this is really the only line I care about changing
            // the rest of this method works exactly as the super.createChildren method does
            dateGrid.styleName = new StyleProxy(this, calendarLayoutStyleFilters);
            // however, unfortunately the event handlers in the super class are marked private
            // so i'll have to copy their contents from the super class into this subclass
            dateGrid.addEventListener(CalendarLayoutChangeEvent.CHANGE, copyOf_DateGrid_changeHandler_from_superclass);
            dateGrid.addEventListener(DateChooserEvent.SCROLL, copyOfDateGrid_scrollHandler_from_superclass);
            mx_internal::dateGrid = dateGrid;
        }

        private function copyOf_DateGrid_changeHandler_from_superclass(event:CalendarLayoutChangeEvent):void
        {
            // this is just a copy of the DateChooser.dateGrid_changeHandler 
            // but, i had to use the selectedDate public setter instead of the private variable
            selectedDate = CalendarLayout(event.target).selectedDate; 
            
            var e:CalendarLayoutChangeEvent = new CalendarLayoutChangeEvent(CalendarLayoutChangeEvent.CHANGE);
            e.newDate = event.newDate;
            e.triggerEvent = event.triggerEvent;
            dispatchEvent(e);
        }

        private function copyOfDateGrid_scrollHandler_from_superclass(event:DateChooserEvent):void
        {
            // this is just a copy of the DateChooser.dateGrid_scrollHandler
            dispatchEvent(event);
        }

    } //EOClass
} //EOPackage

Creation of some utility methods to help us determine the start / end date of a selected week or month


You'll notice in CustomDateChooser we used two methods to help us determine the start and end date of the selected week (getWeekOf) or the start and end date of the selected month (getMonthOf). Here is the implementation of those methods.
package utils
{
    public class CustomDateChooserUtil
    {
        public static function getWeekOf(selectedDate:Date):Object
        {
            // takes in a date and returns an object representing a start and end date for the selected week
            // in a format that DateChooser.selectedRanges likes
            var weekIndex:int = selectedDate.day;
            var weekStartDate:Date = new Date(selectedDate.fullYear, selectedDate.month, selectedDate.date - weekIndex);
            var weekEndDate:Date = new Date(weekStartDate.fullYear, weekStartDate.month, weekStartDate.date + 6);

            return {rangeStart: weekStartDate, rangeEnd: weekEndDate};
        }

        public static function getMonthOf(selectedDate:Date):Object
        {
            // takes in a date and returns an object representing a start and end date for the selected month
            // in a format that DateChooser.selectedRanges likes
            var monthStartDate:Date = new Date(selectedDate.fullYear, selectedDate.month, 1);

            // Flex interprets day 0 to be the last day of the preceeding month
            var monthEndDate:Date = new Date(selectedDate.fullYear, selectedDate.month + 1, 0);

            return {rangeStart: monthStartDate, rangeEnd: monthEndDate};
        }
    } //EOClass
} //EOPackage
There you go. I hope people will find this component useful. Credit must also be given to my colleague, Paul Saieg, as we "paired" on the development of this component.