baskren / Forms9Patch

Simplify image management and text formatting in your Xamarin.Forms apps

Home Page:http://Forms9Patch.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Enhancement] Any pattern to synchronize font sizes on a single page?

smalgin opened this issue · comments

Summary

I have grid with multiple rows that look like this:

Label            [Control]
Longer Label [Control]
Even longer Label[Control]

Of course I need everything to fit to vertical screen.
I had fixed label font size but that caused cut-off labels on phones with smaller DPI.

I switched to Forms9Patch label and it works perfectly, but now all label rows are of different font size.

I know it's technically outside of project scope, but can somebody suggest a pattern to sync font size across the view?
I add all these labels programmatically on the fly, but actual size is known only after page gets rendered.

I am going to try hooking to parent's LayoutChanged event, iterate through labels and set font size to smallest actual font size on the page, but I am not clear if that will cause infinite Layout change cycle...

If you have better ideas, please share. This use case is common enough that everybody will benefit from this pattern.
I'll also post my findings as I go along.

@smalgin

Effectively, Forms9Patch.SegmentedControl had to solve the same problem. If one of its Segment's Labels doesn't fit, that Label's FontSize is reduced until it does. The consequence is, as you are seeing, is a Segment with a Label that looks too small compared to the other segment's labels.

How did I solve it?

Background

Forms9Patch.Button has an event called FittedFontSizeChanged and two properties:

  • FittedFontSize: What is the label's FontSize, after fitting has completed?
  • SynchronizedFontSize: An override of Label.FontSize - allowing FittedFontSize to still be calculated but not used.

They are all just pass throughs to the Forms9Patch.Label implementations:

From Forms9Patch.Button:

    ...

/// <summary>
/// Gets the FittedFontSize of the button's text (assuming it hasn't been overridden by SychronizedFontSize)
/// </summary>
public double FittedFontSize => _label.FittedFontSize;

/// <summary>
/// Overrides the button's label's FontSize for the purpose of font size synchronization between buttons
/// </summary>
public double SynchronizedFontSize
{
    get => _label.SynchronizedFontSize;
    set => _label.SynchronizedFontSize = value;
}

    ...

//event EventHandler _actualFontSizeChanged;
internal event EventHandler<double> FittedFontSizeChanged
{
    add { _label.FittedFontSizeChanged += value; }
    remove { _label.FittedFontSizeChanged -= value; }
}
    ...

But don't panic about Button.FittedFontSizeChanged being internal because, in Forms9Patch.Label:

#region FittedFontSize property
DateTime _lastTimeFittedFontSizeSet = DateTime.MinValue;

internal static readonly BindablePropertyKey FittedFontSizePropertyKey = BindableProperty.CreateReadOnly(nameof(FittedFontSize), typeof(double), typeof(Label), -1.0);
/// <summary>
/// Backing store for the actual font size property after fitting.
/// </summary>
public static readonly BindableProperty FittedFontSizeProperty = FittedFontSizePropertyKey.BindableProperty;
/// <summary>
/// Gets the actual size of the font (after fitting).
/// </summary>
/// <value>The actual size of the font.</value>
public double FittedFontSize
{
    get => (double)GetValue(FittedFontSizeProperty);
    internal set
    {
        if (Math.Abs(value - FittedFontSize) > 0.01)
        {
            SetValue(FittedFontSizePropertyKey, value);
            _lastTimeFittedFontSizeSet = DateTime.Now;
            Device.StartTimer(TimeSpan.FromMilliseconds(25), () =>
            {
                if (DateTime.Now - _lastTimeFittedFontSizeSet < TimeSpan.FromMilliseconds(50))
                    return true;
                FittedFontSizeChanged?.Invoke(this, value);
                return false;
            });
        }
    }
}
#endregion

#region SynchronizedFontSize property

