Connor Park, a Microsoft MVP and blogger from Korea recently ported the Contoso showcase application from being desktop-only, UWP application to a cross-platform application using UWP and Uno Platform. The source code is available in GitHub for you to try and learn from it. Equally importantly, Connor took the time to capture lessons learned during the port in order to help all others wishing to move their UWP desktop applications to Web, macOS, Linux, iOS and Android.
Introduction
Year 2020 will be remembered as a year which radically ‘promoted’ work from home. The COVID-19 virus transformed the workforce trends from being ‘mobile-first’ in the past few years, more towards ‘remote-first’. The distinction is subtle, but important. In the future more emphasis will be put on rich client applications as the workforce is home-bound, without much travel needed.
Therefore it is important for us as software developers to denote this shift in workforce trends and align our development platform priorities. The cross-platform development environments that run on PCs but also on various mobile devices, which in addition to productivity give you ability to control every pixel on any device, will excel further.
Uno Platform, first unveiled in May 2018, is in my opinion the fastest way to develop business, cross-platform applications. In this post, we will discuss points to note and necessary technologies when porting Microsoft Contoso UWP apps to Uno Platform with Prism support.
About Contoso app.
A UWP (Universal Windows Platform) sample app showcasing features useful to enterprise developers, like Azure Active Directory (AAD) authentication, UI controls (including a data grid), Sqlite and SQL Azure database integration, Entity Framework, and cloud API services. The sample is based around creating and managing customer accounts, orders, and products for the fictitious company Contoso.
Migrating the Contoso application
Contoso UWP app
https://github.com/microsoft/Windows-appsample-customers-orders-database
UnoContoso app – Uno Platform
Uno.Samples/UI/UnoContoso at master · unoplatform/Uno.Samples (github.com)
The migration of the application was done in stages, starting from an empty Uno Platform solution and further building out each part of the Contoso project step-by-step.
Migration steps
1. To create a project using the Uno Platform Prism project template, use the CLI environment.
2. Go to the repository folder.
a. Ex) C:\Users\kaki1\source\repos
3. Install the latest project template from Uno Platform.
a. dotnet new –install Uno.ProjectTemplates.Dotnet::3.3.0
4. Create an UnoPrismSample project.
a. dotnet new unoapp-prism -o UnoPrismSample
5. After creating the project, move to the UnoPrismSample folder and open the UnoPrismSample.sln file using Visual Studio 2019.
6. Build -> Configuration Manager -> UnoPrismSample.Uwp -> check Build, Deploy
7. After selecting the UnoPrismSample.Uwp project, proceed with Build and execute.
8. I get an error after building in version 3.3.0.
a. Error XDG0012 The member “AutoWireViewModel” is not recognized or is not accessible. UnoPrismSample.Uwp C:\Users\kaki1\source\repos\UnoPrismSample\UnoPrismSample.Shared\Views\Shell.xaml 10
b. The reason for the error is that the AutoWireViewModel name has been changed to AutowireViewModel.
9. In the Contoso app, copy the .NET Standard 2.0 library Contoso.Models project and Contoso.Repository project and add them to the UnoPrismSample solution.
10. Lastly, porting is performed slowly for each screen unit.
What was difficult when porting?
1. It took a long time to solve the problem that occurred when the Entity Framework Core version changed from 2.1 to 3.x. Please see the changes here.
2. The method of using Sqlite by directly accessing it from the app using Entity Framework Core was not smooth in WASM, iOS, macOS, etc., so it took some time to modify it so that it can be used only through Web API.
3. Since the Contoso UWP app uses the event-driven method, it took time to change the part to MVVM Pattern.
Brief description of the project in your IDE
UnoContoso.Droid : Android header project
UnoContoso.iOS : iOS header project
UnoContoso.macOS : macOS header project
UnoContoso.Uwp : UWP header project
UnoContoso.Wasm : Wasm header project
UnoContoso.Models : Model project, .NET Standard 2.0
UnoContoso.Repository : Repository project, .NET Standard 2.0
UnoContoso.Service : Service project, .NET Core 3.1
UnoContoso.Shared : Shared project
Tips
All code samples can be found in the Uno Contoso repository.
NavigationView
1. AlwaysShowHeader=”False” : Don’t show headers at the top. Not displaying the header is easier to develop due to the following reasons.
a. On iOS, the location of the header is displayed upside down differently from other platforms.
b. It is difficult to use NavigationView.HeaderTemplate because Windows, Wasm, and macOS display the CommandBar at the header position, and Android and iOS display it at the bottom of the screen.
2. NavigationViewBehavior : In order to dynamically implement the menu displayed in the NavigationView and bind the selected Menu to the ViewModel, we created a Behavior For general information about Behavior, please see here.
3. ContentControl : In Prism, this is a control that usually specifies Region. View is navigated through this control. For more details, please refer to Prism’s RegionNavigation.
a. Padding for this control is the default Margin for each screen.
b. For iOS, you must use ios:Padding=”10,20,0,0”.
<NavigationView IsBackButtonVisible="Collapsed" OpenPaneLength="160" IsSettingsVisible="False" AlwaysShowHeader="False" IsTabStop="False"> <i:Interaction.Behaviors> <behaviors:NavigationViewBehavior MenuItems="{Binding Menus}" SelectedMenuItem="{Binding SelectedItem, Mode=TwoWay}"/> </i:Interaction.Behaviors> <ContentControl prismRegions:RegionManager.RegionName="ContentRegion" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch" not_ios:Padding="10,0,0,0" ios:Padding="10,20,0,0"/> </NavigationView>
x:Bind
1. I mainly used x:Bind to improve the performance of Android and iOS apps.
2. To use x:Bind, add a property called ViewModel behind the code.
3. The default mode of x:Bind is OneTime. So, you must add mode=OneWay wherever the property’s data changes.
4. The method of directly connecting x:Bind between the event of the control and the method of the view model, sys:String.Format() cannot be used.
<AppBarButton Click="{x:Bind ViewModel.LoadOrders}" Icon="Refresh" Label="Refresh" /> <AppBarButton Icon="Refresh" Label="Refresh" Command="{x:Bind ViewModel.RefreshCommand}"/> <toolkit:DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="models:Order"> <TextBlock VerticalAlignment="Center" Margin="12,0" Text="{x:Bind sys:String.Format('\{0:d\}', DatePlaced)}"/> </DataTemplate> </toolkit:DataGridTemplateColumn.CellTemplate> <toolkit:DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock VerticalAlignment="Center" Margin="12,0" Text="{Binding DatePlaced, Mode=OneWay, Converter={StaticResource StringFormatConverter}, ConverterParameter='{}{0:g}'}"/> </DataTemplate> </toolkit:DataGridTemplateColumn.CellTemplate>
5. When using IValueConverter Since x:Bind cannot be used in Android, Binding is used.
<TextBlock Grid.Column="2" Margin="0" Padding="0" HorizontalAlignment="Right" Text="{Binding Product.ListPrice, Converter={StaticResource StringFormatConverter}, ConverterParameter='{}{0:n}'}" />
Prism
1. It automatically connects View and ViewModel. Refer to the code prismMvvm:ViewModelLocator.AutowireViewModel=”True”. Click here for details.
<UserControl x:Class="UnoContoso.Views.OrderDetailView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:UnoContoso.Views" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:i="using:Microsoft.Xaml.Interactivity" xmlns:ic="using:Microsoft.Xaml.Interactions.Core" xmlns:toolkit="using:Microsoft.Toolkit.Uwp.UI.Controls" xmlns:prismMvvm="using:Prism.Mvvm" prismMvvm:ViewModelLocator.AutowireViewModel="True" xmlns:uc="using:UnoContoso.UserControls" xmlns:stateTriggers="using:UnoContoso.StateTriggers" xmlns:models="using:UnoContoso.Models" x:Name="Root" mc:Ignorable="d">
2. To use RegionNavigation, you must register it in App.xaml.cs → void RegisterTypes() method using RegisterForNavigation. Click here for details.
containerRegistry.RegisterForNavigation<CustomerListView>(); containerRegistry.RegisterForNavigation<CustomerDetailView>(); containerRegistry.RegisterForNavigation<HomeView>(); containerRegistry.RegisterForNavigation<OrderListView>(); containerRegistry.RegisterForNavigation<OrderDetailView>();
3. For screens using DialogService, manually connect View and ViewModel. App.xaml.cs → void Refer to the code in RegisterTypes() method. See here for more details
containerRegistry.RegisterDialog<MessageControl, MessageViewModel>(); containerRegistry.RegisterDialog<ConfirmControl, ConfirmViewModel>();
4. By using the ObservesProperty of DelegateCommand, you can easily change the use of Command. See here for more details.
ViewDetailCommand = new DelegateCommand(OnViewDetail, () => SelectedCustomer != null) .ObservesProperty(() => SelectedCustomer);
5. EventAggregator allows communication between ViewModels and ViewModels. This is a Loosely Coupled connection. See here for more details.
Step 1. CustomerListViewModel.cs EventAggregator.GetEvent<CustomerEvent>() .Subscribe(ReceivedCustomerEvnet, false); Step 2. CustomerDetailViewModel.cs EventAggregator.GetEvent<CustomerEvent>() .Publish(new EventArgs.CustomerEventArgs { Changes = change, Customer = Customer.Model }); Step 3. CustomerListViewModel.cs private void ReceivedCustomerEvnet(CustomerEventArgs obj) { switch (obj.Changes) { case Enums.EntityChanges.None: break; case Enums.EntityChanges.Changed: { var customer = _allCustomers .FirstOrDefault(c => c.Model.Id == obj.Customer.Id); if (customer == null) return; customer.Model = obj.Customer; customer.UpdateProperty(); } break; case Enums.EntityChanges.Added: { var customer = new CustomerWrapper(_contosoRepository, obj.Customer); _allCustomers.Add(customer); } break; case Enums.EntityChanges.Deleted: { var customer = _allCustomers .FirstOrDefault(c => c.Model.Id == obj.Customer.Id); if (customer == null) return; _allCustomers.Remove(customer); } break; } }
RelativePanel
1. Position each control by relationship. Please click here for details.
2. When changing the position of the CommandBar located at the top to the bottom using VisualState, you must delete the names entered in RelativePanel.LeftOf and RelativePanel.RightOf in the CommandBar control to move to the bottom. This part behaves differently from UWP.
<VisualState> <VisualState.StateTriggers> <stateTriggers:MobileScreenTrigger /> </VisualState.StateTriggers> <VisualState.Setters> <Setter Target="mainCommandBar.(RelativePanel.AlignBottomWithPanel)" Value="True" /> <Setter Target="mainCommandBar.(RelativePanel.AlignLeftWithPanel)" Value="True" /> <Setter Target="mainCommandBar.(RelativePanel.AlignRightWithPanel)" Value="True" /> <Setter Target="mainCommandBar.(RelativePanel.LeftOf)" Value="" /> <Setter Target="mainCommandBar.(RelativePanel.RightOf)" Value="" /> <Setter Target="mainCommandBar.HorizontalAlignment" Value="Stretch" /> <Setter Target="PageTitle.Margin" Value="30,4,0,0"/> <Setter Target="CustomerSearchBox.Width" Value="240" /> <Setter Target="RootDataGrid.Margin" Value="0,10,0,40" /> <Setter Target="CustomerListMobileView.Visibility" Value="Visible"/> <Setter Target="CustomerDataGrid.Visibility" Value="Collapsed"/> </VisualState.Setters> </VisualState>
CommandBar
1. This is a control to place command buttons. For the basics, please refer here.
2. CommandBarBehavior is a behavior added to move DefaultLabelPosition from Right to Bottom when the size changes.
3. Since the DefaultLabelPosition property is an unimplemented property in Uno, most platforms have a default Bottom.
<CommandBar x:Name="mainCommandBar" Background="White" HorizontalAlignment="Right" DefaultLabelPosition="Right" RelativePanel.LeftOf="CustomerSearchBox" RelativePanel.RightOf="PageTitle"> <i:Interaction.Behaviors> <behaviors:CommandBarBehavior/> </i:Interaction.Behaviors> <AppBarButton Icon="Contact" Label="View details" ToolTipService.ToolTip="View details" Command="{x:Bind ViewModel.ViewDetailCommand}"/> <!--skip lines--> <AppBarButton Icon="Refresh" Label="Sync" ToolTipService.ToolTip="Sync with server" Command="{x:Bind ViewModel.SyncCommand}"/> </CommandBar>
CollapsibleSearchBox
1. This is a search control created using UserControl.
2. In Uno Platform, graphic icons must use the SymbolIcon control to display the icons normally on each platform.
<ToggleButton x:Name="searchButton" HorizontalAlignment="Right" VerticalAlignment="Top" Background="Transparent" Checked="SearchButton_Checked" Visibility="Collapsed" Padding="4"> <SymbolIcon Symbol="Find" /> </ToggleButton>
ListView
1. On mobile devices, it is better to use ListView rather than DataGrid because the number of items displayed on the screen and the presence or absence of a horizontal scroll bar affects vertical scrolling performance. See here for more details.
2. In macOS, DataGrid is not yet available, so I use ListView. However, the ListView on macOS also seems to be unable to scroll vertically because there is no ScrollView inside the control. I think macOS support will be added gradually.
DataGrid
1. Data is displayed in the form of a grid, and it is a control that can be modified, and can be used only by installing Windows Community Toolkit. See here for more details.
2. DataGridBehavior was created to support Sort function and right mouse button.
3. You cannot use x:Bind when binding properties to columns.
<toolkit:DataGrid x:Name="OrderListDataGrid" BorderThickness="0" CanUserReorderColumns="False" CanUserResizeColumns="False" GridLinesVisibility="None" IsReadOnly="True" AutoGenerateColumns="False" Margin="0,10,0,0" ItemsSource="{x:Bind ViewModel.Orders, Mode=OneWay}" SelectedItem="{x:Bind ViewModel.SelectedOrder, Mode=TwoWay}" ContextFlyout="{StaticResource DataGridContextMenu}"> <i:Interaction.Behaviors> <behaviors:DataGridBehavior/> </i:Interaction.Behaviors> <toolkit:DataGrid.Columns> <toolkit:DataGridTextColumn Header="Invoice" Tag="InvoiceNumber" Binding="{Binding InvoiceNumber}"/> <toolkit:DataGridTextColumn Header="Customer" Tag="CustomerName" Binding="{Binding CustomerName}"/> <toolkit:DataGridTemplateColumn Header="Date" Tag="DatePlaced"> <toolkit:DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock VerticalAlignment="Center" Margin="12,0" Text="{Binding DatePlaced, Mode=OneWay, Converter={StaticResource StringFormatConverter}, ConverterParameter='{}{0:d}'}" /> </DataTemplate> </toolkit:DataGridTemplateColumn.CellTemplate> </toolkit:DataGridTemplateColumn> <!-- Skip lines --> <toolkit:DataGridTextColumn Header="Status" Binding="{Binding Status}"/> </toolkit:DataGrid.Columns> </toolkit:DataGrid>
VisualState
1. This function allows you to specify the visual appearance of UI elements in a specific state. Click here for details.
2. AdaptiveTrigger : Trigger that declares visual state based on window properties. Click here for details.
3. When designing using VisualState, select 8” Tablet (1280×800) from the upper left corner of the screen for the default size design, and 6” Phone (1920×1080) for the minimum size design. You can work a little more comfortably.
4. For the minimum size, add a Margin of 30 to the left of the PageTitle control to avoid overlapping the hamburger button.
5. MobileScreenTrigger gets the name of the running device when the window size changes, and if it includes the name Mobile, it changes VisualState using SetActive(). UIViewSettings.GetForCurrentView().UserInteractionMode is commented out as not implemented in Uno Platform.
<VisualStateManager.VisualStateGroups> <VisualStateGroup> <VisualState> <VisualState.StateTriggers> <AdaptiveTrigger MinWindowWidth="{StaticResource LargeWindowSnapPoint}" /> </VisualState.StateTriggers> </VisualState> <VisualState> <VisualState.StateTriggers> <AdaptiveTrigger MinWindowWidth="{StaticResource MediumWindowSnapPoint}" /> </VisualState.StateTriggers> </VisualState> <VisualState> <VisualState.StateTriggers> <AdaptiveTrigger MinWindowWidth="{StaticResource MinWindowSnapPoint}" /> </VisualState.StateTriggers> <VisualState.Setters> <Setter Target="PageTitle.Margin" Value="30,4,0,0"/> </VisualState.Setters> </VisualState> <VisualState> <VisualState.StateTriggers> <stateTriggers:MobileScreenTrigger /> </VisualState.StateTriggers> <VisualState.Setters> <!-- Skip lines --> </VisualState.Setters> </VisualState> </VisualStateGroup> </VisualStateManager.VisualStateGroups>
BackButton
1. This is the back button. The GoBackCommand command is declared in ViewModelBase.
<Button x:Name="BackButton" Style="{StaticResource NavigationBackButtonNormalStyle}" Command="{x:Bind ViewModel.GoBackCommand}"/>
TextBox
1. A control used to display and edit plain text. Click here for details.
2. It is recommended to use UpdateSourceTrigger=PropertyChanged when binding TwoWay to Text property. This is because when you modify the content, the bound property is modified immediately. The default is LostFocus.
<TextBox x:Name="FirstName" MinWidth="120" Margin="0,8,16,8" Header="First name" IsReadOnly="{x:Bind ViewModel.Customer.IsInEdit, Converter={StaticResource BoolNegationConverter}, Mode=OneWay}" RelativePanel.AlignLeftWithPanel="True" Text="{x:Bind ViewModel.Customer.FirstName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
InvoiceTemplate (DataTemplate)
1. Use to create the visual structure of data objects. Click here for details.
2. There is 1 HyperlinkButton, and the command is divided into win and not_win. Click here for how to apply the xaml code for each platform.
3. In Windows, you must use win:Command=”{Binding Source={StaticResource ViewModelElement}, Path=ViewModel.ViewInvoiceCommand}” to execute Command.
4. In case of non-Windows, you must use not_win:Command=”{Binding ElementName=DataGrid, Path=DataContext.ViewInvoiceCommand}” to execute Command.
5. I don’t know why there is no way to use ElementName=DataGrid on Windows.
<DataTemplate x:Key="InvoiceTemplate"> <HyperlinkButton Content="{Binding InvoiceNumber}" Margin="12,0" win:Command="{Binding Source={StaticResource ViewModelElement}, Path=ViewModel.ViewInvoiceCommand}" not_win:Command="{Binding ElementName=DataGrid, Path=DataContext.ViewInvoiceCommand}" CommandParameter="{Binding}"/> </DataTemplate>
Other:
1. If you include and use the .Net Standard project in your solution, you need to add the content to the LinkerConfig.xml file of the Wasm project.
<!—LinkerConfig.xml --> <linker> <assembly fullname="UnoContoso.Wasm" /> <assembly fullname="Uno.UI" /> <assembly fullname="System.Core"> <!-- This is required by JSon.NET and any expression.Compile caller --> <type fullname="System.Linq.Expressions*" /> </assembly> <assembly fullname="UnoContoso.Models"/> <assembly fullname="UnoContoso.Repository"/> </linker>
I hope these were useful tips I captured while working on this conversion. If more tips become available in the future I will update this blog post.
Connor Park, Microsoft MVP Developer Technologies
Facebook : https://www.facebook.com/kaki104
Twitter : https://twitter.com/kaki104
Youtube : http://youtube.com/FutureOfDotNet