I am writing a custom control that is derived from a TScrollBox
but I am having a few difficulties overcoming what seems like should be an easy enough problem to solve.
The control will be used to display a caption bar at the top which will be static (ie, never moves when the scrollbox is scrolled), and then underneath the caption bar I will be drawing some values within there own columns such as row numbers etc.
This is what the control currently looks like to give a better idea (very much a early work in progress):
The problem I am facing is flickering and I do not see an easy way to eliminate it. I have a feeling the flickering is been caused because I am trying to draw underneath my caption bar and when the flickering occurs you can actually see the values been drawing underneath the caption bar, although my assumption could be completely wrong.
All the drawing is done on a TGraphicControl
which is child to the scrollbox, the flickering occurs a lot when scrolling fast, when using the scrollbar buttons it still flickers but not as frequently.
I am unable to catch the flickering and show as an image here, but with the code below you can build and install into a new package and test for yourself:
unit MyGrid;
interface
uses
Winapi.Windows,
Winapi.Messages,
System.Classes,
System.SysUtils,
Vcl.Controls,
Vcl.Dialogs,
Vcl.Forms,
Vcl.Graphics;
type
TMyCustomGrid = class(TGraphicControl)
private
FFont: TFont;
FRowNumbers: TStringList;
FRowCount: Integer;
FCaptionBarRect: TRect;
FRowNumbersBackgroundRect: TRect;
FValuesBackgroundRect: TRect;
procedure CalculateNewHeight;
function GetMousePosition: TPoint;
function RowIndexToMousePosition(ARowIndex: Integer): Integer;
function GetRowHeight: Integer;
function RowExists(ARowIndex: Integer): Boolean;
function GetRowNumberRect(ARowIndex: Integer): TRect;
function GetRowNumberTextRect(ARowIndex: Integer): TRect;
function GetValueRect(ARowIndex: Integer): TRect;
function GetValueTextRect(ARowIndex: Integer): TRect;
function GetFirstVisibleRow: Integer;
function GetLastVisibleRow: Integer;
protected
procedure Paint; override;
procedure DrawCaptionBar;
procedure DrawRowNumbers;
procedure DrawValues;
procedure DrawColumnLines;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
end;
TMyGrid = class(TScrollBox)
private
FGrid: TMyCustomGrid;
protected
procedure Loaded; override;
procedure WMVScroll(var Message: TWMVScroll); message WM_VSCROLL;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
end;
const
FCaptionBarHeight = 20;
FRowNumbersWidth = 85;
FValuesWidth = 175;
FTextSpacing = 5;
implementation
constructor TMyCustomGrid.Create(AOwner: TComponent);
var
I: Integer;
begin
inherited Create(AOwner);
FFont := TFont.Create;
FFont.Color := clBlack;
FFont.Name := 'Tahoma';
FFont.Size := 10;
FFont.Style := [];
FRowNumbers := TStringList.Create;
//FOR TEST PURPOSES
for I := 0 to 1000 do
begin
FRowNumbers.Add(IntToStr(I));
end;
Canvas.Font.Assign(FFont);
end;
destructor TMyCustomGrid.Destroy;
begin
FFont.Free;
FRowNumbers.Free;
inherited Destroy;
end;
procedure TMyCustomGrid.Paint;
begin
FCaptionBarRect := Rect(0, 0, Self.Width, GetRowHeight + TMyGrid(Self.Parent).VertScrollBar.Position + 2);
FRowCount := FRowNumbers.Count;
DrawRowNumbers;
DrawValues;
DrawCaptionBar;
DrawColumnLines;
end;
procedure TMyCustomGrid.DrawCaptionBar;
var
R: TRect;
S: string;
begin
{background}
Canvas.Brush.Color := clSkyBlue;
Canvas.Brush.Style := bsSolid;
Canvas.FillRect(FCaptionBarRect);
{text}
Canvas.Brush.Style := bsClear;
R := Rect(FTextSpacing, FCaptionBarRect.Top + TMyGrid(Self.Parent).VertScrollBar.Position, FRowNumbersWidth - FTextSpacing, FCaptionBarRect.Bottom);
S := 'Row No.';
DrawText(Canvas.Handle, PChar(S), Length(S), R, DT_LEFT or DT_SINGLELINE or DT_VCENTER);
R := Rect(FTextSpacing + FRowNumbersWidth, FCaptionBarRect.Top + TMyGrid(Self.Parent).VertScrollBar.Position, FValuesWidth - FTextSpacing, FCaptionBarRect.Bottom);
S := 'Item No.';
DrawText(Canvas.Handle, PChar(S), Length(S), R, DT_LEFT or DT_SINGLELINE or DT_VCENTER);
end;
procedure TMyCustomGrid.DrawRowNumbers;
var
I, Y: Integer;
R: TRect;
S: string;
begin
{background}
FRowNumbersBackgroundRect := Rect(0, FCaptionBarRect.Bottom, FRowNumbersWidth, FCaptionBarRect.Height + GetLastVisibleRow * GetRowHeight + 1);
Canvas.Brush.Color := clCream;
Canvas.Brush.Style := bsSolid;
Canvas.FillRect(FRowNumbersBackgroundRect);
{text}
Y := 0;
// a bit of optimization here, instead of iterating every item in FRowNumbers
// which would be slow - instead determine the the top and last visible row
// and paint only that area.
for I := GetFirstVisibleRow to GetLastVisibleRow do
begin
if RowExists(I) then
begin
R := GetRowNumberTextRect(I);
S := FRowNumbers.Strings[I];
DrawText(Canvas.Handle, PChar(S), Length(S), R, DT_LEFT or DT_SINGLELINE or DT_VCENTER);
Inc(Y, GetRowHeight);
end;
end;
end;
procedure TMyCustomGrid.DrawValues;
var
I, Y: Integer;
R: TRect;
S: string;
begin
{background}
FValuesBackgroundRect := Rect(FRowNumbersBackgroundRect.Width, FCaptionBarRect.Bottom, FValuesWidth + FRowNumbersWidth, FCaptionBarRect.Height + GetLastVisibleRow * GetRowHeight + 1);
Canvas.Brush.Color := clMoneyGreen;
Canvas.Brush.Style := bsSolid;
Canvas.FillRect(FValuesBackgroundRect);
{text}
Y := 0;
// a bit of optimization here, instead of iterating every item in FRowNumbers
// which would be slow - instead determine the the top and last visible row
// and paint only that area.
for I := GetFirstVisibleRow to GetLastVisibleRow do
begin
if RowExists(I) then
begin
R := GetValueTextRect(I);
S := 'This is item number ' + FRowNumbers.Strings[I];
DrawText(Canvas.Handle, PChar(S), Length(S), R, DT_LEFT or DT_SINGLELINE or DT_VCENTER);
Inc(Y, GetRowHeight);
end;
end;
end;
procedure TMyCustomGrid.DrawColumnLines;
begin
Canvas.Brush.Style := bsClear;
Canvas.Pen.Color := clBlack;
{row numbers column}
Canvas.MoveTo(FRowNumbersBackgroundRect.Right, FCaptionBarRect.Top);
Canvas.LineTo(FRowNumbersBackgroundRect.Right, FCaptionBarRect.Height + GetLastVisibleRow * GetRowHeight + 1);
{values column}
Canvas.MoveTo(FValuesBackgroundRect.Right, FCaptionBarRect.Top);
Canvas.LineTo(FValuesBackgroundRect.Right, FCaptionBarRect.Height + GetLastVisibleRow * GetRowHeight + 1);
end;
procedure TMyCustomGrid.CalculateNewHeight;
var
I, Y: Integer;
begin
FRowCount := FRowNumbers.Count;
Y := 0;
for I := 0 to FRowCount -1 do
begin
Inc(Y, GetRowHeight);
end;
if Self.Height <> Y then
Self.Height := Y + FCaptionBarHeight + 1;
end;
function TMyCustomGrid.GetMousePosition: TPoint;
var
P: TPoint;
begin
Winapi.Windows.GetCursorPos(P);
Winapi.Windows.ScreenToClient(Self.Parent.Handle, P);
Result := P;
end;
function TMyCustomGrid.RowIndexToMousePosition(
ARowIndex: Integer): Integer;
begin
if RowExists(ARowIndex) then
Result := ARowIndex * GetRowHeight;
end;
function TMyCustomGrid.GetRowHeight: Integer;
begin
Result := 18;
end;
function TMyCustomGrid.RowExists(ARowIndex: Integer): Boolean;
var
I: Integer;
Y: Integer;
begin
Result := False;
Y := 0;
for I := GetFirstVisibleRow to GetLastVisibleRow -1 do
begin
if ARowIndex = I then
begin
Result := True;
Break;
end;
Inc(Y, GetRowHeight);
end;
end;
function TMyCustomGrid.GetRowNumberRect(ARowIndex: Integer): TRect;
begin
Result.Bottom := RowIndexToMousePosition(ARowIndex) + FCaptionBarHeight + GetRowHeight;
Result.Left := 0;
Result.Right := FRowNumbersWidth;
Result.Top := RowIndexToMousePosition(ARowIndex) + FCaptionBarHeight + 1;
end;
function TMyCustomGrid.GetRowNumberTextRect(ARowIndex: Integer): TRect;
begin
Result := GetRowNumberRect(ARowIndex);
Result.Inflate(-FTextSpacing, 0);
end;
function TMyCustomGrid.GetValueRect(ARowIndex: Integer): TRect;
begin
Result.Bottom := RowIndexToMousePosition(ARowIndex) + FCaptionBarHeight + GetRowHeight;
Result.Left := FRowNumbersWidth;
Result.Right := FValuesBackgroundRect.Right;
Result.Top := RowIndexToMousePosition(ARowIndex) + FCaptionBarHeight + 1;
end;
function TMyCustomGrid.GetValueTextRect(ARowIndex: Integer): TRect;
begin
Result := GetValueRect(ARowIndex);
Result.Inflate(-FTextSpacing, 0);
end;
function TMyCustomGrid.GetFirstVisibleRow: Integer;
begin
Result := TMyGrid(Self.Parent).VertScrollBar.Position div GetRowHeight;
end;
function TMyCustomGrid.GetLastVisibleRow: Integer;
begin
Result := GetFirstVisibleRow + TMyGrid(Self.Parent).Height div GetRowHeight -1;
end;
constructor TMyGrid.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
Self.DoubleBuffered := True;
Self.Height := 150;
Self.HorzScrollBar.Visible := False;
Self.TabStop := True;
Self.Width := 250;
FGrid := TMyCustomGrid.Create(Self);
FGrid.Align := alTop;
FGrid.Parent := Self;
FGrid.CalculateNewHeight;
Self.VertScrollBar.Smooth := False;
Self.VertScrollBar.Increment := FGrid.GetRowHeight;
Self.VertScrollBar.Tracking := True;
end;
destructor TMyGrid.Destroy;
begin
FGrid.Free;
inherited Destroy;
end;
procedure TMyGrid.Loaded;
begin
inherited Loaded;
Self.VertScrollBar.Range := FGrid.Height - FGrid.FCaptionBarRect.Height;
end;
procedure TMyGrid.WMVScroll(var Message: TWMVScroll);
begin
inherited;
Self.Invalidate;
end;
end.
What should I be doing differently to overcome the flicker?
Setting DoubleBuffered
to True for the scrollbox makes little difference here it seems. I experimented a little with the WM_ERASEBACKGROUND
message which just made the scrollbox black.
I also tried implementing a canvas onto the scrollbox and drawing my caption bar directly onto it then setting the padding on the scrollbox to the height of my caption bar and drawing the rest on my TGraphicControl
but this lead to even worse flickering. At this point I don't know what exactly is causing the flickering and how to eliminate it?
One last thing is how can I make the scrollbar scroll at a set increment when using the scrollbar thumb? I have set the vertical scrollbar increment to the equivalent of the row height and this works when pressing the scrollbar button, when using the scrollbar thumb to scroll up and down it is not at a fixed increment. I am trying to get the scrollbar to work in increments not scroll loosely.
A quick fix is to replace Self.Invalidate
with FGrid.Repaint
(or .Update
or .Refresh
) in TMyGrid.WMVScroll
. You will see this eliminates flickering, but it still also demonstrates problems with multiple caption bars drawn when you drag the scroll bar thumb. Explanation: Invalidate
puts a repaint request in the message queue, which is postponed until the queue is empty and thus won't be handled right away, i.e. not when you want to. Repaint
on the other hand is performed immediately. But normally Invalidate
should be sufficient...
The main source of your problem lies in the layout with the 'sticky' header (or caption bar) within the client space. Every windowed control with a TControlScrollBar
uses ScrollWindow
internally which 'moves' your caption bar up and down, depending on scroll direction. You could prevent that with some hacking, but from a design point of view it is also much more nice when the scroll bar starts below the header.
You then have a few options for the internal layout of your component:
alTop
aligned PaintBox for the header, an alRight
aligned ScrollBar and an alClient
aligned PaintBox for the grid. This is what Sertac commented and requires 3 controls within your component.alTop
aligned PaintBox for the header, an alClient
aligned ScrollBox and therein an alTop
aligned PaintBox for the grid. This design has nested controls.TScrollingWinControl
with an added non-client border on the top for the header and an alTop
aligned PaintBox for the grid. This component contains 1 control.TScrollingWinControl
with an added non-client border on the top for the header and draw the grid in its PaintWindow
method. This design requires no extra controls at all.As an example, hereby an implementation of the third option:
unit MyGrid;
interface
uses
System.Classes, System.SysUtils, Winapi.Windows, Winapi.Messages,
Vcl.Controls, Vcl.Forms, Vcl.Graphics, Vcl.ExtCtrls, System.Math,
System.UITypes;
type
TMyCustomGrid = class(TScrollingWinControl)
private const
DefHeaderHeight = 20;
DefRowHeight = 18;
HeaderColor = clSkyBLue;
RowIdColCaption = 'Row no.';
RowIdColWidth = 85;
RowIdColColor = clCream;
TextSpacing = 5;
ValueColCaption = 'Item no.';
ValueColWidth = 175;
ValueColColor = clMoneyGreen;
private
FHeaderHeight: Integer;
FPainter: TPaintBox;
FRowHeight: Integer;
FRows: TStrings;
function GetRowCount: Integer;
procedure PainterPaint(Sender: TObject);
procedure RowsChanged(Sender: TObject);
procedure SetHeaderHeight(Value: Integer);
procedure SetRowHeight(Value: Integer);
procedure SetRows(Value: TStrings);
procedure UpdatePainter;
procedure WMEraseBkgnd(var Message: TWMEraseBkgnd); message WM_ERASEBKGND;
procedure WMNCCalcSize(var Message: TWMNCCalcSize); message WM_NCCALCSIZE;
procedure WMNCPaint(var Message: TWMNCPaint); message WM_NCPAINT;
procedure WMVScroll(var Message: TWMScroll); message WM_VSCROLL;
protected
function CanResize(var NewWidth, NewHeight: Integer): Boolean; override;
procedure Click; override;
procedure CreateParams(var Params: TCreateParams); override;
function DoMouseWheel(Shift: TShiftState; WheelDelta: Integer;
MousePos: TPoint): Boolean; override;
procedure PaintWindow(DC: HDC); override;
property AutoScroll default True;
property HeaderHeight: Integer read FHeaderHeight write SetHeaderHeight
default DefHeaderHeight;
property RowCount: Integer read GetRowCount;
property RowHeight: Integer read FRowHeight write SetRowHeight
default DefRowHeight;
property Rows: TStrings read FRows write SetRows;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
end;
TMyGrid = class(TMyCustomGrid)
public
procedure Test;
published
property AutoScroll;
property HeaderHeight;
property RowHeight;
end;
implementation
function Round(Value, Rounder: Integer): Integer; overload;
begin
if Rounder = 0 then
Result := Value
else
Result := (Value div Rounder) * Rounder;
end;
{ TMyCustomGrid }
function TMyCustomGrid.CanResize(var NewWidth, NewHeight: Integer): Boolean;
begin
Result := inherited CanResize(NewWidth, NewHeight);
NewHeight := FHeaderHeight + Round(NewHeight - FHeaderHeight, FRowHeight);
end;
procedure TMyCustomGrid.Click;
begin
inherited Click;
SetFocus;
end;
constructor TMyCustomGrid.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
ControlStyle := [csCaptureMouse, csClickEvents, csOpaque, csDoubleClicks];
AutoScroll := True;
TabStop := True;
VertScrollBar.Tracking := True;
VertScrollBar.Increment := DefRowHeight;
Font.Name := 'Tahoma';
Font.Size := 10;
FHeaderHeight := DefHeaderHeight;
FRowHeight := DefRowHeight;
FPainter := TPaintBox.Create(Self);
FPainter.ControlStyle := [csOpaque, csNoStdEvents];
FPainter.Enabled := False;
FPainter.Align := alTop;
FPainter.OnPaint := PainterPaint;
FPainter.Parent := Self;
FRows := TStringList.Create;
TStringList(FRows).OnChange := RowsChanged;
UpdatePainter;
end;
procedure TMyCustomGrid.CreateParams(var Params: TCreateParams);
begin
inherited CreateParams(Params);
with Params.WindowClass do
Style := Style and not (CS_HREDRAW or CS_VREDRAW);
end;
destructor TMyCustomGrid.Destroy;
begin
FRows.Free;
inherited Destroy;
end;
function TMyCustomGrid.DoMouseWheel(Shift: TShiftState; WheelDelta: Integer;
MousePos: TPoint): Boolean;
var
Delta: Integer;
begin
with VertScrollBar do
begin
Delta := Increment * Mouse.WheelScrollLines;
if WheelDelta > 0 then
Delta := -Delta;
Position := Min(Round(Range - ClientHeight, Increment), Position + Delta);
end;
Result := True;
end;
function TMyCustomGrid.GetRowCount: Integer;
begin
Result := FRows.Count;
end;
procedure TMyCustomGrid.PainterPaint(Sender: TObject);
const
TextFlags = DT_LEFT or DT_SINGLELINE or DT_VCENTER or DT_NOPREFIX;
var
C: TCanvas;
FromIndex: Integer;
ToIndex: Integer;
I: Integer;
BackRect: TRect;
TxtRect: TRect;
begin
C := FPainter.Canvas;
FromIndex := (C.ClipRect.Top) div FRowHeight;
ToIndex := Min((C.ClipRect.Bottom) div FRowHeight, RowCount - 1);
for I := FromIndex to ToIndex do
begin
BackRect := Bounds(0, I * FRowHeight, RowIdColWidth, FRowHeight);
TxtRect := BackRect;
TxtRect.Inflate(-TextSpacing, 0);
C.Brush.Color := RowIdColColor;
C.FillRect(BackRect);
DrawText(C.Handle, FRows.Names[I], -1, TxtRect, TextFlags);
BackRect.Left := RowIdColWidth;
BackRect.Width := ValueColWidth;
Inc(TxtRect.Left, RowIdColWidth);
Inc(TxtRect.Right, ValueColWidth);
C.Brush.Color := ValueColColor;
C.FillRect(BackRect);
DrawText(C.Handle, FRows.ValueFromIndex[I], -1, TxtRect, TextFlags);
C.MoveTo(BackRect.Left, BackRect.Top);
C.LineTo(BackRect.Left, BackRect.Bottom);
BackRect.Offset(ValueColWidth, 0);
C.Brush.Color := Brush.Color;
C.FillRect(BackRect);
C.MoveTo(BackRect.Left, BackRect.Top);
C.LineTo(BackRect.Left, BackRect.Bottom);
end;
end;
procedure TMyCustomGrid.PaintWindow(DC: HDC);
begin
if FPainter.Height < ClientHeight then
begin
ExcludeClipRect(DC, 0, 0, ClientWidth, FPainter.Height);
FillRect(DC, ClientRect, Brush.Handle);
end;
end;
procedure TMyCustomGrid.RowsChanged(Sender: TObject);
begin
UpdatePainter;
end;
procedure TMyCustomGrid.SetHeaderHeight(Value: Integer);
begin
if FHeaderHeight <> Value then
begin
FHeaderHeight := Value;
RecreateWnd;
end;
end;
procedure TMyCustomGrid.SetRowHeight(Value: Integer);
begin
if FRowHeight <> Value then
begin
FRowHeight := Value;
VertScrollBar.Increment := FRowHeight;
UpdatePainter;
Invalidate;
end;
end;
procedure TMyCustomGrid.SetRows(Value: TStrings);
begin
FRows.Assign(Value);
end;
procedure TMyCustomGrid.UpdatePainter;
begin
FPainter.Height := RowCount * FRowHeight;
end;
procedure TMyCustomGrid.WMEraseBkgnd(var Message: TWMEraseBkgnd);
begin
Message.Result := 1;
end;
procedure TMyCustomGrid.WMNCCalcSize(var Message: TWMNCCalcSize);
begin
inherited;
Inc(Message.CalcSize_Params.rgrc0.Top, HeaderHeight);
end;
procedure TMyCustomGrid.WMNCPaint(var Message: TWMNCPaint);
const
TextFlags = DT_LEFT or DT_SINGLELINE or DT_VCENTER or DT_NOPREFIX;
var
DC: HDC;
OldFont: HFONT;
Brush: HBRUSH;
R: TRect;
begin
DC := GetWindowDC(Handle);
OldFont := SelectObject(DC, Font.Handle);
Brush := CreateSolidBrush(ColorToRGB(HeaderColor));
try
FillRect(DC, Rect(0, 0, Width, FHeaderHeight), Brush);
SetBkColor(DC, ColorToRGB(HeaderColor));
SetRect(R, TextSpacing, 0, RowIdColWidth - TextSpacing, FHeaderHeight);
DrawText(DC, RowIdColCaption, -1, R, TextFlags);
Inc(R.Left, RowIdColWidth);
Inc(R.Right, ValueColWidth);
DrawText(DC, ValueColCaption, -1, R, TextFlags);
MoveToEx(DC, RowIdColWidth, 0, nil);
LineTo(DC, RowIdColWidth, FHeaderHeight);
MoveToEx(DC, RowIdColWidth + ValueColWidth, 0, nil);
LineTo(DC, RowIdColWidth + ValueColWidth, FHeaderHeight);
finally
SelectObject(DC, OldFont);
DeleteObject(Brush);
ReleaseDC(Handle, DC);
end;
inherited;
end;
procedure TMyCustomGrid.WMVScroll(var Message: TWMScroll);
begin
Message.Pos := Round(Message.Pos, FRowHeight);
inherited;
end;
{ TMyGrid }
procedure TMyGrid.Test;
var
I: Integer;
begin
for I := 0 to 40 do
Rows.Add(Format('%d=This is item number %d', [I, I]));
end;
end.
Some general comments regarding your code:
TMyCustomGrid
cannot be without your descendent TMyGrid
which is normally a no-no. The code TMyGrid(Self.Parent).VertScrollBar.Position
is equal to -Top
by the way, which eliminates the need for knowledge of its descendent.TControl
has a Font already, just publish it.TScrollBox
, it generally speaking is better to descend from - in this case - TScrollingWinControl
because only then you have control over which properties should be published.One last thing is how can I make the scrollbar scroll at a set increment when using the scrollbar thumb?
By adjusting the scroll position in WM_VSCROLL
as done in the code above:
procedure TMyCustomGrid.WMVScroll(var Message: TWMScroll);
begin
if FRowHeight <> 0 then
Message.Pos := (Message.Pos div FRowHeight) * FRowHeight;
inherited;
end;