More things I learned creating a GUI tool with PowerShell and WPF.
The snippets in this article are based on the Show-Win32AppUI tool available on GitHub.

Part2 - Creating a multi-page WPF app in PowerShell

(See part1 of this series for information on using PowerShell Runspaces with WPF).

WPF uses .XAML text files to define the layout and properties of supported controls such as Textboxes, Buttons, Status bars etc. XAML is a form is XML, with opening and closing tags that create a hierarchy of controls. A child control such as a Combobox is displayed within a parent container such as a Window.

Very simple apps may be able to fit controls in a single Window, but a wizard-driven interface will usually need multiple Pages to guide the user through selections. There are many ways to create a multi-page WPF app. The method below is the one I prefer:

Main window and child pages

The starting point is a WPF Window control defined in it’s own .XAML file. A Window is a container for other WPF controls, most importantly in this case, a Frame that can load Pages. The main Window can show a header, footer and sidebar that is always visible while the Page within the Frame control changes as the user navigates the app.

MainWindow

Each page can be defined in a separate .XAML file. A Page is also a container so each page can host controls such as Textboxes, Textblocks, Comboboxes etc. The Frame content is updated at runtime to show a new Page when an event occurs such as clicking navigation buttons.

A Frame does have a built-in navigation control, but it isn’t pretty. I turn it off and use Button controls in the main Window.

<!--Partial XAML for a Main Window with a Frame and navigation Buttons-->
<Window
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Height="850" Width="450"
    ...>
    
    <!--Pages will be loaded into this frame at runtime-->
    <Frame x:Name="frame_Pages"
        Grid.Row="2"
        Grid.Column="1"
        Grid.ColumnSpan="3"
        NavigationUIVisibility="Hidden" # Built-in navigation disabled
        Margin="0,10,0,0" />

    <!--Navigation Buttons below the pages-->
    <Button x:Name = "Btn_Previous" 
        Content="Previous"
        Height="35" Width="60"
        Grid.Row="3"
        Grid.Column="2" 
        BorderThickness="0"/>
    
    <Button x:Name = "Btn_Next"
        Content="Next"
        Margin="3,0,0,0"
        Height="35"
        Width="60"
        Grid.Row="3"
        Grid.Column="3"
        BorderThickness="0"/>

See the Show-Win32UI tool for an example of separate XAML files per Page and main Window.

Load XAML controls into PowerShell variables

A thread safe Hashtable collection allows WPF controls to be referenced in PowerShell at runtime. Grouping the controls in a collection is convenient as it simplifies passing them into Runspaces.

### Example of reading controls from XAML files and assigning them to variables

function LoadXml ($filename) {
    # Convert a .XAML file to an XMLDocument
    $XmlLoader = (New-Object System.Xml.XmlDocument)
    $XmlLoader.Load($filename)
    return $XmlLoader
}

# Load the XAML files
$xmlMainWindow = LoadXml("$PSScriptRoot\Xaml\MainWindow.xaml")
$xmlPage1 = LoadXml("$PScriptRoot\Xaml\Page1.xaml")
$xmlPage2 = LoadXml("$PScriptRoot\Xaml\Page2.xaml")

# Collection storing references to all named WPF controls in the UI
$UIControls=[hashtable]::Synchronized(@{})

# Convert Windows and Pages to a XAML object graph
$UIControls.MainWindow = [Windows.Markup.XamlReader]::Load((New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList $xmlMainWindow))
$UIControls.Page1 = [Windows.Markup.XamlReader]::Load((New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList $xmlPage1))
$UIControls.Page2 = [Windows.Markup.XamlReader]::Load((New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList $xmlPage2))

# Add each named control to the $UIControls hashtable (repeat for each Window / Page)
# This allows key controls to be referenced directly at runtime, rather than through a parent-child hierarchy of Page>Control
$XmlMainWindow.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object -Process {
    $UIControls.$($_.Name) = $UIControls.MainWindow.FindName($_.Name)
}

$xmlPage1.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object -Process {
    $UIControls.$($_.Name) = $UIControls.Page1.FindName($_.Name)
}

$xmlPage2.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object -Process {
    $UIControls.$($_.Name) = $UIControls.Page2.FindName($_.Name)
}

# Example of loading first Page into the Frame
# "frame_Pages" in the name of the Frame defined in the XAML file
$UIControls.frame_Pages.Content = $UIControls.Page1