/// <summary>
/// backing store for SynchronizedFontSize property
/// </summary>
public static readonly BindableProperty SynchronizedFontSizeProperty = BindableProperty.Create(nameof(SynchronizedFontSize), typeof(double), typeof(Label), -1.0);
/// <summary>
/// Gets/Sets the SynchronizedFontSize property
/// </summary>
public double SynchronizedFontSize
{
    get => (double)GetValue(SynchronizedFontSizeProperty);
    set => SetValue(SynchronizedFontSizeProperty, value);
}
#endregion SynchronizedFontSize property

    ....

/// <summary>
/// Occurs when label has performed fitting algorithm.  A value of -1 indicates that value of the FontSize property was used.
/// </summary>
public event EventHandler<double> FittedFontSizeChanged;

How does it work?

The simplified version is:

  • Let all of the labels report their FittedFontSize values.
  • Set all of the label's SynchronizedFontSize to the smallest reported value.

Easy peasy, right? Unfortunately - as the below code complexity indicates - If you are not careful, your labels will all just keep getting smaller and smaller.

Here is what SegmentedControl has to do

When a Segment is added to a Forms9Patch, the following happens:

        void InsertSegment(int index, Segment s)
        {
            ...
            var button = s._button;
            ...
            button.FittedFontSizeChanged += OnButtonFittedFontSizeChanged;
            Children.Insert(index + 1, button);
            ...
        }

So what happens in OnButtonFittedFontSizeChanged?

        DateTime _lastFontSizeResetTime = DateTime.MinValue;
        static int _iterations;
        bool _waitingForThingsToCalmDown;
        private void OnButtonFittedFontSizeChanged(object sender, double e)
        {
            if (!SyncSegmentFontSizes)
                return;
            _lastFontSizeResetTime = DateTime.Now;
            if (!_waitingForThingsToCalmDown)
            {
                _waitingForThingsToCalmDown = true;
                Device.StartTimer(TimeSpan.FromMilliseconds(30), () =>
                 {
                     if (DateTime.Now - _lastFontSizeResetTime > TimeSpan.FromMilliseconds(100))
                     {
                         var iteration = _iterations++;
                         var maxFittedFontSize = -1.0;
                         var minFittedFontSize = double.MaxValue;
                         var maxSyncFontSize = double.MinValue;
                         var minSyncFontSize = double.MaxValue;

                         foreach (var segment in _segments)
                         {
                             var segmentFittedFontSize = segment._button.FittedFontSize;

                             if (segmentFittedFontSize < minFittedFontSize && segmentFittedFontSize > 0)
                                 minFittedFontSize = segment._button.FittedFontSize;
                             if (segmentFittedFontSize > maxFittedFontSize)
                                 maxFittedFontSize = segmentFittedFontSize;

                             var segmentSyncFontSize = segment._button.SynchronizedFontSize;
                             if (minSyncFontSize - segmentSyncFontSize > 1)
                                 minSyncFontSize = segmentSyncFontSize;
                             if (segmentSyncFontSize - maxSyncFontSize > 1)
                                 maxSyncFontSize = segmentSyncFontSize;
                         }

                         if (minFittedFontSize >= double.MaxValue / 3)
                             minFittedFontSize = -1;

                         foreach (var segment in _segments)
                         {
                             ((ILabel)segment._button).SynchronizedFontSize = minFittedFontSize;
                         }
                         _waitingForThingsToCalmDown = false;
                         return false;
                     }
                     return true;
                 });
            }
        }

There is a lot going on there (more than I'm happy with) - largely because of Android (can't say much good about Android). Why? Xamarin's and Android's measurement + layout processes can cause a lot of noise in the FittedFontSize value. If you are too reactive to this noise, you'll keep setting the SynchronizedFontSize smaller and smaller until it hits the MinimumFontSize floor. There is likely room for optimization here - hence why I haven't yet turned Forms9Patch.SegmentedControl.OnButtonFittedFontSizeChanged() into something more generic. If you do come up with something simpler (and computationally more efficient) please share!

Conclusion

Your use case appears to be a bit simpler than what's happening in SegmentedControl, so you might be able to get away with a lot less code!

Wow, that's a lot to consume! Appreciate!