Skip to content
Home » Basics of .NET MAUI – Part 11 – Visual States and Control Templates

Basics of .NET MAUI – Part 11 – Visual States and Control Templates

Spread the love

In the previous part of the series we created styles and applied them to the particular elements in our app. We aren’t completely done with the styles yet, though.

The source code for this article is available on Github.

First of all, some controls, even if they look OK, don’t look exactly the way we want. Here I mean the radio buttons, for example. What if I wanted to fill the circles with a color or modify another aspect of their visual representation?

Secondly, some controls should change their visual appearance if something happens, like here – there are two entries in the SettingsPage. The first one got focus and changed slightly its visual representation:

But what if you wanted it to change in a different way? Or what if you wanted to disable this change? Or what if you wanted to change the appearance when you hover your mouse cursor over the entry?

In our app, as of now, if you hover your mouse over a button, or even if you click the button, there is no difference, it looks the same. For some controls this functionality has been implemented, to a certain extent, by defining a couple visual states in the Styles.xaml file. But we need to define more visual states.

So, let’s try to address these two kinds of issues.

Visual States Terminology

Controls can be in different states. A button can be disabled or pressed, a radio button can be checked or unchecked, a switch can be on or off, and so on. In .NET MAUI we use visual states to cater to it. Visual states come with their own terminology, let’s start with that.

Visual states can be defined on a particular view or in a style. This determines their scope. But how do we define visual states in the first place?

Well, visual states are handled by the Visual State Manager. They are placed inside visual state groups. There may be one or more such groups and they are identified by name, just like the visual states themselves. The Visual State Manager defines a visual state group called CommonStates that contains the most frequently used states. The CommonStates group contains the following visual states:

Normal,

Disabled,

Focused,

PointerOver

These states are supported by all types that derive from VisualElement. What you should keep in mind is that all states within one visual state group are mutually exclusive, so if the current state changes to, let’s say, Focused, all the settings for the previous state are cleared.

You get all the above visual states in the CommonStates group out of the box, but you can define your own groups with other visual states, which we are going to do in a while. But first let’s see how it works on an example. We’re going to use just the states from the CommonStates group for now.

To set the visual states we use Setter objects that are grouped inside the Setters property of the VisualState object.

CommonStates

Without further ado, let’s demonstrate how visual states can be implemented and how they work on the example of the Button view. At first, we’ll set the visual states on one particular view, which is the Ready button in the SettingsPage, and then we’ll move it to a style so that it can be used by all the buttons in the app. Here’s the SettingsPage.xaml file:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>    
    ...
        <!--the Ready button-->
        <Button 
            Grid.Row="3" 
            Text="Ready">
            <VisualStateManager.VisualStateGroups>
                <VisualStateGroup x:Name="CommonStates">
                    <VisualState x:Name="Normal" />

                    <VisualState x:Name="Disabled">
                        <VisualState.Setters>
                            <Setter Property="BackgroundColor" Value="Gray" />
                        </VisualState.Setters>
                    </VisualState>

                    <VisualState x:Name="PointerOver">
                        <VisualState.Setters>
                            <Setter Property="Scale" Value="1.1" />
                            <Setter Property="Rotation" Value="180" />
                            <Setter Property="FontSize" Value="30" />
                            <Setter Property="TextColor" Value="White" />
                        </VisualState.Setters>
                    </VisualState>
                </VisualStateGroup>
            </VisualStateManager.VisualStateGroups>
        </Button>
    </Grid>
</ContentPage>

As you can see, we defined three visual states for the button. The first one, Normal, is an empty state. We must still include it because otherwise the button wouldn’t revert from another state to its Normal state. For the Disabled state we defined one Setter. In order to see this state in action, we would have to make the button disabled. You can do it manually by setting its IsEnabled property to False:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>    
    ...
        <!--the Ready button-->
        <Button 
            Grid.Row="3" 
            Text="Ready"
            IsEnabled="False">
            <VisualStateManager.VisualStateGroups>
                ...

If you now run the app, the button will be in the Disabled state and its background color will change:

Remove the IsEnabled property from the code. And now let’s test the third state we defined, PointerOver. Just hover your mouse pointer over the button and you should notice that the four properties defined in the Setters property have changed:

Well, maybe we don’t want the changes to be so extreme, so let’s modify the code a bit. We’ll just change the background color to a slightly brighter shade for the PointerOver state and we’ll reduce the button’s opacity for the Disabled state:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>    
    ...
        <!--the Ready button-->
        <Button 
            ...
            <VisualStateManager.VisualStateGroups>
                <VisualStateGroup x:Name="CommonStates">
                    <VisualState x:Name="Normal" />

                    <VisualState x:Name="Disabled">
                        <VisualState.Setters>
                            <Setter Property="Opacity" Value=".3" />
                        </VisualState.Setters>
                    </VisualState>

                    <VisualState x:Name="PointerOver">
                        <VisualState.Setters>
                            <Setter Property="BackgroundColor" Value="#891C20" />
                        </VisualState.Setters>
                    </VisualState>
                </VisualStateGroup>
            </VisualStateManager.VisualStateGroups>
        ...

