Download Now
Forums
    General

    Animating with easings

    Started by sysrpl on 23 March 2015

    sysrpl23 March 2015, 21:11

    The following is a topic related to the Free Pascal Cross Codebot library. You can read more about about Cross Codebot here. If you are interested in gaining git access to the library, which implements the code or classes in this posting, it is now hosted in this github repository.

    This article describes how to animate cross platform graphics using easings. This example below which demonstrates animation using easings is included in the Cross Codebot git repository.

    In previous forum postings I supplied a background for the basics of fast hardware accelerated drawing using the ISurface interface. Continuing on from those topics, this topic describes how to add motion to applications.

    Motion is simply changes to drawings over time. The changes don't have to be complicated to create a nice effect. They can be something as simple as animating the opacity on a button when it is pressed or released. Or they can be complex involving animating vector graphics. In either case, we call motion applied to graphics animation.

    A fundamental concept in animation is motion interpolation. Motion interpolation is the filling in of motion values between two values. If a button were to animate from 50% opacity to 100% opacity in one second, you can simply fill in the differing levels of transparency like so (time is a value between 0.0 seconds and 1.0 seconds):

    opacity = 0.5 (1 - time) + 1.0 time

    And this is a linear interpolation between 0.5 and 1.0. As time reaches 1.0 seconds the opacity comes closer to 1.0. As time is nearer to 0.0 seconds opacity is comes close to 0.5. You can actually create an a infinite number of interpolation function which work similar to the the listing above. The general definition of animation interpolations. which we shall name as an Easing, is as follows:

    type
      TEasing = function(Percent: Float): Float;
    
    function Linear(Percent: Float): Float;
    begin
      Result = Percent;
    end;

    But wait you say, aren't there supposed to be two values to blends between? Yes there are, and this is where an Interpolate function can be defined:

    { Calculates the effect of an easing on values }
    function Interpolate(Easing: TEasing; Percent: Float; Start, Finish: Float): Float; 
    begin
      if Percent < 0 then
        Result := Start
      else if Percent > 1 then
        Result := Finish
      else
      begin
        Percent := Easing(Percent);
        Result := Start * (1 - Percent) + Finish * Percent;
      end;
    end;

    Now we can simple call Interpolate(Linear, Time, 0.5, 1.0) to receive our new button opacity when it is pressed. But wait, there's even more. Now that we have a general Interpolate function, we can substitute alternate easing functions. Here are a few:

    function Easy(Percent: Float): Float;
    begin
      Result := Percent * Percent * (3 - 2 * Percent);
    end; 
    
    function Drop(Percent: Float): Float;
    begin
      Result := Percent * Percent;
    end;  
    
    function Spring(Percent: Float): Float;
    begin
      Percent := Percent * Percent;
      Result := Sin(PI * Percent * Percent * 10 - PI / 2) / 4;
      Result := Result * (1 - Percent) + 1;
      if Percent < 0.3 then
        Result := Result * Easy(Percent / 0.3);
    end;

    In Cross Codebot we actually supply you with an extendable dictionary of easings, pre-populated with some easings already. To add you own easing simple write the following:

    { Define our own easing function }
    
    function MyQuinticEase(Percent: Float): Float;
    begin
      Percent := Percent - 1;
      Result := 1 + Percent * Percent * Percent * Percent * Percent;
    end;
    
    procedure TForm1.FormCreate(Sender: TObject);
    begin
      Easings['MyQuinticEase'] := MyQuinticEase;
    end;

    Result:

    Now that you understand easings and interpolation, here is the program listing of the example at the top of this page:

    { We are going to allow for variable speed playback }
    
    var
      TimeFactor: Double;
      TimeLast: Double;
      TimeNow: Double;
    
    { Normally you never use step functions for animations, but
      in this case we don't want time to rewind or skip ahead
      if time factor changes. So in this case we step time forward
      only when a timer ticks. }
    
    procedure TimeStep;
    var
      T: Double;
    begin
      if TimeLast = 0 then
        TimeLast := TimeQuery;
      T := TimeQuery;
      TimeNow := TimeNow + (T - TimeLast) * TimeFactor;
      TimeLast := T;
    end;
    
    var
      { The currently selected easing }
      CurrentEasing: TEasing;
      { The most basic of easings, linear }
      Linear: TEasing;
      { Our images }
      Food: TArrayList<IBitmap>;
    
    const
      { Our image sources }
      FoodNames = 'apples,bananas,cherries,doughnuts,eggs,fish,grapes';
    
    procedure TForm1.FormCreate(Sender: TObject);
    var
      B: IBitmap;
      S: string;
    begin
      { Default time to flow at a 1 to 1 rate }
      TimeFactor := 1;
      { Retrieve the linear easing function by name }
      Linear := Easings['Linear'];
      { Make the default current easing linear }
      CurrentEasing := Linear;
      { Our draw list will hold as many easings as are registered }
      DrawList.Count := Easings.Count;
      { Display 4 easings at a time }
      DrawList.Height := DrawList.ItemHeight * 4 + 4;;
      DrawList.ItemIndex := 0;
      SizingPanel.Height := DrawList.Height;
      { Load our food images }
      for S in FoodNames.Split(',') do
      begin
        B := NewBitmap;
        B.LoadFromFile(S + '.jpg');
        Food.Push(B);
      end;
    end;
    
    procedure TForm1.DrawListSelectItem(Sender: TObject);
    begin
      { When you select an item in the draw list, change the current easing }
      CurrentEasing := Easings.Items[DrawList.ItemIndex].Value;
    end;
    
    { We are going to default the animation sequence to 2 seconds,
      with a 0.5 second pause at both ends. This can be sped up or
      slowed down using time factor }
    
    function AnimatedTime: Double;
    begin
      Result := Remainder(TimeNow, 2);
      if Result <= 0.5 then
        Result := 0
      else if Result >= 1.5 then
        Result := 1
      else
        Result := Result - 0.5;
    end;
    
    { This is where we draw the photos }
    
    procedure TForm1.SizingPanelRender(Sender: TObject; Surface: ISurface);
    const
      { Space the photos 150 pixels apart }
      Space = 150;
    
      { Draw an individual photo }
      procedure DrawPhoto(CurIndex, Index: Integer; Time: Float; Point: TPointF; Offset: Float);
      var
        Factor: Float;
        S, D: TRectF;
      begin
        { Corrent the index to fall within the Food array bounds }
        Index := (Index + Food.Length) mod Food.Length;
        { Rhis is the default photo size }
        Factor := 0.5;
        { If the index is current, make it bigger }
        if Index = CurIndex then
          Factor := Interpolate(CurrentEasing, Time, Factor, 0.7, ReverseBox.Checked)
        { Else if the index was current, make it smaller }
        else if Index = (CurIndex + Food.Length - 1) mod Food.Length then
          Factor := Interpolate(CurrentEasing, Time, 0.7, Factor, ReverseBox.Checked);
        { Get the source rectangle }
        S := Food[Index].ClientRect;
        { And the dest rectangle }
        D := S;
        { Scale the dest rectangle by the factor }
        D.Width := D.Width * Factor;
        D.Height := D.Height * Factor;
        { Center the photo at point }
        D.Center(Point);
        { Scroll the photo based on time and the easing }
        D.Y := Offset + Interpolate(CurrentEasing, Time, D.Y - Space, D.Y, ReverseBox.Checked);
        { Draw a white border }
        D.Inflate(10, 10);
        Surface.Rectangle(D);
        Surface.Fill(NewBrush(clWhite));
        { Draw the photo }
        D.Inflate(-10, -10);
        Food[Index].Surface.CopyTo(S, Surface, D);
      end;
    
    var
      Time: Float;
      Index: Integer;
      R: TRectI;
      G: IGradientBrush;
      I: Integer;
    begin
      { Fill the rect with a black to gray gradient }
      R := SizingPanel.ClientRect;
      G := NewBrush(0, R.Top, 0, R.Bottom);
      G.AddStop(clBlack, 0);
      G.AddStop(clGray, 1);
      Surface.FillRect(G, R);
      { Get the animation timing }
      Time := AnimatedTime;
      { Get the current photo index based on TimeNow }
      Index := Trunc(TimeNow / 2) mod Food.Length;
      { And draw all the photos that might currently be visible }
      for I := -2 to 2 do
        DrawPhoto(Index, Index - I, Time, R.MidPoint, Space * I);
    end;
    
    { Draw a background with a dashed set of time lines }
    
    procedure TForm1.DrawListDrawBackground(Sender: TObject; Surface: ISurface;
      Rect: TRectI);
    const
      Align = 0.5;
    var
      Time: Float;
      R: TRectF;
      P: IPen;
    begin
      { Fill with the current control color }
      FillRectColor(Surface, Rect, DrawList.CurrentColor);
      R := Rect;
      { Create a silver pen }
      P := NewPen(clSilver);
      R.X := R.MidPoint.X + 10;
      R.Width := Rect.Width - R.X - 30 - Align;
      { Add the left time boundary to the path }
      Surface.MoveTo(R.Left, R.Top);
      Surface.LineTo(R.Left, R.Bottom);
      { Add the left right time boundary to the path }
      Surface.MoveTo(R.Right, R.Top);
      Surface.LineTo(R.Right, R.Bottom);
      { Stroke the time boundary }
      Surface.Stroke(P);
      { Switch to a red dashed pen }
      P.Color := clRed;
      P.LinePattern := pnDash;
      Time := AnimatedTime;
      { We'll draw the red dotten line as the actual time }
      R.Left := Interpolate(Linear, Time, R.Left, R.Right);
      Surface.MoveTo(R.Left, R.Top);
      Surface.LineTo(R.Left, R.Bottom);
      { And stroke the path with the red pen }
      Surface.Stroke(P);
    end;
    
    { This method draws the items in the TDrawList }
    
    procedure TForm1.DrawListDrawItem(Sender: TObject; Surface: ISurface;
      Index: Integer; Rect: TRectI; State: TDrawState);
    var
      KeyValue: TEasingKeyValue;
      Time: Double;
      R: TRectF;
      A, B, C: TPointF;
    begin
      { Retrieve the KeyValue from to Easings dicntionary }
      KeyValue := Easings.Items[Index];
      { If the item is selected, but it a nice gradiant outline and fill }
      if dsSelected in State then
        FillRectSelected(Surface, Rect, 5);
      { Retrieve the current animation time }
      Time := AnimatedTime;
      { Define the rectangle where the easing should be drawn }
      R := Rect;
      R.Inflate(-10, -30);
      R.Offset(0, -8);
      R.Right := R.Left + 100;
      R.Bottom := R.Bottom - 2;
      { And give it to our Codebot.Graphics.DrawEasing procedure }
      DrawEasing(Surface, Theme.Font, R, KeyValue.Value, ReverseBox.Checked, Time);
      R.Top := R.Bottom + 8;
      R.Bottom := Rect.Bottom;
      { Draw the name beneath the easing }
      Surface.TextOut(Theme.Font, KeyValue.Key, R, drCenter);
      { Create a block to show the easing motion }
      R := TRectF.Create(20, 20);
      { Create two points A and B }
      A := Rect.MidPoint;
      A.Offset(10, 0);
      B := A;
      B.X := Rect.Right - 30;
      C.Y := A.Y;
      { And interpolate A and B to create C, using the X axis }
      C.X := Interpolate(KeyValue.Value, Time, A.X, B.X, ReverseBox.Checked);
      { Center our block on C }
      R.Center(C);
      { And fill it with black }
      Surface.FillRect(NewBrush(clBlack), R);
    end;
    
    procedure TForm1.SlideBarChange(Sender: TObject);
    begin
      { When the slider changes, update the caption }
      SpeedLabel.Caption := 'Speed: %f'.Format([SlideBar.Position]);
      { And the time factor }
      TimeFactor := SlideBar.Position;
    end;
    
    procedure TForm1.TimerTimer(Sender: TObject);
    begin
      { Advance time, but don't reverse it }
      TimeStep;
      { Redraw our controls }
      DrawList.Invalidate;
      SizingPanel.Invalidate;
    end;

    sysrpl12 April 2015, 08:59

    Never animate by moving controls. Animate by moving graphics. To add a text when some event happens first you find the event, in this case mousedown and mouseup. TRectF.Contains will tell you if a point is inside a rect, so you can use that to find the photo. Then to reveal the photo name, use Surface.TextOut to draw the name.

    With that you knowledge can define a type to store a photo's rectangle and draw photo state, supposing that when state is true a name is displayed, and when false it is not:

    type
      TRectState = record
        { Location of the photo }
        Rect: TRectF;
        { When true we show the photo name }
        Named: Boolean;
      end;
    
    var
      { A list of states for each photo }
      RectStates: array[0..6] of TRectState;
      { Index of the photo being pressed }
      RectDown: Integer = -1;

    Ask yourself this right now, do you know what comes next?

    Answer: You fill out DrawList.MouseUp/DrawList.MouseDown and SizingPanelRender.DrawPhoto with the appropriate logic.

    procedure TForm1.SizingPanelMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    var
      I: Integer;
    begin
      if Button = mbLeft then
        for I := Low(RectStates) to High(RectStates) do
          if RectStates[I].Rect.Contains(X, Y) then
          begin
            RectDown := I;
            Break;
          end;
    end;
    
    procedure TForm1.SizingPanelMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    var
      I: Integer;
    begin
      if Button = mbLeft then
      begin
        if (RectDown > -1) and RectStates[RectDown].Rect.Contains(X, Y) then
          RectStates[RectDown].Named := not RectStates[RectDown].Named;
        RectDown := -1;
      end;
    end;