Chapter 41
Customizing a grid

Delphi provides abstract components you can use as the basis for customized components. The most important of these are grids and list boxes. In this chapter, you will see how to create a small one-month calendar from the basic grid component, TCustomGrid.

Creating the calendar involves these tasks:

The resulting component is similar to the TCalendar component on the Samples page of the Component palette.

Creating and registering the component

Creation of every component begins the same way: create a unit, derive a component class, register it, compile it, and install it on the Component palette. This process is outlined in "Creating a new component".

For this example, follow the general procedure for creating a component, with these specifics:

The resulting unit should look like this:

unit CalSamp;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, Grids;

type
  TSampleCalendar = class(TCustomGrid)
  end;

procedure Register;

implementation

procedure Register;
begin
  RegisterComponents('Samples', [TSampleCalendar]);
end;

end.

If you install the calendar component now, you will find that it appears on the Samples page. The only properties available are the most basic control properties. The next step is to make some of the more specialized properties available to users of the calendar.

Note: While you can install the sample calendar component you have just compiled, do not try to place it on a form yet. The TCustomGrid component has an abstract DrawCell method that must be redeclared before instance objects can be created. Overriding the DrawCell method is described in "Filling in the cells" below.

Publishing inherited properties

The abstract grid component, TCustomGrid, provides a large number of protected properties. You can choose which of those properties you want to make available to users of the calendar control.

To make inherited protected properties available to users of your components, redeclare the properties in the published part of your component's declaration.

For the calendar control, publish the following properties and events, as shown here:

type
  TSampleCalendar = class(TCustomGrid)
  published
    property Align;  { publish properties }
    property BorderStyle;
    property Color;
    property Ctl3D;
    property Font;
    property GridLineWidth;
    property ParentColor;
    property ParentFont;
    property OnClick;  { publish events }
    property OnDblClick;
    property OnDragDrop;
    property OnDragOver;
    property OnEndDrag;
    property OnKeyDown;
    property OnKeyPress;
    property OnKeyUp;
  end;

There are a number of other properties you could also publish, but which do not apply to a calendar, such as the Options property that would enable the user to choose which grid lines to draw.

If you install the modified calendar component to the Component palette and use it in an application, you will find many more properties and events available in the calendar, all fully functional. You can now start adding new capabilities of your own design.

Changing initial values

A calendar is essentially a grid with a fixed number of rows and columns, although not all the rows always contain dates. For this reason, you have not published the grid properties ColCount and RowCount, because it is highly unlikely that users of the calendar will want to display anything other than seven days per week. You still must set the initial values of those properties so that the week always has seven days, however.

To change the initial values of the component's properties, override the constructor to set the desired values. The constructor must be virtual.

Remember that you need to add the constructor to the public part of the component's object declaration, then write the new constructor in the implementation part of the component's unit. The first statement in the new constructor should always be a call to the inherited constructor.

type
  TSampleCalendar = class(TCustomGrid
  public
    constructor Create(AOwner: TComponent); override;
  ...
  end;
...
constructor TSampleCalendar.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);                                 { call inherited constructor }
  ColCount := 7;                                                { always seven days/week }
  RowCount := 7;                                    { always six weeks plus the headings }
  FixedCols := 0;                                                        { no row labels }
  FixedRows := 1;                                                { one row for day names }
  ScrollBars := ssNone;                                              { no need to scroll }
  Options := Options - [goRangeSelect] + [goDrawFocusSelected];  {disable range 
selection}
end;

The calendar now has seven columns and seven rows, with the top row fixed, or nonscrolling.

Resizing the cells

When a user or application changes the size of a window or control, Windows sends a message called WM_SIZE to the affected window or control so it can adjust any settings needed to later paint its image in the new size. Your component can respond to that message by altering the size of the cells so they all fit inside the boundaries of the control. To respond to the WM_SIZE message, you will add a message-handling method to the component.

Creating a message-handling method is described in detail in "Creating new message handlers".

In this case, the calendar control needs a response to WM_SIZE, so add a protected method called WMSize to the control indexed to the WM_SIZE message, then write the method so that it calculates the proper cell size to allow all cells to be visible in the new size:

type
  TSampleCalendar = class(TCustomGrid)
  protected
    procedure WMSize(var Message: TWMSize); message WM_SIZE;
  ...
  end;
...
procedure TSampleCalendar.WMSize(var Message: TWMSize);
var
  GridLines: Integer;                                         { temporary local variable }
begin
  GridLines := 6 * GridLineWidth;                 { calculate combined size of all lines }
  DefaultColWidth := (Message.Width - GridLines) div 7;     { set new default cell 
width }
  DefaultRowHeight := (Message.Height - GridLines) div 7;              { and cell 
height }
end;