Hover your mouse cursor over the button, then temporarily disable it by setting the IsEnabled property like you did before and compare the three states:

When done, remove the IsEnabled property again.

Well, we just handled the visual states of the Ready button in the SettingsPage, but what about the other buttons? Can we define visual states that will be shared by all the buttons? Well, we can easily accomplish this by moving the visual states to a style.

Visual States in a Style

As you know, we can define styles on different levels. We usually define them on page level or globally in the App.xaml file. As we want the visual states to apply to all the buttons throughout the entire app, let’s define them in a global style. We already have an implicit style there that applies to all buttons:

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    <Application.Resources>
        <ResourceDictionary>            
            ...
            <Style TargetType="Button">
                <Setter Property="BackgroundColor" Value="#520000" />
                <Setter Property="TextColor" Value="#FFCC1A" />
                <Setter Property="FontSize" Value="18" />
                <Setter Property="FontAttributes" Value="Bold" />
                <Setter Property="HorizontalOptions" Value="Center" />
                <Setter Property="WidthRequest" Value="250" />
                <Setter Property="HeightRequest" Value="50" />
            </Style>

            <Style x:Key="labelBaseStyle" TargetType="Label">
                ...

In order to add visual states to this style, we have to set the VisualStateManager.VisualStateGroups property, which we can do by adding a new Setter object. The VisualStateGroups property is of type VisualStateGroupList. As the Value property of the Setter object is the content property, we can define the VisualStateGroupList object as its child. So, let’s move the visual states code to the style:

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    <Application.Resources>
        <ResourceDictionary>            
            ...
            <Style TargetType="Button">
                <Setter Property="BackgroundColor" Value="#520000" />
                <Setter Property="TextColor" Value="#FFCC1A" />
                <Setter Property="FontSize" Value="18" />
                <Setter Property="FontAttributes" Value="Bold" />
                <Setter Property="HorizontalOptions" Value="Center" />
                <Setter Property="WidthRequest" Value="250" />
                <Setter Property="HeightRequest" Value="50" />
                <Setter Property="VisualStateManager.VisualStateGroups">
                    <VisualStateGroupList>
                        <VisualStateGroup x:Name="CommonStates">
                            <VisualState x:Name="Normal" />

                            <VisualState x:Name="Disabled">
                                <VisualState.Setters>
                                    <Setter Property="Opacity" Value=".3" />
                                </VisualState.Setters>
                            </VisualState>

                            <VisualState x:Name="PointerOver">
                                <VisualState.Setters>
                                    <Setter Property="BackgroundColor" Value="#891C20" />
                                </VisualState.Setters>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateGroupList>
                </Setter>
            </Style>

            <Style x:Key="labelBaseStyle" TargetType="Label">
                ...

Don’t forget to remove this code from the Button view. It should be defined only once, in the style. If you run the app now, it will work the same. But this time it should work for all the buttons. Let’s set the RacePage as the starting page and check it out:

As you can see, it works. But how to we set the visual state for when the button is pressed? Turns out, there are control-specific states that we can use.

Specific Visual States

The visual states described above, like Normal, Disabled, Focused and PointerOver can be defined for any VisualElement. But there are also visual states specific to just one particular state. For example, there’s the IsChecked visual state for the CheckBox, there’s On and Off for the Switch, Selected for the CollectionView, or Checked and Unchecked for the RadionButton. We do have some radio buttons in our app and we will use visual states with them, but first let’s take care of the Button objects. There’s the Pressed visual state that we are going to implement.

So, let’s add the Pressed visual state to the CommonStates group in the App.xaml file:

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    ...
            <Style TargetType="Button">
                ...
                <Setter Property="VisualStateManager.VisualStateGroups">
                    <VisualStateGroupList>
                        <VisualStateGroup x:Name="CommonStates">
                            ...
                            <VisualState x:Name="PointerOver">
                                ...
                            <VisualState x:Name="Pressed">
                                <VisualState.Setters>
                                    <Setter Property="Scale" Value=".98" />
                                    <Setter Property="BackgroundColor" Value="#891C20" />
                                </VisualState.Setters>
                            </VisualState>
                        </VisualStateGroup>
                    ...

If you now press the button, it will both shrink and change color:

We’re done with the Button. Next, let’s say we want to change the appearance of the radio buttons. Well, it’s not that easy using just styles. But don’t worry, this is where visual states and control templates come to the rescue.

ControlTemplates

Control templates is a tool that enables you to define the visual structure of controls. We’re not going to discuss them here in great detail, but we’ll use them to modify the appearance of our radio buttons. We’ll do it in App.xaml because we want uniform radio buttons all throughout the app.

We want the circles of the radio buttons to be filled with the same shade of red or brown – depending on how you perceive this color – as the buttons in Normal state. When checked, a yellow circle will appear inside. It’s always easier to understand if you see it, so this is how the radio buttons should look:

These are the radio buttons in the Players section of the SettingsPage. As you can see, I also added some space between the particular radio buttons. The third button is checked, so a little yellow circle can be seen in it. By the way, it’s the same shade of yellow as the text color on the buttons. Now, how does it look in code? We’re going to modify the part of the App.xaml file where the style for the RadioButton is defined, which is right after the definition of the Entry style. The code should look like this:

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    <Application.Resources>
        <ResourceDictionary>            
            ...
            <Style TargetType="Entry">
                ...
            <ControlTemplate x:Key="RadioButtonTemplate">                
                <FlexLayout JustifyContent="Start">
                    <FlexLayout.Resources>
                        <Style TargetType="Label">
                            <Setter Property="FontSize" Value="18"/>
                            <Setter Property="VerticalOptions" Value="Center" />

                            <Setter Property="Margin" Value="0, 0, 40, 0" />
                        </Style>
                    </FlexLayout.Resources>
                    <Grid     
                        Margin="0, 0, 5, 0"
                        WidthRequest="28"
                        HeightRequest="28"
                        HorizontalOptions="Center"
                        VerticalOptions="Center">
                        <Ellipse
                                Fill="#520000"
                                HeightRequest="24"
                                WidthRequest="24"
                                HorizontalOptions="Center"
                                VerticalOptions="Center" />
                        <Ellipse
                                x:Name="check"
                                Fill="#FFCC1A"
                                HeightRequest="12"
                                WidthRequest="12"
                                HorizontalOptions="Center"
                                VerticalOptions="Center" />
                    </Grid>
                    <ContentPresenter />

                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroupList>
                            <VisualStateGroup x:Name="CommonStates">
                                <VisualState x:Name="Checked">
                                    <VisualState.Setters>
                                        <Setter TargetName="check" Property="Opacity" Value="1" />                                        
                                    </VisualState.Setters>
                                </VisualState>
                                <VisualState x:Name="Unchecked">
                                    <VisualState.Setters>
                                        <Setter TargetName="check" Property="BackgroundColor" Value="#F3F2F1" />
                                        <Setter TargetName="check" Property="Opacity" Value="0" />
                                    </VisualState.Setters>
                                </VisualState>                                
                            </VisualStateGroup>
                        </VisualStateGroupList>
                    </VisualStateManager.VisualStateGroups>

                </FlexLayout>
            </ControlTemplate>
            <Style TargetType="RadioButton">
                <Setter Property="ControlTemplate" Value="{StaticResource RadioButtonTemplate}" />  
            </Style>            

        </ResourceDictionary>
    </Application.Resources>
</Application>

It’s pretty daunting, so let’s analyze it piece by piece. At first we define a ContentTemplate and give it a name, which in this case is RadioButtonTemplate. Then we define the visual representation of the control using XAML markup. In our case the template will be structured as a FlexLayout with a Grid inside, which in turn will contain two Ellipse objects. Ellipse is a type that you can use to draw an ellipse and we’ll use it to draw the bigger outer circle and the smaller inner circle. The names of the properties inside the Grid and the two ellipses are self-explanatory.

We also define a style that will be applied to all labels inside our radio button. We only have one – this is the content of the radio button, which you can see next to it. This content will be placed inside the ContentPresenter object. If you put the object before the Grid, the label would precede the circle, but we want it to follow it, hence the position of the ContentPresenter after the Grid.

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    ...
            <ControlTemplate x:Key="RadioButtonTemplate">                
                <FlexLayout JustifyContent="Start">
                    <FlexLayout.Resources>
                        <Style TargetType="Label">
                            <Setter Property="FontSize" Value="18"/>
                            <Setter Property="VerticalOptions" Value="Center" />
                            <Setter Property="Margin" Value="0, 0, 40, 0" />
                        </Style>
                    </FlexLayout.Resources>
                    <Grid     
                        Margin="0, 0, 5, 0"
                        WidthRequest="28"
                        HeightRequest="28"
                        HorizontalOptions="Center"
                        VerticalOptions="Center">
                        <Ellipse
                                Fill="#520000"
                                HeightRequest="24"
                                WidthRequest="24"
                                HorizontalOptions="Center"
                                VerticalOptions="Center" />
                        <Ellipse
                                x:Name="check"
                                Fill="#FFCC1A"
                                HeightRequest="12"
                                WidthRequest="12"
                                HorizontalOptions="Center"
                                VerticalOptions="Center" />
                    </Grid>
                    <ContentPresenter />

                    <VisualStateManager.VisualStateGroups>
                        ...

