-
Feb 12th, 2024, 11:58 AM
#1
Visual Basic .NET - Using a WebView2 to Render UI for Desktop Applications
In case you missed it, Google won the browser wars although FireFox still nips at their heels. Most every browser now uses Chromium as the base web browser. Before this more universal approach to browsers, there were many shim libraries like jQuery that handled cross-browser capability, but now we really don't have that problem anymore; this makes front-end web development much easier for developers.
So how does this relate to desktop development and VB.NET specifically? Well as a result of Internet Explorer being dropped and Edge being Microsoft's browser, they also released a new desktop control to replace the old WebBrowser called WebView2 (documentation). What is great with the WebView2 is that not only can you send information to the browser from your application like you could in the old WebBrowser but you can send information from the browser back to your application. This means that we can display all of our UI in a WebView2 using all the niceties that front-end web development bring us, but have all of the business logic and simplicity of VB.NET.
The first thing you will need to do is install the WebView2 NuGet package, do this from your WinForm application project:
- From the solution explorer, right-click on your project.
- Click on Manage NuGet Packages.
- From the NuGet Package Manager, click on the Browse tab.
- Search for: Microsoft.Web.WebView2
- Install the most recent version.
- Build your project (Ctrl + Shift + B or Build > Build Solution).
After that the WebView2 control should show up in your toolbox and you can drag and drop the control onto a form.
I am also using Newtonsoft.Json to handle the data as JSON, follow those same steps above for the Newtonsoft.Json package.
The second thing you will need to do is essentially setup a mini web server in your desktop project. This is a major step, so I going to do my best to explain how this work.
I would suggest setting up a file structure in your project to look like this:
Code:
/
└── my-app
├── WebAssets
├── WebPages
└── WebServer
├── Controllers
└── Models
├── WebViewRequest.vb
└── WebViewResponse.vb
- WebAssets would hold things like your third party libraries (Bootstrap) or custom shared code.
- WebPages will hold only folders and the sub-folders will hold the actual HTML, CSS, and JavaScript pages.
- The WebServer > Controllers will have files that represent the controllers which handle things like routing.
- The WebServer > Models hold the request and response models, essentially replicating an HTTP request/response.
Because I will be showing you how to setup this mini web server using a MVC pattern, let's assume that you have: www.something.com/Users/Index.html and www.something.com/Users/Update.html then this would live in WebPages > Users > Index.html and WebPages > Users > Update.html respectively.
Because I have already provided you with two code files, WebViewRequest.vb and WebViewResponse.vb, I will go ahead and provide you with their code:
Code:
Imports Newtonsoft.Json
Public Class WebViewRequest
Public Property Controller As String
Public Property Route As String
Public Property Data As String
Public Function GetModelFromJson(Of T)() As T
If (String.IsNullOrWhiteSpace(Data)) Then
Throw New ArgumentNullException(NameOf(Data))
End If
Dim model = JsonConvert.DeserializeObject(Of T)(Data)
Return model
End Function
Public Function ToJson() As String
Return JsonConvert.SerializeObject(Me)
End Function
End Class
And:
Code:
Imports Newtonsoft.Json
Public Class WebViewResponse
Public Property Status As Integer
Public Property Body As String
Public Shared Function BadRequestResponse(Optional message As String = "") As WebViewResponse
Return New WebViewResponse() With {
.Status = 400,
.Body = message
}
End Function
Public Shared Function NotFoundResponse() As WebViewResponse
Return New WebViewResponse() With {
.Status = 404,
.Body = String.Empty
}
End Function
Public Shared Function InternalServerErrorResponse() As WebViewResponse
Return New WebViewResponse() With {
.Status = 500,
.Body = String.Empty
}
End Function
Public Function ToJson() As String
Return JsonConvert.SerializeObject(Me)
End Function
End Class
Continued in next post...
-
Feb 12th, 2024, 11:58 AM
#2
Re: Visual Basic .NET - Using a WebView2 to Render UI for Desktop Applications
Now that you have the file structure and web request/response classes, it's time to start working on the controllers. Because the controllers will essentially be doing the same thing just for different pages, I would suggest creating a "base" controller class that looks like this:
Code:
Public MustInherit Class BaseController
Public Delegate Function Route(data As WebViewRequest) As WebViewResponse
Public MustOverride ReadOnly Property Routes As Dictionary(Of String, Route)
End Class
What this does is:
- Define a class that can only be inherited.
- Define a delegate called "Route" that represents a reference of a function that will take in a request and return a response.
- Define a key/value collection of strings and Route delegates.
By setting it up like this, you can then create controller classes that inherit from BaseController that will build the collection of Routes as well as the corresponding business logic. Here is an example of one of my controllers in a project I'm working on:
Code:
Imports MyDomainProject.Services
Imports MyDomainProject.Loggers
Imports Newtonsoft.Json
Public Class RolesController
Inherits BaseController
Private ReadOnly _logger As ILogger
Private ReadOnly _roleService As RoleService
Public Sub New(logger As ILogger, roleService As RoleService)
_logger = logger
_roleService = roleService
_routes = BuildRoutes()
End Sub
Private ReadOnly _routes As Dictionary(Of String, Route)
Public Overrides ReadOnly Property Routes As Dictionary(Of String, Route)
Get
Return _routes
End Get
End Property
Private Function GetById(data As WebViewRequest) As WebViewResponse
Dim model As RoleGetByIdModel = Nothing
Try
model = data.GetModelFromJson(Of RoleGetByIdModel)()
Catch ex As Exception
_logger.LogError($"Unable to parse the incoming request's data to a {NameOf(RoleGetByIdModel)}. Exception:{Environment.NewLine}{ex}", True)
Return WebViewResponse.BadRequestResponse("The data is not an object with RoleId property.")
End Try
Dim matchedRole = _roleService.GetRecordById(model.RoleId)
Dim body As String
Dim status As Integer
If (matchedRole IsNot Nothing) Then
status = 200
body = JsonConvert.SerializeObject(matchedRole)
Else
status = 404
body = $"The role does not exist with id: {model.RoleId}"
End If
Return New WebViewResponse() With {
.Status = status,
.Body = body
}
End Function
Private Function GetAll(data As WebViewRequest) As WebViewResponse
Dim roles = _roleService.GetAll()
Dim body As String
Dim status As Integer
If (roles Is Nothing) Then
status = 500
body = "Something went wrong getting the roles"
Else
status = 200
body = JsonConvert.SerializeObject(roles)
End If
Return New WebViewResponse() With {
.Body = body,
.Status = status
}
End Function
Private Function BuildRoutes() As Dictionary(Of String, Route)
Dim routes = New Dictionary(Of String, Route) From {
{"GetAll", New Route(AddressOf GetAll)},
{"Get", New Route(AddressOf GetById)}
}
Return routes
End Function
End Class
Now in my Form's code that uses the WebView2, I can build a controller mapping by defining a key/value collection of strings and BaseControllers. Using the same RolesController example from above:
Code:
Private Function BuildControllerMaps() As Dictionary(Of String, BaseController)
Dim rolesService = New RoleService(My.Application.Database, My.Application.Logger)
Dim rolesController = New RolesController(My.Application.Logger, rolesService)
Return New Dictionary(Of String, BaseController) From {
{"Roles", rolesController}
}
End Function
Now when I go to hit the API endpoint /Roles/Get it will find the Roles key from the BuildControllerMaps method and the Get key from the RolesController's BuildRoutes method and so the function that will be executed is RolesController.GetById.
Continued in next post...
Last edited by dday9; Feb 12th, 2024 at 12:47 PM.
-
Feb 12th, 2024, 11:58 AM
#3
Re: Visual Basic .NET - Using a WebView2 to Render UI for Desktop Applications
Now that you have the controllers setup, it's time to start working on the actual web pages. I mentioned earlier that the WebPages folder will only hold folders and that these sub-folders will hold the actual HTML, CSS, and JavaScript pages.
So continuing with the "Roles" example used earlier and assuming I want a search page, create page, and update page, then I would probably setup the following file structure:
Code:
/
└── my-app
└── WebPages
└── Roles
├── Index.html
└── Upsert.html
Now this is very important because if you miss this step then your HTML pages cannot be loaded into the WebView2. After you add your HTML (or CSS, JavaScript, etc.) file, you need to:
- Open the file properties.
- Change the Build Action to Content.
- Change the Copy to Output Directory to Copy if newer.
There are a couple of key points for the communicate between your webpages and VB.NET code. The first is that if you want to send data from your webpage to your VB.NET code, you will need to invoke the JavaScript window.chrome.webview.postMessage method:
Code:
window.chrome.webview.postMessage('...');
And if you want to send data from your VB.NET code to your webpage, you will need to invoke the VB.NET CoreWebView2.ExecuteScriptAsync method to execute JavaScript code:
Code:
Await MyWebView2.CoreWebView2.ExecuteScriptAsync("console.log('...');")
This is important to know because we need to essentially send requests asynchronously from our JavaScript code and then wait on a response from our VB.NET code. This means that you cannot use typical AJAX requests like fetch. Instead, what I would suggest doing is creating a shared JavaScript function that essentially dispatches an event that indicates we have received data from our VB.NET code:
Code:
const dispatchMessageReceivedEvent = (request, response) => {
const messageReceivedEvent = new CustomEvent('messageReceived', {
detail: { request, response },
bubbles: true,
cancelable: true
});
window.dispatchEvent(messageReceivedEvent);
};
Now we can handle the messageReceived event and do something with the data being returned.
Here is an example of my roles search page in a project I'm working on. I am using Bootstrap 5 and my file structure looks like this:
Code:
/
└── my-app
├── WebAssets
│ ├── Bootstrap
│ │ ├── CSS
│ │ │ └── bootstrap.min.css
│ │ └── JavaScript
│ │ └── bootstrap.bundle.min.js
│ ├── Images
│ │ └── loading.gif
│ └── Shared.js
├── WebPages
│ └── Roles
│ └── Index.html
└── WebServer
├── Controllers
│ ├── BaseController.vb
│ └── RolesController.vb
└── Models
├── WebViewRequest.vb
└── WebViewResponse.vb
I will not be including any of the files in my WebAssets except for Shared.js, but you can find Bootstrap 5 here and you can find a loading gif using a search engine.
This is what my WebAssets/Shared.js file looks like:
Code:
const dispatchMessageReceivedEvent = (request, response) => {
const messageReceivedEvent = new CustomEvent('messageReceived', {
detail: { request, response },
bubbles: true,
cancelable: true
});
window.dispatchEvent(messageReceivedEvent);
};
const buildWebRequest = (controller, route, data) => {
return {
Controller: controller,
Route: route,
Data: data
};
};
const toggleLoader = isVisible => {
let overlay = document.querySelector('.overlay-loading');
if (isVisible) {
if (!overlay) {
overlay = document.createElement('div');
overlay.classList.add('position-fixed', 'top-0', 'start-0', 'vw-100', 'vh-100', 'd-flex', 'justify-content-center', 'align-items-center', 'overlay-loading', 'opacity-50');
overlay.style.zIndex = '9999';
const image = document.createElement('img');
image.setAttribute('src', 'https://app-assets.local/Images/loading.gif');
image.classList.add('img-fluid', 'rounded-circle');
overlay.appendChild(image);
document.body.appendChild(overlay);
}
if (overlay.classList.contains('d-none')) {
overlay.classList.remove('d-none');
}
} else if (overlay && !overlay.classList.contains('d-none')) {
overlay.classList.add('d-none');
}
};
This is what my WebPages/Roles/Index.html page looks like:
HTML Code:
<!doctype html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Roles</title>
<link href="https://app-assets.local/Bootstrap/CSS/bootstrap.min.css" rel="stylesheet">
</head>
<body class="py-4 bg-body-tertiary">
<div class="container">
<h1>Roles</h1>
<a href="Upsert.html" class="btn btn-primary btn-sm ms-auto">Create</a>
<hr />
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdown-sort-by" data-bs-toggle="dropdown" aria-expanded="false">
Sort By
</button>
<ul class="dropdown-menu" aria-labelledby="dropdown-sort-by">
<li>
<a class="dropdown-item" href="#" id="button-sort-by-sort-order">Sort Order</a>
</li>
<li>
<a class="dropdown-item" href="#" id="button-sort-by-role-name">Role Name</a>
</li>
</ul>
</div>
<div id="list-group-roles" class="list-group mt-3">
</div>
</div>
<script src="https://app-assets.local/Bootstrap/JavaScript/bootstrap.bundle.min.js"></script>
<script src="https://app-assets.local/Shared.js"></script>
<script>
const onGetAllMessageReceived = response => {
const responseBody = response.Body;
if (response.Status === 200) {
const roles = JSON.parse(responseBody);
renderRoles(roles);
} else {
alert(responseBody);
}
};
const renderRoles = roles => {
const listGroupRoles = document.querySelector('#list-group-roles');
listGroupRoles.innerHTML = '';
roles.forEach(role => {
const listItemRole = document.createElement('div');
listItemRole.className = 'list-group-item d-flex align-items-center';
listItemRole.innerHTML = `
<div><strong class="sort-order">${role.SortOrder}</strong>. <span class="role-name">${role.RoleName}</span></div>
<a href="Upsert.html?id=${role.RoleId}" class="btn btn-primary btn-sm ms-auto">Edit</a>
`;
listGroupRoles.appendChild(listItemRole);
});
};
const sortRoles = className => {
var listGroupRoles = document.getElementById('list-group-roles');
var listGroupRolesItems = Array.from(listGroupRoles.getElementsByClassName('list-group-item'));
listGroupRolesItems.sort(function (a, b) {
const aElementTextContent = a.querySelector(`.${className}`).textContent;
const bElementTextContent = b.querySelector(`.${className}`).textContent;
var valA = typeof aElementTextContent === 'string' || aElementTextContent instanceof String ? aElementTextContent.toLowerCase() : parseInt(aElementTextContent, 10);
var valB = typeof bElementTextContent === 'string' || bElementTextContent instanceof String ? bElementTextContent.toLowerCase() : parseInt(bElementTextContent, 10);
if (valA < valB) {
return -1;
}
if (valA > valB) {
return 1;
}
return 0;
});
listGroupRolesItems.forEach(listGroupRolesItem => {
listGroupRoles.appendChild(listGroupRolesItem);
});
};
const onSortByRoleOrderClick = e => {
e.preventDefault();
sortRoles('sort-order');
};
const onSortByRoleNameClick = e => {
e.preventDefault();
sortRoles('role-name');
};
const getRoles = () => {
toggleLoader(true);
const webMessage = buildWebRequest('Roles', 'GetAll', JSON.stringify({}));
window.chrome.webview.postMessage(webMessage);
};
const routeMappings = {
GetAll: onGetAllMessageReceived
};
window.addEventListener('load', () => {
getRoles();
document.getElementById('button-sort-by-sort-order').addEventListener('click', onSortByRoleOrderClick);
document.getElementById('button-sort-by-role-name').addEventListener('click', onSortByRoleNameClick);
}, false);
window.addEventListener('messageReceived', e => {
toggleLoader(false);
if (e.detail?.request) {
const request = e.detail.request;
const response = e.detail?.response || {};
if (routeMappings.hasOwnProperty(request.Route)) {
const callback = routeMappings[request.Route];
callback(response);
}
}
});
</script>
</body>
</html>
Continued in next post...
Last edited by dday9; Feb 12th, 2024 at 12:49 PM.
-
Feb 12th, 2024, 11:58 AM
#4
Re: Visual Basic .NET - Using a WebView2 to Render UI for Desktop Applications
In the previous step I included a JavaScript file and HTML file that had some assumptions in it, namely you'll see references to https://app-assets.local/. This is because I setup virtual hosting for my WebView2 which allows me to point a URL (in this case https://app-assets.local/) to a physical directory on the filesystem. To do this, I created a method to "configure" my WebView2 that gets called in the Form's Load event that does the following:
- Call the EnsureCoreWebView2Async method, which is something Microsoft requires but to be honest, I'm not sure why.
- Assert that the directory I'm pointing to exists.
- Set the virtual mapping to the directory using the CoreWebView2.SetVirtualHostNameToFolderMapping method.
- Set the CoreWebView2.Settings.IsWebMessageEnabled which allows us to communicate with the WebView2.
And here is the code:
Code:
Private Async Function ConfigureWebView2() As Task
Await MyWebView2.EnsureCoreWebView2Async(Nothing)
Dim webAssets = AssertApplicationDirectoryPath("WebAssets")
MyWebView2.CoreWebView2.SetVirtualHostNameToFolderMapping("app-assets.local", webAssets, CoreWebView2HostResourceAccessKind.Allow)
MyWebView2.CoreWebView2.Settings.IsWebMessageEnabled = True
End Function
Private Shared Function AssertApplicationDirectoryPath(ParamArray filePathParts() As String) As String
Dim paths = {Application.StartupPath}.Concat(filePathParts).ToArray()
Dim contentPath = Path.Combine(paths)
If (Not Directory.Exists(contentPath)) Then
Throw New ArgumentOutOfRangeException(NameOf(filePathParts), $"The following file does not exit: {contentPath}")
End If
Return contentPath
End Function
Now when the page is loaded in the WebView2, any instance of https://app-assets.local/ knows to point to our application's WebAssets directory. This is useful so that we do not have to rely on relative paths, which in my experience are a pain in the rear to manage.
Finally, we need to actually load the webpage into our WebView2. This is done by:
- Assert that the HTML file we want to load actually exists.
- Construct a new URL using the file we want to load's path.
- Call the CoreWebView2.Navigate method, passing the URL.
And here is the code:
Code:
Private Sub OpenWebPage(container As WebView2, ParamArray filePathParts() As String)
Dim fileContentPath = AssertApplicationFilePath(filePathParts)
Dim url = New Uri(fileContentPath).ToString()
container.CoreWebView2.Navigate(url)
End Sub
Private Shared Function AssertApplicationFilePath(ParamArray filePathParts() As String) As String
Dim filePaths = {Application.StartupPath}.Concat(filePathParts).ToArray()
Dim contentPath = Path.Combine(filePaths)
If (Not File.Exists(contentPath)) Then
Throw New ArgumentOutOfRangeException(NameOf(filePathParts), $"The following file does not exit: {contentPath}")
End If
Return contentPath
End Function
Something else that can be done in between steps 2 and 3 of the last list is to setup virtual hosting for the directory that the HTML file exists so that if you have any resource files (like CSS, JavaScript, etc.) that live in the same directory, those can be referenced too. Here's a modification setting up the virtual hosting tied to https://page-assets.local/:
Code:
Private Sub OpenWebPage(container As WebView2, ParamArray filePathParts() As String)
Dim fileContentPath = AssertApplicationFilePath(filePathParts)
Dim fileDirectoryPath = Path.GetDirectoryName(fileContentPath)
Dim url = New Uri(fileContentPath).ToString()
container.CoreWebView2.SetVirtualHostNameToFolderMapping("page-assets.local", fileDirectoryPath, CoreWebView2HostResourceAccessKind.Allow)
container.CoreWebView2.Navigate(url)
End Sub
Now with all of that being said, here is a slimmed down version of what my MainForm.vb code looks like using the Roles example from earlier:
Code:
Public Class FormMain
Private ReadOnly _controllerMaps As Dictionary(Of String, BaseController)
Sub New()
InitializeComponent()
_controllerMaps = BuildControllerMaps()
End Sub
Private Async Sub FormMain_Load(sender As Object, e As EventArgs) Handles Me.Load
Await ConfigureWebView2()
OpenWebPage(WebView2Container, "WebPages", "Roles", "Index.html")
End Sub
' WebView2 specific
Private Async Sub WebView2Container_WebMessageReceived(sender As Object, e As CoreWebView2WebMessageReceivedEventArgs) Handles WebView2Container.WebMessageReceived
Await WebView2Container.EnsureCoreWebView2Async(Nothing)
Dim message = e.WebMessageAsJson()
Dim request As WebViewRequest = Nothing
Dim response = WebViewResponse.InternalServerErrorResponse()
Try
request = JsonConvert.DeserializeObject(Of WebViewRequest)(message)
Catch
response = WebViewResponse.BadRequestResponse("The payload is not a valid request.")
End Try
If (request IsNot Nothing) Then
If (Not _controllerMaps.ContainsKey(request.Controller)) Then
response = WebViewResponse.NotFoundResponse()
Else
Dim controller = _controllerMaps(request.Controller)
If (Not controller.Routes.ContainsKey(request.Route)) Then
response = WebViewResponse.NotFoundResponse()
Else
Dim route = controller.Routes(request.Route)
response = route(request)
End If
End If
End If
Await WebView2Container.CoreWebView2.ExecuteScriptAsync($"dispatchMessageReceivedEvent({request.ToJson()}, {response.ToJson()});")
End Sub
Private Async Function ConfigureWebView2() As Task
Await WebView2Container.EnsureCoreWebView2Async(Nothing)
Dim webAssets = AssertApplicationDirectoryPath("WebAssets")
WebView2Container.CoreWebView2.SetVirtualHostNameToFolderMapping("app-assets.local", webAssets, CoreWebView2HostResourceAccessKind.Allow)
WebView2Container.CoreWebView2.Settings.IsWebMessageEnabled = True
End Function
Private Sub OpenWebPage(container As WebView2, ParamArray filePathParts() As String)
Dim fileContentPath = AssertApplicationFilePath(filePathParts)
Dim fileDirectoryPath = Path.GetDirectoryName(fileContentPath)
Dim url = New Uri(fileContentPath).ToString()
container.CoreWebView2.SetVirtualHostNameToFolderMapping("page-assets.local", fileDirectoryPath, CoreWebView2HostResourceAccessKind.Allow)
container.CoreWebView2.Navigate(url)
End Sub
Private Shared Function AssertApplicationFilePath(ParamArray filePathParts() As String) As String
Dim filePaths = {Application.StartupPath}.Concat(filePathParts).ToArray()
Dim contentPath = Path.Combine(filePaths)
If (Not File.Exists(contentPath)) Then
Throw New ArgumentOutOfRangeException(NameOf(filePathParts), $"The following file does not exit: {contentPath}")
End If
Return contentPath
End Function
Private Shared Function AssertApplicationDirectoryPath(ParamArray filePathParts() As String) As String
Dim paths = {Application.StartupPath}.Concat(filePathParts).ToArray()
Dim contentPath = Path.Combine(paths)
If (Not Directory.Exists(contentPath)) Then
Throw New ArgumentOutOfRangeException(NameOf(filePathParts), $"The following file does not exit: {contentPath}")
End If
Return contentPath
End Function
' WebServer specific
Private Function BuildControllerMaps() As Dictionary(Of String, BaseController)
Dim rolesService = New RoleService(My.Application.Database, My.Application.Logger)
Dim rolesController = New RolesController(My.Application.Logger, rolesService)
Return New Dictionary(Of String, BaseController) From {
{"Roles", rolesController}
}
End Function
End Class
What you'll see is the following:
- When the Form is created, it will build the controller mappings (like /Roles/GetAll).
- When the Form loads, it configures the WebView2 and opens the /WebPages/Roles/Index.html page.
- When the webpage is loaded, it makes a request to /Roles/GetAll with essentially an emtpy body.
- The WebView2's WebMessageReceived is handled and we try the following:
- Call the EnsureCoreWebView2Async method (again, I don't know why this is required).
- Get the request from the window.chrome.webview.postMessage call.
- Try to parse the request as a WebViewRequest and if it fails, simply return a 400 bad request response.
- Next, try to get the controller by the incoming request's controller property.
- If it cannot find a controller then return a 404 not found response.
- If it can find the controller, try to get the route from the matched controller.
- If it cannot find a route, then return the 404 response.
- If it can find the route, then call the Route delegate method passing in the request.
- At the end of the method, call the dispatchMessageReceivedEvent passing the request and response as the arguments.
- The messageReceived JavaScript event is then triggered that will ultimately call the onGetAllMessageReceived method.
And that's that. You can build very rich UI desktop applications using front-end web development and the traditional VB.NET code. Eventually I will include a VB.NET Codebank Submission with a sample project.
Edit - And here is the codebank link: https://www.vbforums.com/showthread....p-UI&p=5633447
Last edited by dday9; Feb 20th, 2024 at 02:32 PM.
Posting Permissions
- You may not post new threads
- You may not post replies
- You may not post attachments
- You may not edit your posts
-
Forum Rules
|
Click Here to Expand Forum to Full Width
|