Animated resize of a Delphi VCL form

Andreas Rejbrand, 8 May 2021. Originally posted at Stack Overflow.

Question

Is there any reasonably simple and robust way to smoothly animate a programmatic resize of a Delphi VCL form?

For instance, I might have a form with a “Show details” button. When this button is clicked, the form’s height is increased and a panel with details is shown in the new client area.

Of course, I can resize the form by setting its Height (or ClientHeight) property, but that will resize it immediately. I want the form to grow smoothly in height from its original value to the new value, maybe for a total duration of half a second or so.

How to achieve this?

Answer

Yes, this is actually pretty easy.

Probably the simplest way is to base the solution on a TTimer which fires some 30 times per second or so, each time updating the form’s size.

We just have to settle for a mapping T from time to size (width or height), so that T(0) is the original size, T(1) is the final, target size, and T(t) is the intermediate size at time t, normalized to [0, 1].

Here the simplest approach would be to let the size grow or shrink linearly with time. However, this looks bad. Instead, we should use some sigmoid function to make the speed slow at the beginning and the end and maximal at t = 0.5. My favourite sigmoid function is the inverse tangent function, but we could equally well use the hyperbolic tangent function or the error function.

Now, if FFrames[i] is the size of the ith frame, then

var F := 1 / ArcTan(Gamma);

for var i := 0 to High(FFrames) do
begin
  var t := i / High(FFrames);         // [0, 1]
      t := 2*t - 1;                   // [-1, 1]
      t := F*ArcTan(Gamma*t);         // sigmoid transformation
      t := (t + 1) / 2;               // [0, 1]
  FFrames[i] := Round((1 - t) * AFrom + t * ATo);
end;

computes the trajectory according to this scheme. Notice that FFrames[i] is a convex combination of the initial and final sizes.

The following component uses this code to implement animated resizing:

{******************************************************************************}
{                                                                              }
{ Rejbrand Animated Form Resizer                                               }
{                                                                              }
{ Copyright © 2021 Andreas Rejbrand                                            }
{                                                                              }
{ https://english.rejbrand.se/                                                 }
{                                                                              }
{******************************************************************************}

unit WindowAnimator;

interface

uses
  SysUtils, Windows, Types, Classes, Vcl.Forms, Vcl.ExtCtrls;

type
  TWindowAnimator = class(TComponent)
  strict private
  type
    TAxis = (axWidth, axHeight);
  const
    DEFAULT_GAMMA = 10;
    DEFAULT_DURATION = 1000 {ms};
    FrameCount = 256;
  var
    FTimer: TTimer;
    FGamma: Integer;
    FDuration: Integer {ms};
    FFrames: array[0..FrameCount - 1] of Integer;
    FAxis: TAxis;
    FTarget: Integer;
    FAnimStart,
    FAnimEnd: TDateTime;
    FForm: TCustomForm;
    FBeforeProc, FAfterProc: TProc;
    procedure TimerProc(Sender: TObject);
    procedure Plot(AFrom, ATo: Integer);
    procedure Stop;
    procedure Animate(ABeforeProc: TProc = nil; AAfterProc: TProc = nil);
    procedure DoBegin;
    procedure DoFinish;
  public
    constructor Create(AOwner: TComponent); override;
    procedure AnimateWidth(ANewWidth: Integer; ABeforeProc: TProc = nil; AAfterProc: TProc = nil);
    procedure AnimateHeight(ANewHeight: Integer; ABeforeProc: TProc = nil; AAfterProc: TProc = nil);
  published
    property Gamma: Integer read FGamma write FGamma default DEFAULT_GAMMA;
    property Duration {ms}: Integer read FDuration write FDuration default DEFAULT_DURATION;
  end;

procedure Register;

implementation

uses
  Math, DateUtils;

procedure Register;
begin
  RegisterComponents('Rejbrand 2020', [TWindowAnimator]);
end;

{ TWindowAnimator }

procedure TWindowAnimator.Animate(ABeforeProc, AAfterProc: TProc);
begin

  if FForm = nil then
    Exit;

  FBeforeProc := ABeforeProc;
  FAfterProc := AAfterProc;

  DoBegin;
  FAnimStart := Now;
  FAnimEnd := IncMilliSecond(FAnimStart, FDuration);
  FTimer.Enabled := True;

end;

procedure TWindowAnimator.AnimateHeight(ANewHeight: Integer;
  ABeforeProc, AAfterProc: TProc);
begin

  if FForm = nil then
    Exit;

  Stop;
  FAxis := axHeight;
  Plot(FForm.Height, ANewHeight);
  Animate(ABeforeProc, AAfterProc);

end;

procedure TWindowAnimator.AnimateWidth(ANewWidth: Integer;
  ABeforeProc, AAfterProc: TProc);
begin

  if FForm = nil then
    Exit;

  Stop;
  FAxis := axWidth;
  Plot(FForm.Width, ANewWidth);
  Animate(ABeforeProc, AAfterProc);

end;

constructor TWindowAnimator.Create(AOwner: TComponent);
begin
  inherited;
  if AOwner is TCustomForm then
    FForm := TCustomForm(AOwner);
  FGamma := DEFAULT_GAMMA;
  FDuration := DEFAULT_DURATION;
  FTimer := TTimer.Create(Self);
  FTimer.Interval := 30;
  FTimer.OnTimer := TimerProc;
  FTimer.Enabled := False;
end;

procedure TWindowAnimator.DoBegin;
begin
  if Assigned(FBeforeProc) then
    FBeforeProc();
end;

procedure TWindowAnimator.DoFinish;
begin
  if Assigned(FAfterProc) then
    FAfterProc();
end;

procedure TWindowAnimator.Plot(AFrom, ATo: Integer);
begin

  FTarget := ATo;

  var F := 1 / ArcTan(Gamma);

  for var i := 0 to High(FFrames) do
  begin
    var t := i / High(FFrames);         // [0, 1]
        t := 2*t - 1;                   // [-1, 1]
        t := F*ArcTan(Gamma*t);         // sigmoid transformation
        t := (t + 1) / 2;               // [0, 1]
    FFrames[i] := Round((1 - t) * AFrom + t * ATo);
  end;

end;

procedure TWindowAnimator.Stop;
begin
  FTimer.Enabled := False;
end;

procedure TWindowAnimator.TimerProc(Sender: TObject);
begin

  var LNow := Now;

  if (FForm = nil) or (FAnimEnd = 0.0) then
  begin
    FTimer.Enabled := False;
    Exit;
  end;

  if LNow > FAnimEnd then // play it safe
  begin
    FTimer.Enabled := False;
    case FAxis of
      axWidth:
        FForm.Width := FTarget;
      axHeight:
        FForm.Height := FTarget;
    end;
    DoFinish;
    Exit;
  end;

  var t := MilliSecondsBetween(LNow, FAnimStart) / MilliSecondsBetween(FAnimStart, FAnimEnd);
  var i := EnsureRange(Round(t * High(FFrames)), 0, High(FFrames));

  case FAxis of
    axWidth:
      FForm.Width := FFrames[i];
    axHeight:
      FForm.Height := FFrames[i];
  end;

end;

end.

To use this component, simply drop it on a form and use its public methods:

procedure AnimateWidth(ANewWidth: Integer; ABeforeProc: TProc = nil;
  AAfterProc: TProc = nil);
procedure AnimateHeight(ANewHeight: Integer; ABeforeProc: TProc = nil;
  AAfterProc: TProc = nil);

The optional TProc references let you run some code before and/or after the animation; typically, you want to populate any newly obtained client area after an increase in size and hide some content before a reduction in size.

Here’s the component in action, showing and hiding a “Details” text:

Screen recording

Here’s a more complicated example with a three-stage input procedure:

Screen recording

The total duration of the animation, as well as the sharpness of the sigmoid function, can be adjusted using the component’s published properties.