Next, we define two visual states for the RadioButton, Checked and Unchecked. We use the Opacity property to make the inner circle fully opaque in the Checked state and fully transparent in the Unchecked state.

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    ...
                    <ContentPresenter />

                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroupList>
                            <VisualStateGroup x:Name="CommonStates">
                                <VisualState x:Name="Checked">
                                    <VisualState.Setters>
                                        <Setter TargetName="check" Property="Opacity" Value="1" />                                        
                                    </VisualState.Setters>
                                </VisualState>
                                <VisualState x:Name="Unchecked">
                                    <VisualState.Setters>
                                        <Setter TargetName="check" Property="BackgroundColor" Value="#F3F2F1" />
                                        <Setter TargetName="check" Property="Opacity" Value="0" />
                                    </VisualState.Setters>
                                </VisualState>                                
                            </VisualStateGroup>
                        </VisualStateGroupList>
                    </VisualStateManager.VisualStateGroups>
                </FlexLayout>
            ...

We also use the TargetName property in the code above to reference the smaller Ellipse object. If you look at the second ellipse, you’ll see that we set its name to check. This very name is used here in the Setter object. Finally, we assign the RadioButtonTemplate  to the ControlTemplate property in the style for the RadioButton. I also removed the two Setter objects for the content label (setting the FontSize and VerticalOptions properties) because they are now defined inside the control template.

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    ...
            </ControlTemplate>
            <Style TargetType="RadioButton">
                <Setter Property="ControlTemplate" Value="{StaticResource RadioButtonTemplate}" />  
            </Style>            

        </ResourceDictionary>
    </Application.Resources>
</Application>

If you now run the app, all radio buttons will look the same. We already saw the top row of radio buttons in the SettingsPage. Let’s try out the radio buttons in the Bets panel of the RacePage:

At this moment we can only select one radio button in total, which isn’t the behavior we want, but don’t worry, we’re going to fix this in due time.

The states we’ve been using so far were the common states that we can define for all or some of the controls. But we can also create custom visual states. This is what we’re going to do next.

Custom Visual States

As I just mentioned, we can create custom visual states. I’ll demonstrate it on the example of the entries in the Players section of the SettingsPage. We’re going to use visual states for user input validation.

But before we start, let’s slightly modify the style defined for the Entry control in App.xaml. We want the text to be in bold type and the placeholder text to be a nice shade of gray:

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    <...
            <Style TargetType="Entry">
                <Setter Property="BackgroundColor" Value="White" />
                <Setter Property="FontSize" Value="18" />
                <Setter Property="CharacterSpacing" Value="1.5" />
                <Setter Property="HorizontalOptions" Value="Start" />
                <Setter Property="FontAttributes" Value="Bold" />
                <Setter Property="PlaceholderColor" Value="#A0A0A0" />
            </Style>
            ...

And now let’s create the visual states for our Entry controls. As you look at the code of the SettingsPage, you’ll see that the entries are not used directly there. They are inside the PlayerSettings content view that we created before. So, let’s define the visual states inside this content view.

We’re going to create two custom states for the entries where the names of the players will be entered: NameValid and NameInvalid. They will be inside a visual state group called NameValidityStates.

For the entries where the initial amount of money each player starts the game with is entered, we’ll create a visual state group called InitialMoneyValidityStates with three visual states: InitialMoneyValid, InitialMoneyInvalid and InitialMoneyEmpty.

Let’s have a look at the code and then we’ll discuss what’s new. So, here’s the code in the PlayerSettings.xaml file:

