Accessibility in .NET MAUI - Chapter 4 - Font Scaling

Chapters in this series:

  1. Introduction & Motivation
  2. Tools - Windows
  3. Tools - Android & iOS
  4. Font Scaling

To start solving some issues found in our application - in this chapter we'll take a look at font scaling. In the previous chapter we could see that Android's Accessibility Scanner pointed out some issues with TextView elements (Labels in MAUI) being able to scale while being inside of ViewGroups that have a fixed height.

When we look at the analysis of the RecipeListPage we can see that it highlighted the navigation bar and bottom tabs. These are more difficult to get right as they are part of the Shell. We'll take a look at these in separate chapters dedicated to them.

For now let's focus on the rest of the application and other places where font scaling can break data readability or user experience in general.

⚖️ Default font scaling behavior 

MAUI automatically responds to system-level font size preferences on both iOS and Android. This feature ensures that users who require larger text can comfortably use our applications.

On Android we can set font scaling in Settings → Display & Brightness → Font settings

Font settings page for Android displaying setting the font to the largest option

 On iOS we can find it in Settings → Accessibility → Display & Text Size → Larger Text

Font settings page for iOS displaying setting the font to the largest option

 

Once we turn font scaling all the way to the largest option we can go back to our app and see what happens - it automatically effects all texts and we can see what flows out of the screen or is displayed in a layout with fixed size and gets clipped.

When testing it's often a good idea to go to the extreme - we will use the smallest device that we can find and increase font size to the largest size available. The idea is that if the app "survives" in this state - not much will surprise us later. It's also a good idea to look at our app with smaller and regular font sizes, but we usually manage to solve most of the issues when looking at the largest font size and other sizes work alright.

❤️ ScrollView belongs to each* page 

To start our journey with font scaling we will look at 2 pages with font scaling enabled: SettingsPage and IngredientDetailPhone.

Settings page from CookBook app illustrating missing scrolling functionality
Ingredient detail page from CookBook app illustrating missing scrolling functionality

 In both cases we can see that the content of the pages overflows the screen. We don't have any fixed heights set up so the texts wrap where needed and we can see the descriptions. But we cannot get to all the areas of these pages. For these pages we can solve it easily just by wrapping the whole page's content with a ScrollView i.e. 

<base:ContentPageBase x:Class="CookBook.Maui.Pages.SettingsPage"
                      ...>
    <ScrollView>
        <Grid RowDefinitions="Auto, Auto, *, Auto">
            ...
        </Grid>
    </ScrollView>
</base:ContentPageBase>


 We haven't essentially changed anything in the pages' layout. But suddenly we can display the whole page to our user and no amount of text wrapping will surprise us.

Animation of settings page from CookBook app showing scrolling through screen all the way to the bottom and back
Animation of ingredient list page from CookBook app showing scrolling through screen all the way to the bottom and back


 

* Let's address the asterisk here. It would be the best if this was the only solution that we needed - if we could just wrap every page inside of of a ScrollView and call it a day.

The issue at hand is dealing with nested ScrollViews. If we already have a control that enables scrolling by itself (i.e. Editor) or wraps its elements in a ScrollView internally (i.e. CollectionView), we need to consider this. Because if we wrapped these controls inside of an additional ScrollView it would be highly dependent on where a user taps where they start the scrolling gesture - if they start within a CollectionView they would just scroll inside of that control. However, if they start the gesture in an outer ScrollView the CollectionView itself wouldn't scroll and the page would move itself. This might be quite confusing for users.

So if we have multiple controls inside of a page and some of them enable scroll by default - we need to think about what we want to do with scrolling. We'll focus on the cases that can occur when using multiple ScrollViews in a page in a separate chapter.
 

👣 CollectionView.Footer

Another issue related to scrolling can be found in the IngredientListPage. This is a page that has a large scrolling CollectionView as it's main content. There's no issue directly related with the content not fitting or wrapping incorrectly as we don't have any fixed heights set up. However if the font increases or more ingredients get added to the list we can see an issue at hand - there's a floating button in the bottom that overlays our items. This can lead to either some missing information or buttons being unavailable:

Ingredient list page from CookBook app displaying overlapping floating button over the last item's edit button to display that it can't be easily clicked

In this case we cannot just wrap the content in another ScrollView. The CollectionView already scrolls by itself and there's no space under it to scroll to.

CollectionView however has Header and Footer properties exactly for these cases. As we're looking to add content to the bottom - we can use Footer for this. What we want to achieve is that we would be able to scroll the content of our CollectionView above the floating button. To achieve this we need to know what the size of our button is and add a control to the Footer that has the same height.

