Logo

C# Graphics and Animation

43 min read
Lesson slides
1 / 15

Operating Systems I - Lesson 4

C# Graphics and Animation

Draw shapes, text and images with GDI+, then bring them to life with a Timer, independent moving objects, keyboard input and a user-driven line pattern.

By the end of this lesson you will be able to draw shapes, text and images on a Windows Form with GDI+ and bring them to life with a timer, independent moving objects, keyboard input, and a user-driven line pattern.

Objectives

By the end of this lesson you will be able to:

  • Obtain a Graphics object from a Paint event's PaintEventArgs, from a control's CreateGraphics method, or from a Bitmap's own Graphics.FromImage, and explain when to use each one.
  • Use the Windows Forms coordinate system (origin, axis directions, pixels) to plan where a shape is drawn.
  • Use a Pen to draw the outline of a shape and a SolidBrush to fill its interior.
  • Draw lines, rectangles, ellipses, arcs, polygons and text with the drawing methods of the Graphics class.
  • Explain why content drawn with CreateGraphics disappears while content drawn inside Paint persists, and use Invalidate and Refresh to control redraws.
  • Configure a Timer control (Interval, Enabled) and write a Tick handler that updates a position and redraws the form.
  • Detect that a moving object has reached an edge of the form and reverse its direction so it bounces back inside.
  • Animate several independent objects, each with its own position and speed, from one shared Timer.
  • Move a bitmap with DrawImage using the same bounce logic as a plain shape, and play a frame-by-frame sprite animation whose speed is controlled by a TrackBar.
  • Move a control itself (a Label's Left and Top) directly from a Timer's Tick handler, as an alternative to drawing a shape with Graphics, and explain which situation each approach fits.
  • Read arrow-key presses through a form's KeyDown event and use them to choose a moving object's direction instead of bouncing it automatically.
  • Compute a line's endpoint from an angle and a length with Math.Cos and Math.Sin, and draw a multi-line pattern with Graphics.DrawLines and an array of Point values.

Prerequisites

  • The previous lesson, "C# Windows Forms Introduction, Part 2", covered creating a project, naming a control before double-clicking it, creating an event handler through a double-click or the Events tab, and reading generated designer code; revisit it first if any of that feels unfamiliar.
  • Visual Studio with the ".NET desktop development" workload, on Windows.
  • Basic C#: classes, fields, constructors, for/foreach loops, arrays and List<T>, if/else, casting, and using for disposing an object.

Overview

This lesson has three parts. Part 1 (Concepts 1 to 5) is graphics programming with GDI+: getting a drawing surface, drawing shapes and text on it, and controlling when it is redrawn. Part 2 (Concepts 6 and 7) is animation: a Timer repeatedly changes a position and redraws the form, which turns a still picture into something that moves. Part 3 (Concepts 8 to 10) covers three techniques that do not fit the Paint-and-redraw pattern of Part 2: moving a control by its own properties instead of drawing it, choosing a direction with the keyboard instead of bouncing automatically, and drawing a line pattern from numbers the user types in. The tasks build on Parts 1 and 2; Part 3 is background you can apply as optional extensions to those same tasks.

Concept 1: The Graphics Object and GDI+

Definition. GDI+ is the drawing system behind every window on a Windows desktop. In .NET it is exposed through the Graphics class in the System.Drawing namespace: every drawing method (DrawLine, DrawString, and the other shape-drawing methods covered in Concept 4) belongs to a Graphics object, not to the form itself.

Purpose. Before drawing anything on a form, you need a Graphics object standing for that form's surface.

How it works. There are three ways to get one, and the first two both draw onto the form's own on-screen surface. Inside the form's Paint event (created by double-clicking Paint on the Events tab of the Properties window), the handler private void Form1_Paint(object sender, PaintEventArgs e) receives a PaintEventArgs e whose Graphics property is the surface for that repaint: Graphics g = e.Graphics;. Anything drawn here is redrawn on every single Paint event, which is why persistent content belongs here. Outside Paint, any control's CreateGraphics() method returns a Graphics object for its current, on-screen surface: Graphics g = this.CreateGraphics();. This draws directly onto the pixels shown right now; it does not run again on the next repaint (Concept 5 explains what that means in practice). The third way does not touch the form's surface at all: Graphics.FromImage(bitmap) returns a Graphics object whose drawing methods write into that Bitmap's own pixel buffer, in memory, whether or not the form has even been shown yet. This is how a picture can be built by code instead of loaded from a file (Concept 7 and Task 5 use it to draw a sprite once, in the constructor, before the form appears).

flowchart TD
    A["Form shown, resized, uncovered,<br/>or Invalidate() called"] --> B["Paint event fires<br/>with a PaintEventArgs e"]
    B --> C["g = e.Graphics<br/>(redrawn every time Paint fires)"]
    D["Code outside Paint,<br/>e.g. a Button Click"] --> E["g = this.CreateGraphics()<br/>(drawn once, forgotten next repaint)"]
    H["A Bitmap object,<br/>e.g. in the constructor"] --> I["g = Graphics.FromImage(bitmap)<br/>(drawn once, into the bitmap itself)"]
    C --> F[Pen, Brush, DrawXxx methods]
    E --> F
    I --> J["Bitmap now holds the drawing;<br/>draw it later with DrawImage"]

A Graphics obtained from CreateGraphics() or from Graphics.FromImage() must be released with g.Dispose(); (or a using block) once you are done with it; a Graphics obtained from e.Graphics must not be, because the Paint event owns and disposes of it itself. This follows from the same using-for-disposing knowledge already listed in the Prerequisites, applied here because CreateGraphics() and Graphics.FromImage() each hand out a GDI+ resource that nothing else in the framework reclaims automatically.

Concept 2: The Windows Forms Coordinate System

Definition. Every drawing method takes coordinates in pixels on a plane whose origin, (0, 0), is the top-left corner of the drawing surface. The x-axis grows to the right; the y-axis grows downward, the opposite of a geometry class.

How it works. A point at x = 100, y = 50 sits 100 pixels from the left edge and 50 pixels from the top edge. DrawLine(pen, x1, y1, x2, y2) connects point (x1, y1) to point (x2, y2); the four numbers are two coordinate pairs, not four separate measurements.

Example. g.DrawLine(Pens.Black, 20, 20, 150, 90); starts 20 pixels from the left and top and ends 150 pixels from the left, 90 from the top. Because 90 is larger than 20, the end point is lower on screen, even though the line would look like it goes "up" under a geometry-class y-axis.

Concept 3: Pen and Brush

Definition. A Pen draws the outline of a shape; a SolidBrush fills its interior with a color. Outline is a Pen's job, interior is a Brush's job, and the drawing methods are named accordingly: DrawXxx methods take a Pen, FillXxx methods take a Brush.

How it works. new Pen(Color.Black) makes a one-pixel black pen; a second argument sets its width, new Pen(Color.Black, 3). new SolidBrush(Color.Red) makes a red brush. Color offers named colors (Color.Black) and Color.FromArgb(red, green, blue) for a custom color from three numbers 0 to 255; this three-argument overload always produces a fully opaque color, while a separate four-argument overload, Color.FromArgb(alpha, red, green, blue), also lets you set the alpha (transparency) channel directly. For a one-off pen or brush in a named color, the static Pens and Brushes classes hand out a ready-made object per color, Pens.Black or Brushes.Red, so you don't have to construct one yourself when you don't need to choose the color at run time; the rest of this lesson always constructs a Pen or SolidBrush explicitly instead, since that is the common case once the color is chosen at run time rather than fixed in the code.

Example.

Pen outlinePen = new Pen(Color.Black, 2);
SolidBrush fillBrush = new SolidBrush(Color.LightBlue);

These two lines are field declarations, written above the constructor rather than inside a method, each with an inline initializer that runs once when the form object is created. Both objects are stored as fields rather than rebuilt inside Paint, so the same Pen and Brush are reused on every repaint instead of being recreated dozens of times a second once animation is involved. This is why a Pen or a Brush that is normally disposed with Dispose() (or a using block) once you are done with it, since each one wraps an unmanaged GDI resource, is instead kept alive for as long as the form itself: it never falls out of scope, so there is nothing to dispose of until the form closes. A brush built from data that only exists once an object exists, such as a ball's own random color in Concept 7, cannot be a long-lived field the same way, since a new one is needed for each ball; that brush is instead constructed right where it is used and wrapped in a using block, so it is disposed of immediately after that one shape is filled. This is the same general using-for-disposing idea from Concept 1, applied to Pen and Brush objects specifically.

Concept 4: Drawing Shapes and Text

Definition. Graphics provides one method family per shape: DrawLine, DrawRectangle/FillRectangle, DrawEllipse/FillEllipse, DrawArc, DrawPolygon/FillPolygon, and DrawString for text.

How it works. DrawRectangle(pen, x, y, width, height) draws a rectangle whose top-left corner is (x, y). DrawEllipse/FillEllipse(pen or brush, x, y, width, height) do not take a center and a radius; they take a bounding rectangle, and draw the ellipse inscribed in it, so equal width and height produce a circle. DrawArc(pen, x, y, width, height, startAngle, sweepAngle) draws a slice of that same inscribed ellipse: startAngle is measured in degrees from the 3 o'clock point of the ellipse, and a positive sweepAngle sweeps clockwise from there, so startAngle 0 and sweepAngle 180 draws the bottom half; a negative sweepAngle sweeps counterclockwise instead. As an exercise, verify for yourself that startAngle 0 with sweepAngle -90 traces the same quarter of the ellipse as startAngle -90 with sweepAngle 90, just in the opposite direction around it. DrawPolygon/FillPolygon(pen or brush, points) connects an array of Point values into a closed shape, adding one final segment back from the last point to the first that none of the other methods above draw; a Point bundles an x and a y into one value, the same two numbers DrawLine takes as separate arguments. DrawString(text, font, brush, x, y) draws text at (x, y) in the given Font and Brush. new Font("Arial", 16) constructs a Font: the first argument is the font family name as a string, the second is the size in points.

Example.

g.DrawLine(outlinePen, 20, 20, 150, 90);
g.DrawRectangle(outlinePen, 20, 120, 120, 80);
g.FillEllipse(fillBrush, 160, 120, 100, 80);
g.DrawEllipse(outlinePen, 160, 120, 100, 80);
g.DrawArc(outlinePen, 280, 120, 80, 80, 0, 180);
Point[] triangle = { new Point(400, 200), new Point(440, 120), new Point(480, 200) };
g.DrawPolygon(outlinePen, triangle);
g.DrawString("Lab 4", new Font("Arial", 16), fillBrush, 20, 220);

In order: a line from (20, 20) to (150, 90); a 120 by 80 rectangle at (20, 120); an oval filled then outlined in a 100 by 80 box at (160, 120); the bottom half of the circle inscribed in an 80 by 80 box at (280, 120); a triangle through (400, 200), (440, 120) and (480, 200), closed automatically back to its first point; and the text "Lab 4", drawn with the same fillBrush already used for the oval instead of constructing a new brush, starting at (20, 220), below the row of shapes above it.

Concept 5: Redrawing the Form: Invalidate, Refresh, and Why CreateGraphics Drawing Disappears

Definition. Invalidate() marks a control as needing a redraw, so Paint fires the next time the application is free to process it. Refresh() does the same but forces that redraw immediately.

Purpose. The operating system only calls Paint when something requires it (the window is uncovered, resized, first shown) or when your own code asks for one; an animation is code that keeps asking for one redraw after another, each time with something moved a little.

How it works. Content drawn through e.Graphics inside Paint is safe, since it is redrawn every time Paint fires. Content drawn through CreateGraphics() is written only onto the screen's current image: the next time anything invalidates that image, resizing, minimizing and restoring, being covered and uncovered, or an explicit Invalidate()/Refresh(), Windows repaints by raising Paint, and only what Paint itself draws survives. This is why animating an object means updating a position variable and calling Invalidate(), never drawing the new position with CreateGraphics() on top of the old one, since the old position would still be underneath.

Concept 6: The Timer Control and Moving One Object

Definition. A Timer control raises its Tick event repeatedly, once every Interval milliseconds, while its Enabled property is true. It is not visible on the form; it sits in the tray under the designer.

How it works. Set Interval in milliseconds (30 gives roughly 33 updates a second) and double-click the Timer to create a Tick handler. Inside it: add a speed to a position, check whether the new position has passed an edge of the form, and if so, reverse the sign of that speed so the object heads back the other way, a genuine bounce rather than a wraparound. The usable drawing area is ClientSize.Width by ClientSize.Height, not Width/Height: a form's Width and Height (or its combined Size) measure the whole window, including its title bar and borders, while ClientSize measures only the drawable interior inside those borders. A moving shape of a known size stays inside the form exactly while its position, plus its own size, stays between 0 and that client size; bouncing it off Width/Height instead would let it slide slightly under the title bar or past the visible edge, since those extra pixels are not part of the drawable interior at all. This edge-detection-and-reverse technique, and the Ball class built around it in Concept 7, extend the same position-plus-Invalidate() idea by adding the boundary check that Concept 2's coordinate system makes possible.

flowchart LR
    A["Timer ticks every Interval ms"] --> B["Tick handler runs"]
    B --> C["Add SpeedX / SpeedY to X / Y"]
    C --> D{"Past an edge<br/>of ClientSize?"}
    D -->|Yes| E["Reverse that speed's sign"]
    D -->|No| F["Keep the same speed"]
    E --> G["Invalidate()"]
    F --> G
    G --> H["Paint redraws the object<br/>at its new position"]
    H --> A

Example. For one 30 by 30 shape with fields x, y, speedX, speedY:

private void animationTimer_Tick(object sender, EventArgs e)
{
    x += speedX;
    y += speedY;
 
    if (x < 0 || x + 30 > this.ClientSize.Width)
    {
        speedX = -speedX;
    }
    if (y < 0 || y + 30 > this.ClientSize.Height)
    {
        speedY = -speedY;
    }
 
    this.Invalidate();
}

The first two lines move the object by its current speed each tick. The first if is true once the object's left edge has passed the left border, or its right edge (position plus width) has passed the right border; reversing speedX then sends the object the other way. The second if does the same for the top and bottom edges, independently. Invalidate() schedules the redraw that shows the new position (Concept 5). Setting the form's DoubleBuffered property to true, usually in the constructor, draws each frame to an off-screen buffer first and copies the whole result to the screen in one step, removing the flicker a Timer-driven animation otherwise shows.

Concept 7: Many Objects, One Shared Timer, and Animating Images

Definition. One Timer can drive any number of independently moving objects, as long as each keeps its own position and speed rather than sharing one set of variables. Animating an image means either moving one picture with DrawImage using the same bounce logic as a shape, or cycling through a short sequence of pictures so the picture itself appears to move, like a walking sprite.

How it works. The only real requirement is that each object keeps its own position and speed, the same principle behind two labels bouncing independently with their own fields in Concept 8; one convenient way to satisfy it for a growing, unknown number of objects is a collection, for example a List<Ball> of a small class with X, Y, SpeedX, SpeedY and Color fields, grown at run time as new objects are created. This follows from the own-fields-per-object principle, applied to an unknown number of objects instead of a fixed two or three:

private class Ball
{
    public int X;
    public int Y;
    public int SpeedX;
    public int SpeedY;
    public Color Color;
}

Ball is declared private because nothing outside Form1 ever needs it. Every field is a plain, public field rather than a property, since the only code that reads or writes them is Form1's own; public here just means "reachable from the rest of Form1's code", not from outside the class. X and Y hold the ball's current position, SpeedX and SpeedY how much that position changes each tick, and Color the color it was given when it was created. One Tick handler loops over the list with foreach and applies the Concept 6 logic to each element's own fields; giving two elements different starting speeds makes them diverge immediately and never resynchronize, since nothing in the loop links one element's fields to another's. A Random object, new Random(), hands out a different pseudo-random number each time one of its methods is called; rng.Next(min, max) returns an integer from min up to but not including max, so rng.Next(0, 5) can return 0, 1, 2, 3 or 4, but never 5. Calling it once per field when a new Ball is created is what gives each one a spawn position and a pace of its own.

Graphics.DrawImage(image, x, y) draws a Bitmap at (x, y), taking the picture's own size, so the Concept 6 movement and bounce fields apply to an image exactly as to a shape; only the final call changes, from FillEllipse to DrawImage.

A sprite bitmap can be built by code instead of loaded from a file, using Graphics.FromImage (Concept 1). Loading a picture from a file instead, with new Bitmap("file-name"), works exactly the same way for everything below; building the bitmap by code instead, with Graphics.FromImage, means the same sprite-animation logic can be tried without needing an image file of your own.

spriteImage = new Bitmap(32, 32);
using (Graphics gi = Graphics.FromImage(spriteImage))
{
    gi.Clear(Color.Transparent);
    gi.FillEllipse(Brushes.Orange, 0, 0, 32, 32);
    gi.DrawEllipse(Pens.Black, 0, 0, 31, 31);
}

new Bitmap(32, 32) allocates a 32 by 32 pixel image, empty and undefined until something draws into it. Graphics.FromImage(spriteImage) returns a Graphics object whose drawing methods write into that bitmap's own pixels rather than onto the form, wrapped in a using block so it is disposed of as soon as the circle is drawn, since it is only needed once. gi.Clear(Color.Transparent) fills the whole bitmap with a fully transparent color first, so the square corners around the circle stay see-through instead of showing as a solid box when the sprite is drawn on the form later. The two calls after that are the same FillEllipse/DrawEllipse pair from Concept 4, just aimed at gi instead of the form's g. Because this runs in the constructor, before the form is even shown, the sprite already has its circle the first time Form1_Paint draws it.

Importing an image into a project as a resource makes Visual Studio generate a Properties.Resources class holding one entry per imported file, named after that file; Properties.Resources.ResourceManager.GetObject(name) looks up an entry in that generated class by its resource name, at run time. For a sprite that animates in place, keep a List<string> frames of resource names and an integer currentFrame starting at 0. Each Tick, advance and wrap the index with the modulo operator, currentFrame = (currentFrame + 1) % frames.Count;, look up that frame's bitmap with Properties.Resources.ResourceManager.GetObject(frames[currentFrame]) (this returns a plain object, since the method has no way to know in advance what type of resource was requested, so the result is cast to (Bitmap)), and assign it to a PictureBox's Image property. A TrackBar is a slider control the user drags between its Minimum and Maximum, its current position exposed through its Value property; its Scroll event can reassign the Timer's Interval to the bar's Value at run time, so a smaller interval (more ticks per second) plays the frames faster. When no set of frame images is available, the same idea works with bitmaps built the way spriteImage was built above instead of imported ones: build a List<Bitmap> frames once, in the constructor, by repeating the new Bitmap(...) plus Graphics.FromImage(...) pattern in a loop, drawing something a little different into each bitmap (a circle with a growing radius, for instance); then each Tick, advance currentFrame the same way and assign frames[currentFrame] to the PictureBox's Image property directly, with no resource lookup or cast needed, since the list already holds real Bitmap objects.

flowchart TD
    T["One animationTimer_Tick"] --> B1["Ball 1: X, Y, SpeedX, SpeedY"]
    T --> B2["Ball 2: X, Y, SpeedX, SpeedY"]
    T --> S["Sprite bitmap: X, Y, SpeedX, SpeedY"]
    B1 --> P["Form1_Paint draws every<br/>object at its own position"]
    B2 --> P
    S --> P

Concept 8: An Alternative to Graphics: Moving a Control's Own Position

Definition. Everything in Parts 1 and 2 animates by drawing a shape or a bitmap with Graphics inside Form1_Paint. A control that is already on the form, such as a Label, does not need to be drawn at all to move: changing its own Left and Top properties repositions it immediately, with no Paint override, no Graphics object, and no Invalidate() call anywhere.

Purpose. Some things you animate are shapes or pictures you drew yourself, where Graphics and Paint are the only option. Other things are already controls sitting on the form, such as a Label showing a piece of text; moving one of those directly, through its own position properties, is simpler than trying to draw a lookalike with Graphics.

How it works. Each moving Label keeps its own pair of speed fields, for example L1x/L1y for label1 and a separate L2x/L2y for label2, the same own-fields-per-object principle as the Ball class in Concept 7. A shared Timer's Tick handler adds each label's speed to its own Left and Top, then bounces it the same way Concept 6 bounces a drawn shape, except the test now reads the control's own Width and Height instead of a fixed size. The mechanism itself is nothing more than the Concept 6 bounce logic applied to a control's own Left/Top properties instead of a drawn shape's fields.

flowchart LR
    A["Timer ticks every Interval ms"] --> B["Tick handler runs"]
    B --> C["Add L1x / L1y to<br/>label1.Left / label1.Top"]
    C --> D{"Past an edge of<br/>ClientSize.Width/Height?<br/>(test uses label1.Width/Height)"}
    D -->|Yes| E["Reverse L1x or L1y"]
    D -->|No| F["Keep the same speed"]
    E --> G["label1 is already redrawn:<br/>no Invalidate(), no Paint"]
    F --> G
    G --> H["Same four steps repeat<br/>for label2 with L2x/L2y"]
    H --> A

Example.

private void timer1_Tick(object sender, EventArgs e)
{
    // label1's own movement
    label1.Left += L1x;
    label1.Top += L1y;
 
    if (label1.Left + label1.Width > ClientSize.Width || label1.Left < 0)
    {
        L1x = -L1x;
    }
    if (label1.Top + label1.Height > ClientSize.Height || label1.Top < 0)
    {
        L1y = -L1y;
    }
 
    // label2's own movement, with its own speed fields
    label2.Left += L2x;
    label2.Top += L2y;
 
    if (label2.Left + label2.Width > ClientSize.Width || label2.Left < 0)
    {
        L2x = -L2x;
    }
    if (label2.Top + label2.Height > ClientSize.Height || label2.Top < 0)
    {
        L2y = -L2y;
    }
}

label1.Left += L1x; and label1.Top += L1y; move the label itself by changing the position properties Windows Forms already gives every control, the same += pattern as x += speedX on a drawn shape's own field in Concept 6. The two if blocks are the same edge test as Concept 6, reading label1.Width/Height instead of a fixed 30, and reversing that label's own speed field when it is true. The second half of the handler repeats the exact same four lines for label2, using its own L2x/L2y fields so the two labels never affect each other's speed. Nothing here calls Invalidate() or touches Form1_Paint, because a control redraws itself whenever one of its own properties changes; that is a property of the control, not of GDI+ drawing.

Concept 9: Keyboard-Driven Movement

Definition. Instead of bouncing off the edges automatically, a moving object's direction can instead be chosen by the user, by pressing the arrow keys. The object then keeps moving the same way, tick after tick, until a different arrow key is pressed.

Purpose. A bounce is automatic, physics-like motion; a game where the player steers something needs the direction itself to be an input, not a computed reaction to an edge.

How it works. An enum declares a type with a fixed, named set of possible values, compared with == like any other value; enum Position { Left, Right, Up, Down } names the four directions, and a field such as Position objectPosition = Position.Right; holds the current one. The form's KeyDown event (its handler takes a KeyEventArgs e) compares e.KeyCode against Keys.Left, Keys.Right, Keys.Up and Keys.Down, and reassigns objectPosition to match whichever arrow was pressed. For a form to receive KeyDown at all while other controls are on it, the form's own KeyPreview property must be set to true, so key presses reach the form before whichever control currently has focus; this is standard Windows Forms behavior, and without it a form with other controls on it, such as a Button, would never see KeyDown once one of those controls has focus. The Tick handler no longer adds a fixed speed to both x and y every time; instead it checks objectPosition and changes only x (for Left/Right) or only y (for Up/Down), by a fixed step in the matching direction, then calls Invalidate() as usual.

flowchart LR
    K["KeyDown fires<br/>(needs KeyPreview = true)"] --> KC{"Which arrow key?"}
    KC -->|Left| PL["objectPosition = Left"]
    KC -->|Right| PR["objectPosition = Right"]
    KC -->|Up| PU["objectPosition = Up"]
    KC -->|Down| PD["objectPosition = Down"]
 
    T["Timer ticks every Interval ms"] --> R{"Read objectPosition"}
    R -->|Left or Right| MX["Change x by a fixed step"]
    R -->|Up or Down| MY["Change y by a fixed step"]
    MX --> I["Invalidate()"]
    MY --> I

Example.

private enum Position { Left, Right, Up, Down }
private Position objectPosition = Position.Right;
private int x = 0;
private int y = 0;
 
private void Form1_KeyDown(object sender, KeyEventArgs e)
{
    if (e.KeyCode == Keys.Left) objectPosition = Position.Left;
    else if (e.KeyCode == Keys.Right) objectPosition = Position.Right;
    else if (e.KeyCode == Keys.Up) objectPosition = Position.Up;
    else if (e.KeyCode == Keys.Down) objectPosition = Position.Down;
}
 
private void animationTimer_Tick(object sender, EventArgs e)
{
    if (objectPosition == Position.Left) x -= 5;
    else if (objectPosition == Position.Right) x += 5;
    else if (objectPosition == Position.Up) y -= 5;
    else if (objectPosition == Position.Down) y += 5;
 
    this.Invalidate();
}

objectPosition starts at Position.Right, so the object moves right until the first key press. Form1_KeyDown runs once per key press and only ever changes which branch the Tick handler takes next; it does not move anything itself. animationTimer_Tick runs on the Timer's own schedule regardless of whether a key was just pressed, and its four-way if/else if changes exactly one of x or y each tick, by 5 pixels in the direction objectPosition currently names, then Invalidate() schedules the redraw as in Concept 5.

Concept 10: User-Driven Line Patterns with DrawLines and Angles

Definition. Graphics.DrawLines(pen, points) draws a connected sequence of straight segments through an array of Point values in one call, one segment between each pair of consecutive points, unlike DrawLine which only ever draws one segment between two coordinates. A Point is a small structure holding an X and a Y, the same two numbers DrawLine takes as separate arguments, bundled into one value.

Purpose. Reading a few numbers from the user (how many lines, how long, at what angle) and drawing one more line at a time, each turned by that angle from the last, builds a pattern the user controls, instead of a picture fixed in the code.

How it works. A Panel docked to fill part of the form (Dock = DockStyle.Fill, set in the Properties window) makes a plain drawing surface separate from the rest of the form's controls; panel.CreateGraphics() gets a Graphics for it the same way this.CreateGraphics() does for a form (Concept 1), and panel.Refresh() forces it to redraw before the next line is added. The line's own endpoint is computed from a starting point, a length and an angle in degrees: since Math.Cos and Math.Sin expect radians, the angle is converted first, angle * Math.PI / 180, then endX = startX + (int)(Math.Cos(radians) * length) and endY = startY + (int)(Math.Sin(radians) * length). Drawing numberOfLines lines means looping that many times, drawing from the previous endpoint to the newly computed one, then treating that endpoint as the next line's start, so each line continues from where the last one ended; adding the same angle each time turns the whole shape a little further with every line, which is what produces a pattern instead of a straight ray. int.Parse(textBox.Text) converts what the user typed in a TextBox into the integer the rest of the calculation needs. Giving the pen a new Color.FromArgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255)) before each line, from a Random (Concept 7), makes every line in the pattern a different color.

flowchart TD
    S["Start of one loop iteration<br/>(startX, startY, currentAngle, length)"] --> R["radians = currentAngle * Math.PI / 180"]
    R --> E["endX = startX + cos(radians) * length<br/>endY = startY + sin(radians) * length"]
    E --> D["DrawLines from (startX, startY)<br/>to (endX, endY)"]
    D --> U["startX = endX, startY = endY<br/>currentAngle += angle, length += increment"]
    U --> N{"More lines left?"}
    N -->|Yes| S
    N -->|No| Done["Loop ends"]

Example.

private void goButton_Click(object sender, EventArgs e)
{
    int numberOfLines = int.Parse(numberOfLinesBox.Text);
    int angle = int.Parse(angleBox.Text);
    int length = int.Parse(lengthBox.Text);
    int increment = int.Parse(incrementBox.Text);
 
    int startX = canvas.Width / 2;
    int startY = canvas.Height / 2;
    int currentAngle = 0;
    Random rnd = new Random();
    Pen pen = new Pen(Color.Black, 1);
    Graphics g = canvas.CreateGraphics();
    canvas.Refresh();
 
    for (int i = 0; i < numberOfLines; i++)
    {
        pen.Color = Color.FromArgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255));
 
        double radians = currentAngle * Math.PI / 180;
        int endX = startX + (int)(Math.Cos(radians) * length);
        int endY = startY + (int)(Math.Sin(radians) * length);
 
        Point[] points = { new Point(startX, startY), new Point(endX, endY) };
        g.DrawLines(pen, points);
 
        startX = endX;
        startY = endY;
        currentAngle += angle;
        length += increment;
    }
}