<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...>
    ...
    <Grid
        ...
        <Entry 
            x:Name="nameEntry"
            Grid.Column="1"            
            WidthRequest="300"
            TextChanged="OnNameTextChanged">
            <VisualStateManager.VisualStateGroups>
                <VisualStateGroup x:Name="NameValidityStates">
                    <VisualState x:Name="NameValid">
                        <VisualState.Setters>
                            <Setter Property="BackgroundColor" Value="White" />
                            <Setter Property="TextColor" Value="Black" />
                        </VisualState.Setters>
                    </VisualState>
                    <VisualState x:Name="NameInvalid">
                        <VisualState.Setters>
                            <Setter Property="BackgroundColor" Value="#FFDDEE" />
                            <Setter Property="TextColor" Value="Red" />
                        </VisualState.Setters>
                    </VisualState>
                </VisualStateGroup>                
            </VisualStateManager.VisualStateGroups>
        </Entry>
        <Label
            .../>
        <Entry
            x:Name="initialMoneyEntry"                       
            Placeholder="1000" 
            Grid.Column="3" 
            WidthRequest="250"
            TextChanged="OnInitialMoneyTextChanged">
            <VisualStateManager.VisualStateGroups>
                <VisualStateGroup x:Name="InitialMoneyValidityStates">
                    <VisualState x:Name="InitialMoneyValid">
                        <VisualState.Setters>
                            <Setter Property="BackgroundColor" Value="White" />
                            <Setter Property="TextColor" Value="Black" />
                        </VisualState.Setters>
                    </VisualState>
                    <VisualState x:Name="InitialMoneyInvalid">
                        <VisualState.Setters>
                            <Setter Property="BackgroundColor" Value="#FFDDEE" />
                            <Setter Property="TextColor" Value="Red" />
                        </VisualState.Setters>
                    </VisualState>
                    <VisualState x:Name="InitialMoneyEmpty">
                        <VisualState.Setters>
                            <Setter Property="BackgroundColor" Value="White" />
                            <Setter Property="TextColor" Value="White" />
                            <Setter Property="FontAttributes" Value="None" />
                        </VisualState.Setters>
                    </VisualState>
                </VisualStateGroup>
            </VisualStateManager.VisualStateGroups>
        </Entry>
    ...

As you can see, we gave names to the entries because we’ll have to reference them in the code-behind. We also added the TextChanged events that we’ll also handle in the code-behind. We’re going to validate the text as it’s being entered, so these events are necessary. Additionally, I set the Placeholder property to 1000, which seems a reasonable initial money amount for the game. And then we have the visual states with the custom names we mentioned above. There’s nothing special or new about them.

Now, the states should change depending on the current text in the entries. This functionality is handled in the code-behind. To use a state, we must call the static VisualStateManager.GoToState method. This method takes two arguments: the name of the object on which the state should be set and the state itself. Have a look yourself, here’s the code in the PlayerSettings.xaml.cs file:

using System.Text.RegularExpressions;

namespace Slugrace.Controls;

public partial class PlayerSettings : ContentView
{
    public PlayerSettings()
    {
	InitializeComponent();
	GoToNameState(true);
	GoToInitialMoneyState(true, true);
    }

    private void OnNameTextChanged(object sender, TextChangedEventArgs e)
    {
	bool nameValid = e.NewTextValue.Length <= 10;
	GoToNameState(nameValid);
    }

    private void OnInitialMoneyTextChanged(object sender, TextChangedEventArgs e)
    {
	bool isNumeric = int.TryParse(e.NewTextValue, out int enteredInitialMoney);
	bool isInRange = isNumeric && enteredInitialMoney >= 10 && enteredInitialMoney <= 5000;

	bool initialMoneyValid = isInRange;

	bool initialMoneyEmpty = e.NewTextValue == string.Empty;

	GoToInitialMoneyState(initialMoneyValid, initialMoneyEmpty);
    }

    void GoToNameState(bool nameValid)
	{
	    string visualState = nameValid ? "NameValid" : "NameInvalid";
	    VisualStateManager.GoToState(nameEntry, visualState);
	}

    void GoToInitialMoneyState(bool initialMoneyValid, bool initialMoneyEmpty)
    {
        string visualState = initialMoneyValid ? "InitialMoneyValid" : "InitialMoneyInvalid";

	    if (initialMoneyEmpty)
	    {
		visualState = "InitialMoneyEmpty";
	    }

        VisualStateManager.GoToState(initialMoneyEntry, visualState);
    }
}

As you can see, we created two methods here, GoToNameState and GoToInitialMoneyState. Each of the methods calls the static VisualStateManager.GoToState method on the respective Entry object (referenced by name) and passes to it as the second argument the appropriate visual state.

As you type the players’ names and initial amounts of money in the entries, the OnNameTextChanged and OnInitialMoneyTextChanged methods are fired and the input is validated.

Name validation is very simple: The name is valid as long as it’s not longer than 10 characters.

The initial money amount is valid if a numeric value from a specified range is entered. We treated the case when the input is empty separately. This enables us to define three states for the entries.

And now let’s see how it works. Run your app with the SettingsPage set as the starting page and enter all kinds of text in the entries. Some values should be valid, some should be invalid and one of the initial money entries should be empty. Here’s what I got:

The first name entry has valid input, so it’s in the NameValid visual state. The name in the second one is too long, so the entry is in the NameInvalid state.

