JordanMarr / ReactiveElmish.Avalonia

Static Avalonia views for Elmish programs
Other
92 stars 8 forks source link

Best way to use/access Avalonia's StorageProvider from a ViewModel #13

Closed moonshxne closed 1 year ago

moonshxne commented 1 year ago

Elmish.Avalonia has been a lovely and sorely needed abstraction on top of Avalonia!

But, since we aren't creating classes for our ViewModels anymore, what's the best way to access the StorageProvider for stuff like opening and saving files? For now, in each of the ViewModels that need this functionality, I've been doing

let provider: IStorageProvider =
    match Application.Current.ApplicationLifetime with
    | :? IClassicDesktopStyleApplicationLifetime as desktop ->
        desktop.MainWindow.StorageProvider

but the Avalonia docs say this is actually hacky and not recommended for real-world usage. 😅

I also tried to use dependency injection by creating a FilesService and initializing it in App.axaml.fs, but I couldn't access any App in my view model like they did in this example.

How would you go about this?

JordanMarr commented 1 year ago

I'm glad you are enjoying it!

I think the App is inaccessible due to file ordering. But rather than trying to change the example, it would be better IMO to simple create our own Services module:

image

Services.fs

namespace AvaloniaExample

open System
open Microsoft.Extensions.DependencyInjection

// Putting sample service here for convenience
type IFileService = 
    abstract member OpenFile: unit -> string option

type FileService() = 
    interface IFileService with
        member this.OpenFile() = failwith "Not Implemented"

module Services = 
    let container : IServiceProvider = 
        let services = ServiceCollection()
        services.AddSingleton<IFileService>(FileService()) |> ignore
        services.BuildServiceProvider()

View model:

open AvaloniaExample
open AvaloniaExample.Services
open Microsoft.Extensions.DependencyInjection
// Somewhere in your VM
let fileProvider = container.GetService<IFileService>()
let filepath = fileProvider.OpenFile()

I'm not saying this is necessarily the best way to do it, but it is similar to the example you provided.

moonshxne commented 1 year ago

Hmm. The container under the Services module does seem like a clean idea, but the issue is, the FilesService requires a reference to an Avalonia Window in order to access those underlying StorageProvider APIs (which is why the example did the dependency injection inside of App.axaml.cs).

JordanMarr commented 1 year ago

Ahh I see. In that case, you could just make it mutable and initialize it within App:

Services.fs

namespace AvaloniaExample

open System

type IFileService = 
    abstract member OpenFile: unit -> string option

type FileService() = 
    interface IFileService with
        member this.OpenFile() = failwith "Not Implemented"

module Services = 
    let mutable container : IServiceProvider = null

App.axaml.fs

namespace AvaloniaExample

open Avalonia
open Avalonia.Markup.Xaml
open AvaloniaExample.Views
open Avalonia.Controls.ApplicationLifetimes
open Microsoft.Extensions.DependencyInjection

type App() =
    inherit Application()

    override this.Initialize() =
        // Initialize Avalonia controls from NuGet packages:
        let _ = typeof<Avalonia.Controls.DataGrid>

        let services = ServiceCollection()
        // Init file service here
        services.AddSingleton<IFileService>(FileService()) |> ignore
        Services.container <- services.BuildServiceProvider()

        AvaloniaXamlLoader.Load(this)

    override this.OnFrameworkInitializationCompleted() =
        match this.ApplicationLifetime with
        | :? IClassicDesktopStyleApplicationLifetime as desktop ->         
            let view = MainView()
            desktop.MainWindow <- view
            ViewModels.MainViewModel.vm.StartElmishLoop(view)
        | _ -> 
            // leave this here for design view re-renders
            ()

        base.OnFrameworkInitializationCompleted()

At some point I will look into a more integrated way to do DI, but this should work for now.

moonshxne commented 1 year ago

Slight correction -- you'd have to do it in the OnFrameInitializationCompleted method, but yes! This definitely works for now. :)

So, for posterity:

Services.fs

namespace AvaloniaExample

open Avalonia.Controls
open System

type IFileService = 
    abstract member OpenFile: unit -> string option

type FileService(window: Window) = 
    interface IFileService with
        member this.OpenFile() = window.StorageProvider.blah blah blah

module Services = 
    let mutable container : IServiceProvider = null

App.axaml.fs

namespace AvaloniaExample

open Avalonia
open Avalonia.Markup.Xaml
open AvaloniaExample.Views
open Avalonia.Controls.ApplicationLifetimes
open Microsoft.Extensions.DependencyInjection

