fsprojects / Avalonia.FuncUI

Develop cross-plattform GUI Applications using F# and Avalonia!
https://funcui.avaloniaui.net/
MIT License
962 stars 74 forks source link

Does FuncUI really need the MSG type DU's. Can't dispatch just use functions #23

Closed bradphelan closed 5 years ago

bradphelan commented 5 years ago

Here's your counter example rewritten just using functions instead of DU's. It removes the need for a centralised msg handler.

namespace CounterElmishSample

open System
open Avalonia.Controls
open Avalonia.Controls
open Avalonia.FuncUI.DSL
open Avalonia.FuncUI.Components
open Avalonia.FuncUI.Components
open Avalonia.FuncUI.DSL
open Avalonia.FuncUI.DSL
open Avalonia.FuncUI.Types
open Avalonia.Layout

type CustomControl() =
    inherit Control()

    member val Text: string = "" with get, set

[<AutoOpen>]
module ViewExt =
    ()

module Counter =
    open Avalonia.FuncUI.DSL

    type CounterState = {
        count : int
        numbers: int list
    }

    let init = {
        count = 0
        numbers = [0 .. 100_000]
    }

    let increment state = {state with count = state.count + 1}    
    let decrement state = {state with count = state.count - 1}
    let specific number_state = {state with count = number }
    let remove_number = { state with numbers = List.except [number] state.numbers }

    let view (state: CounterState) (dispatch) =
        DockPanel.create [
            DockPanel.children [
                ListBox.create [
                    ListBox.items state.numbers
                    ListBox.itemTemplate (
                        TemplateView.create(fun data ->
                            let data = data :?> int 
                            DockPanel.create [
                                DockPanel.children [
                                    Button.create [
                                        Button.content "delete"
                                        Button.dock Dock.Right
                                        Button.width 50.0
                                        Button.tag data
                                        Button.onClick (fun args ->
                                            let number = (args.Source :?> Button).Tag :?> int
                                            dispatch remove_number number
                                        )
                                    ]                                    
                                    TextBlock.create [
                                        TextBlock.text (sprintf "%A" data)
                                        TextBlock.width 100.0
                                    ]                                    
                                ]
                            ]
                            |> generalize 
                        )                  
                    )
                ]
                (*
                TextBox.create [
                    TextBox.dock Dock.Bottom
                    TextBox.text (sprintf "%i" state.count)
                    TextBox.onTextChanged (fun text ->
                        printfn "new Text: %s" text
                     )
                ]
                TextBlock.create [
                    TextBlock.dock Dock.Top
                    TextBlock.fontSize 48.0
                    TextBlock.foreground "blue"
                    TextBlock.verticalAlignment VerticalAlignment.Center
                    TextBlock.horizontalAlignment HorizontalAlignment.Center
                    TextBlock.text (string state.count)
                ]
                LazyView.create [
                    LazyView.args dispatch
                    LazyView.state state.count
                    LazyView.viewFunc (fun state dispatch ->
                        let view = 
                            TextBlock.create [
                                TextBlock.dock Dock.Top
                                TextBlock.fontSize 48.0
                                TextBlock.foreground "green"
                                TextBlock.verticalAlignment VerticalAlignment.Center
                                TextBlock.horizontalAlignment HorizontalAlignment.Center
                                TextBlock.text (string state)
                            ]

                        view |> fun a -> a :> IView
                    )
                ]
                *)
            ]
        ]       

Is there an advantage to using the DU's here that just plain function composition can't do? For simple functions it is even possible to inline the operations.

Instead of

 Button.onClick (fun args ->
   let number = (args.Source :?> Button).Tag :?> int
   dispatch remove_number number
  )

you could write

 Button.onClick (fun args ->
   let number = (args.Source :?> Button).Tag :?> int
   dispatch (fun state -> { state with numbers = List.except [number] state.numbers })
  )
JaggerJo commented 5 years ago

I personally like the centralised approach of having an update function. I see several benefits of doing it that way:

You are also free to roll you own State & Update management. I actually do this for the drawing app you see in the screenshots. I basically have a lot of different Messages that trigger complex operations. Messages and Operations are also in a different Project and not UI dependent.

