2008-11-08

Simple Clock Custom Control using Swing and Windows.Forms

This article describes how to create a simple analogue clock using Java Swing and .Net Windows.Forms, and it will cover creating a custom control, simple 2D drawing, updating the display regularly with a timer. In addition, the control can display the time in a user-selectable time zone and is resizable.

To give you an idea of the goal, below are images of the Swing and Windows.Forms control, respectively, in a test application:

Clock Control Java Clock Control .Net

Define Custom Control

The custom control has to draw a clock face and hands. We specify the height and width of the clock face and use a high-quality drawing mode to smooth out the lines and avoid jaggies. By default, the control will display the time in the current time zone.

Java Swing

Sub-class JComponent and override the paintComponent() method:

public class ClockPanel extends JPanel {
  private double cx, cy, diameter, radius;
  private final double RADIAN = 180.0 / Math.PI;
  private TimeZone timeZone = null;

  public void paintComponent(Graphics g) {
    super.paintComponent(g);
    Graphics2D g2 = (Graphics2D) g;
    g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    paintFace(g2);
    paintHands(g2);
  }
}

Windows.Forms

Sub-class Control and override OnPaint() method:

  public partial class ClockControl : Control {
    private double cx, cy, diameter, radius;
    private const double RADIAN = 180.0 / Math.PI;
    private TimeZoneInfo tzi = TimeZoneInfo.Local;

    protected override void OnPaint(PaintEventArgs e) {
      base.OnPaint(e);
      Graphics g = e.Graphics;
      g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
      PaintFace(g);
      PaintHands(g);
    }
  }

2D Drawing

The clock is drawn in two steps: first a circular clock face is drawn, then the hands are drawn on top of the face. The face is circular, so the minimum of the height and width of the drawable area is used.

Clock Face

To drawing the clock face's numbers, we convert each number into string, then an image, compute that image's mid-point and place it numbers using that mid-point. If we don't use the mid-point, then each number will be placed too high and on the right of the tip of the clock hands.

Java Swing
  private void paintFace(Graphics2D g2) {
    g2.setPaint(Color.GRAY);
    g2.fill(new Ellipse2D.Double(0, 0, diameter, diameter));
    g2.setPaint(Color.LIGHT_GRAY);
    g2.fill(new Ellipse2D.Double(20, 20, diameter - 40, diameter - 40));
    g2.setPaint(Color.BLACK);
    for (int i = 1; i <= 12; i++) {
      TextLayout tl = new TextLayout(Integer.toString(i), getFont(), g2.getFontRenderContext());
      Rectangle2D bb = tl.getBounds();
      double angle = (i * 30 - 90) / RADIAN;
      double x = (radius - 10) * Math.cos(angle) - bb.getCenterX() / 2;
      double y = (radius - 10) * Math.sin(angle) - bb.getCenterY() / 2;
      tl.draw(g2, (float)(cx + x), (float)(cy + y));
    }
  }
Windows.Forms
    private void PaintFace(Graphics g) {
      SolidBrush brush = new SolidBrush(Color.DarkGray);
      g.FillEllipse(brush, 0, 0, (float)diameter, (float)diameter);
      brush.Color = Color.LightGray;
      g.FillEllipse(brush, 20, 20, (float)(diameter - 40), (float)(diameter - 40));
      brush.Color = Color.Black;
      for (int i = 1; i <= 12; i++) {
        string num = Convert.ToString(i);
        Size size = TextRenderer.MeasureText(g, num, Font, new Size(int.MaxValue, int.MaxValue), TextFormatFlags.NoPadding);
        double angle = (i * 30 - 90) / RADIAN;
        double x = (radius - 10) * Math.Cos(angle) - size.Width/2;
        double y = (radius - 10) * Math.Sin(angle) - size.Height/2;
        g.DrawString(num, Font, brush, (float)(cx+x), (float)(cy+y));
      }
      brush.Dispose();
    }

Calculate Hand Positions

For each tick, we calculate the position of the hour, minute and second hands based on the current time and time zone. The hour hand is moved slightly forward each minute, and the minute hand is moved slightly forward each second, so that they don't jump at the start of the next hour and minute, respectively.

Java Swing
  private void paintHands(Graphics2D g2) {
    Calendar now = Calendar.getInstance(timeZone);
    int hour = now.get(Calendar.HOUR_OF_DAY);
    int minute = now.get(Calendar.MINUTE);
    int second = now.get(Calendar.SECOND);
    double angle = 0.0;
    
    angle = (hour % 12 * 30 + minute / 2) / RADIAN;
    paintHand(g2, 0.4, angle, 6, Color.YELLOW);
    
    angle = (minute * 6 + second / 10) / RADIAN;
    paintHand(g2, 0.6, angle, 4, Color.BLUE);
    
    angle = second * 6 / RADIAN;
    paintHand(g2, 0.8, angle, 2, Color.RED);
  }
Windows.Forms
    private void PaintHands(Graphics g) {
      DateTime dt = DateTime.UtcNow + tzi.GetUtcOffset(DateTimeOffset.UtcNow);
      double angle = 0.0;

      angle = (dt.Hour % 12 * 30 + dt.Minute / 2) / RADIAN;
      PaintHand(g, 0.4, angle, 6f, Color.Yellow);

      angle = (dt.Minute * 6 + dt.Second / 10) / RADIAN;
      PaintHand(g, 0.6, angle, 4f, Color.Blue);

      angle = dt.Second * 6 / RADIAN;
      PaintHand(g, 0.8, angle, 2f, Color.Red);
    }

Draw Hands

Now, we draw each clock hand.