Now when the calendar is resized, it displays all the cells in the largest size that will fit in the control.

Filling in the cells

A grid control fills in its contents cell-by-cell. In the case of the calendar, that means calculating which date, if any, belongs in each cell. The default drawing for grid cells takes place in a virtual method called DrawCell.

To fill in the contents of grid cells, override the DrawCell method.

The easiest part to fill in is the heading cells in the fixed row. The runtime library contains an array with short day names, so for the calendar, use the appropriate one for each column:

type
  TSampleCalendar = class(TCustomGrid)
  protected
    procedure DrawCell(ACol, ARow: Longint; ARect: TRect; AState: TGridDrawState);
      override;
  end;
...
procedure TSampleCalendar.DrawCell(ACol, ARow: Longint; ARect: TRect;
  AState: TGridDrawState);
begin
  if ARow = 0 then 
    Canvas.TextOut(ARect.Left, ARect.Top, ShortDayNames[ACol + 1]);    { use RTL strings }
end;

Tracking the date

For the calendar control to be useful, users and applications must have a mechanism for setting the day, month, and year. Delphi stores dates and times in variables of type TDateTime. TDateTime is an encoded numeric representation of the date and time, which is useful for programmatic manipulation, but not convenient for human use.

You can therefore store the date in encoded form, providing runtime access to that value, but also provide Day, Month, and Year properties that users of the calendar component can set at design time.

Tracking the date in the calendar consists of the processes:

Storing the internal date

To store the date for the calendar, you need a private field to hold the date and a runtime-only property that provides access to that date.

Adding the internal date to the calendar requires three steps:

  1. Declare a private field to hold the date:
    type
      TSampleCalendar = class(TCustomGrid)
      private
        FDate: TDateTime;
      ...
    
  2. Initialize the date field in the constructor:
    constructor TSampleCalendar.Create(AOwner: TComponent);
    begin
      inherited Create(AOwner);         { this is already here }
      ...                                 { other initializations here }
      FDate := Date;                    { get current date from RTL }
    end;
    
  3. Declare a runtime property to allow access to the encoded date.

    You'll need a method for setting the date, because setting the date requires updating the onscreen image of the control:

    type
      TSampleCalendar = class(TCustomGrid)
      private
        procedure SetCalendarDate(Value: TDateTime);
      public
        property CalendarDate: TDateTime read FDate write SetCalendarDate;
      ...
    procedure TSampleCalendar.SetCalendarDate(Value: TDateTime);
    begin
      FDate := Value;                { set new date value }
      Refresh;                       { update the onscreen image }
    end;
    

Accessing the day, month, and year

An encoded numeric date is fine for applications, but humans prefer to work with days, months, and years. You can provide alternate access to those elements of the stored, encoded date by creating properties.

Because each element of the date (day, month, and year) is an integer, and because setting each requires encoding the date when set, you can avoid duplicating the code each time by sharing the implementation methods for all three properties. That is, you can write two methods, one to read an element and one to write one, and use those methods to get and set all three properties.

To provide design-time access to the day, month, and year, you do the following:

  1. Declare the three properties, assigning each a unique index number:
    type
      TSampleCalendar = class(TCustomGrid)
      public
        property Day: Integer index 3 read GetDateElement write SetDateElement;
        property Month: Integer index 2 read GetDateElement write SetDateElement;
        property Year: Integer index 1 read GetDateElement write SetDateElement;
      ...
    
  2. Declare and write the implementation methods, setting different elements for each index value:
    type
      TSampleCalendar = class(TCustomGrid)
      private
        function GetDateElement(Index: Integer): Integer;         { note the Index parameter }
        procedure SetDateElement(Index: Integer; Value: Integer);
      ...
    function TSampleCalendar.GetDateElement(Index: Integer): Integer;
    var
      AYear, AMonth, ADay: Word;
    begin
      DecodeDate(FDate, AYear, AMonth, ADay);             { break encoded date into elements }
      case Index of
        1: Result := AYear;
        2: Result := AMonth;
        3: Result := ADay;
        else Result := -1;
      end;
    end;
    
    procedure TSampleCalendar.SetDateElement(Index: Integer; Value: Integer);
    var
      AYear, AMonth, ADay: Word;
    begin
      if Value > 0 then                                      { all elements must be positive }
      begin
        DecodeDate(FDate, AYear, AMonth, ADay);                  { get current date elements }
        case Index of                                   { set new element depending on Index }
          1: AYear := Value;
          2: AMonth := Value;
          3: ADay := Value;
          else Exit;
        end;
        FDate := EncodeDate(AYear, AMonth, ADay);                 { encode the modified date }
        Refresh;                                               { update the visible calendar }
      end;
    end;
    

