Part of #dotnet #dotnetmaui #MAUIUIJuly!
Follow 💡𝚂𝗆𝖺𝗋𝗍𝗆𝖺𝗇 𝙰𝗉𝗉𝗌📱 on Mastodon
GOALS
Visual Studio (VS) does not (currently) provide a blank .NET Multi-platform Application User Interface (MAUI) template which is in C# only. In this post we shall cover how to modify your new MAUI solution to get rid of the XAML, as well as cover how to do in C# code the things which are currently done in XAML (such as binding). We shall also briefly touch on some of the advantages of doing this.
WHY?
In addition to personal preferences, you can find a bunch of resources about the performance benefits by Brandon Minnick at https://codetraveler.io/maui-markup/ (note that although Brandon uses the CSharpForMarkup package, this tutorial uses out-of-the-box C# - no packages :-) ).
Here's a sneak peek at some of the slides there...
PRE-REQUISITES
- Visual Studio 2022 (any version) with MAUI installed
- .NET 6, 7, or 8
- some minimal familiarity with C#
NOT NEEDED
- knowledge of XAML 😀
CREATE NEW MAUI APP IN VISUAL STUDIO
- File->New->Project
- Set project type to MAUI, select .NET MAUI App, then "Next"
- Name your project (I'll be using CSharpUI but you can choose your own name), set the folder location you want the project to be in, then "Next"
- Select the .NET version you want to use (I'll be using 7, but 6 or 8 - 8 is in Preview at time of writing - will also work), then "Create"
- Finally Debug->Start Debugging, and we will see this (on Windows - may look different if you are on another platform)... This is what we are initially going to re-create, using only C#.
RE-CREATE THE ORIGINAL CONTENTPAGE
I have made the first commit at this point. The repo is at https://github.com/SmartmanApps/CSharpUI. This is preserved in the Master branch - all changes will be made in different branches so that you can swap between them to compare (though referring to the repo is optional - all the information you need is in this blog post).
First we will recreate the ContentPage to look exactly the same as the original, but with the UI 100% in C#. This repo branch is called OriginalContentInCSharp.
The very first thing I'm going to do though is go into the csproj (right click on the project and select "Edit Project File") and disable implicit usings, by changing the relevant line to be...
<ImplicitUsings>disable</ImplicitUsings>
This step is optional, and the reason I'm doing it is so that you can see explicitly what usings have been added - details shouldn't be hidden from the reader in examples. If you do this step and try to run it, you will get a bunch of error messages - you can just click on each one to be taken to the relevant line, then click on the light-globe on the left, and add the suggested missing using statement. most commonly this will be a MAUI library, as per this screenshot...
but we don't need to do it for the ContentPage, as we're about to delete that anyway (or you can do it for the ContentPage too just to make sure you haven't missed any others by checking that the app still works).
Now we start the actual changing the UI to be in C#...
1. Right-click on MainPage.xaml and delete it.
2. Right-click on the project and add a class - click on ".NET MAUI", select ".NET MAUI ContentPage (C#)" and call it MainPage.cs, then "Add".
If you did as I did and disabled implicit usings, then click on the lines with errors and add the missing using (in this case it will be Microsoft.Maui.Controls).
3. I'm gonna cheekily add "in C#" to the Label, so that the full Label text reads "Welcome to .NET MAUI in C#!". 😀
4. Run it. Welcome to your first ContentPage written 100% in C#!
These changes have been committed as "Default C# ContentPage". Now we want to make it look like the original ContentPage...
5. Add some structure ready for our new code to be added: first we delete everything inside the constructor - we won't be re-using any of that - and then I (you don't have to) am going to put some regions in (I always collapse whatever I'm not currently working on to minimise scrolling) which will lay out the structure we will follow, which will be to first define our views, then add them to the GUI, and then after the constructor any methods. So our page currently looks like this...
public class MainPage : ContentPage
{
#region constructor
public MainPage() {
#region views
#endregion
#region assemble GUI
#endregion
}
#endregion
#region methods
#endregion
}
..and then where we are putting the GUI together I'm going to put...
batchbegin();
batchcommit();
For those not familiar with this, normally a layout recalculation is done each time you add an element to the UI, but the batch begin and commit says that we are going to make a bunch of changes, and don't do any recalculations until we are done adding elements. If you have N elements on your page, then using this will eliminate N-1 recalculations. Once your page starts to get complex you will see a big performance difference - even a complex page will still render almost instantly.
6. Define our elements: Well, we get to cheat a bit here, since we're recreating an existing UI - we can just read through MainPage.xaml and see what's there.:-) The ScrollView and VerticalStackLayout are used to position the other elements on the screen, so that'll go in our "Assemble GUI" section - everything else are views. Additionally, because we need to update the button text from a method (which we'll get to in our next step), the button needs to be declared outside the constructor, so that it is in scope and accessible from the method, so that goes at the top, as does our count integer (for the same reason), and the rest goes in our "Views" section, so that we now have this...
Button CounterButton;
int Count=0;
#region constructor
public MainPage() {
#region views
#region Buttons
CounterButton=new Button{
Text="Click me",
HorizontalOptions=LayoutOptions.Center,
};
#endregion
#region Images
Image dotnetBotImage=new Image{
Source="dotnet_bot.png",
HeightRequest=200,
HorizontalOptions=LayoutOptions.Center,
};
#endregion
#region Labels
Label helloWorldLabel=new Label{
Text="Hello, World!",
FontSize=32,
HorizontalOptions=LayoutOptions.Center,
};
Label welcomeLabel=new Label{
Text="Welcome to .NET Multi-platform App UI... with C# UI!",
FontSize=18,
HorizontalOptions=LayoutOptions.Center,
};
#endregion
#endregion
7. Now we need to subscribe a method to the Button's Clicked event. If we start typing it in then VS will come up with a suggested name, so we can just accept that (by pressing tab and enter), and it will create the scaffolding for the method (which we may need to reposition from where it creates it, so that it's inside our "methods" region). So firstly our button code will now look like this...
CounterButton=new Button{
Text="Click me",
HorizontalOptions=LayoutOptions.Center,
};
CounterButton.Clicked+=CounterButton_Clicked;
...and the method shall (initially) look like this...
#region methods
private void CounterButton_Clicked(object sender,System.EventArgs e)
{
throw new System.NotImplementedException();
}
#endregion
Now finally, for this step, we just delete the NotImplementedException and add the necessary code, like so...
private void CounterButton_Clicked(object sender,System.EventArgs e)
{
Count++;
if (Count==1) {CounterButton.Text="Clicked 1 time";}
else {CounterButton.Text=$"Clicked {Count} times";}
}
8. Now we're ready to assemble the GUI, and run it! We need to define a StackLayout (to add a Thickness to the Padding, we will need to add a "using Microsoft.Maui" at the top - again we can just click on the globe to accept this suggestion when it comes up during our typing), add our elements to it, and then put the StackLayout into a ScrollView, and assign the ScrollView to be the Content of the ContentPage. All of this goes after our BatchBegin and before our BatchCommit, like so...
BatchBegin();
VerticalStackLayout mainPageStackLayout=new VerticalStackLayout{
Spacing=25,
Padding=new Thickness(30,0),
};
mainPageStackLayout.Add(dotnetBotImage);
mainPageStackLayout.Add(welcomeLabel);
mainPageStackLayout.Add(CounterButton);
ScrollView mainPageScrollView=new ScrollView{Content=mainPageStackLayout};
Content=mainPageScrollView;
BatchCommit();
...and when we run it, VOILA! Welcome to .NET Multi-platform App UI... with C# UI!
NOTE: if you remove the Height request from the Image definition then you will be able to se the ScrollView in action, as the image is actually very large!
And let's have a look at it running on Android too (if you disabled implicit usings then you'll need to add some missing ones. As before, just click on the error message and light-globe to find them)
These working code changes have all been committed to the OriginalContentInCSharp branch with the message "Original layout fully implemented!".
Now let's make it better!
- how to get rid of the XAML in App.cs!
- how to use a Grid for our layout
- how to implement MVVM/binding
REPLACE APP.XAML WITH APP.CS
1. I have created a new branch for this one, starting from scratch again, and it is called GridAndMVVM. Same as before I'm turning off implicit usings (optional for you), so that there are no hidden details, and I will do all the same steps as in our previous branch up to the first commit, "Default C# ContentPage" (so refer back to that if you need those directions again).
2. At this point I'm going to delete AppShell.XAML, because I never use it. In our next step we'll see how easy it is to bypass. I'm therefore not intending to cover how to make a C# version of it, however if you would like to see that then you can find an implementation of it at github.com/CommunityToolkit/Maui.Markup/tree/main/samples/CommunityToolkit.Maui.Markup.Sample (Thanks to Brandon Minnick for the link). So step 2, I'm deleting AppShell.XAML, and that's it - not replacing it with anything.
3. Next we're going to delete App.XAML and replace it with some code-only classes - multiple, because if you look in App.XAML you'll see it contains references to Colors.XAML and Styles.XAML, so we are going to need to replace those also with code-only versions, which we will then be using in the new version of our ContentPage. So step 3, delete App.XAML.
4. Add a new class and call it App.cs. Delete all the usings that are there - we won't need any of them. Then subclass Application (may need to add using Microsoft.Maui.Controls).
using Microsoft.Maui.Controls;
namespace CSharpUI;
internal class App : Application
{
}
5. Add a constructor in the brackets - we can do this by typing in ctor and pressing tab to accept the suggestion. Then add MainPage=new MainPage(); to the constructor.
public App() {
MainPage=new MainPage();
}
If you run the app now it'll work (provided you haven't missed anything or made any typo's). The difference you'll notice from before is it no longer says "Home" in the top-right, because we are no longer embedding our MainPage in an AppShell - we are just calling it directly from App.cs. I'm going to make another commit at this point as "App.cs added".
6. Next we need to replace Colors.XAML and Styles.XAML so that we have the tools we need there to have a consistent look to our app. As well as creating the classes we'll be creating interfaces, so that we can pass them around between our pages (though in this particular case we only have 1 page). Normally I would have these in separate repo's (re-used by multiple apps), but for this blog post I'll keep them in the same repo.
Add a class and call it Colours.cs (or Colors.cs if you must 😂). As before, delete all the usings there - we won't need them - and change the class to be public (you'll want this if you put it in a separate repo to be re-used by multiple apps), and sub-class INotifyPropertyChanged (which will need using System.ComponentModel). We will get an error message that it isn't implemented, and we can just click on the light-globe to implement it.
using System.ComponentModel;
namespace CSharpUI;
public class Colours : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
...
For this blog post I'm just going to implement 2 colours, Foreground (text) and Background. you will often see in MAUI classes TextColorProperty and BackgroundColorProperty, but I'm going with Foreground rather than text because I actually usually use more than one colour with text! e.g. Warning (usually red) for warning messages, and Foreground for other text, so to call one of them Text would be confusing. If you're wondering, I usually have 6 colours - Foreground, Background, Accent (contrasts with the previous 2 colours - used in things like borders), Warning, EntryText and EntryBackground (usually black on white, to stand out from the other colours I'm using).
The other thing to note here is that our Color Properties will be... Colors - no value converters needed like in XAML! That's a bunch of overhead done away with. :-)
So, now we just need to add our 2 properties. Each needs a backing field, and I use Hexadecimal values to set those, not named colours (though you can do it a different way if you choose - this is just my preferred way to do it - I actually give my users the ability to choose their own colours by entering the hex value in a settings page Entry). We will need using Microsoft.Maui.Graphics for this.
private Color _Foreground=Color.FromArgb("#FF00CC00");
And ditto for Background, but set to whatever colour you want for the background. e.g. #FF000000 for black.
The other thing of note here is my choice of CC for the Green value. I usually use CC as it's web-safe, and not as harsh on the eyes as FF.
Now we need to implement the public property get and set. Note that if we want to make sure the incoming value is different from the existing value (which I usually do) then we have to compare their hex representations, as you can't directly compare Colors.
public Color Foreground
{
get {return _Foreground;}
set {
if (_Foreground.ToArgbHex().ToString()!=value.ToArgbHex().ToString()) {
_Foreground=value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Foreground)));
}
}
}
And ditto for Background.
That's our class implemented, but not yet tested. Now we want to generate an interface for it, which we can do by clicking on the class name, then clicking on the light-globe, then clicking on "Extract Interface".
Now call it IColours (or IColors if you must :-) ), and uncheck PropertyChanged (we don't need to access that through the interface, only the colours), and "OK".
...and that has now been all implemented for us. :-) Note that if you want to do the interface in a separate repo you can just copy the code to a new repo and delete the interface from here (after you have added references to the other repo of course).
7. We'll just do a quick test of Colours before we move onto the replacing Styles.XAML. Switch back to MainPage.cs, and at the top of the class add...
IColours Colours=new Colours();
..and now, bearing in mind I set Foreground to green, and our current default text colour is white (if you don't have a different colour then how you gonna know it's working? ;-) ) add to the Label definition...
TextColor=Colours.Foreground,
then run it, and voila! Green text.
So I'll commit again at this point, and label this one "Colours and IColours added".
8a. Now we are going to replace Styles.XAML, but take it up a notch by creating a View factory (and an interface for it). So first add a new class, call it ViewFactory.cs, make it public, delete the usings, and add a constructor.
We are going to pass our Colours interface to it, so at the top of the class we shall declare "IColours Colours", pass "IColours colours" as a parameter in the constructor, and then assign "Colours=colours". Some people use "this", underscores, and other things, but I just use lower-case for the constructor-scoped one, and upper-case for the one that has scope throughout the class, and in fact throughout the whole app (since it was declared in MainPage, and passed into ViewFactory). Colours here is a local pointer to a global object (and in fact you can make it global if you register it as a service in your root container, but we won't be going into that in this blog). So at this stage our class looks like this...
namespace CSharpUI;
public class ViewFactory
{
IColours Colours;
#region constructor
public ViewFactory(IColours colours) {
Colours=colours;
}
#endregion
#region methods
#endregion
}
Now we'll add a NewLabel method to it, and as part of our NewLabel method, we are going to bind the TextColor and BackgroundColor to the Colours object we have just created! This saves us binding it each time, and gives us a consistent look throughout the app, and in fact a consistent look across all our platforms (because we are now replacing the default colours with our own colours). Of course, if you have cases where you don't want to do that then you can create different methods for each scenario (or even pass the colours that you do want to bind to as parameters), but for now let's stick with the same look for every label. But before we do all that, let's discuss binding (as we haven't covered that yet)...
8b There are several different ways to bind, but after some time I eventually found what I consider to be the best way to do it, which enables you to do it all in one line, and have different binding sources on the same page. Bear in mind that we are now going to have multiple binding sources on the same page, because we now need to bind to our Colours object, plus of course any ViewModel you need to bind to for other things on your Page (such as business logic for the page), and using this syntax gives you that flexibility.
The generic syntax for it is...
object.SetBinding(type.property, new Binding("propertyname", source:sourcename))
So to set our Label (which will require a using Microsoft.Maui.Controls) TextColor to our custom Foreground Color, that will be...
newLabel.SetBinding(Label.TextColorProperty, new Binding(nameof(Colours.Foreground), source:Colours));
...and for the background it will be...
newLabel.SetBinding(Label.BackgroundColorProperty, new Binding(nameof(Colours.Background), source:Colours));
And lastly with this method, we want to optionally tell it what Text we want, and otherwise leave it blank (if we want to bind the Text to a property back in our main code), so the whole method will now look like this...
public Label NewLabel(string text="")
{
Label newLabel=new Label();
if (text.Length>0) {newLabel.Text=text;}
newLabel.SetBinding(Label.TextColorProperty, new Binding(nameof(Colours.Foreground), source:Colours));
newLabel.SetBinding(Label.BackgroundColorProperty, new Binding(nameof(Colours.Background), source:Colours));
return newLabel;
}
And as we did with Colours, we shall extract an interface for it. We can test that when we start making our changes to MainPage, so I'll leave the commit for our next section...
USING A GRID FOR OUR CONTENTPAGE
1. First let's quickly test our ViewFactory so that we can commit those changes. On MainPage, at the top (after our Colours declaration) add "IViewFactory ViewFactory;", initialise it in the constructor (remembering that we need to pass Colours to it), and set the Page Content to be a ViewFactory-generated Label, like so...
using Microsoft.Maui.Controls;
namespace CSharpUI;
public class MainPage : ContentPage
{
IColours Colours=new Colours();
IViewFactory ViewFactory;
public MainPage() {
ViewFactory=new ViewFactory(Colours);
Content=ViewFactory.NewLabel("Our ViewFactory-generated Label!");
}
}
...and VOILA!
...and more importantly, when I run it on Android now, we have the exact same colour scheme (because we've over-ridden the defaults)!
So I'll now commit those changes as "ViewFactory and IViewFactory added". We've now replaced Colors.XAML and Styles.XAML with code-only equivalents (though eventually of course you will add more functionality to them - more colours, more things for the factory to produce), let's now put them to work in a Grid-based version of our MainPage...
2. First we need to create our views. First we have our button... but we haven't added buttons to our ViewFactory yet, nor do we have an accent colour for the button background colour (i.e. a colour that contrasts with both our text and our page background), so I shall leave that as your first exercise - add an Accent colour to your Colours, and add a NewButton method to your ViewFactory, which sets the Button background colour to be the accent colour (OR you can use the existing background colour and set the button border to be an accent colour). :-) For now I'll just code the Button manually again (but we're not going to subscribe to a Clicked event - we're going to bind to a command this time. i.e. MVVM). If you're doing this manually then you will need to add a "using Microsoft.Maui.Graphics" for setting the Color, but if you've added it to your Colours/ViewFactory then you won't need to.
Button CounterButton;
.
.
.
CounterButton=new Button{
Text="Click me",
HorizontalOptions=LayoutOptions.Center,
BackgroundColor=Color.FromArgb("#FF0000CC"),
};
CounterButton.SetBinding(Button.TextColorProperty, new Binding(nameof(Colours.Foreground), source:Colours));
Ditto for the Image
Image dotnetBotImage=new Image{
Source="dotnet_bot.png",
HeightRequest=200,
HorizontalOptions=LayoutOptions.Center,
};
Labels we have added to our ViewFactory already, so we can start using it (if you weren't already for the Button). :-)
#region Labels
#region helloWorldLabel
Label helloWorldLabel=ViewFactory.NewLabel("Hello, World!");
helloWorldLabel.FontSize=32;
helloWorldLabel.HorizontalOptions=LayoutOptions.Center;
#endregion
#region welcomeLabel
Label welcomeLabel=ViewFactory.NewLabel("Welcome to .NET Multi-platform App UI... with C# UI!");
welcomeLabel.FontSize=18;
welcomeLabel.HorizontalOptions =LayoutOptions.Center;
#endregion
#endregion
3. Now we'll start on the main grid. i.e. at the top of the class, so that it's in scope throughout, We'll put...
Grid MainGrid=new Grid();
A few points to note about this design decision...
- In this case we could've made the Grid only have scope within the constructor, because we don't actually need access to it from the methods this time, but at some point in the future you'll undoubtedly be in a situation where you do want to make some changes to elements in the Grid from your methods, so let's just start out with the right habits and declare it at the top, then you won't find yourself in a situation later where you have to change you code because you've realised it's out of scope for what you just tried to do.
- despite how it may appear, I didn't call it MainGrid because it's on the MainPage, but because this is literally the main Grid for the page. You can actually have Grids within Grids, and there may be times you need to do that, so this is the main (i.e. outermost) Grid, and other Grids can be given appropriate names for what we are using them for. e.g. I recently wrote a settings page which contained accounts, and the number of accounts in there can vary, so I have an inner Grid where I add/delete my accounts from, and then in the MainGrid there is content which is static (such as Save/Cancel Buttons at the top, and a Label at the bottom stating the total number of accounts). Again, starting out with good design habits.
Now let's head down to our "Assemble GUI" section at the end of the constructor, add our Batch commands like last time, and replace our Label that we used to test ViewFactory with the MainGrid.
#region assemble GUI
BatchBegin();
Content=MainGrid;
BatchCommit();
#endregion
Our app still works at this point, but because we haven't added anything to the Grid yet, we just have a blank screen. So our canvas is ready for us to start making some art on it. :-)
We now need to start defining some rows and columns for the Grid. They can be of width Auto or Star (asterisk). Auto will make it the exact size required, and Star will take all available room (that hasn't been taken yet). We can define multiple Stars and they will take an equal amount of room each, so columns of Star, Star, Star, Star will give us 4 equal width columns. We can also have multiple Stars to make the sizes different. So if we made columns of Star, 2Star, Star, we would have columns on the left and right which each take a quarter of the screen, and a column in the middle which takes up the middle half of the screen. And as previously mentioned, we can also put Grids within Grids for further fine-tuning. An important thing to note is that we don't have to define all of our rows and columns before we start adding things to it! In fact I usually don't. Harking back to the accounts page example I mentioned before, since I don't know ahead of time how many accounts there are going to be, I just define a new row each time I have a new account to add - how easy is that?! :-)
So let's start adding content...
4. Let's put a row across the top with our Welcome message in it, of Auto size. When we add elements to a Grid, we tell it which column and row to put it in. At first we will only have 1 row, so the co-ordinates will be 0,0 (like arrays, we start counting from 0, not 1). This will change later, but I'll show you how to do that, just to show you how easy it is to adjust as you're going. i.e. you don't have to have everything planned out before you start - you can just start and make adjustments as you're going.
BatchBegin();
MainGrid.AddRowDefinition(new RowDefinition{Height=GridLength.Auto});
MainGrid.Add(welcomeLabel,0,0);
Content=MainGrid;
BatchCommit();
So we have 1 row across the top, which takes the height of the text, and the rest of the screen is so far unused. Of course, if we made the text bigger, the row would also be bigger, so let's do that. I've changed the FontSize of the welcomeLabel to 40, and now we have this...
Ok, that works. 😂
Now let's add our bot to the screen. I'm envisaging we put him on the left-side of the screen, and we'll do some other stuff on the right side, so we need to add 3 definitions now - we need to add a row of type Star to take up the remaining vertical space below the top row, and 2 columns of type Star which will take up half the screen each, then add the bot into the 1st column (0) and 2nd row (1), so our code now is...
BatchBegin();
MainGrid.AddRowDefinition(new RowDefinition{Height=GridLength.Auto});
MainGrid.AddRowDefinition(new RowDefinition{Height=new GridLength(1,GridUnitType.Star)});
MainGrid.AddColumnDefinition(new ColumnDefinition{Width=new GridLength(1,GridUnitType.Star)});
MainGrid.AddColumnDefinition(new ColumnDefinition{Width=new GridLength(1,GridUnitType.Star)});
MainGrid.Add(welcomeLabel,0,0);
MainGrid.Add(dotnetBotImage,0,1);
Content=MainGrid;
BatchCommit();
...and then when we run it...
Ok, the bot is there, but wait! What happened to our Welcome Label? Ah, that's something I wanted to show you about adjusting as you go. Recall that we hadn't defined any columns when we added that, and now we have defined 2 columns, and the label was in cell (0,0), which is now only half as wide! We didn't just define it for the remaining space, but the whole Grid. But that is easy to fix - we say that we want the Label to take up 2 columns with the SetColumnSpan command (after the command where we added it to the Grid initially).
MainGrid.SetColumnSpan(welcomeLabel,2);
Also, the bot could be bigger. We still have the HeightRequest in there from before - if we take that away altogether (either comment it out or delete it) then the bot will be sized to fill the cell it's in, so let's do that.
Yeah, that's better. :-) Note that of course we could've planned all this before we started writing the code, but I wanted to illustrate how easy it is to adjust as you're going, so you don't need to feel constrained to having planned everything out first (who needs UI designers?! 😉 😂).
Now we haven't checked this is working on Android for a while, so we better do that...
Wait! Why is the bot on a white background?? Ah, now remember that the default background on Android is white, and we changed the background to black for our Labels, and you can see the background of the Label is black, but the rest is white, so what we need to do is change the background colour of the ContentPage, which we do by putting this command (assuming we want to bind to our Colours object, which of course you would for a consistent look) pretty much anywhere in the constructor (though I usually do it before I start adding other elements to it)...
this.SetBinding(ContentPage.BackgroundColorProperty, new Binding(nameof(Colours.Background), source:Colours));
Also you would've noticed the bot was smaller. That's because the width of the cell he's in is half the width of the screen, and his size can't exceed the width of the cell (unless we set him to span multiple columns of course), and everything is kept in proportion, so he's no longer filling the height of the cell. Ah, the things you have to think about when designing for different devices. I'm just going to leave him like that for now. That leaves adding the Button and the results of clicking the button in the remaining area that we haven't used...
I'm envisaging we'll split that remaining area into 2 rows, and have the Button on top and the results underneath. We can add more rows, but then as we saw before with the top Label, we'd have to change columnspans (for the bot), or we can just define another Grid, and add that Grid to the remaining space (which therefore leaves everything else we've done so far unaffected), so I'm going to demonstrate that.
First we need to make a results Label, but we need to not assign any text to it, as we are going to bind it to a property in a ViewModel (which we are going to do in the next section)...
Label clickResultsLabel=ViewFactory.NewLabel();
Now we need a Grid with 2 Star rows, and add the Button to the top row, and the Label to the bottom row...
Grid buttonGrid=new Grid();
buttonGrid.AddRowDefinition(new RowDefinition {Height=new GridLength(1,GridUnitType.Star)});
buttonGrid.AddRowDefinition(new RowDefinition {Height=new GridLength(1,GridUnitType.Star)});
buttonGrid.Add(CounterButton,0,0);
buttonGrid.Add(clickResultsLabel,0,1);
...and finally we need to add that Grid to the appropriate remaining cell of our MainGrid, which is (1,1)...
MainGrid.Add(buttonGrid,1,1);
Firstly, you'll notice our button doesn't look like we might expect. Ah, when we did this the first time around (and I just copied the code) we specified to centre it horizontally, but we didn't say anything about vertically, because it was in a VerticalStackLayout, but now it's in a Grid, and so with that horizontal specification it's making the button only as wide as the text. So there are 2 ways we can fix this - we can either remove that horizontal specification, which will then make the button fill the whole cell it's in, or we can add a vertical specification as well, which will then restrict the button to the size of the text it contains (but of course, you can also add other size requests to it, but I'll leave that up to you and your preferences) - I'm going to choose the latter in this case.
VerticalOptions=LayoutOptions.Center,
Secondly you might wonder about the blank Label underneath the Button - well, we didn't bind it to anything yet, which we'll be doing in the next section.
You may also have noticed I haven't used the "Hello World!" Label yet. Well, I thought it'd be neat to make it look like the bot is saying it, and I thought I'd leave that as an exercise for you to try now. :-) I'll give some hints though, as there are multiple ways you could do it. Obviously we need to add something to the cell which the bot is in (0,1), and we want to position the Label so it's more or less in a position where it looks like it's coming from his "mouth". Note that when we add more than 1 thing to a cell, they will layer in the order that we've added them, so as long as we add this after where we added the bot, then it will appear on top of the bot. One thing we could do is just add the Label itself directly, and set it to be Horizontal End and Vertical centre, and it may be more or less in the right place. If not we need to do something which gives us more fine tuning ability, which would be to make another inner grid, put the Label in the Grid, and put the Grid in the right cell. We'd need 2 columns - 1 Star and then an Auto, and put the Label in the 2nd column, which will automatically be the width of the text and on the right. Then vertically we would create 3 rows, again with an Auto one, this time in the middle of the 3 rows, and then the top row will have X Stars, and the bottom Y Stars, then we just need to work out what values of X and Y we need to have the Label where we want it. :-) Obviously we have almost infinite fine-tuning available there as we could have something like 99 Stars in the top and 101 Stars in the bottom, or however many it is we need to get the middle row where we want it. Don't forge also that the text doesn't have to be in one line, if it seems a bit too wide - we can add a \n between the words so that World appears below Hello, making the Label narrower.
So leaving those things for you to try yourself, I'm going to commit this now as "MainGrid complete".
ADD A VIEWMODEL FOR THE BUTTON CLICKS
1. Add a class, call it MainPageViewModel, and delete the default usings.
2. Declare an int called Count and initialise it to 0.
3. Subclass INotifyPropertyChanged and implement the Interface.
4. Add a string property called ClickResultsText, and set it to "Button has been clicked 0 times", plus our usual property code.
So far our class should look like this (either with or without regions, according to your preference)...
using System.ComponentModel;
namespace CSharpUI;
internal class MainPageViewModel : INotifyPropertyChanged
{
int Count=0;
#region events
public event PropertyChangedEventHandler PropertyChanged;
#endregion
#region properties
private string _ClickResultsText="Button has been clicked 0 times";
public string ClickResultsText
{
get {return _ClickResultsText;}
set {
if (_ClickResultsText!=value) {
_ClickResultsText=value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ClickResultsText)));
}
}
}
#endregion
#region methods
#endregion
}
Now we need to add logic to process the Button clicks and update the Label Text.
5. Add a method called Button_Clicked. It can return void, or it can be async and return a task. I'm just going to leave this as void. Note that if you do want to run it as async, the updating of the Label Text will work even if it isn't running in the main thread, because we aren't updating the Label directly. The binding is already there (well, it will be when we add it :-) ), and it doesn't matter if the property is being updated from a different thread.
Now we just need to increment Count and update the Text, so our method will look like this...
void Button_Clicked()
{
Count++;
if (Count>1) {ClickResultsText=$"Button has been clicked {Count} times";}
else {ClickResultsText=$"Button has been clicked 1 time";}
}
6. Now we need to add a Command that the Button will bind to, and will call the Button_Clicked method. Back at the top of our class, either before or after "int Count=0;" this command - which will need "using System.Windows.Input;" and "using Microsoft.Maui.Controls;" -
public ICommand ButtonClickedCommand=>new Command(Button_Clicked);
Now we're ready to go back to MainPage and wire everything up! :-)
7. At the start of the constructor, we need to add our ViewModel...
MainPageViewModel MPVM=new MainPageViewModel();
8. Now we need to bind the clickResultsLabel Text to the property, so in the line after you've created clickResultsLabel, add...
clickResultsLabel.SetBinding(Label.TextProperty, new Binding(nameof(MPVM.ClickResultsText), source:MPVM));
9. And finally (woo hoo!) we need to bind the Button to the Command, so right before the binding of the CounterButton TextColorProperty (I remember a bug in Xamarin if you didn't have the Command first - not sure if it's still there in MAUI, but might as well play it safe), add...
CounterButton.SetBinding(Button.CommandProperty, new Binding(nameof(MPVM.ButtonClickedCommand), source:MPVM));
10. Now run it and click away! :-)
Eh. Maybe we could move the Label to be underneath the Button.
clickResultsLabel.HorizontalOptions=LayoutOptions.Center;
CounterButton=new Button{
VerticalOptions=LayoutOptions.End,
...
Yeah, that's better. :-) And we'll just check Android as well...
Yep, that's pretty good too. I'll make this final (yay!) commit as "ViewModel added".
Hope you all learnt a lot - enjoy being XAML-free! 😀
If this was helpful to you, then "like" and share this post, and...
Follow 💡𝚂𝗆𝖺𝗋𝗍𝗆𝖺𝗇 𝙰𝗉𝗉𝗌📱 on Mastodon
Top comments (0)