The first initial money entry is empty, so it’s in the InitialMoneyEmpty state. The second one has valid input, so it’s in the InitialMoneyValid state. The third and fourth entries have input from outside the range (which is 10-5000), so they’re in the InitialMoneyInvalid state.

In a similar way we can define custom visual states for the other entries in our app. We also have two Entry object in the Ending Conditions section of the SettingsPage and four in the Bets panel in the RacePage. Let’s take care of the former first. But, wait a minute… If we use entries in multiple places, why not move the visual states to the App.xaml file. All entries will be able to use three visual states: Valid, Invalid and Empty. So, the code in the App.xaml file should look like so:

<?xml version = "1.0" encoding = "UTF-8" ?>
<Application ...>
    ...
            <Style TargetType="Entry">
                ...
                <Setter Property="PlaceholderColor" Value="#A0A0A0" />
                <Setter Property="VisualStateManager.VisualStateGroups">
                    <VisualStateGroupList>
                        <VisualStateGroup x:Name="ValidityStates">
                            <VisualState x:Name="Valid">
                                <VisualState.Setters>
                                    <Setter Property="BackgroundColor" Value="White" />
                                    <Setter Property="TextColor" Value="Black" />
                                </VisualState.Setters>
                            </VisualState>
                            <VisualState x:Name="Invalid">
                                <VisualState.Setters>
                                    <Setter Property="BackgroundColor" Value="#FFDDEE" />
                                    <Setter Property="TextColor" Value="Red" />
                                </VisualState.Setters>
                            </VisualState>
                            <VisualState x:Name="Empty">
                                <VisualState.Setters>
                                    <Setter Property="BackgroundColor" Value="White" />
                                    <Setter Property="TextColor" Value="White" />
                                    <Setter Property="FontAttributes" Value="None" />
                                </VisualState.Setters>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateGroupList>
                </Setter>
            </Style>

            <ControlTemplate x:Key="RadioButtonTemplate">                
                ...

Here the names of the states are more general than before because they’re not associated with any particular Entry object, but rather the entire type.

Now you can remove the code responsible for the visual states from the two Entry objects in PlayerSettings.xaml. But leave the names of the entries and the TextChanged events. As the states for particular entries will require different conditions to be met, we’ll implement the state change logic in the code-behind files. Let’s modify the code in the PlayerSettings.xaml.cs file first. It’s the same code as before except for the names of the visual states:

...
public partial class PlayerSettings : ContentView
{
    ...
    void GoToNameState(bool nameValid)
    {
        string visualState = nameValid ? "Valid" : "Invalid";
        VisualStateManager.GoToState(nameEntry, visualState);
    }

    void GoToInitialMoneyState(bool initialMoneyValid, bool initialMoneyEmpty)
    {
        string visualState = initialMoneyValid ? "Valid" : "Invalid";

        if (initialMoneyEmpty)
        {
            visualState = "Empty";
        }

        VisualStateManager.GoToState(initialMoneyEntry, visualState);
    }
}

The validation should work as before. But don’t take my word for it. How about the other entries? Well, the other entries should all accept numeric input in a certain range, so, if we implement the code like before we’ll get a lot of almost identical repetitive code scattered all around the code-behind files. In order to keep our code dry (DRY = Don’t Repeat Yourself – often used to describe code that is not repetitive), we’ll create a static class with a helper method that we can use to validate the user input in the entries. So, in the root of the project add a class and name it Helpers. Make it static and add a method to it. This static method could be named ValidateNumericInputAndSetState and it should combine the functionality of the OnInitialMoneyTextChanged method and the GoToInitialMoneyState method defined in the PlayerSettings.xaml.cs file. Here’s the Helpers class:

namespace Slugrace
{
    public static class Helpers
    {
        public static void ValidateNumericInputAndSetState(string enteredText, int min, int max, VisualElement control)
        {
            bool isNumeric = int.TryParse(enteredText, out int numericValue);
            bool isInRange = isNumeric && numericValue >= min && numericValue <= max;
            bool isValid = isInRange;
            bool isEmpty = enteredText == string.Empty;

            string visualState = isValid ? "Valid" : "Invalid";

            if (isEmpty)
            {
                visualState = "Empty";
            }

            VisualStateManager.GoToState(control, visualState);
        }
    }
}

As you can see, we pass all the data it needs as arguments. We have the text entered in the entry, the minimum and maximum limits of the range we want to check, and the control whose input should be validated.

With that in place, we can modify the code in PlayerSettings.xaml.cs:

namespace Slugrace.Controls;

public partial class PlayerSettings : ContentView
{
    public PlayerSettings()
    {
        InitializeComponent();
        GoToNameState(true);
        VisualStateManager.GoToState(initialMoneyEntry, "Empty");
    }

    private void OnNameTextChanged(object sender, TextChangedEventArgs e)
    {
	...
    }