type App() =
    inherit Application()

    override this.Initialize() =
        // Initialize Avalonia controls from NuGet packages:
        let _ = typeof<Avalonia.Controls.DataGrid>

        AvaloniaXamlLoader.Load(this)

    override this.OnFrameworkInitializationCompleted() =
        match this.ApplicationLifetime with
        | :? IClassicDesktopStyleApplicationLifetime as desktop ->         
            let view = MainView()
            desktop.MainWindow <- view

            let services = ServiceCollection()
            // Init file service here
           services.AddSingleton<IFileService>(FileService(desktop.MainWindow)) |> ignore
           Services.container <- services.BuildServiceProvider()
           ViewModels.MainViewModel.vm.StartElmishLoop(view)
        | _ -> 
            // leave this here for design view re-renders
            ()

        base.OnFrameworkInitializationCompleted()

It's a bit ugly I think, but that's just the nature of DI. This works, and thank you so much for your help!

JordanMarr commented 1 year ago

Another subtle improvement (move services init to services + static members):

Services.fs

namespace AvaloniaExample

open System
open Avalonia.Controls
open Microsoft.Extensions.DependencyInjection

type IFileService = 
    abstract member OpenFile: unit -> string option

type FileService(window: Window) = 
    interface IFileService with
        member this.OpenFile() = Some "c:/files/file.txt" //window.StorageProvider.OpenFilePickerAsync

type Services() = 
    static let mutable container : IServiceProvider = null

    static member Container 
        with get() = container

    static member Init mainWindow = 
        let services = ServiceCollection()
        services.AddSingleton<IFileService>(FileService(mainWindow)) |> ignore
        container <- services.BuildServiceProvider()

    static member Get<'Svc>() = 
        container.GetRequiredService<'Svc>()

App.xaml.fs

    override this.OnFrameworkInitializationCompleted() =
        match this.ApplicationLifetime with
        | :? IClassicDesktopStyleApplicationLifetime as desktop ->         
            let view = MainView()
            desktop.MainWindow <- view
            Services.Init view           
            ViewModels.MainViewModel.vm.StartElmishLoop(view)
        | _ -> 
            // leave this here for design view re-renders
            ()

AboutViewModel.fs

module AvaloniaExample.ViewModels.AboutViewModel

open Elmish.Avalonia
open Elmish
open Messaging
open AvaloniaExample
open Microsoft.Extensions.DependencyInjection

type Model = 
    {
        Version: string
    }

type Msg = 
    | Ok

let init () = 
    { 
        Version = "1.1"
    }, Cmd.ofEffect (fun _ -> 
        let fileProvider = Services.Get<IFileService>()
        let filepath = fileProvider.OpenFile()
        printfn $"Filepath: {filepath}"
    )

let update (msg: Msg) (model: Model) = 
    match msg with
    | Ok -> 
        model, Cmd.ofEffect (fun _ -> bus.OnNext(GlobalMsg.GoHome))

let bindings ()  : Binding<Model, Msg> list = [
    "Version" |> Binding.oneWay (fun m -> m.Version)
    "Ok" |> Binding.cmd Ok
]

let designVM = ViewModel.designInstance (fst (init())) (bindings())

let vm = ElmishViewModel(AvaloniaProgram.mkProgram init update bindings)
JordanMarr commented 1 year ago

Here's another variation, this time added to the update function instead: This passes in only the function signature(s) needed.

FilePickerViewModel.fs

module AvaloniaExample.ViewModels.FilePickerViewModel

open Elmish.Avalonia
open Elmish
open Messaging
open AvaloniaExample

type Model = 
    {
        FilePath: string option
    }

type Msg = 
    | Ok
    | PickFile
    | SetFilePath of string option

let init () = 
    { 
        FilePath = None
    }, Cmd.none

let update tryPickFile (msg: Msg) (model: Model) = 
    match msg with
    | Ok -> 
        model, Cmd.ofEffect (fun _ -> bus.OnNext(GlobalMsg.GoHome))
    | PickFile  -> 
        model, Cmd.OfTask.perform tryPickFile () SetFilePath
    | SetFilePath path ->
        { model with FilePath = path }, Cmd.none

let bindings ()  : Binding<Model, Msg> list = [
    "Ok" |> Binding.cmd Ok
    "FilePath" |> Binding.oneWay (fun m -> m.FilePath |> Option.defaultValue "Not Set")
    "PickFile" |> Binding.cmd PickFile
]

let designVM = 
    ViewModel.designInstance (fst (init ())) (bindings())

let vm () = 
    let tryPickFile () = 
        let fileProvider = Services.Get<FileService>()
        fileProvider.TryPickFile()

    ElmishViewModel(AvaloniaProgram.mkProgram init (update tryPickFile) bindings)

This way, the DI / "service locator" stuff is isolated to the vm () function. It could be better (or not) depending on the situation (how many side effect functions you need, what you need to test, whether you are writing tests at all, etc).

Testing the update function will only require stubbing out the tryPickFile function which is much easier than stubbing out an entire interface.

JordanMarr commented 1 year ago

Added new page to sample for future reference:

https://github.com/JordanMarr/Elmish.Avalonia/blob/main/src/Samples/AvaloniaExample/ViewModels/FilePickerViewModel.fs