wpfdata-binding

How to properly implement dynamic font scaling with a custom font scale factor value?


I am making an App in WPF and it needs to be able to let the user decide the font and the font size (or rather a font scale value) they want. In application settings view, I am planning to add a slider with MinValue and MaxValue of 0.5 and 2.0 respectively. This slider will change a double variable AppFontScale's value in the app's ResourceDictionary. Changing FontScale will affect font sizes throughout the application as follows: (Any_Control).FontSize *= AppFontScale. I am trying to achieve this mostly in XAML with minimal code-behind intervention.

It is important to note that the program needs to be able to change font scaling and fonts during runtime without requiring a restart. That is one of the absolute demands.

What I have tried so far and why it didn't work.

Solution 1. Predefined font size sets
In my project, I made a folder ./Fonts/FontSizes. In here, I created 3 resource dictionaries named SmallFontSize, MediumFontSize, LargeFontSize. Each file had 9 different sizes. For example, MediumFontSize.xaml contained:

<sys:Double x:Key="TitleFontSize1">24</sys:Double>
<sys:Double x:Key="TitleFontSize2">22.5</sys:Double>
<sys:Double x:Key="TitleFontSize3">21</sys:Double>
<sys:Double x:Key="HeadingFontSize1">18</sys:Double>
<sys:Double x:Key="HeadingFontSize2">16.5</sys:Double>
<sys:Double x:Key="HeadingFontSize3">15</sys:Double>
<sys:Double x:Key="DescriptionFontSize1">12</sys:Double>
<sys:Double x:Key="DescriptionFontSize2">10.5</sys:Double>
<sys:Double x:Key="DescriptionFontSize3">9</sys:Double>

This solution offered big time ease but it didn't take me long to realize that these font sizes are either too big or too small for a lot of fonts. So, I searched for a more flexible solution and came up with the idea of rather providing a scaling factor instead of static size sets which brought me to the next solution.

Solution 2. DynamicResourceBindingExtension with converters
What I essentially did was: FontSize="{DynamicResourceBinding AppFontScale, Converter={StaticResource FontScalingHelper}, ConverterParameter=12}"

FontScalingHelper is an IValueConverter where the Convert method is as follows.

public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
    double scaleFactor = System.Convert.ToDouble(value);
    double baseSize = System.Convert.ToDouble(parameter);
    if (scaleFactor < 0.5 || scaleFactor > 2.0) scaleFactor = 1.0;
    if (baseSize < 1) baseSize = 12;
    return baseSize * scaleFactor;
}

This solution worked well when used in a control's body directly in xaml. However, using it in style setters threw the following exception in design-time making designing impossible (funny enough, no exceptions during runtime despite the exception in design-time).

"'DynamicResourceBindingExtension' is not valid for Setter.Value. The only supported MarkupExtension types are DynamicResourceExtension and BindingBase or derived types."

With that exception I had my solution figured out. Just inherit from DynamicResourceExtension. I did, only to find out that the ProvideValue is never called. Reading the documentation then cleared my doubts.

This method supports WPF XAML processor implementation, and is not intended to be called directly. The XAML processor implementation uses this method for proper handling of DynamicResource Markup Extension values during object creation.

Solution 3. Using a converter directly
I modified the FontScalingHelper class to inherit from DependencyObject and IValueConverter. It had a DP FontScale that was bound to AppFontScale using DynamicResource. The problem with this solution was that changing FontScale didn't call the Convert method because the utilizing control class didn't know that AppFontScale had changed. This led me to solution number 4.

Solution 4. Modifying FontScalingHelper and defining global instances for a size range

public class FontScalingHelper : System.Windows.DependencyObject, System.ComponentModel.INotifyPropertyChanged
{
    public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;

    public FontScalingHelper() : base()
    {

    }

    public static readonly System.Windows.DependencyProperty FontScaleProperty = System.Windows.DependencyProperty.Register("FontScale", typeof(double), typeof(FontScalingHelper), new System.Windows.PropertyMetadata(1d, OnScaleChanged));

    public static readonly System.Windows.DependencyProperty FontSizeProperty = System.Windows.DependencyProperty.Register("FontSize", typeof(double), typeof(FontScalingHelper), new System.Windows.PropertyMetadata(12d, OnScaleChanged));

    public static readonly System.Windows.DependencyProperty ScaledFontSizeProperty = System.Windows.DependencyProperty.Register("ScaledFontSize", typeof(double), typeof(FontScalingHelper), new System.Windows.PropertyMetadata(12d));

    public double ScaledFontSize
    {
        get { return (double)GetValue(ScaledFontSizeProperty); }
        private set { SetValue(ScaledFontSizeProperty, value); PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs("ScaledFontSize")); }
    }

    public double FontSize
    {
        get { return (double)GetValue(FontSizeProperty); }
        set { SetValue(FontSizeProperty, value); PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs("FontSize")); ScaledFontSize = FontSize * FontScale; }
    }

    public double FontScale
    {
        get { return (double)GetValue(FontScaleProperty); }
        set { SetValue(FontScaleProperty, value); PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs("FontScale")); ScaledFontSize = FontSize * FontScale; }
    }

    private static void OnScaleChanged(System.Windows.DependencyObject d, System.Windows.DependencyPropertyChangedEventArgs e)
    {
        if (d is FontScalingHelper helper)
        {
            double newFontSize = helper.FontScale * helper.FontSize;
            helper.SetValue(ScaledFontSizeProperty, newFontSize);
        }
    }
}

