Analogowy zegarek

Analogowy zegarek

Autor: Maciej Zelwak

Opublikowano: 12/7/2007, 12:00 AM

Liczba odsłon: 165696

W artykule zajmiemy się programowaniem analogowego zegarka. Przy okazji ćwicząc posługiwanie się biblioteką GDI +, która to oferuje dostęp do szeregu metod graficznych. Począwszy od tych najprostszych, jak rysowanie figur oraz ich wypełnianie, skończywszy na bardziej zaawansowanych własnościach, takich jak anti-aliasing czy też alpha blending. Oczywiście będziemy korzystać tylko z niewielkiej ich części, co jednak nie będzie przeszkodą, w uzyskaniu efektu podobnego do jednego z gadżetów systemu Windows Vista.

Niezbędna matematyka

Zanim zajmiemy się aspektem programistycznym, musimy sobie przypomnieć pewne podstawy matematyczne. Wiadomo, że okrąg ma 360°, czyli 2? lub 1 radian. Dzieląc tą wielkość, przez ilość godzin na tarczy zegara, otrzymamy odległość jaka powinna dzielić kolejne etykietki godzin, czyli 30°. W podobny sposób wyznaczymy kąt dla poszczególnych wskazówek.

sekundyTic = 2.0 * Math.PI * sekundy / 60.0;

Wyrażenie sekundy/60 możemy potraktować jako wartość procentową obrotu wskazówki. Dla przykładu kiedy liczba sekund wynosi 30, czyli wskazówka znajduje się nad szóstką, wartość ta jest równa 50%. Ponieważ wartości kątów muszą być wyrażone w radianach, procent obrotu jest mnożony przez 2?. Znając wartość kąta oraz promień, możemy obliczyć współrzędne punktu określającego koniec wskazówki bądź położenie etykietki. Używamy do tego celu funkcji trygonometrycznych, cosinus do wyznaczenia współrzędnej x, sinus do wyznaczenia współrzędnej y.

Piszemy aplikację

Pora zabrać się za implementację. Tworzymy nowy projekt typu Window Application i wprowadzamy nazwę, czyli AnalogClock. Zmieniamy nazwę pliku z formą na MainForm.cs. Tarczę zegara będziemy rysować w środku okna, więc ustawimy jego rozmiar (Size) na 170x170. Przechodzimy do widoku kodu i sprawdzamy czy używamy niezbędnych przestrzeni nazw.

using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.Drawing;

Na początku klasy dodamy zmienne globalne wykorzystywane w programie.

private Rectangle rect;
private LinearGradientBrush obramowanieKolor;
private SolidBrush tarczaKolor;
private SolidBrush liczbyKolor;
private SolidBrush podpisKolor;
private Pen cienTarczyKolor;
private Pen pioro;
private Pen pioroSek;
private int srednica;
Następnie utworzymy pierwszą metodę, którą nazwiemy inicjujNarzedzia(), a jej wywołanie dopiszemy do konstruktora. Zainicjujemy w niej wszystkie obiekty graficzne, których będziemy używać. GDI+ umożliwia rysowanie każdym obiekcie graficznym, może to być  PictureBox, Panel, lub po prostu mapa bitowa. W tworzonym właśnie programie będzie to obszar roboczy formy. Zmienna ClientSize zawiera jego wymiar. Użyjemy jej do stworzenia obiektu typu Rectangle, czyli prostokąta, o wymiarach zegarka.  srednica = 120;
rect = new Rectangle(this.ClientSize.Width / 2 - srednica / 2, this.ClientSize.Height / 2 - srednica / 2, srednica,srednica);

Następnie zaalokujemy resztę potrzebnych zasobów. Jednym z nich jest pióro (pen), za pomocą którego możemy kreślić po ekranie. Posiada ono wiele właściwości, najważniejszymi dla nas są: kolor (Color), szerokość linii (Width), styl (DashStyle) oraz rodzaj początku i końca linii (StartCap i EndCap). Do określenia niestandardowych barw używamy metody FromArgb klasy Color. Parametrami tej funkcji są trzy liczby całkowite z zakresu 0..255, określające intensywność czerwieni, zieleni oraz błękitu w tworzonym kolorze. Obiekty Brush, czyli inaczej mówiąc pędzle, pozwalają określić rodzaj wypełnienia. 

obramowanieKolor = new LinearGradientBrush(rect, Color.FromArgb(0, 0, 0), Color.FromArgb(60, 60, 60), 60);
tarczaKolor = new SolidBrush(Color.WhiteSmoke);
liczbyKolor = new SolidBrush(Color.FromArgb(10, 10, 10));
podpisKolor = new SolidBrush(Color.Blue);
cienTarczyKolor = new Pen(Color.FromArgb(180, 180, 180), 3);
pioro = new Pen(Color.FromArgb(10, 10, 10), 4);
pioroSek = new Pen(Color.Red, 2);

