Friday, July 8, 2022

Implementing a WPF Zoom feature

Now that we have several complex WPF applications in production, one of our users is demanding a zoom feature because he is having trouble reading some of the text. The incident went to one of our less experienced developers who promptly added a ScaleTransform to the base window that all our applications use. Personally, I would have bought the user some reading glasses, but that's just me.

The result of the ScaleTransform is not brilliant. It is unpredictable and produces undesirable results on most of the screens. We are not going to implement it. I suggested that the user really wants us to increase the font size. We've been pretty good at using styles consistently, so I decided to see how hard it would be to alter our styles and bind font sizes. Our buttons sizes are fixed, so the text can be clipped as it increases in size. I added some intelligence via a converter to prevent clipping.

This is what I came up with. It demonstrates a ViewBox, the simple ScaleTransform technique and the FontSize technique. The ScaleTransform solution has the advantage of being much simpler. The FontSize solution works better. The ViewBox is also simple but does not work well for pages with a lot of controls because they tend to start large and can't get much bigger. It's written using VisualStudio 2022 and C#.

Start a new WPF C# project called ScaleTransform. Add a class called Converters. The MainWindow.xaml looks like this...

<Window x:Class="ScaleTransform.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:ScaleTransform"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="350" Width="400">
    <Window.Resources>
        <local:ButtonWidthConverter x:Key="ButtonWidthConverter"/>
        <local:ButtonHeightConverter x:Key="ButtonHeightConverter"/>
        <local:FontSizeConverter x:Key="FontSizeConverter"/>
        <sys:Decimal x:Key="HeadingFactor">1.5</sys:Decimal>
        <Style TargetType="TextBlock" x:Key="Heading">
            <Setter Property="FontSize" Value="{Binding WindowFontSize, Converter={StaticResource FontSizeConverter}, ConverterParameter={StaticResource HeadingFactor}}"/>
        </Style>
        <Style TargetType="TextBox">
            <Setter Property="FontSize" Value="{Binding WindowFontSize}"/>
        </Style>
        <Style TargetType="Button">
            <Setter Property="FontSize" Value="{Binding WindowFontSize}"/>
            <Setter Property="Width" Value="{Binding WindowFontSize, Converter={StaticResource ButtonWidthConverter}}"/>
            <Setter Property="Height" Value="{Binding WindowFontSize, Converter={StaticResource ButtonHeightConverter}}"/>
        </Style>
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Viewbox>
            <Grid>
                <Grid.RenderTransform>
                    <ScaleTransform ScaleX="{Binding ZoomLevel}" ScaleY="{Binding ZoomLevel}"/>
                </Grid.RenderTransform>
                <Grid.RowDefinitions>
                    <RowDefinition Height="auto"/>
                    <RowDefinition Height="200"/>
                    <RowDefinition Height="auto"/>
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" Style="{StaticResource Heading}" Text="Meeting Notes"/>
                <TextBox Grid.Row="1" Text="The meeting was very boring and eventually everyone fell asleep" TextWrapping="Wrap"/>
                <Button Grid.Row="2" Content="Save Notes"/>
            </Grid>
        </Viewbox>
        <StackPanel Grid.Row="1" Orientation="Horizontal">
            <TextBlock Text="ScaleTransform" Margin="10" FontSize="13"/>
            <Slider Width="100" Value="{Binding ZoomLevel}" Minimum="1" Maximum="5"/>
            <TextBlock Text="FontSize" Margin="10" FontSize="13"/>
            <Slider Width="100" Value="{Binding WindowFontSize}" Minimum="6" Maximum="20"/>
        </StackPanel>
    </Grid>
</Window>

 

The MainWindow.xaml.cs file contains a couple of properties and not much else.

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
 
namespace ScaleTransform
{
    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        private double _ZoomLevel = 1;
        public double ZoomLevel
        {
            get { return _ZoomLevel; }
            set { SetProperty(ref _ZoomLevel, value); }
        }
 
        private double _WindowFontSize = 12;
        public double WindowFontSize
        {
            get { return _WindowFontSize; }
            set { SetProperty(ref _WindowFontSize, value); }
        }
 
        public MainWindow()
        {
            InitializeComponent();
        }
 
        public event PropertyChangedEventHandler PropertyChanged;
        public void SetProperty<T>(ref T storage, T value, [CallerMemberName] string name = "")
        {
            if (!Object.Equals(storage, value))
            {
                storage = value;
                if (PropertyChanged != null)
                    PropertyChanged(this, new PropertyChangedEventArgs(name));
            }
        }
    }
}

The converters are similarly trivial.

using System;
using System.Globalization;
using System.Windows.Data;
 
namespace ScaleTransform
{
    class ButtonWidthConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            Double FontSize = (Double)value;
            return Math.Max(FontSize * 6, 100);
        }
 
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
 
    class ButtonHeightConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            double FontSize = (Double)value;
            return Math.Max(FontSize * 2, 22);
        }
 
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
 
    class FontSizeConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            double FontSize = (Double)value;
            double Factor  ;
            if (parameter == null || !Double.TryParse(parameter.ToString(), out Factor))  Factor = 1;
            return FontSize * Factor;
        }
 
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}
 
If you run this, you will see the default layout.


Moving the ScaleTransform slider results in bigger everything, but a lot of clipping.


Moving the FontSize slider instead produces a useful effect.


Resizing the window demonstrates the ViewBox feature.



Thursday, May 26, 2022

Subscribing to a non-existent changed event