    private void OnInitialMoneyTextChanged(object sender, TextChangedEventArgs e)
    {
        Helpers.ValidateNumericInputAndSetState(e.NewTextValue, 10, 5000, initialMoneyEntry);
    }

    void GoToNameState(bool nameValid)
    {
        ...
    }
}

So, we use the Helpers.ValidateNumericInputAndSetState here. We got rid of the GoToInitialMoneyState method. In order for the initial money entries to be in the Empty state when the app starts, we moved the code responsible for that from the GoToInitialMoneyState method directly to the constructor. We didn’t change anything about the code that validates the input in the name entries because it’s different and it’s used only in this one place.

And now let’s take care of the two entries in the Ending Conditions section. The first one will be used for entering the maximum number of races after which the game is over. This should be a number between 1 and 100. In the final version of the app this entry will be visible only if the second ending condition is selected. If the third condition is selected, the second entry will become visible and you’ll be able to enter the maximum number of minutes the game should last. This number should be between 1 and 120.

Our validation code must pick the correct visual state depending on what text we enter. The Valid state will be chosen if the text is a number between 1 and 100 in the first entry or between 1 and 120 in the second one. We’ll also give names to the entries and add events to them. The two entries are defined directly in the SettingsPage, so let’s add the following code in the SettingsPage.xaml and SettingsPage.xaml.cs files, starting with the former:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>    
    ...
        <!--the Ending Conditions panel-->
        ...
                    <RadioButton
                        Grid.Row="1"
                        Content="The game is over not later than after a given number of races."
                        GroupName="endingConditions" />
                    <Entry
                        x:Name="maxRacesEntry"
                        Grid.Row="1"
                        Grid.Column="1" 
                        Placeholder="Set max number of races (1-100)"
                        WidthRequest="300"
                        HorizontalOptions="Start" 
                        TextChanged="OnMaxRacesTextChanged"/>
                    <RadioButton
                        ...
                    <Entry
                        x:Name="maxTimeEntry"
                        Grid.Row="2"
                        Grid.Column="1" 
                        Placeholder="Set max game time (1-120 min)"
                        WidthRequest="300" 
                        HorizontalOptions="Start" 
                        TextChanged="OnMaxTimeTextChanged"/>                    
                </Grid>
            ...

Here I also changed the value of the WidthRequest property to accommodate the entire placeholder text. And here’s the code-behind:

namespace Slugrace.Views;

public partial class SettingsPage : ContentPage
{
    public SettingsPage()
    {
        InitializeComponent();
        VisualStateManager.GoToState(maxRacesEntry, "Empty");
        VisualStateManager.GoToState(maxTimeEntry, "Empty");
    }

    private void OnMaxRacesTextChanged(object sender, TextChangedEventArgs e)
    {
        Helpers.ValidateNumericInputAndSetState(e.NewTextValue, 1, 100, maxRacesEntry);
    }

    private void OnMaxTimeTextChanged(object sender, TextChangedEventArgs e)
    {
        Helpers.ValidateNumericInputAndSetState(e.NewTextValue, 1, 120, maxTimeEntry);
    }
}

This code doesn’t really differ much from that in the PlayerSettings.xaml.cs file. You can now run the app and check whether the validation works:

As you can see, the first entry is in Invalid state whereas the second one is in Empty state.

And finally, let’s take care of the radio buttons in the Bets panel in RacePage, or, to be precise, in the PlayerBet control where the entry actually is. This is going to be quick and easy, so here’s the PlayerBet.xaml file:

<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...>
    <Grid
        ...
        <Entry 
            x:Name="betAmountEntry"
            Grid.Column="3"  
            WidthRequest="200" 
            Placeholder="1 - 1000"
            TextChanged="OnBetAmountTextChanged" />
        <Slider 
            ...

And here’s the code-behind file (PlayerBet.xaml.cs):

namespace Slugrace.Controls;

public partial class PlayerBet : ContentView
{
    public PlayerBet()
    {
        InitializeComponent();
        VisualStateManager.GoToState(betAmountEntry, "Empty");
    }

    private void OnBetAmountTextChanged(object sender, TextChangedEventArgs e)
    {
        Helpers.ValidateNumericInputAndSetState(e.NewTextValue, 1, 1000, betAmountEntry);
    }
}

If you now run the RacePage, you can see that the text you enter is validated:

By the way, the placeholder text is hardcoded for now. In the final version of the game the fixed value of 1000 will be replaced by the actual amount of money the player currently has and can bet.

Good, now we can create custom visual states. But what if we want to see the entries in the Ending Conditions section of the SettingsPage only if a corresponding radio button is checked? To implement this, we would have to be able to attach visual states to an element and let them set properties on other elements. And, yes, this is possible.

