In this lab, you will create a new cross-platform mobile application using the Xamarin tools for Visual Studio and Xamarin.Forms.
The app you will build is called Minutes; a tracking application which can be used to take notes during meetings, keep track of things assigned to you, or log your own personal thoughts.
- Create solution
- Explore the project
- Run on Windows
- Run on Android
- Run on iOS
- Code the NoteEntry class
- Add a storage interface
- Code the MemoryEntryStore class
- Build the list of notes in XAML
- Improve the visualization for the notes
- Add the edit screen
- Create a new note
- Delete a note
- Add persistent storage
This lab uses Visual Studio on Windows with the Xamarin mobile workload installed. It demonstrates projects for Windows, Android, and iOS. You can also use Visual Studio for Mac; however, this will only create projects targeting Android and iOS and a few of the steps might not match exactly.
Note This lab assumes you are running Visual Studio 2017 on Windows 10 Fall Creators Update. If you are using an older version of Windows, you may not be able to run the UWP version of the application. In this case, you can skip those sections and deploy on iOS and/or Android.
You will create a new Xamarin.Forms application and see how to construct a basic UI using XAML, connect the UI to behavior in C# code, and navigate between multiple screens, sharing the data between them.
You will create a new Xamarin.Forms application using the Visual Studio 2017 New Project wizard.
-
Launch Visual Studio.
-
Navigate to File > New > Project...
-
In the New Project window, navigate to the Installed > Visual C# > Cross-Platform section.
-
Select the Mobile App (Xamarin.Forms) template.
-
Enter "Minutes" in the Name field.
-
Enter a folder in the Location field.
Note: On Windows, it's recommended to use a location close to the root to avoid path-length issues.
- Click the OK button.
-
Select the Blank App template.
-
Select the Android, iOS, and Windows (UWP) platforms.
-
Select .NET Standard as the Code Sharing Strategy.
- Click the OK button to create the solution.
The wizard will create four projects: one for the shared code (as a .NET Standard library) and three for the platform-specific apps which can be run on the actual devices or simulator/emulators.
Project | Description |
---|---|
Minutes | The .NET Standard shared code project. This is shared between all the target platforms and is where most of your code will go. |
Minutes.Android | The Xamarin.Android project which generates the Android-specific binary package which can be deployed and run on Android devices. |
Minutes.iOS | The Xamarin.iOS project which generated the iOS-specific binary package which can be run on iPhone and iPad devices. |
Minutes.UWP | The Universal Windows project which can be run on Windows 10 devices. This project is only available when you create the solution with Visual Studio on Windows. |
Execute the Minutes.UWP app on Windows. Make sure to select Local Machine so it runs locally vs. in a simulator. This will give you a much quicker deployment and startup time which is convenient during development and testing.
Note: This requires Windows 10 Fall Creators Update.
-
In the Solution Explorer, locate the Minutes.UWP (Universal Windows) project.
-
Right-click on the Minutes.UWP (Universal Windows) project.
-
In the context menu, select Set as StartUp Project.
- On the Standard toolbar, locate the Debug Target button.
- Verify that the text on the Debug Target button is "Local Machine". If it is not, select the disclosure arrow on the side of the Debug Target button and choose "Local Machine" from the context menu.
- Click the Debug Target button to run the app.
Execute the Minutes.Android app on an Android device or emulator. The instructions will show using an emulator, however, if a physical device is plugged in via USB, it should show up in the devices drop down as well.
-
In the Solution Explorer, locate the Minutes.Android project.
-
Right-click on the Minutes.Android project.
-
In the context menu, select the Set as StartUp Project entry.
- On the Standard toolbar, locate the Debug Target button.
- Verify that the text on the Debug Target button is some version of an Android emulator. If it is not, select the disclosure arrow on the side of the Debug Target button and choose an Android emulator from the context menu.
If you don't see any Android devices, you might need to do additional setup:
- Click the Debug Target button to run the app.
Run the Minutes.iOS app from either macOS or Windows. You can use the iOS simulator to avoid the need for a physical device. You can also use a physical iOS device if it's been provisioned for development.
Note: You need a network connected Mac for this step; part of the build process and execution of the iOS simulator must occur on a Mac.
You can run the iOS app directly through Visual Studio for Mac, or through the remoted simulator when using Visual Studio for Windows. To create the network connection from Windows to the Mac, see the setup page.
-
In the Solution Explorer, locate the Minutes.iOS project.
-
Right-click on the Minutes.iOS project.
-
In the context menu, select Set as StartUp Project.
- On the Standard toolbar, locate the Solution Platforms drop-down.
- Verify that the text on the Solution Platforms drop-down is "iPhoneSimulator". If it is not, select the disclosure arrow on the side of the Solution Platforms drop-down and choose "iPhoneSimulator".
-
Open the Tools > Options... menu.
-
Navigate to the Xamarin > iOS Settings section.
-
In the "Simulator" area, choose where the simulator window will be displayed.
-
Check Remote Simulator to Windows to display the iOS simulator on the Windows PC.
-
Uncheck Remote Simulator to Windows to show it on the Mac.
-
- Select the disclosure arrow on the side of the Debug Target button and choose your preferred iOS simulator from the context menu.
- Click the Debug Target button to run the app.
Next you will implement the NoteEntry data-model class that holds the data for each entry put into the app.
- Create a new Data folder in the Minutes shared-code project.
- Add a new class to the Data folder named NoteEntry.
- Add
public
to the class definition.
namespace Minutes.Data
{
public class NoteEntry
{
}
}
-
Add a public, read-write property of type
string
to the Entry class named Title. -
Add a public, read-write property of type
string
to the Entry class named Text. -
Add a public, read-write property of type
DateTime
to the Entry class named CreatedDate. -
Add a public, read-write property of type
string
to the Entry class named Id.
The completed code is shown below:
public class NoteEntry
{
public string Title { get; set; }
public string Text { get; set; }
public DateTime CreatedDate { get; set; }
public string Id { get; set; }
}
-
Add a public, default (no-argument) constructor to the NoteEntry class.
-
Initialize the CreatedDate property to the current time. Use
DateTime.Now
. -
Initialize the Id property to a string derived from a
System.Guid
object. UseGuid.NewGuid().ToString()
.
The completed code is shown below:
public class NoteEntry
{
...
public NoteEntry()
{
CreatedDate = DateTime.Now;
Id = Guid.NewGuid().ToString();
}
}
-
Override the
ToString
method in the NoteEntry class. -
Concatenate the entry's Title with the CreatedDate and return the result.
-
Build the solution to check for syntax errors.
The completed code is shown below:
public class NoteEntry
{
...
public override string ToString()
{
return $"{Title} {CreatedDate}";
}
}
Note: For single-line methods like this, you can condense the code by using C#'s expression bodied member syntax. As an example, the below code is exactly the same as above to C#, just shorter! You can use either approach based on what is easier for you to read and understand.
public class NoteEntry
{
...
public override string ToString() => $"{Title} {CreatedDate}";
}
Next, you will define an abstraction for the storage class that collects all the notes together. You will start with a basic in-memory implementation so you can build the UI and test the logic. Later, you can switch to a persistent file so the NoteEntry data is saved to the device.
-
Create a new interface in the Minutes shared-code project. Name it
INoteEntryStore
. -
Use the following definition for the interface:
using Minutes.Data;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Minutes
{
public interface INoteEntryStore
{
Task<NoteEntry> GetByIdAsync(string id);
Task<IEnumerable<NoteEntry>> GetAllAsync();
Task AddAsync(NoteEntry entry);
Task UpdateAsync(NoteEntry entry);
Task DeleteAsync(NoteEntry entry);
}
}
Notice the code is using
System.Threading.Tasks.Task
for all the return types. This is preparation for a later step where you will switch to a file-based storage model that uses asynchronous I/O APIs.
Method | Purpose |
---|---|
GetByIdAsync |
Returns a specific note entry by looking it up via the unique Id property. |
GetAllAsync |
Returns all the note entry objects as an Enumerable list. |
AddAsync |
Adds a new entry to the collection. |
UpdateAsync |
Update an existing entry in the collection. |
DeleteAsync |
Delete an existing entry in the collection. |
Now, you will code the MemoryEntryStore class, which will store a collection of NoteEntry objects in memory. This will implement the INoteEntryStore
interface.
-
Add a new class to the Data folder in the Minutes shared-code project named MemoryEntryStore.
-
Add
public
to the class definition. -
Add the interface to the class definition.
public class MemoryEntryStore : INoteEntryStore
- Add a private, readonly dictionary field of type
Dictionary<string, NoteEntry>
to the class. Name it entries.
private readonly Dictionary<string, NoteEntry> entries = new Dictionary<string, NoteEntry>();
- Add
using
statements for the System.Linq and System.Threading.Tasks namespaces to the top of the file. We'll need some of the extension methods and classes that are part of those namespaces.
using System.Linq;
using System.Threading.Tasks;
-
Implement the GetAllAsync method by returning all the objects in the dictionary's
Values
property. UseToList()
to turn the value collection into an enumerable collection. -
Use
Task.FromResult
to turn theList
result into aTask
return type to match the interface. Here's the code for convenience:
public Task<IEnumerable<NoteEntry>> GetAllAsync()
{
IEnumerable<NoteEntry> result = entries.Values.ToList();
return Task.FromResult(result);
}
- Implement the
AddAsync
method: add the passedNoteEntry
to the dictionary, and returnTask.CompletedTask
to indicate success.
public Task AddAsync(NoteEntry entry)
{
entries.Add(entry.Id, entry);
return Task.CompletedTask;
}
-
Implement
UpdateAsync
by returningTask.CompletedTask
. The code isn't storing the data anywhere except memory so there's no work to do. -
Implement
DeleteAsync
just likeAddAsync
; remove the item from the dictionary and returnTask.CompletedTask
. -
Implement
GetByIdAsync
by looking up the properNoteEntry
and returning it usingTask.FromResult
. Here's the code for convenience:
public Task<NoteEntry> GetByIdAsync(string id)
{
NoteEntry entry = null;
entries.TryGetValue(id, out entry);
return Task.FromResult(entry);
}
- Build the solution to check for syntax errors.
-
Add a new class to the Data folder in the Minutes shared-code project named MockDataExtensionMethods.
-
Add
public
andstatic
to the class definition. -
Add the code given below into the MockDataExtensionMethods class.
using System.Threading.Tasks;
namespace Minutes.Data
{
public static class MockDataExtensionMethods
{
public static void LoadMockData(this INoteEntryStore store)
{
NoteEntry a = new NoteEntry
{
Title = "Sprint Planning Meeting",
Text = "1. Scope 2. Backlog 3. Duration"
};
NoteEntry b = new NoteEntry
{
Title = "Daily Scrum Stand-up",
Text = "1. Yesterday 2. Today 3. Impediments"
};
NoteEntry c = new NoteEntry
{
Title = "Sprint Retrospective",
Text = "1. Reflection 2. Actions"
};
Task.WhenAll(
store.AddAsync(a),
store.AddAsync(b),
store.AddAsync(c))
.ConfigureAwait(false);
}
}
}
-
Open the file App.xaml.cs in the shared-code project.
-
Add the static property shown below to the App class. This will allow us to access the note storage from anywhere in the application.
public static INoteEntryStore Entries { get; set; }
-
In the App constructor, instantiate a MemoryEntryStore object and assign it to the Entries property. You will need to add
using Minutes.Data;
. -
In the App constructor, invoke the LoadMockData extension method on Entries to fill it with sample data.
-
Build the solution to check for syntax errors.
The code is shown below:
public partial class App : Application
{
public static INoteEntryStore Entries { get; set; }
public App ()
{
InitializeComponent();
Entries = new MemoryEntryStore();
Entries.LoadMockData();
MainPage = new Minutes.MainPage();
}
...
}
The next task is to create the UI to display all the NoteEntry
objects. This will replace the default UI you saw earlier.
You will display the list of NoteEntry
objects in a ListView using the default visualization. The ListView
class is used in Xamarin.Forms to display a scrollable, selectable list of data items. You see this style of UI in many mobile applications, and it's straight-forward to create in Xamarin.Forms.
-
Open MainPage.xaml in the shared-code project. This is the main page for the application which is setup in App.xaml.cs as part of the constructor.
-
Remove the contents of the
ContentPage
added by the starter template. When you are finished, the file should like the XAML shown below.
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Minutes"
x:Class="Minutes.MainPage">
</ContentPage>
- Add a
ListView
inside the ContentPage tags.
Note: Please use traditional start and end tags for the
ListView
instead of a self-closing tag; you will add code inside theListView
tags in a later step.
- Use x:Name to assign the name entries to the
ListView
.
<ListView x:Name="entries">
</ListView>
-
Open MainPage.xaml.cs.
-
Override the
OnAppearing
method in the MainPage class. -
You will be calling a
Task
-based method so add theasync
keyword to theOnAppearing
method in front of the return type (void
). -
Inside
OnAppearing
, load the list of entries into theListView
. Make sure to use theawait
keyword as shown below:
protected override async void OnAppearing()
{
base.OnAppearing();
entries.ItemsSource = await App.Entries.GetAllAsync();
}
- Run the app on at least one platform. You should see the output of the
ToString
method displayed for eachNoteEntry
object.
Next, you will customize the display of NoteEntry
objects in the ListView
. Currently, it is calling the ToString
implementation to get a textual representation and displaying it within a Label
for each NoteEntry
.
You'll change this by using a DataTemplate
to direct the ListView
on the proper visualization to use for each item.
There are two options available to us: First, (and best if it works for your app), is to use a built-in template; these are optimized and often correspond directly to a platform's native representation. This approach allows for one or two text items and an optional image.
If your app has requirements for displaying each item that cannot be fulfilled with these templates, you can define a fully custom view.
Since the NoteEntry
objects just have text, you can use a built-in template: the TextCell
.
-
Open MainPage.xaml in the shared-code project.
-
Locate the
ListView
in the XAML. -
Add open and close tags for
ListView.ItemTemplate
inside theListView
. -
Add open and close tags for a new
DataTemplate
inside theListView.ItemTemplate
. -
Add a
TextCell
inside theDataTemplate
.
All your subsequent work will be done inside the open tag of the TextCell
so can use a self-closing tag for the TextCell
.
-
Inside the
TextCell
element tag, assign the string "Title" to theText
property. -
Inside the
TextCell
element tag, assign the string "Text" to theDetail
property. -
Inside the
TextCell
element tag, assign the color Goldenrod to theDetailColor
property. -
Run the app on at least one platform. You should see the hard-coded values "Title" and "Text" displayed for each item in the
ListView
.
The code is shown below for convenience.
<ListView x:Name="entries">
<ListView.ItemTemplate>
<DataTemplate>
<TextCell
Text="Title"
Detail="Text"
DetailColor="Goldenrod" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>>
The goal is to display the data from each NoteEntry
object. To do this, the app will utilize a built-in feature of Xamarin.Forms: data binding.
-
Open MainPage.xaml in the shared-code project.
-
Locate the
TextCell
in the XAML. -
Replace the hard-coded assignment to
Text
with aBinding
expression that displays theTitle
property of the underlyingEntry
object (see below).
<TextCell Text="{Binding Title}" ... />
- Replace the hard-coded assignment to
Detail
with aBinding
expression that displays the Text property of the underlying NoteEntry object. The full code is shown below for convenience.
<ListView x:Name="entries">
<ListView.ItemTemplate>
<DataTemplate>
<TextCell
Text="{Binding Title}"
Detail="{Binding Text}"
DetailColor="Goldenrod" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
- Run the app on at least one platform. You should see the values from the NoteEntry objects displayed in the list.
Now that the app is displaying notes, let's add a new screen to add / edit items so the user can customize the data.
-
Right-click on the Minutes shared-code project in the Solution Explorer and choose Add > New Item.
-
Select Visual C# Items > Xamarin.Forms in the left-hand panel. This will display all the Xamarin.Forms classes you can add.
-
Select Content Page, there are two of them; make sure the details screen indicates that the page uses XAML.
-
Name the new page NoteEntryEditPage.xaml.
-
Click Add to add the page.
This will add two files to the project: a XAML file which has the UI definition, and a C# code behind file where the page-specific behavior can be coded.
Now, update the code to display the new page when the user taps on one of the items in the ListView
.
- Open the MainPage.xaml.cs code behind file.
- Locate the constructor, after the call to
InitializeComponent
, add an event handler to theentries.ItemTapped
event. Name the methodOnItemTapped
.
entries.ItemTapped += OnItemTapped;
-
Add the
OnItemTapped
method; you can auto-generate it with Visual Studio (right-click on the method name with the red-underline and select Quick Actions and Refactorings... > Generate Method) or type it yourself using a standard event handler signature with anItemTappedEventArgs
. -
Get the item you have tapped on from the passed
ItemTappedEventArgs
- it's in theItem
property and cast it to aNoteEntry
object. You will need to addusing Minutes.Data;
either manually, or by right-clicking on theNoteEntry
and selecting Quick Actions and Refactorings... > Using Minutes.Data). -
Call
Navigation.PushAsync
and pass it a new instance of yourNoteEntryEditPage
. -
That method is async, so apply the
async
andawait
keywords. -
The page needs to know about the selected
NoteEntry
, an easy way to do this is to pass it into the constructor. Modify the constructor of theNoteEntryEditPage
to take aNoteEntry
and cache it into a private field. You will need to addusing Minutes.Data;
either manually, or by right-clicking on theNoteEntry
and selecting Quick Actions and Refactorings... > Using Minutes.Data).
private async void OnItemTapped(object sender, ItemTappedEventArgs e)
{
NoteEntry item = e.Item as NoteEntry;
await Navigation.PushAsync(new NoteEntryEditPage(item));
}
private NoteEntry entry;
public NoteEntryEditPage (NoteEntry entry)
{
InitializeComponent ();
this.entry = entry;
}
-
Xamarin.Forms uses a standard paradigm for navigation; you used the method above (
Navigation.PushAsync
). To work on all platforms, you will need to add in aNavigationPage
into the UI structure. Open the App.xaml.cs source file. -
Locate the assignment of the
MainPage
property in the constructor. -
Change the main page to be a
NavigationPage
object and pass in the existingMainPage
object into the constructor of the newNavigationPage
.
public App ()
{
InitializeComponent();
...
MainPage = new NavigationPage(new Minutes.MainPage());
}
-
Run the app on at least one platform and tap on an entry in the
ListView
. It should navigate to your second page and display "Welcome to Xamarin.Forms!". You can go back using the built-in Back button located in the top-left corner. -
You can customize the colors presented along the top of the
NavigationPage
. Let's update the page to use the same Blue as the launch screen. Open App.cs and on theNavigationPage
class:- Set the static
BarBackgroundColor
property toColor.FromHex("#3498db")
- Set the static
BarTextColor
property toColor.White
- Set the static
MainPage = new NavigationPage(new Minutes.MainPage())
{
BarBackgroundColor = Color.FromHex("#3498db"),
BarTextColor = Color.White
};
Next, let's change the second page UI to display the selected note's details.
- Open the NoteEntryEditPage.xaml file and examine it's contents. It should have a
Label
that looks something like:
<Label Text="Welcome to Xamarin.Forms!" ... />
- Delete the
Label
, this should leave the following definitions. If yours is different, go ahead and replace it to look like this:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Minutes.NoteEntryEditPage">
<ContentPage.Content>
<StackLayout>
</StackLayout>
</ContentPage.Content>
</ContentPage>
- Now, let's add UI to display details for each note. Add the following between the open and close tags of the
StackLayout
:
<Label Text="Title" />
<Entry Text="Bind to Title" />
<Label Text="Bind to CreatedDate" TextColor="Gray" HorizontalTextAlignment="End" />
<Label Text="Notes" />
<Frame VerticalOptions="FillAndExpand" HasShadow="False" Padding="0">
<Frame.OutlineColor>
<OnPlatform x:TypeArguments="Color">
<On Platform="iOS" Value="LightGray" />
<On Platform="Android" Value="Transparent" />
<On Platform="UWP" Value="Transparent" />
</OnPlatform>
</Frame.OutlineColor>
<Editor x:Name="textEditor" Text="Bind to Text" />
</Frame>
-
Next, add
Binding
statements to theEntry
,Label
, andEditor
controls which have Bind to xxx on theText
properties. Remember that the binding objects take the form{Binding PROPERTY_NAME}
. -
The
Entry
andEditor
are both editing controls - the first for editing a single line of text, the second for multi-line editing. In XAML data binding, it's good practice to let the binding know you want to utilize two-way bindings and push changes from the UI element back to the data source. You can do this by specifyingMode=TwoWay
on the binding itself. Make sure to apply this for both theEntry
andEditor
binding objects as shown below.
<Entry Text="{Binding Title, Mode=TwoWay}" />
<Editor x:Name="textEditor" Text="{Binding Text, Mode=TwoWay}" />
- The
CreatedDate
will display an (obnoxiously) long date format by default since it's aDateTime
object. One cool trick you can use in Xamarin.Forms data binding is to provide a format string that's used when the object'sToString()
is called. This is done through theStringFormat
property on the binding. Use'{0:g}'
for theCreatedDate
so the app displays a "General" date/time string. The code is shown below for convenience.
<Label Text="{Binding CreatedDate, StringFormat='Created: {0:g}'}" TextColor="Gray" HorizontalTextAlignment="End" />
-
Finally, let's supply the data to the page. Set the
BindingContext
property to provide the default binding source for any binding declared for the page. Open the NoteEntryEditPage.xaml.cs code behind file and locate the constructor. -
Assign the passed
NoteEntry
to the built-inBindingContext
property.
public NoteEntryEditPage (NoteEntry entry)
{
InitializeComponent ();
BindingContext = this.entry = entry;
}
- Run the app on at least one platform and tap on an entry in the
ListView
. It should show the note details!
-
Try editing an entry's title and then pressing the Back button; it should be changed on the main page as well since it is referencing the same object in memory. However, this is actually a bug since the code isn't calling
UpdateAsync
; it's just lucky that it works in this case. -
Open the NoteEntryEditPage.xaml.cs code behind file.
-
Override the
OnDisappearing
method. This is called when the page is being destroyed and the app is transitioning back to the main page. -
If the
entry
field is notnull
, then callApp.Entries.UpdateAsync
to update the values in the note storage. You will need to apply theasync
andawait
keywords.
protected override async void OnDisappearing()
{
base.OnDisappearing();
if (entry != null)
{
await App.Entries.UpdateAsync(entry);
}
}
- Run the app again and set a breakpoint to see the behavior; the runtime behavior won't change, but we've corrected the oversight.
When the user edits an entry, they will often want to immediately begin typing. Currently, the user must tap on the field to begin editing. Let's change that behavior by setting focus automatically.
-
Open NoteEntryEditPage.xaml.cs.
-
Override the
OnAppearing
method and add a call totextEditor.Focus()
. This will place focus on theEditor
control when the screen is displayed. -
Run the app and show the details for a note; it should show the onscreen keyboard for that item.
On some devices, typically phones, you might not want this behavior since it can cover content on a small screen. You can restrict this to be desktop-only using the
Device.RuntimePlatform
flag.
- In the
OnAppearing
override, check theDevice.Idiom
property for eitherTargetIdiom.Desktop
orTargetIdiom.Tablet
and only change focus if it's one of those two platforms.
Note: We could also check the
Device.RuntimePlatform
property for a specific platform such as UWP if we wanted to be more specific.
protected override void OnAppearing()
{
base.OnAppearing();
if (Device.Idiom == TargetIdiom.Desktop
|| Device.Idiom == TargetIdiom.Tablet)
{
textEditor.Focus();
}
}
Notice that the screen looks a bit crammed, and there's a blank bar across the top. This is due to the navigation support you just added to the app. This blank area is a convenient place for a Title to be displayed. Let's adjust a few properties on the main and detail pages to take advantage of this.
-
Open NoteEntryEditPage.xaml and locate the
ContentPage
root element. -
Assign the
Title
property to{Binding Title}
to use the title of the currentNoteEntry
object. -
Assign the
Padding
property to "20" to add a little space around the entire UI (20 units).
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Minutes.NoteEntryEditPage"
Title="{Binding Title}"
Padding="20">
-
Open the MainPage.xaml file and locate the same root element.
-
Set the
Title
property to "Meeting Minutes", and thePadding
property to "20". -
Run the app on at least one platform; notice the difference with the titles on each page.
On some platforms, you may notice that the
MainPage
title is being used as the "Back" text. Since that title is quite long, it can look awkward - however there's a cool trick in Xamarin.Forms that will let you address that.
-
Open MainPage.xaml and locate the root
ContentPage
element. -
Set the
NavigationPage.BackButtonTitle
property to the text "Minutes". -
Run the app on iOS and notice that the back text on the details page is now "Minutes".
Now that the app can display and update notes, let's add support to Add a new note. You can do this by adding a new Entry
edit box to the MainPage.
-
Open MainPage.xaml and locate the
ListView
. It's currently the only UI element on the page. -
Surround the
ListView
with aStackLayout
.
<StackLayout>
<ListView x:Name="entries">
<ListView.ItemTemplate>
<DataTemplate>
<TextCell
Text="{Binding Title}"
Detail="{Binding Text}"
DetailColor="Goldenrod" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackLayout>
- Put an
Entry
element above theListView
, set thex:Name
to "newEntry".- Set the
PlaceHolder
property to "Add a new Entry". - Set the
Margin
property to "0,0,0,20". This adds a 20 unit space below the control to separate it from theListView
.
- Set the
<Entry x:Name="newEntry" Placeholder="Add a new Entry" Margin="0,0,0,20" />
-
Open the code behind file (MainPage.xaml.cs) and locate the constructor.
-
After the
InitializeComponent
method, add aCompleted
event handler to the newEntry control. Wire it up to a method named OnAddNewEntry.
public MainPage()
{
InitializeComponent();
...
newEntry.Completed += OnAddNewEntry;
}
-
Create the
OnAddNewEntry
method. It is a standard event handler. -
In the method, get the
Text
value from the newEntry control and store it in a variable. -
If the string is not empty, create a new
NoteEntry
object and use the text as theTitle
. -
Add the new note to the note entry store (
App.Entries.AddAsync
). Make sure to use theawait
keyword (which means you'll need to add anasync
keyword to the method!). -
Copy the code you used in
OnItemTapped
to navigate to theNoteEntryEditPage
screen, passing it your new item. Make sure to useawait
. -
Finally, set the
newEntry.Text
property to an empty string to clear it out after the navigation call - this will clear the UI so that theEntry
is empty when the app navigates back to this page.
private async void OnAddNewEntry(object sender, EventArgs e)
{
string text = newEntry.Text;
if (!string.IsNullOrWhiteSpace(text))
{
NoteEntry item = new NoteEntry { Title = text };
await App.Entries.AddAsync(item);
await Navigation.PushAsync(new NoteEntryEditPage(item));
newEntry.Text = string.Empty;
}
}
- Run the app on at least one platform and check out the new UI.
- Try adding a new item by typing some text in the field and pressing ENTER or RETURN on the keyboard.
The final missing feature is support to delete a note. You will do this by adding a button to the Details page.
-
Open NoteEntryEditPage.xaml.
-
After the
ContentPage
open tag, add a newToolbarItem
. This needs to be in theToolbarItems
collection; use the following XAML to define it:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
...>
<ContentPage.ToolbarItems>
<ToolbarItem Text="Delete" Icon="delete.png" Clicked="OnDeleteEntry" />
</ContentPage.ToolbarItems>
...
</ContentPage>
This will add a new Button to the navigation toolbar. Since the app is using a
NavigationPage
, this is a great area to put extra functionality relevant to the screen.
-
Open NoteEntryEditPage.xaml.cs and add an event handler method named OnDeleteEntry to handle the button click.
-
In the event handler, ask the user whether they really want to delete the note. You can use the built-in
DisplayAlert
method which is part of theContentPage
base class. It takes a minimum of three parameters, with an optional fourth if you need a cancel button:- Title - set this to
"Delete Entry?"
- Description - set this to
$"Are you sure you want to delete the entry {Title}?"
- OK Button Text - set this to
"Yes"
- Cancel Button Text - set this to
"No"
- Title - set this to
-
DisplayAlert
is asynchronous and returns a true/false depending on which choice the user makes. Useawait
to get the value. -
If the user taps "OK", remove the selected note (
entry
field) from the note store (App.Entries.DeleteAsync
). -
Set the
entry
field to null so the code doesn't try to update it later. -
Call
Navigation.PopAsync
to return to the main screen. This is also anawait
able operation.
private async void OnDeleteEntry(object sender, EventArgs e)
{
if (await DisplayAlert("Delete Entry", $"Are you sure you want to delete the entry {Title}?", "Yes", "No"))
{
await App.Entries.DeleteAsync(entry);
entry = null; // deleted!
await Navigation.PopAsync();
}
}
The ToolbarItem
needs an image, particularly on UWP where no text is displayed by default. Images and graphics are often one of the areas where you will need to provide platform-specific values because the sizes change from platform-to-platform.
There are three folders in the assets folder included with this lab. You will need to copy the images from the ios, android, and windows folders into the project.
-
Open the assets folder in an Explorer or Finder window.
-
In Visual Studio, expand the Minutes.Android project and expand the Resources folder in the Solution Explorer. You should see several drawable folders:
-
Drag the contents from each folder in assets into the same-named folder in Visual Studio. On Windows, you can drag the entire folder, on the Mac you need to drag each file (folder drags replace the folder which isn't what you want!)
-
Expand each of the folders in VS and verify that the delete.png file is there
- Select each one and verify that the properties indicate the Build Action is set to AndroidResource
- In Visual Studio, expand the Minutes.iOS project in the Solution Explorer. Locate the Asset Catalogs node in the project; right-click on it and select Add Asset Catalog.
- In the dialog, make sure Asset Catalog is selected, and that the name is Assets since this is the first one. Click Add to add the item to the project.
-
If the new asset catalog (Assets) doesn't open on it's own, double click on it in the project.
-
Click the (+) button in the top left corner to add a new asset and select Add Image Set from the popup menu.
- Drag the three icons in the ios folder from assets into the new Image Set. You should match the name to the placeholder - for example, delete@2x.png should be placed into the box labeled 2x as shown below:
- Close the asset catalog.
-
In Visual Studio, expand the Minutes.UWP project.
-
Drag the single delete.png icon in the windows folder from assets into the root of the UWP project.
-
Select the icon in the Solution Explorer and verify that the Build Action is set to Content.
-
Run the app on any platform.
-
Select an entry in the
ListView
. -
Tap the trash can icon in the top right corner. You should see your prompt.
- Selecting "Yes" should delete the entry and return you to the main page and the will be gone; selecting "No" should stay on the details screen.
The final step is to add a persistence data store so that the notes are saved when the user starts and stops the app. It will store the list of notes in an XML file with the following structure:
<minutes>
<entry title="The Title" text="The description" createdDate="2018-04-16" />
<entry title="The Title" text="The description" createdDate="2018-04-16" />
<entry title="The Title" text="The description" createdDate="2018-04-16" />
</minutes>
This requires that you implement a new INoteEntryStore
implementation to save to a file. You could also use SQLite, or a cloud service such as Azure to store your data.
-
Create a new C# class in the Data folder. Name it FileEntryStore.
-
Have the class implement the
INoteEntryStore
interface. You can right-click on the interface itself, select Quick Actions and Refactorings... and get Visual Studio to stub out all the required methods. -
Add a field of type
List<NoteEntry>
to hold the loaded entries from the file. Name the field loadedNotes. -
Add a field of type
string
to hold the filename. -
Add a public, default (no-argument) constructor to the class and set the filename field using the following code and adding the appropriate
using
directive:
string folder = Environment.GetFolderPath(Environment.SpecialFolder.InternetCache);
if (string.IsNullOrEmpty(folder))
folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
this.filename = Path.Combine(folder, "minutes.xml");
This code stores the file into the proper location on all supported platforms. Each platform has different requirements, but luckily this API abstracts that knowledge away from us.
- Add a static method named
ReadDataAsync
toFileEntryStore
that takes astring
filename and returns aTask<IEnumerable<NoteEntry>>
. Since it will load from a file, it's more efficient to use the async file I/O features of .NET; add theasync
keyword to the method signature.
private static async Task<IEnumerable<NoteEntry>> ReadDataAsync(string filename)
{
}
-
First check if the file exists using the
File.Exists
API, and if not, returnEnumerable.Empty<NoteEntry>()
. You will need to add ausing
directive for the System.Linq namespace. -
If the file does exist, use a
StreamReader
to load the file; use theReadToEndAsync
method and apply theawait
keyword. -
Next, check the returned string and make sure it has data; if not, return
Enumerable.Empty<NoteEntry>()
. -
Finally, use the following code to parse the XML text with the
XDocument
class (from the System.Xml.Linq namespace, so you will need to add ausing
directive) to turn XML into an object graph:
IEnumerable<NoteEntry> result =
XDocument.Parse(text)
.Root
.Elements("entry")
.Select(e =>
new NoteEntry
{
Title = e.Attribute("title").Value,
Text = e.Attribute("text").Value,
CreatedDate = (DateTime)e.Attribute("createdDate")
});
- Return the enumerable data from the method.
The final code for the method is shown below:
private static async Task<IEnumerable<NoteEntry>> ReadDataAsync(string filename)
{
if (!File.Exists(filename))
{
return Enumerable.Empty<NoteEntry>();
}
string text;
using (StreamReader reader = new StreamReader(filename))
{
text = await reader.ReadToEndAsync().ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(text))
{
return Enumerable.Empty<NoteEntry>();
}
IEnumerable<NoteEntry> result = XDocument.Parse(text)
.Root
.Elements("entry")
.Select(e =>
new NoteEntry
{
Title = e.Attribute("title").Value,
Text = e.Attribute("text").Value,
CreatedDate = (DateTime)e.Attribute("createdDate")
});
return result;
}
-
Add a static method named SaveDataAsync that takes a filename and an enumerable collection of
NoteEntry
objects and returns aTask
. -
Take the collection of notes and turn it into XML with the following C# code that uses LINQ to XML:
XDocument root = new XDocument(
new XElement("minutes",
notes.Select(n =>
new XElement("entry",
new XAttribute("title", n.Title ?? ""),
new XAttribute("text", n.Text ?? ""),
new XAttribute("createdDate", n.CreatedDate)))));
- Use the
StreamWriter
class to write theXDocument
to the given filename. Use the asynchronousWriteAsync
method, and use theToString()
method on theXDocument
to turn it into text.
The final code is shown below:
private static async Task SaveDataAsync(string filename,
IEnumerable<NoteEntry> notes)
{
XDocument root = new XDocument(
new XElement("minutes",
notes.Select(n =>
new XElement("entry",
new XAttribute("title", n.Title ?? ""),
new XAttribute("text", n.Text ?? ""),
new XAttribute("createdDate", n.CreatedDate)))));
using (StreamWriter writer = new StreamWriter(filename))
{
await writer.WriteAsync(root.ToString()).ConfigureAwait(false);
}
}
-
Add a method named
InitializeAsync
that returns aTask
. This method will be used to initialize the loadedNotes field. It will be called from each method to ensure the data has been loaded prior to doing anything else. -
Check the loadedNotes field for
null
. If it is not initialized, call theReadDataAsync
method to get theIEnumerable
of notes. -
Use the
await
keyword and put the resultingIEnumerable
into aList<NoteEntry>
and assign it to the loadedNotes field.
private async Task InitializeAsync()
{
if (loadedNotes == null)
{
loadedNotes = (await ReadDataAsync(filename)).ToList();
}
}
-
Add the
AddAsync
method to the class if you haven't already. -
Call
InitializeAsync
to ensure the notes are loaded from disk. Use theasync
andawait
keywords. -
Check whether the passed
NoteEntry
is already present in the loadedNotes. The easiest way to do this is by checking theId
property. -
If the entry is not present, add it to the loadedNotes collection.
-
Call
SaveDataAsync
to write the data back to disk.
public async Task AddAsync(NoteEntry entry)
{
await InitializeAsync();
if (!loadedNotes.Any(ne => ne.Id == entry.Id))
{
loadedNotes.Add(entry);
await SaveDataAsync(filename, loadedNotes);
}
}
-
Add the
DeleteAsync
method to the class if you haven't already. -
Call
InitializeAsync
to ensure the notes are loaded from disk. Use theasync
andawait
keywords. -
Remove the passed note from the loadedNotes collection. The
Remove
method returns a true/false whether any matching item was found. -
Call
SaveDataAsync
to write the data back to disk if an item was removed.
public async Task DeleteAsync(NoteEntry entry)
{
await InitializeAsync();
if (loadedNotes.Remove(entry))
{
await SaveDataAsync(filename, loadedNotes);
}
}
-
Add the
GetAllAsync
method to the class if you haven't already. -
Call
InitializeAsync
to ensure the notes are loaded from disk. Use theasync
andawait
keywords. -
Return the loadedNotes collection ordered by
CreatedDate
in descending order.
public async Task<IEnumerable<NoteEntry>> GetAllAsync()
{
await InitializeAsync();
return loadedNotes.OrderByDescending(n => n.CreatedDate);
}
-
Add the
GetByIdAsync
method to the class if you haven't already. -
Call
InitializeAsync
to ensure the notes are loaded from disk. Use theasync
andawait
keywords. -
Return the specific note from the loadedNotes collection using the
Id
to find it.
public async Task<NoteEntry> GetByIdAsync(string id)
{
await InitializeAsync();
return loadedNotes.SingleOrDefault(n => n.Id == id);
}
-
Add the
UpdateAsync
method to the class if you haven't already. -
Call
InitializeAsync
to ensure the notes are loaded from disk. Use theasync
andawait
keywords. -
Add a test to ensure that the passed note is in the loadedNotes collection. If not, throw an exception.
-
Call
SaveDataAsync
to push any changes to in-memory objects back to disk.
public async Task UpdateAsync(NoteEntry entry)
{
await InitializeAsync();
if (!loadedNotes.Contains(entry))
{
throw new Exception($"NoteEntry {entry.Title} was not found in the {nameof(FileEntryStore)}. Did you forget to add it?");
}
await SaveDataAsync(filename, loadedNotes);
}
-
Open the App.xaml.cs file and locate the line where the
Entries
property is assigned. -
Comment out the existing
MemoryEntryStore
and replace it with a newFileEntryStore
. -
Comment out the
LoadMockData
call on the next line - there's no need to use test data anymore. -
Run the app on at least one platform. You should be able to add/update and delete notes just as before.
-
Close the app (force it to close using the platform-specific gesture if necessary.
-
Run the app again; you should still see your data.
You have completed this lab and built a Xamarin.Forms application that runs on multiple platforms, manipulates data, uses multiple screens, and stores data in a local file!
Keep learning about building mobile apps with Xamarin by creating a FREE account at Xamarin University!