Ebook Manager and Catalogue App - .NET Core for Desktop
.NET Core 3 marks a significant release in the reboot of .NET. Now that the fundamental framework is in place, Microsoft has been able to look at technologies that, while no longer en vogue, are running on millions of machines around the world.
WinForms and WPF have been victims of their own success: Microsoft simply dare not change the framework around them and risk breaking applications that may have been running successfully for several years.
C# 8 has a similar theme in that it introduces features such as nullable reference types, and interface implementations that are designed to improve legacy code bases.
A legacy code base is any code that has already been written, whether that was 10 years or 10 minutes ago!
In this, the first chapter, we'll create the Ebook Manager application. Following this, we'll pick up our Ebook Manager built with .NET Core 2 and migrate it over to .NET Core 3.
In .NET Core 2, a number of significant performance enhancements were made, and so there is a real drive to upgrade existing WinForms apps to .NET Core 3. Microsoft has boasted that .NET Core 2.1 had over 30% performance boost for Bing.
The topics that we'll cover are as follows:
- Creating a new WinForms application in .NET Core 3.0
- Migrating an existing WinForms application to .NET Core 3.0
- Nullable reference types
- XAML Islands, and how they can be used to add functionality to existing WinForms applications
- Tree shaking and compilation
Technical requirements
To follow along with the first part of the chapter, you'll need the WinForms designer. At the time of writing, this is in pre-release and can be downloaded from https://aka.ms/winforms-designer.
For the XAML Islands section, you will need to be running Windows 10 1903 or later. By the time this book is published, it is expected that the 1903 release will have been delivered automatically to all Windows 10 machines; however, if you are running an earlier version, then you can force an update by visiting the following link: https://www.microsoft.com/en-us/software-download/windows10.
Creating a new WinForms application
Let's start by creating a new .NET Core 3.0 WinForms application and later we'll also see how to upgrade an old .NET Core WinForms app to 3.0, so that we can show both ways of achieving this.
Using Visual Studio 2019, we will create a simple Windows Forms App template project. You can call the application anything you like, but I called mine eBookManager:

The process of creating a new project has changed slightly in Visual Studio 2019, and you are required to select the type of application, followed by where to create it:

The project will be created and will look as follows:

Our solution needs a class library project to contain the classes that drive the eBookManager application. In .NET Core 3.0, we have the option of creating either a .NET Core class library or a .NET Standard class library.
.NET Standard is a bit of a strange concept. In and of itself, it is not a technology, but a contract; creating a .NET Standard class library simply prevents you from using anything that is .NET Core—(or framework—) specific, and does not adhere to .NET Standard. The following document illustrates the .NET Standard versions, and what releases of Core and Framework support them: https://github.com/dotnet/standard/blob/master/docs/versions.md.
Add a new Class Library (.NET Standard) project to your solution and call it eBookManager.Engine:

A class library project is added to the solution with the default class name. Change this class to Document:

The Document class will represent a single eBook. When thinking of a book, we can have multiple properties representing a single book, but that would be representative of all books. An example of this would be the author. All books must have an author; otherwise, they would not exist.
The properties I have added to the class are merely my interpretation of what might represent a book. Feel free to add additional code to make this your own. Open the Document.cs file and add the following code to the class:
public class Document
{
public string Title { get; set; }
public string FileName { get; set; }
public string Extension { get; set; }
public DateTime LastAccessed { get; set; }
public DateTime Created { get; set; }
public string FilePath { get; set; }
public string FileSize { get; set; }
public string ISBN { get; set; }
public string Price { get; set; }
public string Publisher { get; set; }
public string Author { get; set; }
public DateTime PublishDate { get; set; }
public DeweyDecimal Classification { get; set; }
public string Category { get; set; }
}
You will notice that I have included a property called Classification of type DeweyDecimal. We have not added this class yet and will do so next. To the eBookManager.Engine project, add a class called DeweyDecimal. If you don't want to go to this level of classification for your eBooks, you can leave this class out. I have included it for the sake of completeness. We're going to introduce a neat little feature that's been in Visual Studio for some time: if you hover over the DeweyDecimal text, you'll see a lightbulb appear (you can bring this menu up manually by holding the Ctrl key and the dot key (Ctrl + .). I will be using this shortcut profusely throughout the rest of the book!):

This allows us to create a new class with a couple of keystrokes. It also means that the name of the class will match the class name in the calling code.
The DeweyDecimal system is quite big. For this reason, I have not catered for every book classification available. I have also assumed that you only want to be working with programming eBooks. In reality, however, you may want to add other classifications, such as literature, the sciences, the arts, and so on. It is up to you:
- Open up the DeweyDecimal class and add the following code to the class:
public class DeweyDecimal
{
public string ComputerScience { get; set; } = "000";
public string DataProcessing { get; set; } = "004";
public string ComputerProgramming { get; set; } = "005";
}
Word nerds may disagree with me here, but I would like to remind them that I'm a code nerd. The classifications represented here are just so that I can catalog programming- and computer science-related eBooks. As mentioned earlier, you can change this to suit your needs.
- We now need to add in the heart of the eBookManager.Engine solution. This is a class called DocumentEngine and will contain the methods you need in order to work with the documents:

Your eBookManager.Engine solution will now contain the following classes:
- DeweyDecimal
- Document
- DocumentEngine
- We now need to add a reference to eBookManager.Engine from the eBookManager project:

The eBookManager.Engine project will be available in the Projects section in the Reference Manager screen:

- Once we have added the reference, we need a Windows Form that will be responsible for importing new books. Add a new form called ImportBooks to the eBookManager solution:

- We'll create a separate project for extension methods. Add the eBookManager.Helper class library project (again, as a .NET Standard class library project):

- We'll reference that from our main project (as before):