Now you can set the calendar's day, month, and year at design time using the Object Inspector or at runtime using code. Of course, you have not yet added the code to paint the dates into the cells, but now you have the needed data.

Generating the day numbers

Putting numbers into the calendar involves several considerations. The number of days in the month depends on which month it is, and whether the given year is a leap year. In addition, months start on different days of the week, dependent on the month and year. Use the IsLeapYear function to determine whether the year is a leap year. Use the MonthDays array in the SysUtils unit to get the number of days in the month.

Once you have the information on leap years and days per month, you can calculate where in the grid the individual dates go. The calculation is based on the day of the week the month starts on.

Because you will need the month-offset number for each cell you fill in, the best practice is to calculate it once when you change the month or year, then refer to it each time. You can store the value in a class field, then update that field each time the date changes.

To fill in the days in the proper cells, you do the following:

  1. Add a month-offset field to the object and a method that updates the field value:
    type
      TSampleCalendar = class(TCustomGrid)
      private
        FMonthOffset: Integer;                                      { storage for the offset }
      ...
      protected
        procedure UpdateCalendar; virtual;                      { property for offset access }
      end;
    ...
    procedure TSampleCalendar.UpdateCalendar;
    var
      AYear, AMonth, ADay: Word;
      FirstDate: TDateTime;                             { date of the first day of the month }
    begin
      if FDate <> 0 then                            { only calculate offset if date is valid }
      begin
        DecodeDate(FDate, AYear, AMonth, ADay);                       { get elements of date }
        FirstDate := EncodeDate(AYear, AMonth, 1);                       { date of the first }
        FMonthOffset := 2 - DayOfWeek(FirstDate);        { generate the offset into the 
    grid }
      end;
      Refresh;                                                  { always repaint the control }
    end;
    
  2. Add statements to the constructor and the SetCalendarDate and SetDateElement methods that call the new update method whenever the date changes:
    constructor TSampleCalendar.Create(AOwner: TComponent);
    begin
      inherited Create(AOwner);                                       { this is already here }
      ...                                                         { other initializations here }
      UpdateCalendar;                                                    { set proper offset }
    end;
    
    procedure TSampleCalendar.SetCalendarDate(Value: TDateTime);
    begin
      FDate := Value;                                                { this was already here }
      UpdateCalendar;                                       { this previously called Refresh }
    end;
    
    procedure TSampleCalendar.SetDateElement(Index: Integer; Value: Integer);
    begin
      ...
        FDate := EncodeDate(AYear, AMonth, ADay);                 { encode the modified date }
        UpdateCalendar;                                     { this previously called Refresh }
      end;
    end;
    
  3. Add a method to the calendar that returns the day number when passed the row and column coordinates of a cell:
    function TSampleCalendar.DayNum(ACol, ARow: Integer): Integer;
    begin
      Result := FMonthOffset + ACol + (ARow - 1) * 7;          { calculate day for this 
    cell }
      if (Result < 1) or (Result > MonthDays[IsLeapYear(Year), Month]) then
        Result := -1;                                                 { return -1 
    if invalid }
    end;

    Remember to add the declaration of DayNum to the component's type declaration.

  4. Now that you can calculate where the dates go, you can update DrawCell to fill in the dates:
    procedure TCalendar.DrawCell(ACol, ARow: Longint; ARect: TRect; AState: TGridDrawState);
    var
      TheText: string;
      TempDay: Integer;
    begin
      if ARow = 0 then                                        { if this is the header row ...}
        TheText := ShortDayNames[ACol + 1]                           { just use the day name }
      else begin
        TheText := '';                                           { blank cell is the default }
        TempDay := DayNum(ACol, ARow);                            { get number for this cell }
        if TempDay <> -1 then TheText := IntToStr(TempDay);        { use the number if 
    valid }
      end;
      with ARect, Canvas do
        TextRect(ARect, Left + (Right - Left - TextWidth(TheText)) div 2,
          Top + (Bottom - Top - TextHeight(TheText)) div 2, TheText);
    end;
    

Now if you reinstall the calendar component and place one on a form, you will see the proper information for the current month.

Selecting the current day

Now that you have numbers in the calendar cells, it makes sense to move the selection highlighting to the cell containing the current day. By default, the selection starts on the top left cell, so you need to set the Row and Column properties both when constructing the calendar initially and when the date changes.

To set the selection on the current day, change the UpdateCalendar method to set Row and Column before calling Refresh:

procedure TSampleCalendar.UpdateCalendar;
begin
  if FDate <> 0 then
  begin
    ... { existing statements to set FMonthOffset }
    Row := (ADay - FMonthOffset) div 7 + 1;
    Col := (ADay - FMonthOffset) mod 7;
  end;
  Refresh; { this is already here }