Java Swing
  private void paintHand(Graphics2D g2, double proportion, double angle, float width, Color color) {
    double x = radius * proportion * Math.sin(angle);
    double y = -radius * proportion * Math.cos(angle);
    g2.setPaint(color);
    g2.setStroke(new BasicStroke(width, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
    g2.draw(new Line2D.Double(cx, cy, cx + x, cy + y));
  }
Windows.Forms
    private void PaintHand(Graphics g, double proportion, double angle, float width, Color color) {
      double x = radius * proportion * Math.Sin(angle);
      double y = -radius * proportion * Math.Cos(angle);
      Pen pen = new Pen(color, width);
      pen.EndCap = System.Drawing.Drawing2D.LineCap.Round;
      g.DrawLine(pen, (float)cx, (float)cy, (float)(cx + x), (float)(cy + y));
      pen.Dispose();
    }

Timer

The clock control updates itself every second using a timer. We set up the timer in the control's constructor.

Java Swing

Each 1000 ms, the timer will add a repaint request to the Swing event queue, which eventually results in calling the object's paintComponent() method.

  public ClockPanel() {
    super();
    ...  
    setTimeZone(TimeZone.getDefault());
    Timer t = new Timer(1000, new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        repaint();
      }
    });
    t.start();
  }

Windows.Forms

Each 1000 ms, the timer will call the timer_Tick() function, which in turn invalidates the clock control so that Windows.Forms will generate a OnPaint() event (that's how I think it works).

    public ClockControl() {
      CalculateSize(); // Control's size is available at this point.
      Timer timer = new System.Windows.Forms.Timer();
      timer.Enabled = true;
      timer.Interval = 1000;
      timer.Tick += new EventHandler(timer_Tick);
    }

    void timer_Tick(object sender, EventArgs e) {
      Invalidate();
    }

Time Zones

For interest, the clock control displays the time is a specified time zone, so we provide methods for an external caller to get and set the control's time zone.

Java Swing

  public TimeZone getTimeZone() { return timeZone; }
  public void setTimeZone(TimeZone tz) { timeZone = tz; }

Windows.Forms

    public TimeZoneInfo TZI {
      get { return tzi; }
      set { tzi = value; }
    }

Test Code

To test the clock control, create a test application.

NetBeans

  1. Using NetBeans, create a new application sample form called SimpleClockView.
  2. Drag the clock's source code icon from the Projects window into SimpleClockView's Design pane.
  3. Drag a JComboBox, called timeZoneList into the Design pane.
  4. Add the following action handler into timeZoneList to change the clock's time zone when the user selects a new time zone:
      private void timeZoneListActionPerformed(java.awt.event.ActionEvent evt) {
        // TODO add your handling code here:
        String tzID = (String) this.timeZoneList.getSelectedItem();
        clockPanel1.setTimeZone(TimeZone.getTimeZone(tzID));
      }                                            
    
  5. Initialize timeZoneList with a list of time zones in SimpleClockView's constructor:
    public class SimpleClockView extends FrameView {
    
        public SimpleClockView(SingleFrameApplication app) {
            super(app);
    
            initComponents();
            String[] sortedTzID = TimeZone.getAvailableIDs();
            Arrays.sort(sortedTzID);
            timeZoneList.setModel(new javax.swing.DefaultComboBoxModel(sortedTzID));
        ...
    

SharpDevelop

  1. Using SharpDevelop, create a new Windows Applications Form called Form1.
  2. Drag your new clock control from the Toolbox into the Form1's Designer pane.
  3. Drag a ListBox, called timeZoneList into the Design pane.
  4. Bind timeZoneList's SelectedIndexChange event to the timeZoneList_SelectedIndexChange() function to change the clock's time zone when the user selects a new time zone:
        private void timeZoneList_SelectedIndexChanged(object sender, EventArgs e) {
          String tzId = this.timeZoneList.SelectedValue as String;
          if (tzId != null) this.clockControl1.TZI = TimeZoneInfo.FindSystemTimeZoneById(tzId);
        }
    
  5. Initialize timeZoneList with a list of time zones in Form1's constructor:
    namespace ClockNS {
      public partial class Form1 : Form {
        public Form1() {
          InitializeComponent();
          this.timeZoneList.DataSource = TimeZoneInfo.GetSystemTimeZones();
          this.timeZoneList.DisplayMember = "DisplayName";
          this.timeZoneList.SelectedValue = "Id";
        }
      ...
      }
    }
    

Handling Resizing

A nice additional feature is for the clock control to resize itself when the test application's is resized.

Java Swing

Add a componentResized handler to recalculate the component's size and request Swing to repaint the component. Your component may have a small inset within its boundary, so you should take that into account when calculating the clock's size.

  public ClockPanel() {
    super();
  
    addComponentListener(new ComponentAdapter() {
      @Override
      public void componentResized(ComponentEvent e) {
        calculateSize();
        repaint();
      }
    });
    ...
  }

  private void calculateSize() {
    Insets insets = getInsets();
    int width = getWidth() - insets.left - insets.right;
    int height = getHeight() - insets.top - insets.bottom;
    diameter = Math.min(width, height);
    cx = cy = radius = diameter / 2;
  }

Windows.Forms

Override the base class' OnResize() function to recalculate the clock's size and redraw the control.

    protected override void OnResize(EventArgs e) {
      base.OnResize(e);
      CalculateSize();
      Invalidate();
    }

    private void CalculateSize() {
      diameter = Math.Min(this.Width, this.Height) - 2;
      cx = cy = radius = diameter / 2;
    }

Conclusion

I've described how to create a custom control in Java Swing + NetBeans and .Net Windows.Forms + SharpDevelop using (nearly) the same procedure and structure. I experiment with both environments because a feature in one motivates me to find an equivalent feature in the other and to synthesize a common (and hopefully better) solution.

See Also

No comments:

Post a Comment