The first block reads the four text boxes and converts each one from text to an int with int.Parse. startX/startY are set to the middle of the canvas panel, canvas.Width / 2 and canvas.Height / 2; currentAngle starts at 0 and is turned by angle degrees after every line, so angle is really a turn amount rather than one fixed direction; canvas.Refresh() clears whatever the panel showed before this click. The for loop runs once per line: it picks a new random pen color, converts the running currentAngle to radians and computes the endpoint that many pixels away from the current start point in that direction, draws a two-point line with DrawLines from start to that endpoint, then moves startX/startY to the endpoint just drawn, turns currentAngle by another angle degrees, and grows length by increment. With angle set to 90 and length growing by 1 each time, the direction turns a quarter-circle after every line while the line gets a little longer, which is what traces out a square-like spiral instead of one straight ray.

Lab Tasks

Tasks 1 to 5 build one project, GraphicsAnimationLab, adding to the same Form1 step by step. The complete program listing at the end of this page holds the finished code for all five tasks, each block marked with the task that adds it; compare only the blocks up to your current task. Task 6 is a second, separate project, FrameAnimation; it needs no image files of your own, since Concept 7 shows how to build its frames at run time, but it also tells you where to plug in real ones if you have a set.

Task 1: Draw a Static Picture with Graphics in the Paint Event

  1. Create a Windows Forms App (.NET) project named GraphicsAnimationLab. Expected: an empty project opens, showing Form1 in the designer.
  2. Select Form1, open the Events tab of the Properties window, and double-click Paint. Expected: an empty Form1_Paint(object sender, PaintEventArgs e) is created.
  3. Add the outlinePen and fillBrush fields from Concept 3 above the constructor. Expected: the project builds.
  4. Inside Form1_Paint, get Graphics g = e.Graphics; and add the seven drawing calls from the Concept 4 example. Expected: the project builds with no errors.
  5. Run the project. Expected: a diagonal line near the top, a rectangle and a light blue oval with a black outline below it, half a circle and a triangle to their right, and the text "Lab 4" underneath.
  6. Drag the form's corner to resize it while running. Expected: the picture is still there, because Paint redraws it every time.

