Я думаю что каждый (или почти каждый), из тех, кто приходит в WPF из WinForms, поначалу испытывает растерянность по поводу функционала стандартных контролов.
Казалось бы – вот он — знакомый контрол.
Он очень похож на старого знакомого из WinForms. Даже сигнатура обычных методов либо полностью совпадает, либо претерпела незначительную трансформацию (ну, например, свойство Enabled получило приставку Is). Настроек у контролов много, от параметров визуализации рябит в глазах.
Но при чуть более тесном знакомстве и попытке натянуть привычные способы построения интерфейса на XAML и приходит та самая растерянность.
Как же так? Ну неужели у кнопки нет свойства Image? Вы ведь шутите, правда?
Все дело в том, что у WPF (точнее у XAML) совершенно иная идеология организации интерфейса. Базовые контролы представляют лишь базовый (простите за тавтологию) функционал. Простота базовых контролов компенсируется мощными механизмами шаблонов и стилей.
Существуют и сторонние библиотеки компонентов, но они, чаще всего, либо бесполезны, либо безнадежно устарели, либо сильно платные.
Не так давно я в очередной раз столкнулся с необходимостью решения этой очень простой (казалось бы) задачи. Я истерзал весь гугл запросами типа “XAML button with image” “WPF button image text” и т.д.
Среди десятков просмотренных результатов нашлись очевидные как очевидные (и при этом неудобные) пути решения, так и более изощренные.
Небольшое отступление номер 1
После первых же экспериментов стало очевидно, что XAML и иконки в виде png – вещи несовместимые. Не буду долго растекаться почему так – литературы на эту тему хватает, скажу только что в итоге получается и некрасиво, и неудобно, и нефункционально. Картинки размытые, наложенные эффекты и анимация выглядят удручающе и т.д…
Но не стоит огорчаться – в сети десятки и сотни ресурсов с векторными картинками.
Лучшее из того, что я нашел – SyncFusion Metro Studio 2 (не реклама). Это бесплатный продукт, в котором есть 1700 векторных иконок и средства вывода этих иконок в XAML. Результат получается в виде сложного элемента, из которого достаточно скопировать лишь Path, который описывает саму геометрию иконки.
С этим элементом я поступаю так – в проект добавляю ResourceDictionary с именем Icons.xaml и кладу в него все нужные мне иконки:
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Resource dictionary entries should be defined here. -->
<Path x:Key="IconTriangle" Stretch="Uniform" Data="M50.25,133.5 L49.75,158.25 76,147.25 z" Fill="Black" Stroke="Black"/>
</ResourceDictionary>
Но давайте вернемся к способам реализации кнопки с иконкой.
Первый и самый очевидный способ – описать нужный Content кнопки прямо в коде формы
<Button
HorizontalAlignment="Center"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<ContentControl Width="16" Height="16" Margin="4" Content="{DynamicResource IconTriangle}"/>
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center">Button</TextBlock>
</StackPanel>
</Button>
Ну, казалось бы, всё на месте. Есть кнопка, у нее есть иконка и текст. Тех, кто поверит в то, что всё так просто и попытается организовать таким подходом красивый и стильный интерфейс, очень скоро ждёт разочарование. И вот почему: простая настройка стиля приводит к распуханию XAML за счет дублирования описания параметров элементов прямо в коде формы. 10 кнопок – 10 идентичных описаний. Простое изменение типа «а давайте-ка покрасим текст на кнопках в зеленый цвет» превращается в утомительный копипаст и еще большее распухание формы.
Второй очевидный способ — наследование от Button
А давайте напишем «свою кнопку с блекджеком и сами знаете с чем еще»?
Наследуемся от Button и добавляем DependencyProperty для ContentControl, через который из XAML формы можно будет задать содержимое для иконки. Не буду останавливаться на подробностях реализации (внизу будет ссылка на источники, там можно будет почитать), но опишу минусы – содержимое кнопки придется задавать из конструктора наследника, на C#. Отсюда получаем массу очевидных и неочевидных проблем, не говоря уже о том, что это не очень хорошо пахнет.
Третий очевидный способ — создадим UserControl.
Создадим UserControl, на который покладем одну лишь кнопку. В UserControl создадим DependencyProperty, через которое будем задавать иконку для ContentControl, который лежит в кнопке. Этот способ по праву заслуживает медаль за максимальную корявость. Он наследует почти все недостатки предыдущих способов, и добавляет множество собственных. В коде формы мы получаем UserControl, но теряем кнопку. Теряем вместе со всеми свойствами и событиями. Автор идеи предлагает вытащить все, что было потеряно, через те самые DependencyProperty, в общем вы поняли. Становится непонятно за что же мы боролись.
Четвёртый способ — AttachedProperty
Этот способ я отношу к неочевидным и изощренным. В оригинале статьи автор предлагает задавать картинку через AttachedProperty. Забегая вперед скажу, что именно этот способ я и выбрал для использования в своем продукте и именно его я опишу максимально подробно. Он не лишен некоторых недостатков на этапе разработки (опишу ниже), но всё же мне он понравился больше остальных. В оригинале автор использовал иконку в виде картинки png, я же модифицировал способ для использования векторной иконки и добавил плюшек.
Итак, совсем немного теории. Что же такое это самое AttachedProperty
Каждый XAML разработчик сталкивался с Attached свойствами когда, например, задавал контролу свойство Grid.Column.
Если в трёх словах – то это своей идее немного похоже на Extension из Linq. Можно зарегистрировать свойство, значение которого можно задать любому DependencyObject. Выглядит это примерно так (пример из MSDN):
public class AquariumObject
{
public static readonly DependencyProperty IsBubbleSourceProperty = DependencyProperty.RegisterAttached(
"IsBubbleSource",
typeof(Boolean),
typeof(AquariumObject),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender)
);
public static void SetIsBubbleSource(UIElement element, Boolean value)
{
element.SetValue(IsBubbleSourceProperty, value);
}
public static Boolean GetIsBubbleSource(UIElement element)
{
return (Boolean)element.GetValue(IsBubbleSourceProperty);
}
}
В этом коде регистрируется свойство IsBubleSource. В результате любому DependencyObject, например тому же Button, можно задать его значение:
<Button AquariumObject.IsBubbleSource="True">Button</Button>
Общий смысл этого кода – при задании свойства IsBubbleSource для кнопки мы автоматически попадаем в метод SetIsBubbleSource, который устанавливает значение. При получении значения, соответственно, попадаем в метод GetIsBubbleSource. Это все происходит автоматически, достаточно лишь написать методы с именами Set и Get, остальное – дело платформы.
Несмотря на то, что написано не так уж и мало кода, самому Button от такой операции ни жарко ни холодно – он просто становится хранилищем обособленного значения, которое можно задавать и спрашивать. Конечно, можно реализовать в методах SetIsBubbleSource и GetIsBubbleSource хитрую логику, которая будет приводить element к Button, доставать из него содержимое, и производить с содержимым различные операции, но это опять плохо пахнет, делать так не нужно.
Приступаем к практической части
Небольшое отступление 2
В оригинале автор использует имя класса EyeCandy и namespace проекта, но это слишком длинно и я надеюсь, что мне простят сокращение – namespace Ext и имя класса E.
В проект WPF добавляем следующий класс:
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Ext
{
public class E
{
public static readonly DependencyProperty IconProperty;
static E()
{
var metadata = new FrameworkPropertyMetadata(null);
IconProperty = DependencyProperty.RegisterAttached("Icon",
typeof(FrameworkElement),
typeof(E), metadata);
}
public static FrameworkElement GetIcon(DependencyObject obj)
{
return obj.GetValue(IconProperty);
}
public static void SetIcon(DependencyObject obj, FrameworkElement value)
{
obj.SetValue(IconProperty, value);
}
}
}
Что же здесь происходит? Мы зарегистрировали Attached свойство Icon типа FrameworkElement со значением по умолчанию равным null.
Теперь создадим шаблон для нашей кнопки. Я не буду останавливаться на объяснения «что такое шаблоны и как они работают» – если вдруг кому-то это неизвестно – информации в сети очень много.
Итак, добавляем в наш проект ResourceDictionary с именем Styles.xaml (если вдруг в проекте еще нет ресурса стилей). В этом ResourceDictionary добавим следующий код:
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Microsoft_Windows_Themes="clr-namespace:Microsoft.Windows.Themes;assembly=PresentationFramework.Aero">
<Style x:Key="ButtonFocusVisual">
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate>
<Rectangle Margin="2" SnapsToDevicePixels="true" Stroke="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" StrokeThickness="1" StrokeDashArray="1 2"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Resource dictionary entries should be defined here. -->
<LinearGradientBrush x:Key="ButtonNormalBackground" EndPoint="0,1" StartPoint="0,0">
<GradientStop Color="#F3F3F3" Offset="0"/>
<GradientStop Color="#EBEBEB" Offset="0.5"/>
<GradientStop Color="#DDDDDD" Offset="0.5"/>
<GradientStop Color="#CDCDCD" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="ButtonNormalBorder" Color="#FF707070"/>
<Style TargetType="{x:Type Button}">
<Setter Property="FocusVisualStyle" Value="{StaticResource ButtonFocusVisual}"/>
<Setter Property="Background" Value="{StaticResource ButtonNormalBackground}"/>
<Setter Property="BorderBrush" Value="{StaticResource ButtonNormalBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Padding" Value="1"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Microsoft_Windows_Themes:ButtonChrome x:Name="Chrome" BorderBrush="{TemplateBinding BorderBrush}" Background="{TemplateBinding Background}" RenderMouseOver="{TemplateBinding IsMouseOver}" RenderPressed="{TemplateBinding IsPressed}" RenderDefaulted="{TemplateBinding IsDefaulted}" SnapsToDevicePixels="true">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal"/>
<VisualState x:Name="MouseOver"/>
<VisualState x:Name="Pressed"/>
<VisualState x:Name="Disabled">
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="IconContent">
<EasingDoubleKeyFrame KeyTime="0" Value="0.5"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<StackPanel>
<ContentControl
Content="{DynamicResource IconTriangle}"
Width="16"
Height="16"
x:Name="IconContent" Margin="4" RenderTransformOrigin="0.5,0.5" IsEnabled="{Binding IsEnabled, ElementName=Chrome}">
<ContentControl.Effect>
<DropShadowEffect Opacity="0" BlurRadius="2"/>
</ContentControl.Effect>
<ContentControl.RenderTransform>
<TransformGroup>
<ScaleTransform/>
<SkewTransform/>
<RotateTransform/>
<TranslateTransform/>
</TransformGroup>
</ContentControl.RenderTransform>
</ContentControl>
<TextBlock x:Name="textBlock" Margin="4" TextWrapping="Wrap" Text="{TemplateBinding Content}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
</StackPanel>
</Microsoft_Windows_Themes:ButtonChrome>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="true">
<Setter Property="RenderDefaulted" TargetName="Chrome" Value="true"/>
</Trigger>
<Trigger Property="ToggleButton.IsChecked" Value="true">
<Setter Property="RenderPressed" TargetName="Chrome" Value="true"/>
</Trigger>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Foreground" Value="#ADADAD"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<SolidColorBrush x:Key="IconColor" Color="Black" />
</ResourceDictionary>
Вкратце о том, что это такое. Эта запись ResourceDictionary описывает шаблон любой кнопки нашего проекта. В этом шаблоне задается обычное оформление для кнопки WPF, но переопределяется ее содержимое. В качестве содержимого выступает StackPanel, в котором лежат ContentControl и TextBlock, т.е. точно так же, как и в самом первом примере. Кроме этого в шаблоне я задал следующее поведение для иконки – если для кнопки задано IsEnabled == False, то иконка получает прозрачность 50%, и становится похожей на неактивную.
Добавим на нашу форму 4 простые кнопки. Назначим каждой кнопке свой текст, например вот так: Content=«Кнопка 1».
Запускаем приложение и получаем первый сюрприз.
Лично я ожидал увидеть 4 одинаковые кнопки с треугольником. Попытки вытрясти у гугла ответ по запросам типа “Assign same DynamicResource two elements wpf” и тому подобные не дали ничего вразумительного. Возможно кто-нибудь объяснит мне в чем я неправ, но, в принципе, данный нюанс поведения большой роли не играет – лично мне не нужны одинаковые иконки на кнопках на одной форме (на разных все работает нормально), поэтому пойдем дальше.
Идентичные иконки на каждой кнопке приложения – это не то, чего мы добиваемся, и именно здесь мы пускаем в дело наше секретное оружие – класс Ext.E и механизм AttachedProperty. Для непосвященных следующие строчки будут напоминать какую-то магию (как и для меня, когда я это впервые увидел), но, если попытаться вникнуть, то становится понятно за счет чего все это работает.
Итак, идем в наш ресурсный файл Styles.xaml и добавляем в него новый namespace:
xmlns:Ext="clr-namespace:Ext"
После этого опускаемся ниже и в шаблоне кнопки находим строчку, в которой создается ContentControl и задается его содержимое:
<ContentControl
Content="{DynamicResource IconTriangle}" ...
Меняем вторую строчку на:
Content="{Binding (Ext:E.Icon), RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Button}}}"
Эта строчка заставляет ContentControl обратиться к свойству Ext.E.Icon у кнопки и получить из него своё содержимое. После этого остается добавить код, устанавливающий значение свойства Ext.E.Icon в саму кнопку. Делается это в коде формы, на которой создается кнопка.
<Button
Ext:E.Icon="{DynamicResource IconTriangle}"
Content="Кнопка 1" />
Примитивный вариант кнопки с иконкой готов. Меняя значение IconTriangle на имена других ресурсов, можно задавать различные иконки на кнопках. При этом, в отличии от первых трех способов, мы сохраняем у кнопки все ее врожденные способности к стилизации (за исключением возможности менять структуру Content, само собой). Содержимое кнопки задается не из C#, и все свойства с событиями остались на своем месте.
Пойдем немного дальше
Если мы попытаемся использовать эту кнопку в реальном проекте, то столкнемся вот с чем:
- Размер иконки не настраивается.
- Ориентация кнопки (вертикальная или горизонтальная) не настраивается.
Если точнее, то все настраивается, но только в шаблоне, т.е. для всех кнопок сразу, но клонирование шаблонов – это грабли и сплошное неудобство. Боролись мы не за это.
Расширим класс Ext.E. Добавим туда еще два AttachedProperty
- IconSize типа double
- Orientation типа Orientation
Исходный код одним архивом будет в конце статьи, поэтому я не буду дублировать аналогичные методы класса Ext.E в статье.
Опишу лишь изменения, которые нужно сделать в шаблоне Button.
Размеры ContentControl связываем со значением IconSize:
<ContentControl
Content="{Binding (Ext:E.Icon), RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Button}}}"
Width="{Binding (Ext:E.IconSize), RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Button}}}"
Height="{Binding (Ext:E.IconSize), RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Button}}}"...
Ориентацию StackPanel связываем со значением Orientation
<StackPanel Orientation="{Binding (Ext:E.Orientation), RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Button}}}">
В результате кнопка получила дополнительные параметры, и мы можем написать вот так:
<Button
Ext:E.Icon="{DynamicResource IconLock}"
Ext:E.IconSize="32"
Ext:E.Orientation="Horizontal"
Content="Кнопка 1"/>
В результате нехитрых манипуляций можно получить вот такой зоопарк (первая кнопкаIsEnabled=«False»):
"
Ну и напоследок упомяну об ограничениях
Все они касаются процесса и средств разработки:
— XAML дизайнер VisualStudio 2010 реагирует на подобное описание кнопки как-то так:
"
— Blend 4 и VisualStudio 2012 ведут себя лучше, но тоже с особенностями:
- После изменения класса Ext.E лучше перезагрузить среду разработки. Без этого изменения чаще всего не определяются и дизайнеры ругаются на то, что добавленные или измененные свойства не существуют.
- По неустановленному мною алгоритму значения, заданные кнопке с помощью AttachedProperty, то видны дизайнеру, то нет. Чаще не видны, и форма выглядит как-то так:
Но касается это только Designer, в режиме выполнения приложения всё работает как нужно.
Возможно (и даже скорее всего) я описал велосипед, но тот факт что за два дня поисков я не нашел более приемлемой бесплатной реализации говорит о том, что на этом фронте не всё ладно.
Кроме того я получил возможность немного разобраться с механизмом расширения стандартных контролов нестандартным способом и применений этому механизму можно найти массу.
Благодарю за внимание.
Полезные ссылки:
WPF Control Development — 3 Ways to build an ImageButton
blogs.msdn.com/b/knom/archive/2007/10/31/wpf-control-development-3-ways-to-build-an-imagebutton.aspx
Using Attached Properties to Create a WPF Image Button
www.hardcodet.net/2009/01/create-wpf-image-button-through-attached-properties
Пользовательские свойства зависимостей
msdn.microsoft.com/ru-ru/library/ms753358.aspx
SyncFusion Metro Studio 2
www.syncfusion.com/downloads/metrostudio
Автор: ICELedyanoj