Chapters in this series:
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 ViewGroup
s 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
On iOS we can find it in Settings → Accessibility → Display & Text Size → Larger Text
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
.
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.
* 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 ScrollView
s. 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 ScrollView
s 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:
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:
👌 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.
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.
🎁 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.
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:
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 Margin
s 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:
🚫 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:
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" />
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!