Task 2: Draw Directly with CreateGraphics and Force a Redraw

  1. Add a Button (Name: drawNowButton, Text: Draw Now), away from the Task 1 shapes, and double-click it. Expected: an empty drawNowButton_Click(object sender, EventArgs e) is created.
  2. Inside the generated handler, use this.CreateGraphics() to get a Graphics, then draw a red rectangle with a Pen and DrawRectangle (Concepts 3 and 4), as in the drawNowButton_Click method in the complete program listing, then call g.Dispose();. Expected: the project builds.
  3. Run and click Draw Now. Expected: a red rectangle appears immediately.
  4. While still running, resize the window, or cover it with another window and bring it back. Expected: the Task 1 shapes are still visible, but the red rectangle is gone, because it was drawn with CreateGraphics() outside Form1_Paint, so nothing redraws it on the next Paint event.
  5. Optional exercise: create a second, separate project with a docked Panel, four TextBox inputs (number of lines, angle, length, increment) and a Button, and build the user-driven line pattern from Concept 10. Expected: clicking the button draws the requested number of connected, randomly colored line segments on the panel, turning by the entered angle and growing by the entered increment each time.

Task 3: Move One Shape with a Timer and Bounce It Off the Edges

  1. Add a Timer (Name: animationTimer, Interval: 30) and double-click it to create animationTimer_Tick. Expected: an empty animationTimer_Tick(object sender, EventArgs e) is created.
  2. Add a Button (Name: addBallButton, Text: Add Ball) and double-click it. Expected: an empty addBallButton_Click(object sender, EventArgs e) is created.
  3. Above the constructor, add the Ball class (Concept 7) and the balls/rng fields from the complete program listing. Expected: the project builds (System.Collections.Generic must be in the using list for List<Ball> to compile).
  4. In the constructor, after InitializeComponent();, set this.Width = 700; this.Height = 500; this.DoubleBuffered = true;.
  5. Complete addBallButton_Click: create a Ball with a random position that keeps the whole 30 by 30 circle on screen (rng.Next(0, ClientSize.Width - 30) for X, the same pattern with Height for Y) and a random speed from 2 up to but not including 6 in each axis (rng.Next(2, 6) for SpeedX and SpeedY), a random Color.FromArgb(rng.Next(256), rng.Next(256), rng.Next(256)), add it to balls, and set animationTimer.Enabled = true;. Expected: the project builds.
  6. Add a foreach loop over balls to the end of Form1_Paint that fills a 30 by 30 ellipse for each one in its own color: for each Ball b, wrap new SolidBrush(b.Color) in a using block (Concept 3) and call FillEllipse with it, since each ball's color is only known once the ball exists and cannot be a reused field the way outlinePen and fillBrush are. Expected: the project builds.
  7. Complete animationTimer_Tick with the Concept 6 movement and bounce logic, adapted to loop over balls with foreach. Expected: the project builds with no errors.
  8. Run, and click Add Ball once. Expected: one colored circle appears at a random position and starts moving; on reaching any edge of the form, it bounces back inward instead of leaving or stopping.
  9. Optional exercise: give the form a KeyDown handler and an enum Position field as in Concept 9, set this.KeyPreview = true;, and use the arrow keys to steer the most recently added ball instead of only bouncing it automatically: inside the foreach loop in animationTimer_Tick, compare the loop variable against the last ball by reference, if (b == balls[balls.Count - 1]), and apply the Concept 9 direction-based movement only inside that branch, leaving the automatic bounce check in the else branch for every other Ball in balls. Expected: pressing an arrow key changes that ball's direction of travel immediately, and it keeps moving that way until a different arrow key is pressed.

