|
Celem artykułu jest ukazanie możliwości graficznych interfejsu GDI+ (Graphics Device Interface) i omówienie jego najciekawszych możliwości oraz kilku dobrych praktyk i sztuczek. Jest przeznaczony dla programistów chcących poznać możliwości graficzne .NET. Do zrozumienia artykułu potrzebna jest podstawowa znajomość środowiska Visual Studio .NET, Designera dla Windows Forms oraz języka C#. Zaczynamy!
Czym jest GDI+?
Aby lepiej zrozumieć rolę GDI+ powiemy o możliwościach graficznych Windows:

Środowisko .NET i API Win32 korzystają z interfejsu GDI+. Silnik graficzny (Engine GDI+) jest reprezentowany przez przestrzeń nazw System.Drawing w .NET Framework. Dostarcza klas pozwalających na wykorzystanie możliwości graficznych systemu. Zawiera także inne przestrzenie nazw, spośród których w artykule poświęcimy uwagę System.Drawing.Drawing2D. Biblioteka GDIPlus zawiera funkcje używane przez API GDI+ i fizycznie w systemie jest plikiem DLL, podobnie jak biblioteki GDI i DirectX. Z kolei te biblioteki odwołują się już do sterowników sprzętowych.
GDI+ daje programiście ogromną korzyść - może z języka wysokiego poziomu wywołać funkcje rysujące np. linię czy elipsę, czyli odpowiednią metodę instancji klasy System.Drawing.Graphics, a .NET Framework zajmie się resztą, korzystając z akceleracji sprzętowej.
Wspomniana klasa jest najważniejszą klasą z punktu widzenia programisty (oczywiście w kontekście GDI+). Jej instancje zawsze wiążemy z pewną przestrzenią roboczą: dla GDI+ jest obojętne, czy rysujemy na mapie bitowej, prostokącie klienta w oknie, czy papierze w drukarce. Możemy pracować w taki sam sposób, niezależnie od "medium" związanego z instancją. Prostokąt klienta (ClientRectangle) - obszar roboczy w oknie, na którym można wykonywać operacje graficzne, zwykle jest to wnętrze okna, bez obramowań i paska tytułowego. Przez system Windows jako okno traktowane są też wszystkie kontrolki, więc i ich definicja ta dotyczy.
Najlepiej wszystko wytłumaczyć na praktycznym przykładzie, więc stwórzmy nowe rozwiązanie, w którym będzie znajdował się nasz pierwszy projekt - Aplikacja Windows Forms.
Przykład nr 1 - Proste czyszczenie