I needed a TextBlock control with a more pessimistic text trimming mechanism. None of the TextTrimming options were working for me, so I decided to subclass TextBlock and detect when the size or the text was changed. Then I could adjust the text as needed.

TextBlock exposes a SizeChanged event so that was easy.

AddHandler Me.SizeChanged, AddressOf me_SizeChanged

But it does not expose a TextChanged event.

AddHandler Me.TextChanged, AddressOf me_TextChanged

Text is a DependencyProperty so I can get a DependencyPropertyDescriptor and get notified whenever the value changes.

Private dpText As DependencyPropertyDescriptor
 
dpText = DependencyPropertyDescriptor.FromProperty(TextBlock.TextProperty, GetType(TextBlock))
dpText.AddValueChanged(Me, AddressOf me_TextChanged)


Thursday, April 21, 2022

Access a dictionary's keyvaluepair by position

I have some generic code that takes a dictionary with unknown generic types and returns all KeyValuePairs with keys that match a specific condition. The problem I have is that I cannot iterate through the collection of KeyValuePairs because that would require code like...

For Each o As KeyValuePair(Of T1, T2) In IDict

but I don't know T1 or T2 at design time. Similarly the following lines throw invalid cast errors.

For Each o As KeyValuePair(Of Object, Object) In IDict

For Each o As KeyValuePair(Of Object, Object) In IDict.Cast(Of KeyValuePair(Of Object, Object))

There is the option to iterate over a DictionaryEntry collection which does not require me to know the generic types at design time

For Each o As DictionaryEntry In IDict

Now I can examine the keys and values but I can't find a way to convert the DictionaryEntry back to the equivalent KeyValuePair. But I can track the index of the current DictionaryEntry and use IDict.AsQueryable(index) to get the matching KeyValuePair.

Here's an example in Visual Basic and WPF. Just click the [Find Y] button.

Start a Visual Basic WPF application using .Net Framework.

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:GetKeyValuePair"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="FindYCommand"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource FindYCommand}" Executed="FindY_Executed"/>
    </Window.CommandBindings>
    <StackPanel Orientation="Horizontal" Height="22">
        <ComboBox ItemsSource="{Binding TheDictionary}"
                  SelectedItem="{Binding TheSelection}"
                  DisplayMemberPath="Key" Width="100"/>
        <Button Content="Find Y" Command="{StaticResource FindYCommand}"/>
    </StackPanel>
</Window>

-------------------------------------------------------------------------------

Imports System.ComponentModel
Imports System.Runtime.CompilerServices
 
Class MainWindow
    Implements INotifyPropertyChanged
 
    Private _TheDictionary As New Dictionary(Of String, Boolean?) From {{"MAYBE", Nothing}, {"YES", True}, {"NO", False}}
    Public ReadOnly Property TheDictionary
        Get
            Return _TheDictionary
        End Get
    End Property
 
    Private _TheSelection As KeyValuePair(Of String, Boolean?)
 
    Public Property TheSelection As KeyValuePair(Of String, Boolean?)
        Get
            Return _TheSelection
        End Get
        Set(value As KeyValuePair(Of String, Boolean?))
            SetProperty(_TheSelection, value)
        End Set
    End Property
 
    Private Sub FindY_Executed(sender As Object, e As ExecutedRoutedEventArgs)
        TheSelection = FindY(TheDictionary)
    End Sub
 
    Private Function FindY(IDict As IDictionary) As Object
        ' Let's assume that we don't know TheDictionary generic types.
        ' We can't iterate through the KeyValuePairs because requires us to know the types at design time
        ' Find the first dictionary entry with a key that contains Y.
        ' AsQueryable returns a list of KeyValuePairs
 
        Dim index As Integer = 0
 
        If IDict.Count = 0 Then Return Nothing
        For Each o As DictionaryEntry In IDict
            If o.Key.ToString.Contains("Y") Then
                Return IDict.AsQueryable(index)
            End If
            index += 1
        Next
        Return Nothing
    End Function
 
    Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <CallerMemberName> Optional PropertyName As String = "") As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function
    Public Sub NotifyPropertyChanged(PropertyName As String)
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(PropertyName))
    End Sub
End Class
 




 

Thursday, March 31, 2022

Setting Window style in XAML

If you use styles (you DO use styles, right?) you may find that you cannot set the style of a Window or User Control to a static resource in XAML. Consider the following resource dictionary which contains the GreenWindowStyle style.

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Style TargetType="Window" x:Key="GreenWindowStyle">
        <Setter Property="Background" Value="Lime"/>
    </Style>
</ResourceDictionary>

I'd like to set my Window's style to GreenWindowStyle. The Window tag has a style attribute so I should be able to use it like this.

<Window x:Class="WindowStyle.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WindowStyle"
        mc:Ignorable="d"
        Style="{StaticResource GreenWindowStyle}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <ResourceDictionary Source="/Styles.xaml"/>
    </Window.Resources>
    <Grid>
 
    </Grid>
</Window>

 But I get an exception that InitializeComponent cannot find GreenWindowStyle. 




The problem, of course, is that the reference to GreenWindowStyle is parsed before the reference to Styles.xaml. How can we put it after? Try this...

<Window x:Class="WindowStyle.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WindowStyle"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <ResourceDictionary Source="/Styles.xaml"/>
    </Window.Resources>
    <Window.Style>
        <Binding Source="{StaticResource GreenWindowStyle}"/>
    </Window.Style
>
    <Grid>
 
    </Grid>
</Window>

Now the reference to GreenWindowStyle is after the reference to Styles.xaml so we get this beautiful window!