Task 4: Animate Several Independent Balls from One Shared Timer

  1. With Task 3 still running, click Add Ball five or six more times, at different moments. Expected: each click adds one more circle, at a new random position and speed; every circle keeps moving and bouncing on its own path, because each keeps its own X, Y, SpeedX and SpeedY.
  2. Watch two circles collide. Expected: they pass through each other, because animationTimer_Tick only checks each ball against the form's edges, never against another ball; this is expected, since no such check was written.
  3. Stop the project, change animationTimer's Interval from 30 to 100 in the designer, and run again. Expected: every ball moves visibly slower, since a larger interval means fewer position updates per second. Change it back to 30 afterward.
  4. Optional exercise: add two Label controls to the form and, in a separate Tick handler or an extra block in animationTimer_Tick, bounce each one by changing its own Left/Top directly, with its own pair of speed fields, as in Concept 8, instead of drawing anything with Graphics. Expected: both labels move and bounce independently of each other and of the balls, with no Form1_Paint code added for them at all.

Task 5: Animate an Image with DrawImage

  1. Above the constructor, add these fields from the complete program listing: private Bitmap spriteImage; private int spriteX = 50, spriteY = 50, spriteSpeedX = 4, spriteSpeedY = 3;. spriteImage is built in the constructor in the next step; the four int fields give the sprite its starting position, (50, 50), and its own speed, separate from any ball's. Expected: the project builds.
  2. In the constructor, after Task 3 step 4, build a small circular bitmap at run time with Graphics.FromImage(spriteImage), following the bitmap-building example in Concept 7 (also shown in the complete program listing), so the project needs no imported image file. Expected: the project builds; Graphics.FromImage draws onto the bitmap itself, not onto the form, which is how it gets its circle before the form is shown.
  3. Add g.DrawImage(spriteImage, spriteX, spriteY); to the end of Form1_Paint. Expected: the project builds.
  4. In animationTimer_Tick, after the foreach loop, add the sprite's own movement and bounce logic, checked against spriteImage.Width/Height the same way as a ball. Expected: the project builds with no errors.
  5. Run without clicking Add Ball. Expected: nothing moves yet, since animationTimer is only enabled inside addBallButton_Click; the sprite sits still at (50, 50).
  6. Click Add Ball once. Expected: the ball and the sprite move and bounce at the same time, at their own independent speeds, driven by the one animationTimer.
  7. Optional: replace the generated bitmap with a real picture. Add an existing image file to the project, set its Copy to Output Directory property to Copy if newer, and replace the three bitmap-building lines with spriteImage = new Bitmap("your-file-name.png");. Expected: the imported picture moves and bounces exactly as the generated circle did, since neither DrawImage nor the bounce logic depends on how the bitmap was created.