First of all - let's add x:Name to the floating button. We'll get code like this (we omitted some properties as they don't have any effect on the size):

<Button x:Name="CreateButtonPhone"
        Grid.Row="1"
        WidthRequest="65"
        Margin="0, 0, 14, 9"
        Padding="0"
        FontSize="50"
        Style="{StaticResource PrimaryButtonStyle}"
        Text="{x:Static texts:IngredientListPageTexts.Add_Button_Text_Phone}"
        .../>

From the code above - we can see that the button has some FontSize set which sets its Height. But on top of that it also has a bottm Margin set - we need to consider this when calculating the size required for our Footer. To add multiple values together we can use a MultiBinding and MultiMathExpressionConverter from the MAUI CommunityToolkit.

<CollectionView.Footer>
    <BoxView Color="Transparent">
        <BoxView.HeightRequest>
            <MultiBinding Converter="{StaticResource MultiMathExpressionConverter}"
                          ConverterParameter="x0 + x1 + x2">
                <Binding Path="Height" Source="{x:Reference CreateButtonPhone}"/>
                <Binding Path="Margin.Top" Source="{x:Reference CreateButtonPhone}"/>
                <Binding Path="Margin.Bottom" Source="{x:Reference CreateButtonPhone}"/>
            </MultiBinding>
        </BoxView.HeightRequest>
    </BoxView>
</CollectionView.Footer>

By using this code we don't set a fixed HeightRequest to our Footer, but we rather calculate it according to the real size that we need to have - the Height of the button + Margin.Top + Margin.Bottom. This way even if the user has their font scaling set to a larger number and the floating button gets larger - we ensure that all the items in our CollectionView are accessible and users can interact with them:

Animation of ingredient list page from CookBook app displaying that the list can now be scrolled to get even the last item vertically above the floating button to enable all user interactions with it

 

👌 Don't set fixed heights

If we want to size controls in our app strictly and they contain texts we need to enable them a way to grow. Horizontal scrolling is often something that we don't want to enable and we want our users to be able to only scroll vertically. This means that the width is usually limited - at most by the width of the screen. And if we fix height by setting HeightRequest or specify RowDefinition's Height in a Grid - the text won't be able to fit while scaling.

We can see this directly in RecipesListPage where we just need a slightly longer recipe name and it won't fit into the dedicated place. 
 

Recipes list page from CookBook app with fixed height for recipes - displaying that a recipe name got truncated

Looking at the code, we can see that the root Border in the CollectionView's ItemTemplate doesn't have limitation for WidthRequest but limits the height by specifying HeightRequest:

<DataTemplate>
    <Border WidthRequest="{OnIdiom Phone=-1,
                                   Desktop=300}"
            HeightRequest="200"
            Padding="0">
    ...
    </Border>
</DataTemplate>

This is here to provide nice UI - all the items are of the same size and it's also easier to pick images for the recipes when we know that they have an aspect ratio where they will be in "landscape" orientation.

We can still keep the majority of the requested design but also enable the usage for users who set their font scaling up. Plus we can also solve for even longer recipe names. The only change in this case is to simply use MinimumHeightRequest instead of HeightRequest. This way if everything fits on the screen fine - we still retain the requested design. But if something doesn't fit we are still able to display the untruncated value.

Recipes list page from CookBook app with variable height for recipes - displaying that a recipe name gets fully displayed

 

🎁 Wrapping controls with FlexLayout

In page IngredientListPagePhone we have another wrapping situation to address. When we take a look at the page we can see 2 buttons "pinned" at the bottom of the page - they aren't part of the page's scrolling and they are always displayed.

This works fine when the buttons fit on the page just fine side-by-side.

Ingredient edit page from CookBook app with 2 buttons at the bottom with small font, showing that in this configuration the app displays both buttons fine

However, once we either scale the font or have a longer text in a different localization - an issue arises - the buttons don't fit next to each other. We used Grid to position the buttons on the left and right as this is our desired layout:

<Grid Grid.Row="1"
      ColumnDefinitions="Auto, *, Auto"
      Margin="0, 0, 0, 10">
    <Button Grid.Column="0"
            Text="Cancel changes"
            Command="{Binding GoBackCommand}"
            Style="{StaticResource ErrorButtonStyle}" />
    <Button Grid.Column="2"
            Text="Save to server"
            Command="{Binding SaveCommand}"
            Style="{StaticResource PrimaryButtonStyle}" />
</Grid>

The result are 2 bottoms that overlap each other:

Ingredient edit page from CookBook app with 2 buttons at the bottom with enlarged font, showing that in this configuration the buttons overlap each other and the cancel button is only partially visible

This is neither a case of a single control's text wrapping, nor a scrolling issue. In this case we need to wrap multiple controls and wrap them only if there's insufficient space. This is where FlexLayout comes in handy:

<FlexLayout Grid.Row="1"
            Direction="Row"
            JustifyContent="SpaceBetween"
            Wrap="Wrap"
            Margin="0, 0, 0, -10">
    <Button Text="Cancel changes"
            Margin="0, 0, 0, 10"
            Command="{Binding GoBackCommand}"
            Style="{StaticResource ErrorButtonStyle}" />
    <Button Text="Save to server"
            Margin="0, 0, 0, 10"
            Command="{Binding SaveCommand}"
            Style="{StaticResource PrimaryButtonStyle}" />
</FlexLayout>

Let's go through the code above.

First of all - if everything fits just fine on the screen we want to keep the initial behavior of the buttons - to show up on the left and right side of our screen. We can achieve by setting the primary axis to horizontal by setting Direction to value Row. Then we set the behavior along the primary axis by setting property JustifyContent to value SpaceBetween. This will keep the 1st item at the **start** and the last item at the **end** while leaving empty space **between** them.
 

Now if they don't fit, we want them to wrap by setting the Wrap property to value Wrap. Next up - we want some space between the items when they get wrapped. That's what the Margins are for. We set a bottom Margin for each individual control. This way the space below the controls will be the same whether they wrap or not. And to compensate for the fact that we don't necessarily want to add a margin underneath the FlexLayout, we can set a negative bottom Margin for FlexLayout itself - this way we get a vertical space between the wrapped items without adding an unwanted space below the layout. And here's the wrapped result:

Ingredient edit page from CookBook app with 2 buttons at the bottom with enlarged font and FlexLayout applied, showing that in buttons get wrapped one under another vertically and both of them are fully visible


🚫 Disabling font scaling

In some cases font scaling is not something that we want. If we use labels to display icons using FontAwesome or some custom font and these icons aren't necessary to convey information - we might want to disable font scaling in individual cases.

We can see this in RecipeListPage where the icons representing recipe types don't fit within the border:
 

Recipes list page from CookBook app with enlarged fonts showing that even icons got enlarged and don't fit their dedicated space anymore

The value for recipe type is represented in 3 different ways:

  • Icon
  • Color
  • Text

So the icon is a redundant information in this case and users can figure out the type of the recipe by other means that are accessible - therefore we can set the label's FontAutoScalingEnabled property to false and have the icon not scale.

<Label VerticalOptions="Center"
       FontFamily="{x:Static fonts:Fonts.FontAwesome}"
       Text="{Binding FoodType, Converter={StaticResource FoodTypeToIconConverter}}"
       TextColor="White"
       FontAutoScalingEnabled="False" />
Recipes list page from CookBook app with enlarged fonts but icons have font auto-scaling disabled showing that the icon fits fine even if the other texts get scaled


Where we might also want to consider disabling font scaling is that identifying and solving all of these (and other) cases can take some time. And as MAUI enables font scaling by default we can also disable this behavior either for the whole app or for individual pages until they are ready for font scaling.

On Android we have the ability to disable font scaling globally by overriding the configuration in MainActivity:

protected override void AttachBaseContext(Context? @base)
{
    if (@base?.Resources is not null)
    {
        Android.Content.Res.Configuration configuration = new(@base.Resources.Configuration);
        configuration.FontScale = 1.0f;
        ApplyOverrideConfiguration(configuration);
    }
    base.AttachBaseContext(@base);
}

However, there is no equivalent configuration-level override on iOS.

So to solve this while using MAUI styling we can use property FontAutoScalingEnabled that is by deafult set to true for all controls that display text including Label, Button, Entry or Editor.

Therefore, when we want to disable automatic scaling we need to create a default style for each of the controls and set the value to false

<Style TargetType="Label">
    <Setter Property="FontAutoScalingEnabled" Value="False" />
</Style>
<Style TargetType="Button">
    <Setter Property="FontAutoScalingEnabled" Value="False" />
</Style>
<Style TargetType="Entry">
    <Setter Property="FontAutoScalingEnabled" Value="False" />
</Style>
<Style TargetType="Editor">
    <Setter Property="FontAutoScalingEnabled" Value="False" />
</Style>

With this approach we can control where these styles are applied. We can specify them on the App level and override in each page that is ready for scaling. And once we have the whole app ready we can easily remove them and have everything scaling as intended.
 

Conclusion

We can see that font scaling can cause a whole bunch of situations that manifest in different ways in our apps. As MAUI responds to system-scaling by default we need to count on this and design our apps accordingly - we never know what device or font scale our user will decide on.

In this chapter we looked at some cases where font scaling can cause an issue and we showed their solutions. There are still some more system-level issues coming up to address in the next chapters so stay tuned that involve navigation bar, tab bars and other application areas. But for now - we can already get into work to make our apps' content readable at any font size!

Previous (Tools for Android & iOS)Next (COMING SOON)

Hledáte odborníky pro
Váš softwarový projekt?

Bezplatná konzultace