WPF to Go: sposoby na zachowanie proporcji kontrolek
Męczyłem się wczoraj z różnymi sposobami żeby kontrolka zachowywała ustalone proporcje. Sposobów w sumie będzie kilka, ale chyba tylko jeden wydaje mi utrzymywalny i daje efekt taki jakiego można się spodziewać. A więc dzisiaj będzie o "aspect ratio".
Jeszcze słowem wstępu. Zmieniłem nazwę cyklu na WPF to Go (głównie żeby pasował do nazwy bloga na zine.net) oraz pozbyłem się numeracji odcinków, żeby nie wprowadzać zamieszania. W międzyczasie dwa "odcinki" cyklu pojawiły się na spotkaniach warszawskiej grupy .NET (WG.NET): wykład o RoutedEvents, oraz godzinna klepanina kodu pt: zróbmy własny custom Panel (układanie kontrolek a'la wachlarz kart na ręku). RoutedEvents posiadam w wersji screencastowej ale bez dźwięku, który leży gdzieś tam i czeka na obrobienie przez życzliwą duszę (jeżeli masz doświadczenie w edycji audio i video zgłoś się do WG.NET - potrzebujemy Cię !!). Custom Panel najprawdopodobniej wrzucę tutaj jak tylko znajdę czas i natchnienie ;). Ok to zaczynamy.
Post widoczny również na [2-many.net]
Potrzebowałem aby Grid tudzież inna kontrolka zachowayała proporcje np aby boki miały długości w propocjach 3/2. Po kilku minutach znalazłem kontrolkę Viewbox. Marzenie: w Viewboxa wrzucamy co nam się podoba (sztuk jeden) i ustawiamy właściwość Stretch, która mówi czy zagnieżdżony element będzie zachowywał proporcje (Uniform) czy np. wypełniał całą dostępną przestrzeń (Fill) itp. Wygląda to mniej więcej tak
<Viewbox Stretch="Uniform">
<!-- Jaka tam kontrolka -->
</Viewbox>
Niestety jest jeden minus: to co jest w Viewboxie musi mieć wprost podane wymiary: Width i Height. Kłóci się to dla mnie z ideą WPF'a gdzie powinno się unikać takich manewrów. Ale spróbować warto. Wrzuciłem w Viewboxa Grida z ustawionymi rozmiarami 300 / 200, włączyłem gridline'y (
ShowGridLines="True") i nacisnąłem F5. Zobaczyłem to:
Viewbox powiększa / pomniejsza nie tylko kontrolkę, ale wszystko inne co ma rozmiar np: border'y, gridline'y i zapewne też paddingi i marginy. Słabe...
Podejście drugie: odziedziczę jakiś tam mój panel po Gridzie, w metodzie
MeasureOverride ustawię jeden z rozmiarów "na sztywno" na podstawie drugiego rozmiaru i jakiego DependencyProperty (właściwości) takiego jak "AspectRatio". Do moich celów by wystarczyło (przyjąłem że Grid ma być wyższy niż szerszy, więc Width byłoby wyliczane). Coś mi jednak mówiło, że musi być prostsza metoda... mam za każdym dziedziczyć kontrolkę kiedy chcę zrobić jakąś mini rzecz? To na pewno się da zrobić w XAML'u...
Oczywiście, że się da ;) Do pomocy przyszedł DataBinding i Convertery. Nie będę się teraz rozpisywał o DataBindingu bo przyjdzie na to czas - jest banalnie prosty. Może wyglądać mniej więcej tak:
<Grid x:Name="PlayersLayout"
Width="{Binding
RelativeSource={RelativeSource self},
Path=ActualHeight,
Mode=OneWay}">
RelativeSource w Bindingu może wskazywać na dowolny element potomny czy poprzedzający w hierarchi kontrolek (np można użyć
FindAncestor zamiast self, który będzie szukać przodka). Użyłem
self czyli właśiwość Width będzie powiązana z inną właściwością Grid'a - podaje się ją jako
Path (w tym przypadku
ActualHeight). Bindowanie jest tylko w jedna stronę (tak na wszelki wypadek i żeby WPF się za bardzo nie męczył. Tym samym udało się spowodować że kontrolka zachowuje proporcje 1:1. W Path niestety nie można wpisać np
ActualHeight * 2 ;) Do pomocy przychodzi interfejs
IValueConverter.
Czasem zdarzy się że oprócz tego, że chcemy się zbindować do jakiejś wartości, będziemy ją chcieli przekonwertować (najprostszy przykład przekonwertować np typy). W tym celu należy zaimplementować IValueConverter z dwiema metodami
Convert i
ConvertBack:
1 [ValueConversion(typeof(Double), typeof(Double))]
2 public class AspectRatioConverter : IValueConverter
3 {
4 #region IValueConverter Members
5
6 public object Convert(object value, Type targetType,
7 object parameter, CultureInfo culture)
8 {
9 Double num = (Double)value;
10
11 if (parameter != null)
12 num /= System.Convert.ToDouble(parameter, NumberFormatInfo.InvariantInfo);
13
14 return num;
15 }
16
17 public object ConvertBack(object value, Type targetType,
18 object parameter, CultureInfo culture)
19 {
20 Double num = (Double)value;
21
22 if (parameter != null)
23 num *= System.Convert.ToDouble(parameter);
24
25 return num;
26 }
27
28 #endregion
29 }
Tak naprawdę wszystko co robią te metody to biorą jeden obiekt, mnożą go dzielą przez parametr i zwracają. Atrybut
ValueConversion nie jest niezbędny, ale zaleca się stosowanie go, aby inni wiedzieli jak go stosować i/lub podpowiadało nam VS (w końcu metody przyjmują gołe obiekty). Najważniejsze w tym kodzie jest chyba ta linijka i NumberFormatInfo.
num /= System.Convert.ToDouble(parameter, NumberFormatInfo.InvariantInfo); Oczywiście kiedy parametr w XAMLu przekazałem jako cyfrę 1.5 dostałem wyjątkiem FormatException (w końcu w Polsce używa się przecinka a nie kropki). Zmiana na przecinek oczywiście spowodowała, że rozwiązanie się nie kompilowało. Trzeba przekazać NumberFormatInfo.InvariantInfo (chociaż podejrzewałem że będzie przekazywany InvariantCulture domyślnie... widać nie).
Żeby użyć naszego konwertera w XAMLu trzeba się trochę nagimnastykować. Trzeba stworzyć jego instancję i jakoś wykorzystać w XAML'u - służą do tego Resource'y (znowu wybiegam trochę z cyklem ale co tam ;)). Najprościej będzie zrobić to np: w oknie, w którym Converter będzie użyty:
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Zombiaki.App.MainWin"
x:Name="Window"
xmlns:zombiaki="clr-namespace:Zombiaki.UI;assembly=Zombiaki.UI">
<Window.Resources>
<zombiaki:AspectRatioConverter x:Key="aspectRatio" />
</Window.Resources>
Po pierwsze należy zdefinować przestrzeń nazw xml'ową, wskazującą na przestrzeń nazw CLR'ową, a następnie przypisać jakiś klucz naszemu konwerterowi (Jeżeli ktoś się zastanawia co to za namespace: Zombiaki... to jest już inna historia, o której opowiadałem w czwartek na spotkaniu WG.NET ;)). Teraz w naszym Gridzie można zrobić tak (i w każdej innej kontrolce):
<Grid x:Name="PlayersLayout"
Width="{Binding
RelativeSource={RelativeSource self},
Path=ActualHeight,
Mode=OneWay,
Converter={StaticResource aspectRatio},
ConverterParameter=1.2}"
>
Do Bindigu dodajemy elementy
Converter (wskazujący na
StaticResource, który przed chwilą zdefiniowaliśmy) oraz
ConverterParameter, czyli nasz aspect ratio.
Trochę wyprzedziłem cykl opowiadając o DataBindigu i Resource'ach - wydaje mi się jednak że jest to na tyle proste, że można się w tym połapać. Convertery są bardzo proste i bardzo potężne. W połączeniu z DataBindigiem dają nam naprawdę dużo możliwości. Zachęcam do eksperymentowania.