Task 6: Frame-by-Frame Sprite Animation with a Timer and a TrackBar

Create a second, separate project, FrameAnimation, for this task.

  1. Add a Timer (Name: animationTimer, Interval: 50, Enabled: checked), a PictureBox (Name: frameImage, SizeMode: StretchImage), and a TrackBar (Name: speedBar, Minimum: 50, Maximum: 100). SizeMode: StretchImage scales whatever picture is assigned to frameImage.Image to fill the PictureBox's own bounds, rather than clipping it, so every frame fills the same box even if the frames were not all drawn at exactly that size. Expected: the project builds, with animationTimer, frameImage and speedBar placed on the form.

  2. Build the frame sequence, above the constructor. If you have your own set of animation frame images (a walking or running sprite sheet split into separate files), import them as project resources, right-click the project, Add, Existing Item, select them together, and continue with step 3's imported-images bullet. Otherwise, no image files are needed: declare a List<Bitmap> frames = new List<Bitmap>(); field, and in the constructor, after InitializeComponent();, build 8 bitmaps in a loop, one per frame, the same way spriteImage is built in Concept 7, a new Bitmap(64, 64) plus a using (Graphics.FromImage(...)) block, drawing a circle that grows by 4 pixels of radius each frame while staying centered in the 64 by 64 bitmap. FillEllipse still takes a bounding rectangle, not a center and a radius (Concept 4), so a chosen radius has to be converted first: for frame index i from 0 to 7, int radius = 4 * (i + 1); gives radii 4, 8, 12, and so on up to 32, and the bounding rectangle that keeps a circle of that radius centered in the 64 by 64 bitmap is int x = 32 - radius; int y = 32 - radius; int size = radius * 2;, passed as FillEllipse(Brushes.Orange, x, y, size, size). Add each finished bitmap to frames before moving to the next iteration, then continue with step 3's generated-bitmaps bullet.

  3. Finish declaring the frame list, matching whichever half of step 2 you followed:

    • Imported images: declare List<string> frames with the resources' names, without their extension, and int currentFrame = 0;, as in the Concept 7 example.
    • Generated bitmaps: after the loop from step 2, set frameImage.Image = frames[0]; so the box shows the first frame before the timer's first tick, and declare int currentFrame = 0;.

    Expected either way: the project builds.

  4. Double-click animationTimer and write the Tick handler. The first line is the same for both branches, advancing and wrapping the index with the modulo operator: currentFrame = (currentFrame + 1) % frames.Count; (Concept 7). Then:

    • Imported images: fetch the bitmap with Properties.Resources.ResourceManager.GetObject(frames[currentFrame]), cast it to Bitmap, and assign it to frameImage.Image.
    • Generated bitmaps: assign frames[currentFrame] to frameImage.Image directly, with no lookup or cast, since frames already holds real Bitmap objects.

    Expected: the project builds.

  5. Run the project. Expected: the picture box cycles through the frames once every 50 milliseconds, giving the appearance of continuous motion (a pulsing circle for the generated frames, or your own sprite for imported ones).

  6. Double-click speedBar and, in its Scroll handler, set animationTimer.Interval = speedBar.Value;. Expected: the project builds.

  7. Run, and drag the track bar toward 50, then toward 100. Expected: the animation visibly speeds up near 50 (a shorter interval means more ticks per second) and slows down near 100, changing live as the bar is dragged.

