Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

F# & WPF: basic UI update

Tags:

wpf

f#

I just started to use WPF. I made a drag and drop UI for my file processing scripts (F#). How can I update the textBlock to give progress feedback? The UI in the current version only updates after processing all files. Do I need to define a DependencyProperty type and set a Binding? What would be a minimal version of that in F# ?

Here is my current app converted to a F# script:

#r "WindowsBase.dll"
#r "PresentationCore.dll"
#r "PresentationFramework.dll"

open System
open System.Windows
open System.Windows.Controls

[< STAThread >] 
do 
    let textBlock = TextBlock()        
    textBlock.Text <- "Drag and drop a folder here"

    let getFiles path =         
        for file in IO.Directory.EnumerateFiles path do
            textBlock.Text <- textBlock.Text + "\r\n" + file // how to make this update show in the UI immediatly?                                
            // do some slow file processing here..
            Threading.Thread.Sleep 300 // just a placeholder to simulate the delay of file processing

    let w = Window()        
    w.Content <- textBlock    
    w.Title <- "UI test"
    w.AllowDrop <- true        
    w.Drop.Add(fun e ->
        if e.Data.GetDataPresent DataFormats.FileDrop then
            e.Data.GetData DataFormats.FileDrop 
            :?> string []
            |> Seq.iter getFiles)

    let app = Application()  
    app.Run(w)
    |> ignore
like image 739
Goswin Rothenthal Avatar asked Mar 08 '17 09:03

Goswin Rothenthal


2 Answers

By calling Threading.Thread.Sleep 300 on the UI thread, you block the windows message pump, and prevent any updates from occurring on that thread.

The simplest way to handle this would be to move everything into an async workflow, and do the update on a background thread. However, you will need to update the UI on the main thread. Within an async workflow, you can switch back and forth directly.

This requires a couple of small changes to your code to work:

#r "WindowsBase.dll"
#r "PresentationCore.dll"
#r "PresentationFramework.dll"

open System
open System.Windows
open System.Windows.Controls

[< STAThread >] 
do 
    let textBlock = TextBlock()        
    textBlock.Text <- "Drag and drop a folder here"

    let getFiles path =
        // Get the context (UI thread)
        let ctx = System.Threading.SynchronizationContext.Current
        async {         
            for file in IO.Directory.EnumerateFiles path do
                // Switch context to UI thread so we can update control
                do! Async.SwitchToContext ctx
                textBlock.Text <- textBlock.Text + "\r\n" + file // Update UI immediately

                do! Async.SwitchToThreadPool ()
                // do some slow file processing here.. this will happen on a background thread
                Threading.Thread.Sleep 300 // just a placeholder to simulate the delay of file processing
        } |> Async.StartImmediate

    let w = Window()        
    w.Content <- textBlock    
    w.Title <- "UI test"
    w.AllowDrop <- true        
    w.Drop.Add(fun e ->
        if e.Data.GetDataPresent DataFormats.FileDrop then
            e.Data.GetData DataFormats.FileDrop 
            :?> string []
            |> Seq.iter getFiles)

    let app = Application()  
    app.Run(w)
    |> ignore

Note that it's also possible to do this via data binding. In order to bind (and have it update), you'd need to bind to a "view model" - some type that implements INotifyPropertyChanged, and then create the binding (which is ugly in code). The issue with the UI thread becomes somewhat simpler - you still need to push the work off the UI thread, but when binding to a simple property, you can set the value on other threads. (If you use a collection, you still need to switch to the UI thread, however.)

The example converted to using a binding would be something like the following:

#r "WindowsBase.dll"
#r "PresentationCore.dll"
#r "PresentationFramework.dll"
#r "System.Xaml.dll"

open System
open System.Windows
open System.Windows.Controls
open System.Windows.Data
open System.ComponentModel

type TextWrapper (initial : string) =
    let mutable value = initial
    let evt = Event<_,_>()

    member this.Value 
        with get() = value 
        and set(v) = 
            if v <> value then
                value <- v
                evt.Trigger(this, PropertyChangedEventArgs("Value"))

    interface INotifyPropertyChanged with
        [<CLIEvent>]
        member __.PropertyChanged = evt.Publish


[< STAThread >] 
do 
    let textBlock = TextBlock()        

    // Create a text wrapper and bind to it
    let text = TextWrapper "Drag and drop a folder here"        
    textBlock.SetBinding(TextBlock.TextProperty, Binding("Value")) |> ignore
    textBlock.DataContext <- text

    let getFiles path =
        async {         
            for file in IO.Directory.EnumerateFiles path do
                text.Value <- text.Value + "\r\n" + file // how to make this update show in the UI immediatly?                                

                // do some slow file processing here.. this will happen on a background thread
                Threading.Thread.Sleep 300 // just a placeholder to simulate the delay of file processing
        } |> Async.Start

    let w = Window()        
    w.Content <- textBlock    
    w.Title <- "UI test"
    w.AllowDrop <- true        
    w.Drop.Add(fun e ->
        if e.Data.GetDataPresent DataFormats.FileDrop then
            e.Data.GetData DataFormats.FileDrop 
            :?> string []
            |> Seq.iter getFiles)

    let app = Application()  
    app.Run(w)
    |> ignore

Note that this could be simplified if you wanted to use something like FSharp.ViewModule (makes creating the INotifyPropertyChanged portion much nicer).

Edit:

This same script could be done using XAML and FSharp.ViewModule, and make it easier to extend later. If you use paket to reference FSharp.ViewModule.Core and FsXaml.Wpf (latest version), you could move the UI definition to a XAML file (assuming name of MyWindow.xaml), like so:

<Window 
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"         
    Title="UI Test" AllowDrop="True" Width="500" Height="300" Drop="DoDrop">
    <ScrollViewer >
        <TextBlock Text="{Binding Text}" />
    </ScrollViewer>
</Window>

Note that I "improved" the UI here - it's wrapping the text block in a scroll viewer, setting sizing, and declaring the binding and event handler in XAML instead of code. You can easily extend this with more bindings, styles, etc.

If you place this file in the same location as your script, you can then write:

#r "WindowsBase.dll"
#r "PresentationCore.dll"
#r "PresentationFramework.dll"
#r "System.Xaml.dll"   
#r "../packages/FSharp.ViewModule.Core/lib/portable-net45+netcore45+wpa81+wp8+MonoAndroid1+MonoTouch1/FSharp.ViewModule.dll"
#r "../packages/FsXaml.Wpf/lib/net45/FsXaml.Wpf.dll"
#r "../packages/FsXaml.Wpf/lib/net45/FsXaml.Wpf.TypeProvider.dll"

open System
open System.Windows
open System.Windows.Controls
open System.Windows.Data
open System.ComponentModel
open ViewModule
open ViewModule.FSharp
open FsXaml

type MyViewModel (initial : string) as self =
    inherit ViewModelBase()

    // You can add as many properties as you want for binding
    let text = self.Factory.Backing(<@ self.Text @>, initial)
    member __.Text with get() = text.Value and set(v) = text.Value <- v            

    member this.AddFiles path =
        async {         
            for file in IO.Directory.EnumerateFiles path do
                this.Text <- this.Text + "\r\n" + file                 
                // do some slow file processing here.. this will happen on a background thread
                Threading.Thread.Sleep 300 // just a placeholder to simulate the delay of file processing
        } |> Async.Start

// Create window from XAML file
let [<Literal>] XamlFile = __SOURCE_DIRECTORY__ + "/MyWindow.xaml"
type MyWindowBase = XAML<XamlFileLocation = XamlFile>
type MyWindow () as self =  // Subclass to provide drop handler
    inherit MyWindowBase()

    let vm = MyViewModel "Drag and drop a folder here"
    do 
        self.DataContext <- vm

    override __.DoDrop (_, e) = // Event handler specified in XAML
        if e.Data.GetDataPresent DataFormats.FileDrop then
            e.Data.GetData DataFormats.FileDrop :?> string []
            |> Seq.iter vm.AddFiles

[< STAThread >] 
do 
    Application().Run(MyWindow()) |> ignore

Note that this works by creating a "view model" for binding. I moved the logic into the ViewModel (which is common), then use FsXaml to create the Window from the Xaml, and the vm is used as the DataContext of the window. This will "wire up" any bindings for you.

With a single binding, this is more verbose - but as you extend the UI, the benefits become much more clear very quickly, as adding properties is simple, and styling becomes much simpler when using XAML vs trying to style in code. If you start using collections, for example, it's incredibly difficult to create the proper templates and styles in code, but trivial in XAML.

like image 150
Reed Copsey Avatar answered Nov 07 '22 13:11

Reed Copsey


The issue with the example you posted is that you're running the processing on the UI thread. As noted in the comments, there is good tutorial on doing async processing in F# here.

Once you've done that you'll encounter another problem: you can't update the UI from a background thread. Instead of directly updating the UI from the background thread, you'll need to 'invoke' your update on the UI thread. Details on that can be found here.

like image 24
N_A Avatar answered Nov 07 '22 14:11

N_A