Napiszemy na sam początek bardzo prosty program, którego zadaniem będzie ukazanie, w jaki sposób tworzy się obiekt klasy Graphics i w jaki sposób z niego się korzysta. Interfejs programu jest przedstawiony na rysunku - składa się z jednego przycisku (Button) i jednej kontrolki typu Panel. Panel posiada dodatkowo obramowanie (BorderStyle: FixedSingle). Po naciśnięciu przycisku "Wyczyść" będzie wywoływany poniższy kod:
private void btnClear_Click(object sender, System.EventArgs e) { Graphics g = Graphics.FromHwnd(pnlGraphics.Handle); g.Clear(Color.White); g.Dispose(); }
Tworzymy instancję obiektu Graphics za pomocą statycznej metody tej klasy. Jedynym argumentem tej metody jest "uchwyt" do okna (tutaj: do panelu), którego zawartość ma modyfikować. Następnie czyścimy ten obszar na biały kolor. Na koniec - ręcznie zwalniamy zasoby, z których korzystał obiekt Graphics. Gdy skończymy korzystać z obiektu Graphics, oznaczany jest on jako gotowy do usunięcia przez Garbage Collectora ("śmieciarza"). Można pozostawić "śmieciarzowi" zasoby do zwolnienia, jednak takie rozwiązanie sprawdza się tylko w małych aplikacjach. Najlepiej ręcznie zwalniać zasoby, gdy nie są już potrzebne. Przykład z życia wzięty - gdy pisałem przeglądarkę do obrazków, okazało się, że potrafi zajmować ona ponad 200MB w pamięci. Każdy niepotrzebny już obrazek pozostawiłem "śmieciarzowi" do usunięcia, ten jednak miał przerwę na kawę :).
Najefektywniej jest zaalokować wszystkie potrzebne zasoby (np.: obiekty Graphics, Pen, Brush) podczas uruchamiania programu, korzystać z nich a usunąć je przy zamykaniu. Redukujemy w ten sposób narzut związany z tworzeniem nowych obiektów za każdym razem, gdy rysujemy. Pióro (Pen) i pędzel (Brush) zostaną omówione w dalszej części artykułu.
Pozostał jeszcze jeden detal do omówienia. Po uruchomieniu aplikacji, wyczyszczeniu panelu i po przesunięciu okna tak, by obszar roboczy panelu znalazł się poza ekranem, lub po przesłonięciu go innym oknem otrzymujemy malowniczy widoczek: to, co zostało zasłonięte, jest teraz w kolorze tła kontrolki. Dlaczego tak się dzieje? Ponieważ dokładnie tak kazaliśmy :). W żadnym momencie poza obsługą kliknięcia przycisku "Wyczyść" nie wypełniamy ponownie panelu, więc system go tylko wypełnia kolorem tła kontrolki. To jest domyślne, aczkolwiek niepożądane przez nas zachowanie.
Przykład 2 - Wydarzenie Paint
Zmodyfikujmy nasz projekt (zachowując jego dotychczasową wersję na przyszłość) i dodajmy obsługę zdarzenia Paint w panelu. Występuje ono, gdy zaistnieje potrzeba odświeżenia część (lub całość) wyświetlanej kontrolki, na przykład po przesłonięciu, lub gdy jest rysowana po raz pierwszy. Dwa razy klikamy na panelu i piszemy:
private void pnlGraphics_Paint(object sender, System.Windows.Forms.PaintEventArgs e) { Graphics g = Graphics.FromHwnd(pnlGraphics.Handle); g.FillRectangle(Brushes.Yellow, e.ClipRectangle); g.Dispose(); }
Względem pierwszego projektu zmieniła się tylko druga linijka. Za pomocą metody FillRectangle wypełniamy obszar, który powinien zostać odrysowany od nowa. Właściwość e.ClipRectangle (obszar obcinania, obszar odświeżania) zawiera dane o prostokącie, którego obszar należy odświeżyć. Wielkość i położenie prostokąta wskazuje, która część naszej kontrolki została przesłonięta i wymaga ponownego narysowania.
Teraz po uruchomieniu aplikacji zobaczymy panel z żółtym tłem. Można nacisnąć przycisk "Wyczyść" i spróbować przesłonić znów panel. To, co będzie wymagało odrysowania zostanie ponownie narysowane, pozostawiając malowniczy efekt, ale obrazuje to, że nie trzeba zawsze odświeżać całej powierzchni kontrolki. Dzięki temu można uzyskać dużo większą wydajność, szczególnie w złożonych graficznie aplikacjach.
Zbudujmy więc złożoną graficznie aplikację :).
Przykład nr 3 - Złożona graficznie aplikacja
Zmodyfikujmy pierwszy projekt. Stwórzmy mini-benchmark rysujący kolorowe linie. Ponieważ każdy benchmark informuje użytkownika o szybkości działania, dodajmy StatusBar na dole formularza, gdzie będziemy pokazywać czas działania programu.
Zaimplementujmy metodę, która czyści obszar roboczy związany z obiektem Graphics podanym jako argument, rysuje zadaną ilość linii o losowych współrzędnych i kolorach. Generator liczb losowych inicjujemy zawsze tą samą liczbą, by wyniki każdego wywołania metody były powtarzalne.
private TimeSpan DrawLines(Graphics g, int nLines, int width, int height) { DateTime startTime = DateTime.Now; g.Clear(Color.White); Random rnd = new Random(37337); Pen p = new Pen(Color.Black, 1.0f); for(int i = 0; i < nLines; i++) { int x1 = rnd.Next(width); int y1 = rnd.Next(height); int x2 = rnd.Next(width); int y2 = rnd.Next(height); int red = rnd.Next(255); int green = rnd.Next(255); int blue = rnd.Next(255); p.Color = Color.FromArgb(red, green, blue); g.DrawLine(p, x1, y1, x2, y2); } p.Dispose(); return DateTime.Now - startTime; }
Nowościami tu są: pióro (Pen) oraz metoda DrawLine obiektu Graphics. Pióro jest obiektem, który w GDI+ pozostawia swój "ślad" na ekranie. Posiada wiele właściwości, a najważniejszymi dla nas są: jego kolor (Color), szerokość (Width), styl (DashStyle) oraz rodzaj początku i końca (StartCap i EndCap) linii. Istnieje też macierz transformacji pióra (Transform), o której powiemy sobie kilka słów. Pen posiada wiele więcej właściwości, lecz zajmiemy się teraz tymi najprostszymi. Kolor pióra nie wymaga chyba wyjaśnień. Szerokość oznacza to, jak duża jest końcówka naszego narzędzia. Wielkość 1.0f oznacza, że ma ona jeden piksel szerokości, wielkość 10.0f oznacza, że ma dziesięć pikseli szerokości. Styl linii określa, czy linia jest ciągła, czy przerywana, oraz jaki jest "wzorzec przerywania" linii - kropka-kreska, kropka-kreska-kreska, czy inny. Istnieje możliwość zdefiniowania własnego wzorca przez użytkownika (DashPattern). Styl początku oraz końca linii pozwala na rysowanie na przykład strzałek lub kółek na początku lub końcu linii i można ustawić te właściwości niezależnie od siebie. Rysowanie linii w różnych stylach nie zostało omówione w tym artykule, ponieważ zajęłoby by zbyt wiele miejsca, a dokładnie opisane jest w MSDN Library.
Transformacje realizowane są przez mnożenie macierzowe. Pozwala to na łatwe skalowanie, obroty, przesunięcia i złożenia różnych transformacji przestrzennych "za jednym zamachem". W .NET macierz transformacji składa się z 6 współczynników: m11, m12, m21, m22, dx, dy, dostępnych kolejno przez właściwość o sygnaturze public float[] Elements { get; }. Pierwsze cztery to współczynniki przekształceń współrzędnych, ostatnie dwa to przesunięcie, którego dokonuje macierz. Przekształcenie, którego dokonuje macierz transformacji można zapisać w ten sposób:

P oznacza punkt, M oznacza macierz (Matrix) transformacji, której jest on poddawany. Wynikiem mnożenia jest nowy punkt o przetransformowanych współrzędnych. Warto zwrócić uwagę na rozszerzenie wektora reprezentującego współrzędne punktu o dodatkowy element, aby umożliwić obliczenie jednocześnie np. obrotu i przesunięcia. Po przemnożeniu na kartce P przez M możemy zobaczyć, co otrzymamy w x´ i y´.
Niestety dokumentacja MSDN dla wersji 1.1 .NET Framework (w chwili pisania artykułu) pomija niestety informację o dokładnym zachowaniu się pióra w zależności od jego szerokości, przy stosowaniu transformacji jednocześnie na piórze i na obiekcie Graphics. Gdy po przemnożeniu szerokości piórka (będącego faktycznie wektorem, w skład którego wchodzi szerokość i wysokość "śladu") któraś z wielkości osiągnie wartość większą niż 1.5f, zachowanie piórka zupełnie się zmienia - szerokość i wysokość określają jego kształt. Linię o szerokości dokładnie jednego piksela ("hairline" - linia włosowa) możemy osiągnąć przypisując szerokości zerową wartość. Mówiąc prościej: kształt "śladu" piórka jest wynikiem przekształceń istniejących dla obiektu Graphics oraz transformacji przypisanej do samego narzędzia.
Więcej o transformacjach opowiemy sobie w kolejnych częściach arykułu.
Wracajmy do kodu programu. Obsługę kliknięcia przycisku poddajemy modyfikacji:
private void btnDraw_Click(object sender, System.EventArgs e) { pnlGraphics.Invalidate(); }
Metoda Invalidate powoduje odświeżenie zawartości kontrolki, dla której została ona wywołana. Sprowadza się to w sumie do wywołania metody OnPaint dla tej kontrolki (ale nie należy metody OnPaint wywoływać jawnie!). Metoda OnPaint wysyła komunikat o wydarzeniu Paint do wszystkich nasłuchujących (czyli tych, którzy potrzebują się o tym dowiedzieć). Dodajmy jeszcze jego obsługę (najprościej - dwukrotnie klikając na panelu w Designerze):
private void pnlGraphics_Paint(object sender, System.Windows.Forms.PaintEventArgs e) { int width = pnlGraphics.ClientRectangle.Width; int height = pnlGraphics.ClientRectangle.Height; TimeSpan ts = DrawLines(e.Graphics, 5000, width, height); sbStatus.Text = "Narysowano 5000 linii w czasie " + ts.ToString(); }
Po skompilowaniu i uruchomieniu naszego programu, w panelu ukaże się kolorowa sieczka :). Przesłaniając okno lub przesuwając je poza ekran możemy wymusić odświeżenie części panelu. Warto zwrócić uwagę na to, że czas działania metody DrawLines w wydarzeniu Paint jest dłuższy niż czas jej działania dla wywołania przez przycisk. [przypis autora: tak przynajmniej dzieje się u mnie, być może zachowanie jest różne dla różnych kart graficznych i sterowników]. Jest to spowodowane tym, że w wydarzeniu Paint mamy dodatkowo podany obszar obcinania (ClipRectangle). Każda rysowana linia musi być odpowiednio przez GDI+ skracana tak, by znajdowała się tylko w tym obszarze i by jej końce nie wychodziły poza ten region.
Uwaga na przyszłość - aby efektywnie skorzystać z obszaru obcinania należy zmienić logikę aplikacji. Nie wystarczy przyjęcie, że GDI+ sam poradzi sobie efektywnie z obcinaniem. Mechanizmy obcinania niewidocznych elementów zależą od konkretnego problemu. Nie zawsze jest efektywne obcinanie każdego pojedynczego elementu (np. każdej linii skomplikowanego rysunku). Można własnoręcznie stworzyć mechanizmy nazywane "bounding rectangle" (otaczający prostokąt) dla problemów dwuwymiarowych lub, wybiegając jeszcze śmielej w przyszłość - "bounding box" (otaczający prostopadłościan). Grupując obiekty znajdujące się blisko siebie i tworzące pewną całość (na przykład wszystkie linie wchodzące w skład rysunku koła zębatego) możemy wyznaczyć obszar, w którym znajduje się cała grupa i przedstawić go jako prostokąt (prostopadłościan). Dzięki temu, gdy mamy projekt na przykład zegarka (dużo, dużo trybików!) możemy sprawdzać, czy dana grupa jest aktualnie widoczna na ekranie. Zamiast setki porównań dokonujemy jednego, a jeśli ono jest odrzucone - nie musimy "obcinać" każdego elementu należącego do tej grupy, ponieważ grupa już jest "wycięta". Szczegóły implementacyjne tej metody wykraczają poza zakres tego artykułu, być może metoda zostanie dokładnie omówiona w przyszłości (razem z przykładem).
Teraz zajmiemy się upiększeniem odświeżania. Łatwo można zaobserwować, że za każdym razem, gdy rysowana jest kontrolka, widać, jak pojawiają się kolejne linie. Nie jest to przyjemny efekt, więc postaramy się go wyeliminować.
Przykład nr 4 - Podwójne buforowanie (automat)
Zachowajmy nasz ostatni projekt w bezpiecznym miejscu i modyfikujmy go dalej. Technika eliminacji efektu migotania przy odświeżaniu nazywana jest podwójnym buforowaniem (double buffering). Jej idea jest bardzo prosta - zamiast wykonywać serię kosztownych operacji, które widzi użytkownik, wykonujemy je w sposób niewidoczny i przedstawiamy mu jedynie ich końcowy efekt za pomocą jednej szybkiej operacji kopiowania. Chyba ma to pewien związek z tym, jak spada tempo pracy, gdy ktoś patrzy nam przez ramię :). Windows Forms posiadają wbudowany mechanizm podwójnego buforowania. Należy posłużyć się bardzo prostym wybiegiem. Stwórzmy nową kontrolkę użytkownika (User Control), zmodyfikujmy ją, by dziedziczyła po klasie System.Windows.Forms.Panel i zmodyfikujmy jej konstruktor:
public class PanelDB : System.Windows.Forms.Panel
W konstruktorze naszej kontrolki umieszczamy kod:
public PanelDB() { InitializeComponent(); this.SetStyle( ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer, true); }
Metodą SetStyle (musi być wywołana po InitializeComponent) ustawiamy trzy właściwości: AllPaintingInWmPaint (bez zagłębiania się zbytnio w WinAPI: logika rysująca ma znaleźć się tylko w Paint, a PaintBackground, czyli obsługa rysowania samego tła kontrolki nie jest wywoływana), UserPaint (sami rysujemy kontrolkę od podstaw, system nam nie pomaga) i DoubleBuffer (obsługa podwójnego bufora). Gdy wszystkie są ustawione, sposób rysowania jest zgoła odmienny. Metoda obsługująca wydarzenie Paint jest wywoływana tak, że obiekt Graphics przekazywany jako argument dla niej jest stworzony dla mapy bitowej znajdującej się w pamięci, a nie na ekranie. Rysowanie za pomocą tego obiektu Graphics odbywa się "poza ekranem", więc nie jest widoczne dla użytkownika. Dopiero po zakończeniu rysowania GDI+ wyświetla mapę bitową w miejscu, gdzie miało odbywać się odświeżanie. Ponieważ dzieje się to prawie natychmiastowo - jest przyjemniejsze dla oka. Dodatkową korzyścią jest większą wydajność - pamięć, w której znajduje się bufor "pozaekranowy" (offscreen buffer) jest szybsza niż pamięć ekranu. Więcej informacji na temat stylów kontrolek można znaleźć w dokumentacji System.Windows.Forms.ControlStyles.
Jedyne, co zostało do zrobienia - to podmienić kontrolkę w kodzie naszej aplikacji:
public class frmExample04 : System.Windows.Forms.Form { [...] // Podmiana - używamy teraz panelu podwójnie buforowanego private PanelDB pnlGraphics; [...] #region Windows Form Designer generated code private void InitializeComponent() { [...] // Podmiana - używamy teraz panelu podwójnie buforowanego this.pnlGraphics = new Example04.PanelDB(); [...] } [...] }
Ponieważ nowa kontrolka dziedziczy całą funkcjonalność ze starej kontrolki, w zasadzie jest to jedyne, co musimy zrobić, aby uzyskać podwójne buforowanie. Proste, prawda? :)
Przykład nr 5 - Własna kontrolka: CoolButton (Fajny przycisk)