Summary

  • A Graphics object is the GDI+ drawing surface, obtained one of three ways: e.Graphics inside Paint is redrawn on every repaint; this.CreateGraphics() draws once, directly onto the current screen image, and is not remembered; Graphics.FromImage(bitmap) draws into a Bitmap's own pixels instead of the form, which is how a picture can be built by code before the form is even shown.
  • The coordinate system starts at (0, 0) in the top-left corner, x growing right and y growing down, in pixels.
  • A Pen draws outlines, a SolidBrush fills interiors, Pens/Brushes hand out ready-made ones for named colors, and DrawString draws text with a Font (family name, then size in points). An ellipse and an arc are defined by a bounding rectangle, not a center and radius; an arc adds a starting angle (from the 3 o'clock position) and a sweep angle, positive for clockwise and negative for counterclockwise. DrawPolygon/FillPolygon connects an array of Point values into a closed shape, adding a final segment back to the first point. Pens and brushes are normally disposed once you are done with them; a long-lived one is kept as a field instead and reused, and a short-lived one built from data known only at the point of use (a ball's own random color) is built there and wrapped in a using block.
  • Invalidate() schedules a redraw for the next opportunity, Refresh() forces it immediately; anything that must survive a resize or the next animation frame has to be drawn inside Paint, never through CreateGraphics().
  • A Timer raises Tick every Interval milliseconds while Enabled is true. Adding a speed to a position, reversing that speed's sign when the position plus the object's size passes an edge of ClientSize, and calling Invalidate(), produces one bouncing object; DoubleBuffered = true removes the resulting flicker.
  • The same Tick handler drives any number of independent objects, provided each keeps its own position and speed, for example in a small class stored in a List<T> and updated with foreach; a Random object's .Next(min, max) returns an integer from min up to but not including max, which is what gives each new object its own spawn point and speed.
  • DrawImage(image, x, y) moves a picture exactly like DrawRectangle moves a shape, so the same bounce logic applies unchanged. A sprite that animates in place cycles through a frame list with currentFrame = (currentFrame + 1) % frames.Count, either resource names fetched with Properties.Resources.ResourceManager.GetObject and cast to Bitmap, or bitmaps built at run time and used directly; a TrackBar's Scroll event can change the Timer's Interval at run time to change the speed.
  • A control already on the form, such as a Label, can be animated without Graphics at all, by changing its own Left/Top directly from a Tick handler; a KeyDown handler and an enum of directions let the user steer an object's direction instead of it bouncing automatically; and Graphics.DrawLines with an array of Point values, together with Math.Cos/Math.Sin on an angle converted to radians, turns a few numbers typed by the user into a line pattern.