Setting States on Other Objects

Let’s start with the radio buttons and entries in the Ending Conditions section of the SettingsPage. The two entries should be by default invisible and disabled. To ensure that, we have to set Opacity to 0 and IsEnabled to False on each of them. Let’s do it then. Here’s the code in the SettingsPage.xaml page:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>    
    ...
        <!--the Ending Conditions panel-->
        ...
                    <RadioButton
                        ...
                    <RadioButton
                        ...
                    <Entry
                        x:Name="maxRacesEntry"
                        Grid.Row="1"
                        Grid.Column="1" 
                        Placeholder="Set max number of races (1-100)"
                        WidthRequest="300"
                        HorizontalOptions="Start"                         
                        Opacity="0"
                        IsEnabled="False"                        
                        TextChanged="OnMaxRacesTextChanged"/>
                    <RadioButton
                        ...
                    <Entry
                        x:Name="maxTimeEntry"
                        Grid.Row="2"
                        Grid.Column="1" 
                        Placeholder="Set max game time (1-120 min)"
                        WidthRequest="300" 
                        HorizontalOptions="Start" 
                        Opacity="0"
                        IsEnabled="False"
                        TextChanged="OnMaxTimeTextChanged"/>                    
                </Grid>
            ...

Then we’ll add visual states to the radio buttons that will change the state not of themselves, but of the entries. Look how it’s done:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>    
    ...
        <!--the Ending Conditions panel-->
        ...
                <Grid
                    ...
                    <RadioButton
                        ...
                    <RadioButton
                        Grid.Row="1"
                        Content="The game is over not later than after a given number of races."
                        GroupName="endingConditions">
                        <VisualStateManager.VisualStateGroups>
                            <VisualStateGroup x:Name="CommonStates">
                                <VisualState x:Name="Unchecked" />
                                <VisualState x:Name="Checked">
                                    <VisualState.Setters>
                                        <Setter TargetName="maxRacesEntry" Property="Opacity" Value="1" />
                                        <Setter TargetName="maxRacesEntry" Property="IsEnabled" Value="True" />
                                    </VisualState.Setters>
                                </VisualState>
                            </VisualStateGroup>
                        </VisualStateManager.VisualStateGroups>
                    </RadioButton>
                    <Entry
                        x:Name="maxRacesEntry"
                        ...
                    <RadioButton
                        Grid.Row="2"
                        Content="The game is over not later than after the racing time you set has elapsed."
                        GroupName="endingConditions">
                        <VisualStateManager.VisualStateGroups>
                            <VisualStateGroup x:Name="CommonStates">
                                <VisualState x:Name="Unchecked" />
                                <VisualState x:Name="Checked">
                                    <VisualState.Setters>
                                        <Setter TargetName="maxTimeEntry" Property="Opacity" Value="1" />
                                        <Setter TargetName="maxTimeEntry" Property="IsEnabled" Value="True" />
                                    </VisualState.Setters>
                                </VisualState>
                            </VisualStateGroup>
                        </VisualStateManager.VisualStateGroups>
                    </RadioButton>
                    <Entry
                        x:Name="maxTimeEntry"
                        ...

There are a couple things worth mentioning here. First, we’re using the Unchecked state as an empty state, which means the default values (the ones set on the respective Entry object) will be used when the radio button is unchecked. Next, in the Checked state we set Opacity to 1 and IsEnabled to True, so the entry will be visible and you will be able to enter text in it when the radio button is checked.

Now, if you attach visual states to an object, which is not the object on which the properties are supposed to be set, you must somehow reference the other object. You do it by setting the TargetName property to its name, like here: The visual states are attached to the radio buttons, but the properties are set on the entries. The entries are referenced by their names.

Let’s also check the first radio button by setting its IsChecked property to True. This will make it the first ending condition the default one:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>    
    ...
        <!--the Ending Conditions panel-->
        ...
                    <RadioButton
                        Content="The game is over when there is only one player with any money left."
                        IsChecked="True"
                        GroupName="endingConditions" />                    
                    <RadioButton
                        ... 

Now if you run the app, the first ending will be checked and you won’t see any of the two entries:

If you select one of the other two radio buttons, their corresponding entry will show up:

Good. That’s it. We’ve covered the basics of visual states and control templates. You might have noticed that some of the values in the styles were repeated multiple times. For example, we used the same color value for the background color of the Button, the color of the Border and the color of the outer circle of the RadioButton. If we wanted to change the color now, we would have to do it in multiple places, which is tedious and error-prone. But there is a way to solve this problem – we can use markup extensions. And there are many other use cases for markup extensions, so let’s talk about them in the next part of the series.


Spread the love

Leave a Reply