Teraz ustawiamy parametry końcówek piór, które będą odpowiedzialne za rysowanie wskazówek. Jedna z końcówek będzie okrągła a druga zakończona strzałką.

pioro.EndCap = LineCap.ArrowAnchor;
pioro.StartCap = LineCap.RoundAnchor;
pioroSek.EndCap = LineCap.ArrowAnchor;
pioroSek.StartCap = LineCap.RoundAnchor;
cienTarczyKolor.EndCap = LineCap.ArrowAnchor;
cienTarczyKolor.StartCap = LineCap.RoundAnchor;

Wróćmy do widoku formy i dodajmy do niej obsługę zdarzenia Paint, w którym to będziemy wywoływać funkcję odpowiedzialną za rysowanie. Obsługa zdarzenia Paint jest wykonywana za każdym razem, kiedy zachodzi potrzeba odświeżenia wyświetlanej kontrolki. 

private void MainForm_Paint(object sender, PaintEventArgs e)
{
rysuj(e.Graphics);
}

Po czym przechodzimy do tworzenia metody odpowiedzialnej za rysowanie, czyli rysuj(Graphics graphics). Zacznijmy od zdefiniowania niezbędnych zmiennych:

//wspolrzedne srodka okna
int srWidth;
int srHeight;

//czas w danej chwili
int minuty;
int godziny;
double sekundy;

//rotacje wskazowek
double minutyTic;
double godzinyTic;
double sekundyTic;

float promien; //dlugosc wskazowki
int ramka; //szerokość czarnej ramki
int stopnie; //odleglosc miedzy liczbami

DateTime czas;

Zainicjujemy część z nich. W zmiennych srWidth i srHeight przechowujemy współrzędne środka formy. Szerokość czarnego obramowania przechowuje ramka.

srWidth = this.ClientSize.Width / 2;
srHeight = this.ClientSize.Height / 2;

ramka = 18;

Możemy już zająć się tarczą zegara. Korzystamy z metod FillEllipse i DrawEllipse, które umożliwiają rysowanie elips. Pierwsza z nich wypełnia wnętrze figury przy pomocy pędzla, który podajemy jako parametr. Efektem wypełnienia może być gradient, wzór zaczerpnięty z bitmapy lub po prostu wybrany kolor. Druga z metod pozostawia środek pusty. Oprócz pędzla podajemy również wymiary oraz położenie rysowanego okręgu. Możemy to zrobić na dwa sposoby, podając cztery liczby, czyli współrzędne dwóch przeciwległych narożników prostokąta w który będzie wpisana elipsa. Bądź też podając prostokąt w który będzie wpisana elipsa jako obiekt typu Rectangle.

graphics.FillEllipse(obramowanieKolor, srWidth - srednica / 2 - ramka/2, srHeight - srednica / 2 - ramka/2, srednica + ramka, srednica + ramka);
graphics.FillEllipse(tarczaKolor, rect);
graphics.DrawEllipse(cienTarczyKolor, rect);

Ułatwimy sobie pozycjonowanie liczb i przenieśmy początek układu współrzędnych obszaru roboczego formy z lewego górnego rogu do jej środka.

graphics.TranslateTransform(srWidth, srHeight);

Następnie ustawimy rozmiar, typ i formatowanie czcionki. Oczywiście jej wygląd nie jest obligatoryjny i zależy od upodobań programisty.

StringFormat format = new StringFormat();
format.Alignment = StringAlignment.Center;
format.LineAlignment = StringAlignment.Center;
Font textFont = new Font("Century", 12F, FontStyle.Bold);

Ponieważ nie zdefiniowaliśmy jeszcze położenia kolejnych liczb, robimy to teraz:

stopnie = 360 / 12;
promien = 49;

Dla przejrzystości kodu dodajmy dwie osobne metody, których jedynym zadaniem będzie zwracanie współrzędnych dla cyfr i dekoracji.

private float obliczX(float stopnie, float r)
{
return (float)(r * Math.Cos((Math.PI / 180) * stopnie));
}

private float obliczY(float stopnie, float r)
{
return (float)(r * Math.Sin((Math.PI / 180) * stopnie));
}

Wracamy do funkcji rysuj(Graphics graphics) i w pętli dodajemy po kolei dwanaście liczb.

for (int i = 1; i <= 12; i++)
{
graphics.DrawString(i.ToString(), textFont, liczbyKolor, -1 * obliczX(i * stopnie + 90, promien)+1, -1 * obliczY(i * stopnie + 90, promien)+2, format);
}