Stwórzmy własną kontrolkę, która będzie udawała przycisk z Outlooka. Będzie wyglądać, jak na załączonym obrazku, czyli wyświetlać ikonę, napis oraz wypełnione płynnie zmieniającym się kolorem (liniowym gradientem) tło. Dodatkową cechą tego przycisku jest jego wielostanowość (normalny, wciśnięty, normalny z kursorem nad sobą, wciśnięty z kursorem) oraz zmienny wygląd. Uwaga: nie będziemy skupiać się na dodaniu możliwości całkowitego dostosowania przycisku w Designerze (np. zmiana kolorów czy położenia napisu), ponieważ wykracza to tematycznie poza artykuł.
Na sam początek warto zdecydować, z jakiej kontrolki będzie dziedziczyć nasza. .NET Framework posiada klasę System.Windows.Forms.ButtonBase, zalecaną jako baza do tworzenia własnych przycisków. My jednak napiszemy własny przycisk od podstaw i wyprowadzimy go z klasy System.Windows.Forms.Control. Dzięki temu będziemy mieli więcej pracy i więcej się nauczymy :).
Po stworzeniu nowej kontrolki użytkownika, można ustawić styl kontrolki na podwójnie buforowany (sposób był podany wcześniej), a następnie wstawić prywatne pola, przedstawiające jej stan oraz przechowujące obiekty graficzne. Do klasy dopisujemy:
private LinearGradientBrush _normalStateBrush; private LinearGradientBrush _hoverStateBrush; private LinearGradientBrush _activeStateBrush; private LinearGradientBrush _activeHoverStateBrush; private bool _isActive = false; private bool _isHoveredOn = false; private Icon _symbol; private bool _resourcesReady = false;
Pierwsze cztery pola to pędzle (Brush), którymi będziemy się posługiwać przy rysowaniu kontrolki. Kolejne dwa określają stan kontrolki - czy kontrolka jest aktywna i czy nad kontrolką znajduje się kursor. Oczywiście do pól _isActive i _isHoveredOn warto dodać właściwości z akcesorami get, aby można było z zewnątrz sprawdzić stan kontrolki. Przedostatnie pole to ikona, która będzie wyświetlana przy tekście przycisku. W utworzonych akcesorach należy zadbać o zwalnianie pamięci i odświeżenie po zmianie obrazka. Ostatnie pole określa, czy obiekty graficzne są gotowe do użycia (wyjaśnione w dalszej części artykułu).
Istnieje kilka typów pędzli używanych do wypełniania obszarów. Przestrzeń nazw System.Drawing.Drawing2D daje nam HatchBrush (pędzel "z wzorkiem"), LinearGradientBrush (pędzel gradientowy), PathGradientBrush (do wypełniania ścieżek - GraphicsPath), a System.Drawing daje SolidBrush (wypełnienie jednolite) i TextureBrush (wypełnienie teksturą). W zależności od typu pędzla, związane z nim są różne właściwości. Najprostszy pędzel posiada tylko informację o własnym kolorze, najbardziej skomplikowany posiada informację o teksturze, czy o rodzaju i kolorach gradientu. Skorzystamy z LinearGradientBrush w naszej kontrolce. Najprościej przygotować sobie taki pędzel wywołując odpowiedni konstruktor (pomimo że można ręcznie ustawiać wszystkie z jego licznych właściwości). W tym celu zdefiniujemy sobie pomocniczą metodę, której będziemy używać w przyszłości:
private void PrepareLinearGradientBrush(ref LinearGradientBrush brush, Rectangle rectangle, Color color1, Color color2, LinearGradientMode linearGradientMode) { if(brush != null) brush.Dispose(); if(rectangle.Width > 0 && rectangle.Height > 0) brush = new LinearGradientBrush(rectangle, color1, color2, linearGradientMode); }
Jeśli jako argument został podany istniejący już pędzel - jest on usuwany, a na jego miejsce powstaje nowy. Argument rectangle określa prostokąt, w którym ma zmieścić się nasz gradient. Oczywiście musi on mieć jakąś powierzchnię, więc sprawdzana jest jego szerokość i wysokość. Argumenty color1 i color2 oznaczają dwa kolory, pomiędzy którymi zostanie stworzone płynne przejście, linearGradientMode określa rodzaj gradientu i jest parametrem mającym najbardziej istotny wpływ na wygląd gradientu. Może przyjąć wartości Horizontal (poziomy), Vertical (pionowy), ForwardDiagonal (lewy-górny róg do przeciwnego), BackwardDiagonal (prawy-górny róg do przeciwnego), w enumeracji System.Drawing.Drawing2D.LinearGradientMode. Istotne jest przekazanie argumentu brush przez referencję, operujemy na argumencie, a nie na jego kopii.
Zaimplementujmy teraz metodę, w której będziemy inicjować wszystkie obiekty graficzne potrzebne do narysowania naszej kontrolki - niech będą to po prostu nasze pędzle.
private void PrepareObjects() { PrepareLinearGradientBrush(ref _normalStateBrush, ClientRectangle, Color.YellowGreen, Color.Green, LinearGradientMode.Vertical); PrepareLinearGradientBrush(ref _hoverStateBrush, ClientRectangle, Color.White, Color.Green, LinearGradientMode.Vertical); PrepareLinearGradientBrush(ref _activeStateBrush, ClientRectangle, Color.YellowGreen, Color.White, LinearGradientMode.Horizontal); PrepareLinearGradientBrush(ref _activeHoverStateBrush, ClientRectangle, Color.Green, Color.White, LinearGradientMode.Horizontal); _resourcesReady = true; }
Powyższy kod nie wymaga chyba tłumaczenia - tworzymy cztery obiekty typu LinearGradientBrush, które będziemy wykorzystywać przy zapełnianiu tła kontrolki oraz oznaczamy, że zasoby graficzne są gotowe. Tło rysujemy za pomocą następującej metody:
private void DrawBackground(Graphics g) { if(_isActive) { if(_isHoveredOn) g.FillRectangle(_activeHoverStateBrush, ClientRectangle); else g.FillRectangle(_activeStateBrush, ClientRectangle); } else { if(_isHoveredOn) g.FillRectangle(_hoverStateBrush, ClientRectangle); else g.FillRectangle(_normalStateBrush, ClientRectangle); } }
W zależności od stanu kontrolki jest ona wypełniana tak, aby ten stan użytkownikowi ukazać. Teraz pozostało tylko "samo gęste", czyli przeciążenie metody OnPaint, odpowiedzialnej za rysowanie kontrolki. Każda kontrolka posiada tą metodę, więc przeciążenie jej powoduje zmianę sposobu rysowania kontrolki. Pomimo że opisanie, co robi ta metoda zajęło tylko jedno zdanie - jest to jedno z najważniejszych zdań w tym artykule. A kod metody może wyglądać tak:
protected override void OnPaint(PaintEventArgs e) { if(!_resourcesReady) PrepareObjects(); float offsetX = 0.0f; DrawBackground(e.Graphics); e.Graphics.DrawRectangle(Pens.Blue, 0, 0, Width - 1, Height - 1); if(_symbol != null) { e.Graphics.DrawIcon(_symbol, 2, (ClientRectangle.Height - _symbol.Height) / 2); offsetX = _symbol.Width + 4.0f; } RectangleF layoutRect = new RectangleF(offsetX, 2.0f, (float)(ClientRectangle.Width - 4) - offsetX, (float)(ClientRectangle.Height - 4)); StringFormat sf = new StringFormat(); sf.Alignment = StringAlignment.Center; sf.LineAlignment = StringAlignment.Center; e.Graphics.DrawString(Text, Font, Brushes.Black, layoutRect, sf); base.OnPaint(e); }
Zmienna offsetX przyda się przy obliczaniu położenia prostokąta, w który będzie wpisany tekst (jeśli będziemy mieli ikonę do wyświetlenia - tekst nie może na nią nachodzić, więc go przesuwamy). Po narysowaniu tła i ramki (Graphics.DrawRectangle rysuje niewypełniony prostokąt), pokazujemy ikonę za pomocą metody DrawIcon klasy Graphics (jeśli jest przypisana do kontrolki). Metoda ta pobiera jako argumenty kolejno: ikonę, oraz współrzędne X i Y jej lewego górnego rogu. Ikona jest rysowana w swoim normalnym rozmiarze, nie jest skalowana (jednak istnieją metody pozwalające na skalowanie ikon). Gdy ikona jest narysowana - dodajemy do offsetX szerokość ikony i dodatkowo o 4 punkty, aby zapewnić odstęp pomiędzy tekstem i ikoną. Następnie tworzymy prostokąt (nie będzie on narysowany), w który wpiszemy tekst. Prostokąt rozpoczyna się od punktu określonego przez współrzędne (offsetX, 2.0f), a kończy się w prawym dolnym rogu kontrolki. Jest przesunięty o 3 punkty względem niego (nie 4, jak można wnioskować z kodu - współrzędne w GDI+ zaczynamy liczyć od zera), ponieważ chcemy mieć obramowanie szerokości 1 punktu i 2 punkty odstępu pomiędzy obramowaniem a zawartością przycisku (tekstem i ikoną). Ostatnią operacją do wykonania przed rysowaniem tekstu jest określenie sposobu jego formatowania. Chcemy, aby był centrowany poziomo (StringFormat.Alignment) i pionowo (StringFormat.LineAlignment). Enumeracja StringAlignment z przestrzeni nazw System.Drawing może mieć trzy wartości: Near, Center, Far, oznaczające odpowiednio położenie bliskie względem początku prostokąta, dalekie oraz centralne. Jest to istotne przy lokalizacji programów dla kultur, w których linia tekstu rozpoczyna się od prawej strony i piszemy w lewo. Przedostatnia instrukcja rysuje tekst (Text)* zadaną czcionką (Font)*, za pomocą czarnego koloru (Brushes.Black), który ma mieścić się w zadanym prostokącie (layoutRect) i być formatowany według naszego przepisu (sf). Właściwości oznaczone gwiazdkami są dziedziczone po System.Windows.Forms.Control i jak się okazuje - są całkiem przydatne. Aby zmiana tekstu kontrolki powodowała jej odświeżenie - potrzeba jawnie wywołać metodę Invalidate w przeciążonej właściwości Text - dopiszcie sami :). Na sam koniec wywołujemy metodę OnPaint dla klasy bazowej kontrolki, co zapewnia nam poinformowanie ewentualnych "słuchaczy" (listeners) "podłączonych" do wydarzenia Paint naszej kontrolki.
Wszystko pięknie, ale gdybyśmy teraz skompilowali projekt używający tej kontrolki, okazałoby się, że nie jest tak bardzo "cool", jak chcieliśmy. Nie reaguje na myszkę! W bardzo prosty sposób możemy ją do tego zmusić :).
protected override void OnMouseEnter(EventArgs e) { _isHoveredOn = true; Invalidate(); base.OnMouseEnter(e); } protected override void OnMouseLeave(EventArgs e) { _isHoveredOn = false; Invalidate(); base.OnMouseLeave(e); }
Przeciążając powyższe dwie metody otrzymujemy wymaganą funkcjonalność. Po przesunięciu kursora nad kontrolkę - informowana jest o tym, że jest nad nią kursor (zmienna _isHoveredOn), następnie jest odświeżana, a na koniec informowani są o wydarzeniu wszyscy "słuchacze".
Jeszcze jedna rzecz, o której łatwo w euforii zapomnieć. Co stanie się, gdy zmienimy wielkość kontrolki? Nasz gradient będzie nieładny - pamięta cały czas swoją wielkość, jaką miał przy tworzeniu. Trzeba go odświeżyć. Jak? Tak samo łatwo jak poprzednio:
protected override void OnResize(EventArgs e) { PrepareObjects(); Invalidate(); base.OnResize(e); }
Dobrze było napisać wcześniej metodę przygotowującą obiekty :). Tym bardziej, że powinna być jeszcze dodana w konstruktorze - na "dzień dobry" nasza kontrolka nie ma żadnych pędzli, którymi "potrafi się namalować", więc trzeba jej ten sprzęt zapewnić. Do konstruktora dodajemy wywołanie tej metody i z głowy.
Pozostał jeszcze jeden "higieniczny" detal - nie mamy żadnego mechanizmu, do zmiany stanu kontrolki z aktywnego na nieaktywny i vice versa. I dobrze - tym powinny zajmować się aplikacje korzystające z kontrolki - na przykład możemy nie chcieć by nasza kontrolka po każdym kliknięciu zmieniała swój stan, tylko pozostawimy decyzję "czynnikom zewnętrznym", czyli logice ewentualnej aplikacji, w której kontrolka będzie używana. Dostarczamy tylko 3 metody, których opis pominę:
public void ToggleActive() { _isActive = !_isActive; Invalidate(); } public void Activate() { _isActive = true; Invalidate(); } public void Deactivate() { _isActive = false; Invalidate(); }
Nasza kontrolka jest gotowa! Projekt kończymy tworząc przykładową aplikację korzystającą z kontrolki (znajduje się w załączonych kodach). Teraz najlepsze przed nami, czyli tworzenie fajnych ikonek i poszukiwanie ładnych kolorków :). W końcowej fazie można do kontrolki dodać możliwość obsługi z poziomu Designera, ale już najwyższa pora na…
Uwagi, podsumowanie, wizje na przyszłość
GDI+ daje spore możliwości użytkownikowi, począwszy od bardzo prostych funkcji, jak rysowanie linii i punktów, aż do skomplikowanych, jak wypełnianie powierzchni gradientem. Klasa Graphics stanowi serce GDI+, a najlepszą metodą na jego poznanie jest naciśnięcie kropki w Visual Studio po nazwie obiektu tej klasy i eksperymentowaniu. Graphics posiada metody DrawXXX i FillXXX, które odpowiednio rysują figury puste i wypełnione. Dzięki temu artykułowi poznaliśmy podstawy GDI+ i bazując na nim, możemy poznawać dalej możliwości tej biblioteki.
Mam nadzieję, że razem się czegoś nauczyliśmy i będę wdzięczny wszystkie za sugestie odnośnie tego, co chcielibyście poznać w kolejnych częściach artykułu. Dziękuję za uwagę i życzę miłego dnia :)
|