Complete program listing

This is the finished Form1.cs for the GraphicsAnimationLab project used in Tasks 1 through 5. Each block is marked with the task that adds it, so compare only the blocks up to whichever task you are currently on. This file holds the editable half of the partial class Form1; the generated half (InitializeComponent, and the fields it creates for drawNowButton, addBallButton and animationTimer) is produced automatically once those three controls are added in the designer exactly as the tasks describe, and their Paint, Click and Tick events are wired by double-clicking them there.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
 
namespace GraphicsAnimationLab
{
    public partial class Form1 : Form
    {
        // Task 1: a Pen draws outlines, a SolidBrush fills interiors.
        private Pen outlinePen = new Pen(Color.Black, 2);
        private SolidBrush fillBrush = new SolidBrush(Color.LightBlue);
 
        // Task 3: one small class per moving ball, so every ball keeps its
        // own position and its own speed instead of sharing one set of
        // variables.
        private class Ball
        {
            public int X;
            public int Y;
            public int SpeedX;
            public int SpeedY;
            public Color Color;
        }
 
        // Task 3 and Task 4: every ball currently on the form, all moved by
        // the one shared animationTimer.
        private List<Ball> balls = new List<Ball>();
        private Random rng = new Random();
 
        // Task 5: a small bitmap, animated with the same bounce logic as a ball.
        private Bitmap spriteImage;
        private int spriteX = 50;
        private int spriteY = 50;
        private int spriteSpeedX = 4;
        private int spriteSpeedY = 3;
 