Bezwzględnie należy pamiętać o pomnożeniu obu współrzędnych przez -1, w przeciwnym wypadku otrzymalibyśmy liczbę 12 na dole zegarka.

Dalej w podobny sposób dodajemy cztery elementy dekoracyjne. Oczywiście pętlę można wykonać dwanaście razy.

promien = 61;
stopnie = 360 / 4;

for (int i = 1; i <= 4; i++)
{
graphics.DrawEllipse(pioro, -1 * obliczX(i * stopnie + 90, promien)-1, -1 * obliczY(i * stopnie + 90, promien)-1, 2, 2);
}

Przechodzimy do kreślenia wskazówek. Aplikacja będzie odświeżana kilkanaście razy na sekundę, dzięki temu uzyskamy efekt ciągłego ruchu wskazówki sekund, taki jak w cyfrowych zegarkach. Najpierw pobieramy aktualny czas i zapisujemy go do zmiennych:

czas = DateTime.Now;
godziny = czas.Hour;
minuty = czas.Minute;
sekundy = czas.Second + (czas.Millisecond * 0.001);

Obliczamy rotację:

godzinyTic = 2.0 * Math.PI * (godziny + minuty / 60.0) / 12.0;

Każda wskazówka będzie rzucała cień na tarczę. Obliczamy współrzędne obu końców i rysujemy linię wykorzystując metodę DrawLine().

Point pktSrodek = new Point(0, 0);
Point pktCienGodzina = new Point((int)((promien * Math.Sin(godzinyTic)) + 2),(int)((-(promien) * Math.Cos(godzinyTic)) + 2));
graphics.DrawLine(cienTarczyKolor, pktSrodek, pktCienGodzina);

Point pktWskGodzina = new Point((int)(promien * Math.Sin(godzinyTic) ), (int)(-(promien) * Math.Cos(godzinyTic)));
graphics.DrawLine(pioro, pktSrodek, pktWskGodzina);

Podobną technikę wykorzystujemy do dodania minut i sekund. Zmieniamy tylko długość promienia.

minutyTic = 2.0 * Math.PI * (minuty + sekundy / 60.0) / 60.0; promien = 57;
Point pktCienMinuta = new Point((int)(promien * Math.Sin(minutyTic) + 2), (int)(-(promien) * Math.Cos(minutyTic) + 2));
graphics.DrawLine(cienTarczyKolor, pktSrodek, pktCienMinuta);
Point pktWskMinuta = new Point((int)(promien * Math.Sin(minutyTic)), (int)(-(promien) * Math.Cos(minutyTic)));
graphics.DrawLine(pioro, pktSrodek, pktWskMinuta);

Wskazówka zegara jest znacznie cieńsza i w kolorze czerwonym.

Point pktCienSekunda = new Point((int)(promien * Math.Sin(sekundyTic)), (int)(-(promien) * Math.Cos(sekundyTic)));
graphics.DrawLine(pioroSek, pktSrodek, pktCienSekunda);

Point pktWskSekunda = new Point((int)(promien * Math.Sin(sekundyTic)), (int)(-(promien) * Math.Cos(sekundyTic)));
graphics.DrawLine(pioroSek, pktSrodek, pktWskSekunda);

Jeśli teraz uruchomimy program, po chwili zorientujemy się, że wskazówki w ogóle się nie poruszają. Musimy przerysować okno aplikacji. Przechodzimy do zakładki formy i z okna Toolbox przeciągamy Timer. Zmieniamy jego nazwę na timer. We własnościach czasomierza, ustawiamy czas odświeżania (Interval) na 100. Jeśli wpiszemy 1000 (1 sekunda), otrzymamy klasycznie poruszającą się wskazówkę. Ustawiamy Enabled na true. Klikamy dwa razy kontrolkę. Wygeneruje się metoda, do której dodamy tylko jedną linię:

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

Metoda Invalidate() powoduje odświeżenie zawartości kontrolki, dla której została ona wywołana. Wywołuje metodę OnPaint(), której nie należy jednak wywoływać jawnie. Metoda OnPaint() wysyła komunikat o wydarzeniu Paint do wszystkich obiektów, które potrzebują o tym wiedzieć.

Po uruchomieniu aplikacji zauważamy nieprzyjemne miganie podczas odświeżania obrazka. W celu eliminacji tego efektu włączamy podwójne buforowanie (double buffering). Technika ta polega na wykonaniu wszystkich operacji rysowania w tle, w sposób niewidoczny dla użytkownika, a następnie wyświetlenie za pomocą szybkiej operacji kopiowania. Należy odpowiednio zmodyfikować konstruktor aplikacji.