We've now set up the basics needed for our eBookManager application. Next, we will venture further into the guts of the application by writing some code.
Virtual storage spaces and extension methods
Let's start by discussing the logic behind virtual storage space. This is a single virtual representation of several physical spaces on your hard drive (or hard drives). A storage space will be seen as a single area where a specific group of eBooks is stored. I use the term "stored" loosely because the storage space doesn't exist. It's more representative of a grouping than a physical space on the hard drive:
- To start creating virtual storage spaces, add a new class called StorageSpace to the eBookManager.Engine project. Open the StorageSpace.cs file and add the following code to it:
using System;
using System.Collections.Generic;
namespace eBookManager.Engine
{
[Serializable]
public class StorageSpace
{
public int ID { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<Document> BookList { get; set; }
}
}
Note that you need to include the System.Collections.Generic namespace here, because the StorageSpace class contains a property called BookList of type List<Document> that will contain all the books in that particular storage space.
Now we need to focus our attention on the eBookManager.Helper project and add a new class called ExtensionMethods. This will be a static class because extension methods need to be static in nature in order to act on the various objects defined by the extension methods.
- The new ExtensionMethods class will initially look as follows:
public static class ExtensionMethods
{
}
Let's add the first extension method to the class called ToInt(). What this extension method does is take a string value and try to parse it to an integer value. I am too lazy to type Convert.ToInt32(stringVariable) whenever I need to convert a string to an integer. It is for this reason that I use an extension method.
- Add the following static method to the ExtensionMethods class:
public static int ToInt(this string value, int defaultInteger = 0)
{
try
{
if (int.TryParse(value, out int validInteger))
{
// Out variables
return validInteger;
}
else
{
return defaultInteger;
}
}
catch
{
return defaultInteger;
}
}
The ToInt() extension method acts only on strings. This is defined by this string value in the method signature, where value is the variable name that will contain the string you are trying to convert to an integer. It also has a default parameter called defaultInteger, which is set to 0. Unless the developer calling the extension method wants to return a default integer value of 0, they can pass a different integer to this extension method (-1, for example).
The other methods of the ExtensionMethods class are used to provide the following logic:
- Read and write to the data source
- Check whether a storage space exists
- Convert bytes to megabytes
- Convert a string to an integer (as discussed previously)
The ToMegabytes method is quite easy. To avoid having to write this calculation all over the place, defining it inside an extension method makes sense:
public static double ToMegabytes(this long bytes) =>
(bytes > 0) ? (bytes / 1024f) / 1024f : bytes;
We also need a way to check whether a particular storage space already exists.
Be sure to add a project reference to eBookManager.Engine from the eBookManager.Helper project.
What this extension method also does is return the next storage space ID to the calling code. If the storage space does not exist, the returned ID will be the next ID that can be used when creating a new storage space:
public static bool StorageSpaceExists(this List<StorageSpace> space, string nameValueToCheck, out int storageSpaceId)
{
bool exists = false;
storageSpaceId = 0;
if (space.Count() != 0)
{
int count = (from r in space
where r.Name.Equals(nameValueToCheck)
select r)
.Count();
if (count > 0) exists = true;
storageSpaceId = (from r in space
select r.ID).Max() + 1;
}
return exists;
}
We also need to create a method that will write the data we have to a file after converting it to JSON:
public async static Task WriteToDataStore(this List<StorageSpace> value,
string storagePath, bool appendToExistingFile = false)
{
using (FileStream fs = File.Create(storagePath))
await JsonSerializer.SerializeAsync(fs, value);
}
Essentially, all we're doing here is creating a stream and serializing the StorageSpace list into that stream.
Note that we're using the new syntactical sugar here from C# 8, allowing us to add a using statement with an implicit scope (that is, until the end of the method).
You'll need to install System.Text.Json from the package manager console:
Install-Package System.Text.Json -ProjectName eBookManager.Helper
This allows you to use the new .NET Core 3 JSON serializer. Apart from being more succinct than its predecessor, or even third-party tools such as Json.NET, Microsoft claims that you'll see a speed improvement, as it makes use of the performance improvements introduced in .NET Core 2.x.
Lastly, we need to be able to read the data back again into a List<StorageSpace> object and return that to the calling code:
public async static Task<List<StorageSpace>> ReadFromDataStore(this List<StorageSpace> value, string storagePath)
{
if (!File.Exists(storagePath))
{
var newFile = File.Create(storagePath);
newFile.Close();
}
using FileStream fs = File.OpenRead(storagePath);
if (fs.Length == 0) return new List<StorageSpace>();
var storageList = await JsonSerializer.DeserializeAsync<List<StorageSpace>>(fs);
return storageList;
}
The method will return an empty list, that is, a <StorageSpace> object, and nothing is contained in the file. The ExtensionMethods class can contain many more extension methods that you might use often. It is a great way to separate often-used code.
The DocumentEngine class
The purpose of this class is merely to provide supporting code to a document. In the eBookManager application, I am going to use a single method called GetFileProperties() that will (you guessed it) return the properties of a selected file. This class also only contains this single method. As the application is modified for your specific purposes, you can modify this class and add additional methods that are specific to documents.
Inside the DocumentEngine class, add the following code:
public (DateTime dateCreated, DateTime dateLastAccessed, string fileName, string fileExtension, long fileLength, bool error) GetFileProperties(string filePath)
{
var returnTuple = (created: DateTime.MinValue,
lastDateAccessed: DateTime.MinValue, name: "", ext: "",
fileSize: 0L, error: false);
try
{
FileInfo fi = new FileInfo(filePath);
fi.Refresh();
returnTuple = (fi.CreationTime, fi.LastAccessTime, fi.Name,
fi.Extension, fi.Length, false);
}
catch
{
returnTuple.error = true;
}
return returnTuple;
}
The GetFileProperties() method returns a tuple as (DateTime dateCreated, DateTime dateLastAccessed, string fileName, string fileExtension, long fileLength, bool error) and allows us to inspect the values returned from the calling code easily.
Before getting the properties of the specific file, the tuple is initialized by doing the following:
var returnTuple = (created: DateTime.MinValue, lastDateAccessed: DateTime.MinValue, name: "", ext: "", fileSize: 0L, error: false);
If there is an exception, I can return default values. Reading the file properties is simple enough using the FileInfo class. I can then assign the file properties to the tuple by doing this:
returnTuple = (fi.CreationTime, fi.LastAccessTime, fi.Name, fi.Extension, fi.Length, false);
The tuple is then returned to the calling code, where it will be used as required. We will have a look at the calling code next.
The ImportBooks form
The ImportBooks form does exactly what the name suggests. It allows us to create virtual storage spaces and to import books into those spaces. The form design is as follows:

Although this kind of prefixing isn't widely used today, this used to be a common practice for WinForms developers. The reason behind it is that WinForms never really lent itself very well to a separation of business and presentation layers (there have been attempts to rectify this, notably with the MVP pattern), meaning that referencing controls directly from code-behind was a common practice and, as such, it made sense to indicate the type of control you were dealing with.
When this form loads, if any storage spaces have been defined then they will be listed in the dlVirtualStorageSpaces combo box. Clicking on the Select source folder button will allow us to select a source folder in which to look for eBooks.
If a storage space does not exist, we can add a new virtual storage space by clicking the btnAddNewStorageSpace button. This will allow us to add a name and description for the new storage space and click on the btnSaveNewStorageSpace button. Selecting an eBook from the tvFoundBooks TreeView will populate the File details group of controls to the right of the form. You can then add additional Book details and click on the btnAddeBookToStorageSpace button to add the book to our space.
The following steps describe changes to be made to the ImportBooks code-behind:
- You need to ensure that the following namespaces are added to your class (these should replace any existing namespaces there):
using eBookManager.Engine;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using static eBookManager.Helper.ExtensionMethods;
using static System.Math;
- Next, let's start at the most logical place: the ImportBooks() constructor and the class-level variables. Add the following declarations above the constructor:
private string _jsonPath;
private List<StorageSpace> _spaces;
private enum _storageSpaceSelection { New = -9999, NoSelection = -1 }
The usefulness of the enumerator will become evident later on in the code. The _jsonPath variable will contain the path to the file used to store our eBook information.
- Modify the constructor as follows:
public ImportBooks()
{
InitializeComponent();
_jsonPath = Path.Combine(Application.StartupPath, "bookData.txt");
}
_jsonPath is initialized in the executing folder for the application and the file is hardcoded to bookData.txt. You could provide a settings screen to configure these settings if you chose to improve this project.
- Because we want to load some data when the form loads, we'll attach the Form_Load event. An easy way to create an event handler in WinForms is to select the event in the form designer and simply double-click next to the event that you wish to handle:

The new event should load the following code from the data store asynchronously:
private async void ImportBooks_Load(object sender, EventArgs e)
{
_spaces = await _spaces.ReadFromDataStore(_jsonPath);
}
- Next, we need to add another two enumerators that define the file extensions that we will be able to save in our application:
private HashSet<string> AllowedExtensions => new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
{ ".doc", ".docx", ".pdf", ".epub", ".lit" };
private enum Extension { doc = 0, docx = 1, pdf = 2, epub = 3, lit = 4 }
We can see the implementation of the AllowedExtensions property when we look at the PopulateBookList() method.
Populating the TreeView control
All that the PopulateBookList() method does is populate the TreeView control with files and folders found at the selected source location. Consider the following code in the ImportBooks code-behind:
public void PopulateBookList(string paramDir, TreeNode paramNode)
{
DirectoryInfo dir = new DirectoryInfo(paramDir);
foreach (DirectoryInfo dirInfo in dir.GetDirectories())
{
TreeNode node = new TreeNode(dirInfo.Name);
node.ImageIndex = 4;
node.SelectedImageIndex = 5;
if (paramNode != null)
paramNode.Nodes.Add(node);
else
tvFoundBooks.Nodes.Add(node);
PopulateBookList(dirInfo.FullName, node);
}
foreach (FileInfo fleInfo in dir.GetFiles()
.Where(x => AllowedExtensions.Contains(x.Extension)).ToList())
{
TreeNode node = new TreeNode(fleInfo.Name);
node.Tag = fleInfo.FullName;
int iconIndex = Enum.Parse(typeof(Extension),
fleInfo.Extension.TrimStart('.'), true).GetHashCode();
node.ImageIndex = iconIndex;
node.SelectedImageIndex = iconIndex;
if (paramNode != null)
paramNode.Nodes.Add(node);
else
tvFoundBooks.Nodes.Add(node);
}
}
The first place we need to call this method is obviously from within itself, as this is a recursive method. The second place we need to call it is from the btnSelectSourceFolder button click event (again, as before, select the click property and double-click):
private void btnSelectSourceFolder_Click(object sender, EventArgs e)
{
try
{
FolderBrowserDialog fbd = new FolderBrowserDialog();
fbd.Description = "Select the location of your eBooks and documents";
DialogResult dlgResult = fbd.ShowDialog();
if (dlgResult == DialogResult.OK)
{
tvFoundBooks.Nodes.Clear();
string path = fbd.SelectedPath;
DirectoryInfo di = new DirectoryInfo(path);
TreeNode root = new TreeNode(di.Name);
root.ImageIndex = 4;
root.SelectedImageIndex = 5;
tvFoundBooks.Nodes.Add(root);
PopulateBookList(di.FullName, root);
tvFoundBooks.Sort();
root.Expand();
}
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
This is all quite straightforward code. Select the folder to recurse and populate the TreeView control with all the files found that match the file extension contained in our AllowedExtensions property. We also need to look at the code when someone selects a book in the tvFoundBooks TreeView control. When a book is selected, we need to read the properties of the selected file and return those properties to the file details section:
private void tvFoundBooks_AfterSelect(object sender, TreeViewEventArgs e)
{
DocumentEngine engine = new DocumentEngine();
string path = e.Node.Tag?.ToString() ?? "";
if (File.Exists(path))
{
var (dateCreated, dateLastAccessed, fileName, fileExtention, fileLength, hasError) = engine.GetFileProperties(e.Node.Tag.ToString());
if (!hasError)
{
txtFileName.Text = fileName;
txtExtension.Text = fileExtention;
dtCreated.Value = dateCreated;
dtLastAccessed.Value = dateLastAccessed;
txtFilePath.Text = e.Node.Tag.ToString();
txtFileSize.Text = $"{Round(fileLength.ToMegabytes(), 2).ToString()} MB";
}
}
}
You will notice that it is here that we are calling the GetFileProperties() method on the DocumentEngine class that returns the tuple.
Populating the storage space list
The next stage is to populate our list of storage spaces:
private void PopulateStorageSpacesList()
{
List<KeyValuePair<int, string>> lstSpaces =
new List<KeyValuePair<int, string>>();
BindStorageSpaceList((int)_storageSpaceSelection.NoSelection, "Select Storage Space");
void BindStorageSpaceList(int key, string value) =>
lstSpaces.Add(new KeyValuePair<int, string>(key, value));
if (_spaces is null || _spaces.Count() == 0) // Pattern matching
{
BindStorageSpaceList((int)_storageSpaceSelection.New, " <create new> ");
}
else
{
foreach (var space in _spaces)
{
BindStorageSpaceList(space.ID, space.Name);
}
}
dlVirtualStorageSpaces.DataSource = new
BindingSource(lstSpaces, null);
dlVirtualStorageSpaces.DisplayMember = "Value";
dlVirtualStorageSpaces.ValueMember = "Key";
}
Let's add the call to this new method to the ImportBooks_Load method:
private async void ImportBooks_Load(object sender, EventArgs e)
{
_spaces = await _spaces.ReadFromDataStore(_jsonPath);
PopulateStorageSpacesList();
if (dlVirtualStorageSpaces.Items.Count == 0)
{
dlVirtualStorageSpaces.Items.Add("<create new storage space > ");
}
lblEbookCount.Text = "";
}
We now need to add the logic for changing the selected storage space. The SelectedIndexChanged() event of the dlVirtualStorageSpaces control is modified as follows:
private void dlVirtualStorageSpaces_SelectedIndexChanged(object sender, EventArgs e)
{
int selectedValue = dlVirtualStorageSpaces.SelectedValue.ToString().ToInt();
if (selectedValue == (int)_storageSpaceSelection.New) // -9999
{
txtNewStorageSpaceName.Visible = true;
lblStorageSpaceDescription.Visible = true;
txtStorageSpaceDescription.ReadOnly = false;
btnSaveNewStorageSpace.Visible = true;
btnCancelNewStorageSpaceSave.Visible = true;
dlVirtualStorageSpaces.Enabled = false;
btnAddNewStorageSpace.Enabled = false;
lblEbookCount.Text = "";
}
else if (selectedValue != (int)_storageSpaceSelection.NoSelection)
{
// Find the contents of the selected storage space
int contentCount = (from c in _spaces
where c.ID == selectedValue
select c).Count();
if (contentCount > 0)
{
StorageSpace selectedSpace = (from c in _spaces
where c.ID == selectedValue
select c).First();
txtStorageSpaceDescription.Text = selectedSpace.Description;
List<Document> eBooks = (selectedSpace.BookList == null)
? new List<Document> { }
: selectedSpace.BookList;
lblEbookCount.Text = $"Storage Space contains { eBooks.Count()} {(eBooks.Count() == 1 ? "eBook" : "eBooks")}";
}
}
else
{
lblEbookCount.Text = "";
}
}
I will not go into a detailed explanation of the code here as it is relatively obvious what it is doing.
We also need to add code to save a new storage space. Add the following code to the Click event of the btnSaveNewStorageSpace button:
private void btnSaveNewStorageSpace_Click(object sender, EventArgs e)
{
try
{
if (txtNewStorageSpaceName.Text.Length != 0)
{
string newName = txtNewStorageSpaceName.Text;
bool spaceExists =
(!_spaces.StorageSpaceExists(newName, out int nextID))
? false
: throw new Exception("The storage space you are trying to add already exists.");
if (!spaceExists)
{
StorageSpace newSpace = new StorageSpace();
newSpace.Name = newName;
newSpace.ID = nextID;
newSpace.Description =
txtStorageSpaceDescription.Text;
_spaces.Add(newSpace);
PopulateStorageSpacesList();
// Save new Storage Space Name
txtNewStorageSpaceName.Clear();
txtNewStorageSpaceName.Visible = false;
lblStorageSpaceDescription.Visible = false;
txtStorageSpaceDescription.ReadOnly = true;
txtStorageSpaceDescription.Clear();
btnSaveNewStorageSpace.Visible = false;
btnCancelNewStorageSpaceSave.Visible = false;
dlVirtualStorageSpaces.Enabled = true;
btnAddNewStorageSpace.Enabled = true;
}
}
}
catch (Exception ex)
{
txtNewStorageSpaceName.SelectAll();
MessageBox.Show(ex.Message);
}
}
The last few methods deal with saving eBooks in the selected virtual storage space. Modify the Click event of the btnAddBookToStorageSpace button. This code also contains a throw expression. If you haven't selected a storage space from the combo box, a new exception is thrown:
private async void btnAddeBookToStorageSpace_Click(object sender, EventArgs e)
{
try
{
int selectedStorageSpaceID =
dlVirtualStorageSpaces.SelectedValue.ToString().ToInt();
if ((selectedStorageSpaceID != (int)_storageSpaceSelection.NoSelection)
&& (selectedStorageSpaceID != (int)_storageSpaceSelection.New))
{
await UpdateStorageSpaceBooks(selectedStorageSpaceID);
}
else throw new Exception("Please select a Storage Space to add your eBook to"); // throw expressions
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
If you enter this code, you'll notice that the UpdateStorageSpaceBooks method does not yet exist; let's rectify that.
Saving a selected book to a storage space
The following code basically updates the book list in the selected storage space if it already contains the specific book (after confirming this with the user). Otherwise, it will add the book to the book list as a new book:
private async Task UpdateStorageSpaceBooks(int storageSpaceId)
{
try
{
int iCount = (from s in _spaces
where s.ID == storageSpaceId
select s).Count();
if (iCount > 0) // The space will always exist
{
// Update
StorageSpace existingSpace = (from s in _spaces
where s.ID == storageSpaceId
select s).First();
List<Document> ebooks = existingSpace.BookList;
int iBooksExist = (ebooks != null)
? (from b in ebooks
where $"{b.FileName}".Equals($"{txtFileName.Text.Trim()}")
select b).Count()
: 0;
if (iBooksExist > 0)
{
DialogResult dlgResult = MessageBox.Show($"A book with the same name has been found in Storage Space {existingSpace.Name}. Do you want to replace the existing book entry with this one ?", "Duplicate Title",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (dlgResult == DialogResult.Yes)
{
Document existingBook = (from b in ebooks
where $"{ b.FileName}".Equals($"{txtFileName.Text.Trim()}")
select b).First();
SetBookFields(existingBook);
}
}
else
{
// Insert new book
Document newBook = new Document();
SetBookFields(newBook);
if (ebooks == null)
ebooks = new List<Document>();
ebooks.Add(newBook);
existingSpace.BookList = ebooks;
}
}
await _spaces.WriteToDataStore(_jsonPath);
PopulateStorageSpacesList();
MessageBox.Show("Book added");
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
We call a helper method in the preceding method, called SetBookFields:
private void SetBookFields(Document book)
{
book.FileName = txtFileName.Text;
book.Extension = txtExtension.Text;
book.LastAccessed = dtLastAccessed.Value;
book.Created = dtCreated.Value;
book.FilePath = txtFilePath.Text;
book.FileSize = txtFileSize.Text;
book.Title = txtTitle.Text;
book.Author = txtAuthor.Text;
book.Publisher = txtPublisher.Text;
book.Price = txtPrice.Text;
book.ISBN = txtISBN.Text;
book.PublishDate = dtDatePublished.Value;
book.Category = txtCategory.Text;
}
Lastly, as a matter of housekeeping, the ImportBooks form contains the following code for displaying and enabling controls based on the button click events of the btnCancelNewStorageSpace and btnAddNewStorageSpace buttons:
private void btnCancelNewStorageSpaceSave_Click(object sender, EventArgs e)
{
txtNewStorageSpaceName.Clear();
txtNewStorageSpaceName.Visible = false;
lblStorageSpaceDescription.Visible = false;
txtStorageSpaceDescription.ReadOnly = true;
txtStorageSpaceDescription.Clear();
btnSaveNewStorageSpace.Visible = false;
btnCancelNewStorageSpaceSave.Visible = false;
dlVirtualStorageSpaces.Enabled = true;
btnAddNewStorageSpace.Enabled = true;
}
private void btnAddNewStorageSpace_Click(object sender, EventArgs e)
{
txtNewStorageSpaceName.Visible = true;
lblStorageSpaceDescription.Visible = true;
txtStorageSpaceDescription.ReadOnly = false;
btnSaveNewStorageSpace.Visible = true;
btnCancelNewStorageSpaceSave.Visible = true;
dlVirtualStorageSpaces.Enabled = false;
btnAddNewStorageSpace.Enabled = false;
}
All that remains now is for us to complete the code in the Form1.cs form, which is the startup form.
Creating the main eBookManager form
Start off by renaming Form1.cs to eBookManager.cs. This is the startup form for the application, and it will list all existing storage spaces that were previously saved:

Design your eBookManager form as follows:
- A ListView control for existing storage spaces
- A ListView for eBooks contained in the selected storage space
- A button that opens the file location of the eBook
- A menu control to navigate to the ImportBooks.cs form
- Various read-only fields to display the selected eBook information:

The following using statements will be needed in this section:
using eBookManager.Engine;
using eBookManager.Helper;
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows.Forms;
using System.Linq;
using System.Diagnostics;
Bear in mind that you won't be able to use this to include libraries with extension methods, so you'll need to include eBookManager.Helper manually.
Let's now start designing our eBookManager form with the help of the following steps:
- The constructor and load methods are quite similar to those in the ImportBooks.cs form. They read any available storage spaces and populate the storage spaces list view control with the previously saved storage spaces:
private string _jsonPath;
private List<StorageSpace> _spaces;
public eBookManager()
{
InitializeComponent();
_jsonPath = Path.Combine(Application.StartupPath,
"bookData.txt");
}
private async void eBookManager_Load(object sender, EventArgs e)
{
_spaces = await _spaces.ReadFromDataStore(_jsonPath);
// imageList1
this.imageList1.Images.Add("storage_space_cloud.png", Image.FromFile("img/storage_space_cloud.png"));
this.imageList1.Images.Add("eBook.png", Image.FromFile("img/eBook.png"));
this.imageList1.Images.Add("no_eBook.png", Image.FromFile("img/no_eBook.png"));
this.imageList1.TransparentColor = System.Drawing.Color.Transparent;
// btnReadEbook
this.btnReadEbook.Image = Image.FromFile("img/ReadEbook.png");
this.btnReadEbook.Location = new System.Drawing.Point(103, 227);
this.btnReadEbook.Name = "btnReadEbook";
this.btnReadEbook.Size = new System.Drawing.Size(36, 40);
this.btnReadEbook.TabIndex = 32;
this.toolTip1.SetToolTip(this.btnReadEbook, "Click here to open the eBook file location");
this.btnReadEbook.UseVisualStyleBackColor = true;
this.btnReadEbook.Click += new System.EventHandler(this.btnReadEbook_Click);
// eBookManager Icon
this.Icon = new System.Drawing.Icon("ico/mainForm.ico");
PopulateStorageSpaceList();
}
private void PopulateStorageSpaceList()
{
lstStorageSpaces.Clear();
if (!(_spaces == null))
{
foreach (StorageSpace space in _spaces)
{
ListViewItem lvItem = new ListViewItem(space.Name, 0);
lvItem.Tag = space.BookList;
lvItem.Name = space.ID.ToString();
lstStorageSpaces.Items.Add(lvItem);
}
}
}
- If the user clicks on a storage space, we need to be able to read the books contained in that selected space:
private void lstStorageSpaces_MouseClick(object sender, MouseEventArgs e)
{
ListViewItem selectedStorageSpace =
lstStorageSpaces.SelectedItems[0];
int spaceID = selectedStorageSpace.Name.ToInt();
txtStorageSpaceDescription.Text = (from d in _spaces
where d.ID == spaceID
select d.Description).First();
List<Document> ebookList =
(List<Document>)selectedStorageSpace.Tag;
PopulateContainedEbooks(ebookList);
}
- We now need to create the method that will populate the lstBooks list view with the books contained in the selected storage space:
private void PopulateContainedEbooks(List<Document> ebookList)
{
lstBooks.Clear();
ClearSelectedBook();
if (ebookList != null)
{
foreach (Document eBook in ebookList)
{
ListViewItem book = new ListViewItem(eBook.Title, 1);
book.Tag = eBook;
lstBooks.Items.Add(book);
}
}
else
{
ListViewItem book = new ListViewItem("This storage space contains no eBooks", 2);
book.Tag = "";
lstBooks.Items.Add(book);
}
}
- We also need to clear the selected book's details when the selected storage space is changed. I have created two group controls around the file and book details. This code just loops through all the child controls; if the child control is a textbox, it clears it:
private void ClearSelectedBook()
{
foreach (Control ctrl in gbBookDetails.Controls)
{
if (ctrl is TextBox)
ctrl.Text = "";
}
foreach (Control ctrl in gbFileDetails.Controls)
{
if (ctrl is TextBox)
ctrl.Text = "";
}
dtLastAccessed.Value = DateTime.Now;
dtCreated.Value = DateTime.Now;
dtDatePublished.Value = DateTime.Now;
}
- The MenuStrip that was added to the form has a click event on the ImportEbooks menu item. It simply opens up the ImportBooks form:
private async void mnuImportEbooks_Click(object sender, EventArgs e)
{
ImportBooks import = new ImportBooks();
import.ShowDialog();
_spaces = await _spaces.ReadFromDataStore(_jsonPath);
PopulateStorageSpaceList();
}
- The following method wraps up the logic to select a specific eBook and populate the file and eBook details on the eBookManager form:
private void lstBooks_MouseClick(object sender, MouseEventArgs e)
{
ListViewItem selectedBook = lstBooks.SelectedItems[0];
if (!String.IsNullOrEmpty(selectedBook.Tag.ToString()))
{
Document ebook = (Document)selectedBook.Tag;
txtFileName.Text = ebook.FileName;
txtExtension.Text = ebook.Extension;
dtLastAccessed.Value = ebook.LastAccessed;
dtCreated.Value = ebook.Created;
txtFilePath.Text = ebook.FilePath;
txtFileSize.Text = ebook.FileSize;
txtTitle.Text = ebook.Title;
txtAuthor.Text = ebook.Author;
txtPublisher.Text = ebook.Publisher;
txtPrice.Text = ebook.Price;
txtISBN.Text = ebook.ISBN;
dtDatePublished.Value = ebook.PublishDate;
txtCategory.Text = ebook.Category;
}
}
- Lastly, when the book selected is the one you wish to read, click on the Read eBook button to open the file location of the selected eBook:
private void btnReadEbook_Click(object sender, EventArgs e)
{
string filePath = txtFilePath.Text;
FileInfo fi = new FileInfo(filePath);
if (fi.Exists)
{
Process.Start("explorer.exe", Path.GetDirectoryName(filePath));
}
}
This completes the code logic contained in the eBookManager application.
It is time to fire up the application and test it out.
Running the eBookManager application
To run the application, please perform the following steps:
- When the application starts for the first time, there will be no virtual storage spaces available. To create one, we will need to import some books. Click on the Import eBooks menu item:

- The Import eBooks screen opens. You can add a new storage space and select the source folder for eBooks:

- Once you have selected an eBook, add the information about the book and save it to the storage space. After you have added all the storage spaces and eBooks, you will see a list of virtual storage spaces. As you click on a storage space, the books it contains will be listed:

- Selecting an eBook and clicking on the Read eBook button will open up the file location containing the selected eBook.
- Lastly, let's have a look at the JSON file generated for the Ebook Manager application. Initially, this will be stored in the output location of the project:

In the following, I've used VS Code to nicely format the JSON:

The keyboard shortcut to format JSON in VS Code is Shift + Alt + F.
As you can see, the JSON file is quite nicely laid out, and it is easily readable.
Now let's see how to upgrade an existing WinForms app to .NET Core 3.
Upgrading to .NET Core 3
In order to follow this section, you won't need the WinForms app from the first edition—any WinForms app will do; however, it is recommended that you use that application, especially for the later section where we will discuss C# 8 features.
You can download the original project from the following location:
https://github.com/PacktPublishing/CSharp7-and-.NET-Core-2.0-Blueprints
If you download and run the application, you should see that it still works fine:

Let's now investigate how we can run this exact same code base under .NET Core 3. We'll start with the project file. Basically, we need to tell Visual Studio that we now have a .NET Core 3 project, and not a Framework one.
If you have PowerTools installed (https://marketplace.visualstudio.com/items?itemName=VisualStudioProductTeam.ProductivityPowerPack2017), you can do this from within Visual Studio; if not, then simply open the .csproj file using your favorite text editor:

Change the contents of the .csproj file to the following:
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>netcoreapp3.0</TargetFramework> <LangVersion>8.0</LangVersion> <AssetTargetFallback>uap10.0.18362</AssetTargetFallback> <UseWindowsForms>true</UseWindowsForms> </PropertyGroup> <ItemGroup> <PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\eBookManager.Controls\eBookManager.Controls.csproj" /> <ProjectReference Include="..\eBookManager.Engine\eBookManager.Engine.csproj" /> <ProjectReference Include="..\eBookManager.Helper\eBookManager.Helper.csproj" /> </ItemGroup> <ItemGroup> <Reference Include="System"> <HintPath>System</HintPath> </Reference> </ItemGroup> </Project>
This is, essentially, all that's needed; however, you will need to decide what to do about the project's resources. You can manually just check that they are all set to copy to the output directory; alternatively, we can add an ItemGroup to the project file, such as the following:
<ItemGroup> <None Update="ico\importBooks.ico"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="ico\mainForm.ico"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\add_ebook_to_storage_space.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\add_new_storage_space.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\docx16.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\docxx16.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\eBook.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\epubx16.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\folder-close-x16.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\folder_exp_x16.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\image sources.txt"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\no_eBook.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\pdfx16.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\ReadEbook.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="img\storage_space_cloud.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>
As you can see, the whole thing is a great deal simpler than the previous version of the file.
Unfortunately, Preview 1 was not stable enough to make the changes necessary for this chapter, and so we are bypassing the designer.
The next step is to delete the following files, found under Properties:
- AssemblyInfo.cs
- Settings.Designer.cs
- Settings.settings
In fact, by the end of this chapter, the entire Properties folder will be gone.
Actually, that's it. Simply reload the project and hit F5. The app is now running under .NET Core. However, it's very likely you'll get an error at this point. The reason is that we have two other projects that are still running under .NET Framework:
- eBookManager.Engine
- eBookManager.Helper
We'll need to migrate each project in a similar way; let's start with eBookManager.Engine. As before, edit the project file and replace what you find there with the following:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netcoreapp3.0</TargetFramework> </PropertyGroup> </Project>
As you can see, this is even simpler than before. In fact, it is not necessary to have this target 3.0; we could target 2.2, or even 2.1 if we so chose. Again, we'll delete AssemblyInfo.cs.
Finally, we come to eBookManager.Helper. Edit the project file again to match the following:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netcoreapp3.0</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\eBookManager.Engine\eBookManager.Engine.csproj" /> </ItemGroup> </Project>
Again, we'll delete AssemblyInfo.cs. We'll also need to remove a stray reference to System.Windows.Forms in ExtensionMethods.cs.
Fixing compilation errors
Finally, we'll need to restructure some of the code that depends on embedded image resources. If you run the code as-is, you'll likely get an error similar to the following:

At the time of writing, WinForms on .NET Core 3.0 doesn't support binary serialization. As a result, we need to make a few small changes.
Resource files
The first thing we'll need to do is to read the files from the output directory, so we'll change the Copy to Output Directory setting on the image and icon files; highlight all the files, and then change the Copy to Output Directory action to Copy if newer:

The next step is to go to the eBookManager screen.
The eBookManager screen
In the eBookManager.Designer.cs file, remove the imageList1 section:

Also remove the btnReadEbook section:

And finally, remove the this.Icon assignment in the eBookManager section:

We'll move the code that has been removed into the Form_Load event of eBookManager.cs:
private void Form1_Load(object sender, EventArgs e) { System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(eBookManager)); this.components = new System.ComponentModel.Container(); // imageList1 //this.imageList1.ImageStream = ((System.Windows.Forms.ImageListStreamer)(resources.GetObject("imageList1.ImageStream"))); this.imageList1.Images.Add("storage_space_cloud.png", Image.FromFile("img/storage_space_cloud.png")); this.imageList1.Images.Add("eBook.png", Image.FromFile("img/eBook.png")); this.imageList1.Images.Add("no_eBook.png", Image.FromFile("img/no_eBook.png")); this.imageList1.TransparentColor = System.Drawing.Color.Transparent; // btnReadEbook this.btnReadEbook.Image = Image.FromFile("img/ReadEbook.png"); this.btnReadEbook.Location = new System.Drawing.Point(103, 227); this.btnReadEbook.Name = "btnReadEbook"; this.btnReadEbook.Size = new System.Drawing.Size(36, 40); this.btnReadEbook.TabIndex = 32; this.toolTip1.SetToolTip(this.btnReadEbook, "Click here to open the eBook file location"); this.btnReadEbook.UseVisualStyleBackColor = true; this.btnReadEbook.Click += new System.EventHandler(this.btnReadEbook_Click); // eBookManager Icon this.Icon = new System.Drawing.Icon("ico/mainForm.ico"); PopulateStorageSpaceList(); }
importBooks screen
A similar change is needed for importBooks.Designer.cs. The following section should be removed:

Remove the setter for the btnAddeBookToStorageSpace image in the same file:

Remove the image for btnAddNewStorageSpace (again, in the same file):

Finally, remove the icon setter for the form:

We'll move this into the Form_Load event of ImportBooks.cs, which should now look as follows:
private void ImportBooks_Load(object sender, EventArgs e) { // tvImages this.tvImages.Images.Add("docx16.png", Image.FromFile("img/docx16.png")); this.tvImages.Images.Add("docxx16.png", Image.FromFile("img/docxx16.png")); this.tvImages.Images.Add("pdfx16.png", Image.FromFile("img/pdfx16.png")); this.tvImages.Images.Add("epubx16.png", Image.FromFile("img/epubx16.png")); this.tvImages.Images.Add("folder-close-x16.png", Image.FromFile("img/folder-close-x16.png")); this.tvImages.Images.Add("folder_exp_x16.png", Image.FromFile("img/folder_exp_x16.png")); this.tvImages.TransparentColor = System.Drawing.Color.Transparent; // btnAddeBookToStorageSpace this.btnAddeBookToStorageSpace.Image = Image.FromFile("img/add_ebook_to_storage_space.png"); // btnAddNewStorageSpace this.btnAddNewStorageSpace.Image = Image.FromFile("img/add_new_storage_space.png"); // ImportBooks this.Icon = new System.Drawing.Icon("ico/importBooks.ico"); PopulateStorageSpacesList(); if (dlVirtualStorageSpaces.Items.Count == 0) { dlVirtualStorageSpaces.Items.Add("<create new storage space>"); } lblEbookCount.Text = ""; }
ProcessStartInfo
Finally, the following will need to be changed in eBookManager.cs:
private void btnReadEbook_Click(object sender, EventArgs e) { string filePath = txtFilePath.Text; FileInfo fi = new FileInfo(filePath); if (fi.Exists) { var processStartInfo = new ProcessStartInfo(filePath, Path.GetDirectoryName(filePath)) { // Change in .NET Core - this defaulted to true in WinForms UseShellExecute = true }; Process.Start(processStartInfo); } }
The reason is that ProcessStartInfo in .NET Framework used to default to UseShellExecute = true. However, in .NET Core, it now defaults to false, and will therefore fail without this change.
That's it! If you run the app, you're now running it under .NET Core 3. It's the same application (albeit with some minor code changes), but now it's running the .NET Core runtime!
Benefits of upgrading to .NET Core
Let's start with the elephant in the room. You can't now take Ebook Manager and run it on Linux—it is not now cross-platform. WinForms always was, still is, and probably always will be, a Windows-specific technology.
Upgrading essentially gives you three benefits:
-
Speed: .NET Core 2.1 saw some significant speed improvements. Your mileage may vary with this, and it's likely that it will depend on exactly what your application is doing. For example, the Ebook Manager application scans the hard drive to retrieve books: it's unlikely that the memory allocation improvements made in .NET Core 2.1 are going to make a huge difference to the speed of that.
-
Support: Once you've upgraded to .NET Core, your app will now be running on a far more active piece of technology; in the future, Microsoft is less likely to change .NET Framework, except for security bug patches, but .NET Core has an active road-map.
- Enthusiasm: It's hard to get people excited about (or to get people at all) working on a WinForms application written fifteen years ago.
C# 8 brings a host of new features to the table, including the following:
- Nullable reference types
- Default implementations of interfaces
- Records
- Recursive patterns
- Async streams
- Ranges
- Static local functions
- Using declarations
Taking the top two headline features from that list, it's clear that there is synergy between being able to run legacy code in .NET Core 3 and being able to apply some of these features to help to update and maintain legacy code.
Understanding nullable reference types
In .NET Core 2.1 (or any previous version of .NET), we could legitimately type the following code and run it:
string test = null; Console.WriteLine(test.Length);
And, of course it would crash. Obviously, no-one familiar with the language would write this; however, they might write something as follows:
string test = FunctionCanReturnNull(); Console.WriteLine(test.Length);
Nullable reference types are an opt-in feature (that is, you have to explicitly turn it on) that simply gives a warning to say that you have a potential for a reference type to be null. Let's try turning this on for our Ebook Manager. It can be turned on a class-by-class basis by adding the following directive to the top of a file:
#nullable enable
However, you can also switch it on for the entire project by adding the following line to the .csproj file:
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<LangVersion>8.0</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
At the time of writing, this property will be automatically added to the .csproj file.
https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references
This is a granular feature, so it can be turned off or on for specific sections of code. There are a few warnings that pop up, so let's focus on StorageSpace.cs:

So, what exactly is this telling us?
To answer that, let's look first at ID. ID is a value type, and therefore cannot be null. If nothing is assigned to ID, it will have the default value: 0. Name, however is a string (which is a reference type), and therefore can be null and, in fact, will be null unless we set it otherwise. If we want one of these fields to be nullable, then we can certainly do that (and in the case of Description, we probably should):

But what about Name? We would probably not want that to be null. There's a couple of options here; one is to add a blank string as a default initializer as follows:
public string Name { get; set; } = string.Empty;
This isn't ideal. In fact, getting a null reference exception might actually be preferable to it being blank and bypassing that.
This is just my opinion, but it is much better to have software crash at runtime and alert you to an error in the logic than to soldier on and potentially corrupt data or, worse, request or update data in a third-party system!
Another option is to add a constructor. The following is an example:
[Serializable] public class StorageSpace { public StorageSpace(string name) { Name = name; } public int ID { get; set; } public string Name { get; set; } public string? Description { get; set; } public List<Document>? BookList { get; set; } }
This clears up the warnings and ensures that anyone creating the class provides a name, which we are saying can never be null. This is instantiated in ImportBooks.cs, so now we'll have to provide that parameter:
private void btnSaveNewStorageSpace_Click(object sender, EventArgs e) { try { if (txtNewStorageSpaceName.Text.Length != 0) { string newName = txtNewStorageSpaceName.Text; // null conditional operator: "spaces?.StorageSpaceExists(newName) ?? false" // throw expressions: bool spaceExists = (space exists = false) ? return false : throw exception // Out variables bool spaceExists = (!spaces.StorageSpaceExists(newName, out int nextID)) ? false : throw new Exception("The storage space you are trying to add already exists."); if (!spaceExists) { StorageSpace newSpace = new StorageSpace(newName); newSpace.ID = nextID; newSpace.Description = txtStorageSpaceDescription.Text; spaces.Add(newSpace);
Now we know that the Name property can never be null, it's worth remembering that the warnings that you get here are just that, warnings; and, like all warnings, it is your prerogative to ignore them. However, C# 8 does have a feature (which I've heard referred to as the dammit operator) that allows you to insist that, despite what the compiler believes, you know the variable will not be null; it looks as follows:
string test = null; Console.WriteLine(test!.Length); Console.ReadLine();
Obviously, if you do this, the preceding code will crash, so if you do decide that you know better than the compiler, be sure!
Exploring XAML Islands
For this section, you will need to be running Windows 10 1903 or later. By the time this book is published, it is expected that the 1903 release will be delivered automatically to all Windows 10 machines; however, if you are running an earlier version, then you can force an update by visiting the following link: https://www.microsoft.com/en-us/software-download/windows10.
In 2019, when this chapter was written, we noted that the TreeView in the import books section looks a little dated. In fact, you'd think it was a TreeView from 2005 when WinForms was all the rage! Also, we'd like to bind our data to the TreeView, rather than build it up separately. While there are some data binding capabilities in WinForms, we are stuck with the general appearance of TreeView.
Unless, that is, we use one of the nice new UWP controls in WinForms. That's exactly what XAML Islands gives us! We can take an existing UWP control, or even create our own, and use it directly from an existing WinForms application.
Let's try and use the TreeView from the UWP Community Toolkit inside our WinForms application.
UWP TreeView
There are a number of setup requirements for this, which I'll detail later.
The first step is to ensure (as detailed in the Technical requirements section) that you're running Windows 10, version 1903 or later. Please follow the information in that section if you are not. The second step is to install the Windows 10 SDK; for this, you can use the following link: https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk.
We will be performing the following article for the next step: https://docs.microsoft.com/en-us/windows/apps/desktop/modernize/desktop-to-uwp-enhance#set-up-your-project.
Add the following NuGet package to your WinForms project:
Microsoft.Windows.SDK.Contracts
Install the XamlHost NuGet package into the WinForms app:
Install-Package Microsoft.Toolkit.Forms.UI.XamlHost
Now we can replace our existing TreeView with the UWP one.
The first thing that we need to consider is XamlHost.
WIndowsXamlHost
Let's create our TreeView; we'll do this in the code-behind for ImportBooks.cs. We're going to add some code to the constructor, which will now look as follows:
private readonly Microsoft.Toolkit.Forms.UI.XamlHost.WindowsXamlHost _windowsXamlHostTreeView;
public ImportBooks() { InitializeComponent(); _jsonPath = Path.Combine(Application.StartupPath, "bookData.txt"); spaces = spaces.ReadFromDataStore(_jsonPath); var windowsXamlHostTreeView = new WindowsXamlHost(); windowsXamlHostTreeView.InitialTypeName = "Windows.UI.Xaml.Controls.TreeView"; windowsXamlHostTreeView.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowOnly; windowsXamlHostTreeView.Location = new System.Drawing.Point(12, 60); windowsXamlHostTreeView.Name = "tvFoundBooks"; windowsXamlHostTreeView.Size = new System.Drawing.Size(513, 350); windowsXamlHostTreeView.TabIndex = 8; windowsXamlHostTreeView.Dock = System.Windows.Forms.DockStyle.None; windowsXamlHostTreeView.ChildChanged += windowsXamlHostTreeView_ChildChanged; this.Controls.Add(windowsXamlHostTreeView); }
Let's quickly review what we've done here (it's actually not that much). Firstly, we've created a new WIndowsXamlHost object. This is the basis for XAML Islands; it acts as a wrapper around your UWP control, so it will work in a WinForms context.
Although this chapter discusses WinForms, the same is true for WPF and, while the exact syntax may differ slightly, the basic principle is the same.
The things to notice on this code sample are as follows:
- We're setting the name to tvFoundBooks, which is the same name as our WinForms app had.
- We're listening to the ChildChanged event: this is so that we can set some specifics on the control itself (we'll come back to this shortly).
- The InitialTypeName is how XAML Islands knows which UWP control to invoke.
- We're adding the host control to the current form (we also set the location).
ItemTemplate
Now that we've set up the host control, we can have a look at the ChildChanged event that we mentioned; this is where we set up the UWP control (rather than the host control):
private Windows.UI.Xaml.Controls.TreeView? _tvFoundBooks = null;
private void windowsXamlHostTreeView_ChildChanged(object? sender, EventArgs e) { if (sender == null) return; var host = (WindowsXamlHost)sender; _tvFoundBooks = (Windows.UI.Xaml.Controls.TreeView)host.Child; _tvFoundBooks.ItemInvoked += _tvFoundBooks_ItemInvoked; _tvFoundBooks.ItemsSource = DataSource; const string Xaml = "<DataTemplate xmlns=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\"><TreeViewItem ItemsSource=\"{Binding Children}\" Content=\"{Binding Name}\"/></DataTemplate>"; var xaml = XamlReader.Load(Xaml); _tvFoundBooks.ItemTemplate = xaml as Windows.UI.Xaml.DataTemplate; }
Don't worry so much about why _tvFoundBooks is a class-level variable, we'll come back to that shortly. In the preceding code sample, we have a gated check to ensure that sender is not null, and then we're forcing it to a WindowsXamlHost type. Once we have this type, we can get whatever is inside the host by calling the .Child property.
As before, we're listening to the ItemInvoked event (again, we'll come back to this shortly). The first really new thing here is that we're setting the ItemsSource, and the ItemTemplate. We'll come back to ItemsSource, but the template is worth exploring. Unlike WinForms, UWP uses XAML to define how its controls look. This means that you have control over exactly what goes into the TreeView; for example, each node could have an image, or text, or both. However, if you don't specify ItemTemplate, then the rendering engine doesn't know what to display, or how.
The preceding XAML is probably the simplest one that will display anything. You'll notice there are a few binding statements; they are binding to properties relative to the ItemsSource. Let's have a look at exactly what it is we're binding to.
TreeView Item model and ItemsSource
In order to bind something to a control in UWP, you need something. Essentially, what that means is that we need a model.
A model, in .NET terms, is simply a class that holds data.
We're going to create a new class, and we'll call it Item:
public class Item { public string Name { get; set; } public ObservableCollection<Item> Children { get; set; } = new ObservableCollection<Item>(); public ItemType ItemType { get; set; } public string FullName { get; set; } public override string ToString() { return Name; } }
Most of this class should be self-explanatory; we're holding the Name and FullName (that is, the name and path) of the file. The ObservableCollection is a special type of Collection that allows the UI framework to be notified when it changes.
For the code that we're writing here, we could get away with this simply being a List; however, ObservableCollection is good practice when dealing with desktop XAML frameworks such as UWP, and this will make extensibility easier.
Finally, we're holding the type of the item, which is a new enumerated type:
public enum ItemType { Docx, Docxx, Pdfx, Epubx, Folder }
Back in ImportBooks.cs, we're going to set up our ItemsSource. The first step is to add a class-level variable called DataSource:
public ObservableCollection<Models.Item> DataSource { get; set; }
Our next change is in the btnSelectSourceFolder_Click event handler:
private void btnSelectSourceFolder_Click(object sender, EventArgs e) { try { FolderBrowserDialog fbd = new FolderBrowserDialog(); fbd.Description = "Select the location of your eBooks and documents"; DialogResult dlgResult = fbd.ShowDialog(); if (dlgResult == DialogResult.OK) { UpdateBookList(fbd.SelectedPath); } } catch (Exception ex) { MessageBox.Show(ex.Message); } }
As you can see, the new method is hugely simplified compared to the previous version; we've extracted all the real logic into a new method, so let's see that next:
private void UpdateBookList(string path) { DirectoryInfo di = new DirectoryInfo(path); var bookList = new List<Models.Item>(); var rootItem = new Models.Item() { Name = di.Name }; rootItem.ItemType = Models.ItemType.Folder; PopulateBookList(di.FullName, rootItem); bookList.Add(rootItem); DataSource = new ObservableCollection<Models.Item>(bookList); _tvFoundBooks.ItemsSource = DataSource.OrderBy(a => a.Name); }
Here, we're setting up the root item of our TreeView; however, you'll notice that the only reference that we actually have to the TreeView is at the end, where we refresh ItemsSource. PopulateBookList is our next port of call. As before, this method is essentially in two parts; let's see the first part:
public void PopulateBookList(string paramDir, Models.Item rootItem) { if (rootItem == null) throw new ArgumentNullException();
rootItem.FullName = paramDir;
rootItem.ItemType = Models.ItemType.Folder; DirectoryInfo dir = new DirectoryInfo(paramDir); foreach (DirectoryInfo dirInfo in dir.GetDirectories()) { var item = new Models.Item(); item.Name = dirInfo.Name; rootItem.Children.Add(item); PopulateBookList(dirInfo.FullName, item); }
Here, we're recursively traversing the directory structure and populating our new model. Notice that we're setting the item type and the FullName (the directory path) at the start, and then we iterate through all the sub-directories, re-calling our method.
For the second part of the function, we'll process any files that are in the current directory (that is, whichever directory is at the top of the recursion stack at the time):
foreach (FileInfo fleInfo in dir.GetFiles().Where(x => AllowedExtensions.Contains(x.Extension)).ToList()) { var item = new Models.Item(); item.Name = fleInfo.Name; item.FullName = fleInfo.FullName; item.ItemType = (Models.ItemType)Enum.Parse(typeof(Extention), fleInfo.Extension.TrimStart('.'), true); rootItem.Children.Add(item); } }
Our next change is to the ItemInvoked method; the new method should look as follows:
private void _tvFoundBooks_ItemInvoked(Windows.UI.Xaml.Controls.TreeView sender, Windows.UI.Xaml.Controls.TreeViewItemInvokedEventArgs args) { var selectedItem = (Models.Item)args.InvokedItem; DocumentEngine engine = new DocumentEngine(); string path = selectedItem.FullName.ToString(); if (File.Exists(path)) { var (dateCreated, dateLastAccessed, fileName, fileExtention, fileLength, hasError) = engine.GetFileProperties(selectedItem.FullName.ToString()); if (!hasError) { txtFileName.Text = fileName; txtExtension.Text = fileExtention; dtCreated.Value = dateCreated; dtLastAccessed.Value = dateLastAccessed; txtFilePath.Text = selectedItem.FullName.ToString(); txtFileSize.Text = $"{Round(fileLength.ToMegabytes(), 2).ToString()} MB"; } } }
Again, this is very marginally changed; instead of storing the full filename (with the path) in the node tag property, we're now just referencing the underlying model, so it's much clearer. Our next step is to remove the existing WinForms TreeView control.
Removing the existing TreeView
The following code should be removed from ImportBooks.Designer.cs:
// // tvFoundBooks // this.tvFoundBooks.Location = new System.Drawing.Point(12, 41); this.tvFoundBooks.Name = "tvFoundBooks"; this.tvFoundBooks.Size = new System.Drawing.Size(513, 246); this.tvFoundBooks.TabIndex = 8; this.tvFoundBooks.AfterSelect += new System.Windows.Forms.TreeViewEventHandler(this.tvFoundBooks_AfterSelect);
This will remove the control itself. Later, we'll need to remove the following code that adds the TreeView to the controls collection:
this.Controls.Add(this.tvFoundBooks);
That's it. If you now run the project, you'll see a UWP TreeView control right in the middle of a WinForms application.
Tree shaking and compiling to a single executable
The main reason why web applications have grown in popularity, eclipsing their desktop cousins, is the problem of deployment. It sounds a trivial issue on the face of it, but it definitely is not. There have been many attempts to solve the problem, from technologies such as ClickOnce to the App Store model (of UWP, Apple, and Google). One of the reasons why this is so difficult in the desktop world and so simple in the web world is that, while both may have a complex tree of dependencies, the web allows those dependencies to mostly live on the server, so they don't need to be directly deployed to the client machine.
One useful feature in .NET Core 3 is the ability to bundle all of your dependencies into a single executable.
In .NET Core 3, we can compile our project into a single executable by adding the following line to the .csproj file:
<PublishSingleFile>true</PublishSingleFile> <RuntimeIdentifier>win-x64</RuntimeIdentifier> <PublishReadyToRun>true</PublishReadyToRun>
When you publish the application, you'll get a single executable.
You can even reduce the size of this executable by using built-in tree shaking. This is the process of removing dependencies that are not used by the application; this requires the following additional line in the .csproj file:
<PublishTrimmed>true</PublishTrimmed>
Summary
In this chapter, we migrated an existing WinForms application over to .NET Core 3. This means that, even though we might have an application that is 10 or 15 years old, you can update it to use the latest framework and take advantage of the performance improvements and new features afforded by .NET Core 3.
Taking advantage of the upgrade, we then investigated one of the key features of C# 8: nullable reference types. This means that, without the use of any third-party software, we were able to expose dozens of potential bugs in our legacy code base.
We didn't stop there; we then used XAML Islands to extend our application by incorporating UWP features into WinForms. This is potentially the most exciting feature, as it essentially means that you can rewrite a legacy WinForms application from the outside in.
In the final section, we took advantage of the new packaging methods available in .NET Core 3. We used the process of tree shaking to reduce the size of our output file, and compiled our code to a single executable.
Like me, you may be seeing a pattern with these features. .NET Core has, in this release, incorporated Windows-only features, meaning you can now take a legacy WinForms app and convert it to use the latest .NET Core, thereby benefiting from all the performance improvements. You can extend it by using component creating in WPF or UWP without needing to rewrite the application. Additionally, deployment is now made easier, as we can now compile into a single executable.
In the next chapter, we'll be looking ASP.NET Core 3 and creating an MVC application that uses Cosmos DB.