        public Form1()
        {
            InitializeComponent();
 
            this.Width = 700;
            this.Height = 500;
            this.DoubleBuffered = true; // Task 3: removes the flicker of a Timer-driven animation.
 
            // Task 5: build a small circular sprite at run time, so the
            // project does not depend on an imported image file.
            spriteImage = new Bitmap(32, 32);
            using (Graphics gi = Graphics.FromImage(spriteImage))
            {
                gi.Clear(Color.Transparent);
                gi.FillEllipse(Brushes.Orange, 0, 0, 32, 32);
                gi.DrawEllipse(Pens.Black, 0, 0, 31, 31);
            }
        }
 
        // Task 1 (later tasks add more drawing to this same handler):
        // everything drawn here is redrawn on every repaint, so it survives
        // a resize, a minimize, or another window passing over the form.
        private void Form1_Paint(object sender, PaintEventArgs e)
        {
            Graphics g = e.Graphics;
 
            // Task 1: the static picture.
            g.DrawLine(outlinePen, 20, 20, 150, 90);
            g.DrawRectangle(outlinePen, 20, 120, 120, 80);
            g.FillEllipse(fillBrush, 160, 120, 100, 80);
            g.DrawEllipse(outlinePen, 160, 120, 100, 80);
            g.DrawArc(outlinePen, 280, 120, 80, 80, 0, 180);
            Point[] triangle = { new Point(400, 200), new Point(440, 120), new Point(480, 200) };
            g.DrawPolygon(outlinePen, triangle);
            g.DrawString("Lab 4", new Font("Arial", 16), fillBrush, 20, 220);
 
            // Task 3 and Task 4: every ball currently in the list.
            foreach (Ball b in balls)
            {
                using (SolidBrush ballBrush = new SolidBrush(b.Color))
                {
                    g.FillEllipse(ballBrush, b.X, b.Y, 30, 30);
                }
            }
 
            // Task 5: the sprite, drawn with DrawImage like any other picture.
            g.DrawImage(spriteImage, spriteX, spriteY);
        }
 
        // Task 2: drawn directly on the form's current surface, outside
        // Paint. Resize the window or cover it with another window afterward
        // and this rectangle is gone, because Form1_Paint never drew it.
        private void drawNowButton_Click(object sender, EventArgs e)
        {
            Graphics g = this.CreateGraphics();
            using (Pen p = new Pen(Color.Red, 3))
            {
                g.DrawRectangle(p, 450, 20, 100, 60);
            }
            g.Dispose();
        }
 
        // Task 3 (one click) and Task 4 (several clicks): a new, independent
        // ball every time the button is pressed.
        private void addBallButton_Click(object sender, EventArgs e)
        {
            Ball b = new Ball();
            b.X = rng.Next(0, this.ClientSize.Width - 30);
            b.Y = rng.Next(0, this.ClientSize.Height - 30);
            b.SpeedX = rng.Next(2, 6);
            b.SpeedY = rng.Next(2, 6);
            b.Color = Color.FromArgb(rng.Next(256), rng.Next(256), rng.Next(256));
            balls.Add(b);
            animationTimer.Enabled = true;
        }
 
        // Task 3 and Task 4: one shared Timer moves every ball and the
        // sprite, bouncing each one independently off the edges of the form.
        private void animationTimer_Tick(object sender, EventArgs e)
        {
            foreach (Ball b in balls)
            {
                b.X += b.SpeedX;
                b.Y += b.SpeedY;
 
                if (b.X < 0 || b.X + 30 > this.ClientSize.Width)
                {
                    b.SpeedX = -b.SpeedX;
                }
                if (b.Y < 0 || b.Y + 30 > this.ClientSize.Height)
                {
                    b.SpeedY = -b.SpeedY;
                }
            }
 
            // Task 5: the sprite bounces the same way, on its own speed.
            spriteX += spriteSpeedX;
            spriteY += spriteSpeedY;
 
            if (spriteX < 0 || spriteX + spriteImage.Width > this.ClientSize.Width)
            {
                spriteSpeedX = -spriteSpeedX;
            }
            if (spriteY < 0 || spriteY + spriteImage.Height > this.ClientSize.Height)
            {
                spriteSpeedY = -spriteSpeedY;
            }
 
            this.Invalidate();
        }
    }
}