this.SetStyle(ControlStyles.DoubleBuffer, true);
this.SetStyle(ControlStyles.UserPaint, true);
this.SetStyle(ControlStyles.AllPaintingInWmPaint, true);

Wykorzystujemy metodę SetStyle() do modyfikacji trzech właściwości: DoubleBuffer (uruchmienie podwójengo buforowania), UserPaint ( sami rysujemy od podstaw) i AllPaintingInWmPaint (za rysowanie odpowiada metoda Paint).

Następnie polepszymy jakość generowanej grafiki, poprzez wprowadzenie wygładzania krawędzi. Każdy kto grał w gry komputerowe, spotkał się z techniką anti-aliasingu. Dotyczy ona prawie wszystkich krzywych. Ze względu na konieczność uzyskania rozsądnego kompromisu między gładkością, a rozmyciem, stosuje się różne sposoby zaniku jasności. w programie decydujemy się na HighQualityBicubic.

Wystarczy dopisać gdzieś na początku metody rysuj(Graphics graphics):

graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.TextRenderingHint = TextRenderingHint.AntiAlias;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;

Dodając do konstruktora następujący kod, pozbywamy się zakładki programu z paska zadań.

this.ShowInTaskbar = false;

Działanie zegarka będziemy kończyć wybierając pozycję z rozwijanego menu. Przeciągamy z Toolboxa następną kontrolkę, tym razem będzie to ContextMenuStrip. Zmieniamy nazwę na contextMenuStrip. Dodajemy opcję Zamknij i klikamy ją dwukrotnie. Dodajemy wywołanie jednej metody:

private void zamknijToolStripMenuItem_Click(object sender, EventArgs e)
{
Close();
}

Menu pojawi się gdy klikniemy formę prawym przyciskiem myszy. Dołączamy obsługę zdarzenia reagującego na kliknięcie myszką, gdzie sprawdzamy który przycisk został użyty, jeśli prawy to wyświetlamy menu kontekstowe.

private void MainForm_MouseClick(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
{
this.contextMenuStrip.Show(Control.MousePosition);
}
}

Gadżet jest już prawie na ukończeniu, pozostało jeszcze pozbyć się otaczającej zegar formy. Trzeba zmienić jej kolor (BackColor) na taki, który nie występuje w obrębie zegarka. To bardzo ważne, ponieważ w przeciwnym wypadku zniknie część pikseli. Nie możemy jednak wybrać koloru zdecydowanie różniącego się od czarnej ramki, gdyż pojawi się wtedy wokół niej nieciekawa obwódka. Eksperymentalnie ustawiamy kolor RGB 59;59;59. Następnie wpisujemy tą samą wartość do właściwości TransparencyKey. Usuwamy górny pasek okna, zmieniając w tym celu własność FormBorderStyle na None.

Dopóki nie obsłużymy odpowiednich zdarzeń, nie będzie można przesuwać zegarka po ekranie. Dopiszmy więc do klasy aplikacji zmienne globalne.

private Point MouseAktualnaPoz, MouseNowaPoz, formPoz, formNowaPoz;
private bool mouseDown = false;

Wróćmy do widoku okna i dołóżmy zdarzenia dla myszki jeśli jej klawisz jest wciśnięty (MouseDown), jeśli wskażnik się porusza (MouseMove) i kiedy klawisz jest puszczony (MouseUp).

Jeśli wciśniemy lewy przycisk, w zmiennych MouseAktualnaPoz i formPoz zostaną zapisane aktualne położenia myszki i formy.

private void MainForm_MouseDown(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
mouseDown = true;
MouseAktualnaPoz = Control.MousePosition;
formPoz = Location;
}
}

Przesunięcie w tym stanie myszki spowoduje wyliczenie nowych pozycji i przeniesienie zegarka w nowe miejsce.

private void MainForm_MouseMove(object sender, MouseEventArgs e)
{
if (mouseDown == true)
{
MouseNowaPoz = Control.MousePosition;
formNowaPoz.X = MouseNowaPoz.X - MouseAktualnaPoz.X + formPoz.X;
formNowaPoz.Y = MouseNowaPoz.Y - MouseAktualnaPoz.Y + formPoz.Y;
Location = formNowaPoz;
formPoz = formNowaPoz;
MouseAktualnaPoz = MouseNowaPoz;
}
}

private void MainForm_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
mouseDown = false;
}

W ten sposób zakończyliśmy tworzenie zegarka. Najlepszym sposobem na poszerzenie wiedzy z zakresu GDI+ jest eksperymentowanie z jej klasami. Dzięki temu artykułowi poznaliśmy podstawy i bazując na nich, już jesteśmy w stanie pisać własne, równie interesujące programy.