I could basically write a web version and use $(FSharp MVU Framework) without touching the Logic Project.

bradphelan commented 5 years ago

Using function composition doesn't mean that the functions can't be isolated from the view. Doing it inline was just an example of the flexibility. If you wish to have a centralized system you still could but I'm not sure forcing it is necessary.

JaggerJo commented 5 years ago

Hmm, that's right and as your example shows you can totally do that.

I think I personally just like having my Messages 😄.

bradphelan commented 5 years ago

I don't think that DUs exclusively equals messages is the right idea here. Functions are also messages. In the case of FuncUI it seems that DU's are trying to fake function overloading.

    type Msg =
    | Increment
    | Decrement
    | Specific of int
    | RemoveNumber of int

    let update (msg: Msg) (state: CounterState) : CounterState =
        match msg with
        | Increment -> { state with count = state.count + 1 }
        | Decrement -> { state with count = state.count - 1 }
        | Specific number -> { state with count = number }
        | RemoveNumber number -> { state with numbers = List.except [number] state.numbers }

DU's are good where the pattern will be used in many different places and you want to garuntee exhaustive matching. Here this will never be the case. The message is dispatched in one place and picked up in once place. Why is the above better than

    let increment (state: CounterState) = { state with count = state.count + 1 }
    let decrement (state: CounterState) = { state with count = state.count - 1 }
    let set (state: CounterState) = { state with count = number }
    let remove (state: CounterState ) = { state with numbers = List.except [number] state.numbers }

That is four lines instead of 11 with no loss of seperation of view and model.

JaggerJo commented 5 years ago

If you are doing it that way you can have your functions in one place, but you still need a way to tie your function to the desired view event. You could reference them directly but the you have a tight coupling between the view & logic.

Also all functions you would want to dispatch would need to have the same signature or get the arguments baked in using partial application.

This would not work because 'number' is not passed.

let set (state: CounterState) = { state with count = number }
let remove (state: CounterState ) = { state with numbers = List.except [number] state.numbers 

so the functions would look more like this because all functions you would dispatch need to have the same signature. (or have to be dynamically invoked - but that's slow and unsafe)

type UpdateFunc = 'state * obj -> 'state

Maybe I am missing something here ? (I typed this on the train)

I would say both methods have some advantages and disadvantages. In the end it boils down to preference, and what's commonly used.

Maybe you also find some discussions about this in the elm / fable community.

https://guide.elm-lang.org/architecture/

bradphelan commented 5 years ago

I did a small example of uisng FuncUI without messages. The comment I made above was done without actually trying to compile it. I've a fork with a change to the Counter example. Maybe it's interesting

https://github.com/bradphelan/Avalonia.FuncUI/commit/f6de6438a3c7ea3b99d357cfe846c1d62dd5dd38

My update functions are defined

    let increment  (state: CounterState) : CounterState = { state with count =  state.count + 1 }
    let decrement  (state: CounterState) : CounterState = { state with count =  state.count + 1 }
    let set_count  (state:CounterState) count : CounterState = { state with count = count;}

and the view is defined

    let view (state: CounterState) (dispatch): View =
        Views.dockpanel [
            Attrs.children [
                Views.button [
                    Attrs.dockPanel_dock Dock.Bottom
                    Attrs.onClick (Binder.command increment dispatch)
                    Attrs.content "-"
                ]
                Views.button [
                    Attrs.dockPanel_dock Dock.Bottom
                    Attrs.onClick (Binder.command decrement dispatch)
                    Attrs.content "+"
                ]
                Views.textBox[
                    Attrs.dockPanel_dock Dock.Top
                    Attrs.fontSize 48.0
                    Attrs.verticalAlignment VerticalAlignment.Center
                    Attrs.horizontalAlignment HorizontalAlignment.Stretch
                    Attrs.text (string state.count)
                    Attrs.onKeyUp (Binder.oneWay 0 set_count dispatch )
                ]
            ]
        ]       

I created a Binder object for managing commands and bindings. Just an experiment.