I had to make a range of global instances of this class in a resource dictionary to contain preset base sizes as follows.

<FontSupport:FontScalingHelper x:Key="ScaledFontSize30" FontScale="{DynamicResource ApplicationFontScale}" FontSize="30"/>
.
.
.
<FontSupport:FontScalingHelper x:Key="ScaledFontSize7" FontScale="{DynamicResource ApplicationFontScale}" FontSize="7"/>

This solution has worked well so far in a control's body and in style setters. However, it feels very unintuitive and for every unique size I have to use, I have to define another resource.

My main question: Is there a better way to do this where it works in design and run time no matter if I am using it directly in a control's body or in a style setter? I basically want to be able to do something like: <TextBox Text="Test" FontSize="{Binding BaseValue=13, FontScale={DynamicResource AppFontScale}}"/>
I know that this exact format is not possible but something similar would be awesome. If not, which of the above solutions would you prefer over the other and why? And are there any improvements that can be made in your preferred method?

Bonus: why is there no built in functionality to support font scaling like this in WPF? If there is, why can't I find it despite searching the internet for weeks?

Edit 1: Reworded question to change focus from "best way" to "proper implementation method."

Edit 2: Further enhanced question to add more context.


Solution

  • Using DependencyObject is valid and right solution. However, I have a few comments:

    Do not use System.Windows.DependencyObject and System.ComponentModel.INotifyPropertyChanged together. Use DependencyObject for controls and other WPF related stuff and INotifyPropertyChanged for view models.

    While setting values internally to properties use SetCurrentValue instead of SetValue to prevent breaking bindings that was potentially set in XAML.

    Make the ScaledFontSize readonly.

    Add validation for properties.

    public class FontScalingHelper : DependencyObject
    {
        public static readonly DependencyProperty FontScaleProperty = DependencyProperty.Register(
            name: "FontScale",
            propertyType: typeof(double),
            ownerType: typeof(FontScalingHelper),
            typeMetadata: new FrameworkPropertyMetadata(
                defaultValue: 1.0,
                propertyChangedCallback: FontScaleChanged),
            validateValueCallback: ValidateDoubleValue);
    
        public double FontScale
        {
            get { return (double)GetValue(FontScaleProperty); }
            set { SetValue(FontScaleProperty, value); }
        }
    
        public static readonly DependencyProperty FontSizeProperty = DependencyProperty.Register(
            name: "FontSize",
            propertyType: typeof(double),
            ownerType: typeof(FontScalingHelper),
            typeMetadata: new FrameworkPropertyMetadata(
                defaultValue: 12.0,
                propertyChangedCallback: FontSizeChanged),
            validateValueCallback: ValidateDoubleValue);
    
        public double FontSize
        {
            get { return (double)GetValue(FontSizeProperty); }
            set { SetValue(FontSizeProperty, value); }
        }
    
        // Readonly property key:
        private static readonly DependencyPropertyKey _scaledFontSizePropertyKey = DependencyProperty.RegisterReadOnly(
            name: "ScaledFontSize",
            propertyType: typeof(double),
            ownerType: typeof(FontScalingHelper),
            typeMetadata: new FrameworkPropertyMetadata(
                defaultValue: 12.0),
            validateValueCallback: ValidateDoubleValue);
    
        // Public readonly property:
        public static readonly DependencyProperty ScaledFontSizeProperty = _scaledFontSizePropertyKey.DependencyProperty;
    
        public double ScaledFontSize
        {
            get { return (double)GetValue(ScaledFontSizeProperty); }
            private set { SetValue(_scaledFontSizePropertyKey, value); } // private setter
        }
    
        private static void FontScaleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is FontScalingHelper helper)
            {
                double newFontSize = ((double)e.NewValue) * helper.FontSize;
                helper.ScaledFontSize = newFontSize;
            }
        }
    
        private static void FontSizeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is FontScalingHelper helper)
            {
                double newFontSize = helper.FontScale * ((double)e.NewValue);
                helper.ScaledFontSize = newFontSize;
            }
        }
    
        private static bool ValidateDoubleValue(object value)
        {
            return value is double dValue && Double.IsFinite(dValue);
        }
    }
    

    But TBH thing might be a lot simpler to you if ApplicationFontScale was a static resource.

    For e.g.:

    public class FontScaleHolder : DependencyObject
    {
        public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
            name: "Value",
            propertyType: typeof(double),
            ownerType: typeof(FontScaleHolder),
            typeMetadata: new FrameworkPropertyMetadata(
                defaultValue: 1.0),
            validateValueCallback: ValidateDoubleValue);
    
        public double Value
        {
            get { return (double)GetValue(ValueProperty); }
            set { SetValue(ValueProperty, value); }
        }
    
        private static bool ValidateDoubleValue(object value)
        {
            return value is double dValue && Double.IsFinite(dValue);
        }
    }
    

    then add it in App.xaml:

    <FontSupport:FontScaleHolder x:Key="ApplicationFontScale" />
    

    and finally you could use a converter:

    FontSize="{Binding Source={StaticResource ApplicationFontScale}, Path=Value, Converter={StaticResource FontScalingHelper}, ConverterParameter=12}
    

    like in second solution.