# Show the user interface
$UIControls.MainWindow.ShowDialog()  

Using WPF Styles to simplify XAML

XAML styles are analogous to CSS in HTML. The XAML style can apply to all controls of a type, such as all TextBoxes, defining properties such as the colour, font, border etc. If a property needs to be changed, it only needs to be updated in one place. Its also a flexible solution because Style properties can still be over-ridden on an individual control.

Styles can be defined in a separate XAML file and then imported on each Window / Page as a ResourceDictionary.

<!--Styles.xaml defines a TextBox style called ModernTextBox-->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Style TargetType="{x:Type TextBox}"
            x:Key="ModernTextBox">
        <Setter Property="BorderThickness" Value="0,0,0,1"/>
        <Setter Property="BorderBrush" Value="LightGray"/>
        <Setter Property="Background" Value="Transparent"/>
        <Setter Property="FontSize" Value="18"/>
    </Style>
</ResourceDictionary>


<!--MainWindow.xaml imports Styles.xml as a ResourceDictionary-->
<Window
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Height="850" Width="450"
    Title="Main Window">
    <!--Import a style so it can used by controls in this Window-->
    <Window.Resources>
            <ResourceDictionary Source="Styles.xaml"/>     
    </Window.Resources>
    ...


<!--Page1.xaml imports Styles.xml as a ResourceDictionary--> 
<Page
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Background="Transparent">
    <!--Import a style so it can used by controls in this Page-->
    <Page.Resources>
        <ResourceDictionary Source="Styles.xaml"/>
    </Page.Resources>
    ...
    
<!--MainWindow.xaml or Page1.xaml uses the style on a Textbox as shown below-->
<TextBox x:Name="txt_InstallArgs"
    Width="330"
    Height="30"
    Style="{StaticResource ModernTextBox}"/>

Define the WPF layout with the Grid control

The Grid control overlays invisible rows and columns on a parent container such as a Window or Page. The Controls are then positioned based on the Row and Column number. It is fairly simple to design the Grid layout using a basic text editor. For a more WYSIWYG experience, use Microsoft Visual Studio to view the UI at design time.

There are three options for Grid Row/Column height / width:

SizeMeaning
[Pixels]A fixed size
“Auto”Expand to fit content
“*”Fit to remaining space in Window / Page
<!-- Example positioning a Textbox above a TextBlock using a Grid
     TextBox is in Grid.Row = "1" and TextBlock is in Grid.Row = "2"-->
<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="5"/>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="5"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="5"/>
        <RowDefinition Height="auto"/>
        <RowDefinition Height="auto"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="5"/>
    </Grid.RowDefinitions>
    <TextBox 
        Width="350"
        Grid.Column="1"
        Grid.Row="1"
        Style="{StaticResource ModernTextBox}"/>

    <TextBlock 
        Grid.Column="1"
        Grid.Row="2"
        Text="Enter your name"
        Style="{StaticResource ModernTextBlock}"/>   
        
</Grid>

Use margins to create space between controls

The Margin property is available on most controls. The margin creates a buffer of blank space around a control. The margin can either be the same all round, or different for each vector - left, top, right, and bottom

<!--Example setting a Margin around a control-->
    
<!--Different margin for left, top, right, and bottom -->
<ComboBox x:Name="combo_Supercedence"
    Grid.Column="0"  
    Grid.Row="11"
    Width="360"
    Height="24"
    Margin="15,5,0,0"/>
    
<!--Same margin all round -->
<ComboBox x:Name="combo_Supercedence"
    Grid.Column="0"  
    Grid.Row="11"
    Width="360"
    Height="24"
    Margin="5"/>   

Defining Event Handlers

WPF controls support events that occur based on user input. Some events are common to most controls, such as MouseEnter and MouseLeave while other events are specific to a control type, such as SelectionChanged in a ComboBox.

PowerShell uses an “Add_[Event Name]” syntax to define the code to run when the event fires as below. The per-control event list is available in the MS Documentation.

# Example event handler - code will run when the Combo box selection changes
$UIControls.combo_language.Add_SelectionChanged({
    
    $Language = $UIControls.combo_language.SelectedItem
    
})

# Example event handler - code will run when the button is left clicked
$UIControls.btn_Next.Add_Click({
    
    $UIControls.frame_Pages.Content = $UIControls.Page2
    
})



This article was originally posted on Write-Verbose.com