end;

Note that you are now reusing the ADay variable previously set by decoding the date.

Navigating months and years

Properties are useful for manipulating components, especially at design time. But sometimes there are types of manipulations that are so common or natural, often involving more than one property, that it makes sense to provide methods to handle them. One example of such a natural manipulation is a "next month" feature for a calendar. Handling the wrapping around of months and incrementing of years is simple, but very convenient for the developer using the component.

The only drawback to encapsulating common manipulations into methods is that methods are only available at runtime. However, such manipulations are generally only cumbersome when performed repeatedly, and that is fairly rare at design time.

For the calendar, add the following four methods for next and previous month and year. Each of these methods uses the IncMonth function in a slightly different manner to increment or decrement CalendarDate, by increments of a month or a year. After incrementing or decrementing CalendarDate, decode the date value to fill the Year, Month, and Day properties with corresponding new values.

procedure TCalendar.NextMonth;
begin
  DecodeDate(IncMonth(CalendarDate, 1), Year, Month, Day);
end;

procedure TCalendar.PrevMonth;
begin
  DecodeDate(IncMonth(CalendarDate, -1), Year, Month, Day);
end;

procedure TCalendar.NextYear;
begin
  DecodeDate(IncMonth(CalendarDate, 12), Year, Month, Day);
end;

procedure TCalendar.PrevYear;
begin
  DecodeDate(CalendarDate, -12), Year, Month, Day);
end;

Be sure to add the declarations of the new methods to the class declaration.

Now when you create an application that uses the calendar component, you can easily implement browsing through months or years.

Navigating days

Within a given month, there are two obvious ways to navigate among the days. The first is to use the arrow keys, and the other is to respond to clicks of the mouse. The standard grid component handles both as if they were clicks. That is, an arrow movement is treated like a click on an adjacent cell.

The process of navigating days consists of

Moving the selection

The inherited behavior of a grid handles moving the selection in response to either arrow keys or clicks, but if you want to change the selected day, you need to modify that default behavior.

To handle movements within the calendar, override the Click method of the grid.

When you override a method such as Click that is tied in with user interactions, you will nearly always include a call to the inherited method, so as not to lose the standard behavior.

The following is an overridden Click method for the calendar grid. Be sure to add the declaration of Click to TSampleCalendar, including the override directive afterward.

procedure TSampleCalendar.Click;
var
  TempDay: Integer;
begin
  inherited Click;                              { remember to call the inherited method! }
  TempDay := DayNum(Col, Row);                 { get the day number for the clicked cell }
  if TempDay <> -1 then Day := TempDay;                            { change day if 
valid }
end;

Providing an OnChange event

Now that users of the calendar can change the date within the calendar, it makes sense to allow applications to respond to those changes.

Add an OnChange event to TSampleCalendar.

  1. Declare the event, a field to store the event, and a dynamic method to call the event:
    type
      TSampleCalendar = class(TCustomGrid)
      private
        FOnChange: TNotifyEvent;
      protected
        procedure Change; dynamic;
      ...
      published
        property OnChange: TNotifyEvent read FOnChange write FOnChange;
      ...
    
  2. Write the Change method:
    procedure TSampleCalendar.Change;
    begin
      if Assigned(FOnChange) then FOnChange(Self);
    end;
    
  3. Add statements calling Change to the end of the SetCalendarDate and SetDateElement methods:
    procedure TSampleCalendar.SetCalendarDate(Value: TDateTime);
    begin
      FDate := Value;
      UpdateCalendar;
      Change;                                               { this is the only new statement }
    end;
    
    procedure TSampleCalendar.SetDateElement(Index: Integer; Value: Integer);
    begin
        ...                                           { many statements setting element values }
        FDate := EncodeDate(AYear, AMonth, ADay);
        UpdateCalendar;
        Change;                                                                { this is new }
      end;
    end;
    

Applications using the calendar component can now respond to changes in the date of the component by attaching handlers to the OnChange event.

Excluding blank cells

As the calendar is written, the user can select a blank cell, but the date does not change. It makes sense, then, to disallow selection of the blank cells.

To control whether a given cell is selectable, override the SelectCell method of the grid.

SelectCell is a function that takes a column and row as parameters, and returns a Boolean value indicating whether the specified cell is selectable.

You can override SelectCell to return false if the cell does not contain a valid date:

function TSampleCalendar.SelectCell(ACol, ARow: Longint): Boolean;
begin
  if DayNum(ACol, ARow) = -1 then Result := False            { -1 indicates 
invalid date }
  else Result := inherited SelectCell(ACol, ARow);      { otherwise, use inherited value }
end;

Now if the user clicks a blank cell or tries to move to one with an arrow key, the calendar leaves the current cell selected.