From 7f88c91d7173b75e7a6e3296be516261dd93c5e8 Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Sat, 6 Jan 2024 17:29:46 +0100 Subject: [PATCH] - Environment State: - allow values to not conform to IWritable. This makes dealing with values that don't change nicer. - Component Base: - extract core component logic into reusable base class. - expose `ForceRender` method. - DSL: - Panel add create - Text Block add padding overload - TODO App: - Add modal host sample --- src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj | 1 + src/Avalonia.FuncUI/Components/Component.fs | 57 +------- .../Components/ComponentBase.fs | 74 +++++++++++ src/Avalonia.FuncUI/DSL/Base/Panel.fs | 14 +- src/Avalonia.FuncUI/DSL/TextBlock.fs | 33 ++--- .../Experimental.EnvironmentState.fs | 25 ++-- .../Examples.EnvApp/Program.fs | 4 +- .../Examples.TodoApp/Examples.TodoApp.fsproj | 1 + .../Examples.TodoApp/ModalHost.fs | 108 +++++++++++++++ .../Examples.TodoApp/Program.fs | 125 ++++++++++++++---- .../Program.fs | 1 + 11 files changed, 327 insertions(+), 116 deletions(-) create mode 100644 src/Avalonia.FuncUI/Components/ComponentBase.fs create mode 100644 src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs diff --git a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj index 9cf5d37a..b53a4370 100644 --- a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj +++ b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj @@ -49,6 +49,7 @@ + diff --git a/src/Avalonia.FuncUI/Components/Component.fs b/src/Avalonia.FuncUI/Components/Component.fs index d74ed3bd..ad313375 100644 --- a/src/Avalonia.FuncUI/Components/Component.fs +++ b/src/Avalonia.FuncUI/Components/Component.fs @@ -11,61 +11,10 @@ open Avalonia.Threading [] [] type Component (render: IComponentContext -> IView) as this = - inherit Border () - let context = new Context(this) - let componentId = Guid.Unique + inherit ComponentBase () - let mutable lastViewElement : IView option = None - let mutable lastViewAttrs: IAttr list = List.empty - - member internal this.Context with get () = context - member internal this.ComponentId with get () = componentId - - member private this.UIThreadUpdate() : unit = - let nextViewElement = Some (render context) - - // reset internal context counter - context.AfterRender () - - // update view - VirtualDom.updateBorderRoot (this, lastViewElement, nextViewElement) - lastViewElement <- nextViewElement - - let nextViewAttrs = context.ComponentAttrs - - // update attrs - Patcher.patch ( - this, - { Delta.ViewDelta.ViewType = typeof - Delta.ViewDelta.ConstructorArgs = null - Delta.ViewDelta.KeyDidChange = false - Delta.ViewDelta.Outlet = ValueNone - Delta.ViewDelta.Attrs = Differ.diffAttributes (lastViewAttrs, nextViewAttrs) } - ) - - lastViewAttrs <- nextViewAttrs - - context.EffectQueue.ProcessAfterRender () - - member private this.Update () : unit = - Dispatcher.UIThread.Post (fun _ -> this.UIThreadUpdate ()) - - override this.OnInitialized () = - base.OnInitialized () - - (context :> IComponentContext).trackDisposable ( - context.OnRender.Subscribe (fun _ -> - this.Update () - ) - ) - - this.UIThreadUpdate () - - override this.OnDetachedFromLogicalTree (eventArgs: Avalonia.LogicalTree.LogicalTreeAttachmentEventArgs) = - base.OnDetachedFromLogicalTree eventArgs - (context :> IDisposable).Dispose () - - override this.StyleKeyOverride = typeof + override this.Render ctx = + render ctx type Component with diff --git a/src/Avalonia.FuncUI/Components/ComponentBase.fs b/src/Avalonia.FuncUI/Components/ComponentBase.fs new file mode 100644 index 00000000..5973da07 --- /dev/null +++ b/src/Avalonia.FuncUI/Components/ComponentBase.fs @@ -0,0 +1,74 @@ +namespace Avalonia.FuncUI + +open System +open System.Diagnostics.CodeAnalysis +open Avalonia.Controls +open Avalonia.FuncUI +open Avalonia.FuncUI.Types +open Avalonia.FuncUI.VirtualDom +open Avalonia.Threading + +[] +[] +[] +type ComponentBase() as this = + inherit Border () + let context = new Context(this) + let componentId = Guid.Unique + + let mutable lastViewElement : IView option = None + let mutable lastViewAttrs: IAttr list = List.empty + + member internal this.Context with get () = context + member internal this.ComponentId with get () = componentId + + abstract member Render : IComponentContext -> IView + + member private this.UIThreadUpdate() : unit = + let nextViewElement = Some (this.Render context) + + // reset internal context counter + context.AfterRender () + + // update view + VirtualDom.updateBorderRoot (this, lastViewElement, nextViewElement) + lastViewElement <- nextViewElement + + let nextViewAttrs = context.ComponentAttrs + + // update attrs + Patcher.patch ( + this, + { Delta.ViewDelta.ViewType = typeof + Delta.ViewDelta.ConstructorArgs = null + Delta.ViewDelta.KeyDidChange = false + Delta.ViewDelta.Outlet = ValueNone + Delta.ViewDelta.Attrs = Differ.diffAttributes (lastViewAttrs, nextViewAttrs) } + ) + + lastViewAttrs <- nextViewAttrs + + context.EffectQueue.ProcessAfterRender () + + member private this.Update () : unit = + Dispatcher.UIThread.Post (fun _ -> this.UIThreadUpdate ()) + + member this.ForceRender () = + this.Update () + + override this.OnInitialized () = + base.OnInitialized () + + (context :> IComponentContext).trackDisposable ( + context.OnRender.Subscribe (fun _ -> + this.Update () + ) + ) + + this.UIThreadUpdate () + + override this.OnDetachedFromLogicalTree (eventArgs: Avalonia.LogicalTree.LogicalTreeAttachmentEventArgs) = + base.OnDetachedFromLogicalTree eventArgs + (context :> IDisposable).Dispose () + + override this.StyleKeyOverride = typeof \ No newline at end of file diff --git a/src/Avalonia.FuncUI/DSL/Base/Panel.fs b/src/Avalonia.FuncUI/DSL/Base/Panel.fs index 4c510bcd..f40010f1 100644 --- a/src/Avalonia.FuncUI/DSL/Base/Panel.fs +++ b/src/Avalonia.FuncUI/DSL/Base/Panel.fs @@ -1,25 +1,27 @@ namespace Avalonia.FuncUI.DSL [] -module Panel = +module Panel = open Avalonia.Controls open Avalonia.FuncUI.Types open Avalonia.FuncUI.Builder open Avalonia.Media.Immutable open Avalonia.Media + let create (attrs: IAttr list): IView = + ViewBuilder.Create(attrs) + type Panel with - static member children<'t when 't :> Panel>(value: IView list) : IAttr<'t> = let getter : ('t -> obj) = (fun control -> control.Children :> obj) - + AttrBuilder<'t>.CreateContentMultiple("Children", ValueSome getter, ValueNone, value) - + static member background<'t when 't :> Panel>(value: IBrush) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(Panel.BackgroundProperty, value, ValueNone) - + static member background<'t when 't :> Panel>(color: string) : IAttr<'t> = - color |> Color.Parse |> ImmutableSolidColorBrush |> Panel.background + color |> Color.Parse |> ImmutableSolidColorBrush |> Panel.background static member background<'t when 't :> Panel>(color: Color) : IAttr<'t> = color |> ImmutableSolidColorBrush |> Panel.background \ No newline at end of file diff --git a/src/Avalonia.FuncUI/DSL/TextBlock.fs b/src/Avalonia.FuncUI/DSL/TextBlock.fs index b964da0b..4cc1461e 100644 --- a/src/Avalonia.FuncUI/DSL/TextBlock.fs +++ b/src/Avalonia.FuncUI/DSL/TextBlock.fs @@ -2,46 +2,46 @@ namespace Avalonia.FuncUI.DSL open Avalonia.Media.Immutable [] -module TextBlock = +module TextBlock = open Avalonia open Avalonia.Controls open Avalonia.Controls.Documents - open Avalonia.Media + open Avalonia.Media open Avalonia.FuncUI.Builder open Avalonia.FuncUI.Types let create (attrs: IAttr list): IView = ViewBuilder.Create(attrs) - + type TextBlock with - + static member text<'t when 't :> TextBlock>(value: string) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.TextProperty, value, ValueNone) - + static member background<'t when 't :> TextBlock>(value: IBrush) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.BackgroundProperty, value, ValueNone) - + static member background<'t when 't :> TextBlock>(color: string) : IAttr<'t> = color |> Color.Parse |> ImmutableSolidColorBrush |> TextBlock.background static member background<'t when 't :> TextBlock>(color: Color) : IAttr<'t> = color |> ImmutableSolidColorBrush |> TextBlock.background - + static member fontFamily<'t when 't :> TextBlock>(value: FontFamily) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.FontFamilyProperty, value, ValueNone) - + static member fontSize<'t when 't :> TextBlock>(value: double) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.FontSizeProperty, value, ValueNone) - + static member fontStyle<'t when 't :> TextBlock>(value: FontStyle) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.FontStyleProperty, value, ValueNone) - + static member fontWeight<'t when 't :> TextBlock>(value: FontWeight) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.FontWeightProperty, value, ValueNone) - + static member foreground<'t when 't :> TextBlock>(value: IBrush) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.ForegroundProperty, value, ValueNone) - + static member foreground<'t when 't :> TextBlock>(color: string) : IAttr<'t> = color |> Color.Parse |> ImmutableSolidColorBrush |> TextBlock.foreground @@ -50,20 +50,23 @@ module TextBlock = static member inlines<'t when 't :> TextBlock>(value: InlineCollection) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.InlinesProperty, value, ValueNone) - + static member inlines<'t when 't :> TextBlock>(values: IView list (* TODO: Change to IView *)) : IAttr<'t> = let getter : ('t -> obj) = (fun control -> control.Inlines :> obj) AttrBuilder<'t>.CreateContentMultiple("Inlines", ValueSome getter, ValueNone, values) static member lineHeight<'t when 't :> TextBlock>(value: float) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.LineHeightProperty, value, ValueNone) - + static member maxLines<'t when 't :> TextBlock>(value: int) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.MaxLinesProperty, value, ValueNone) static member padding<'t when 't :> TextBlock>(value: Thickness) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.PaddingProperty, value, ValueNone) + static member padding<'t when 't :> TextBlock>(padding: float) : IAttr<'t> = + padding |> Thickness |> TextBlock.padding + static member padding<'t when 't :> TextBlock>(horizontal: float, vertical: float) : IAttr<'t> = (horizontal, vertical) |> Thickness |> TextBlock.padding @@ -78,6 +81,6 @@ module TextBlock = static member textTrimming<'t when 't :> TextBlock>(value: TextTrimming) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.TextTrimmingProperty, value, ValueNone) - + static member textWrapping<'t when 't :> TextBlock>(value: TextWrapping) : IAttr<'t> = AttrBuilder<'t>.CreateProperty(TextBlock.TextWrappingProperty, value, ValueNone) \ No newline at end of file diff --git a/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs b/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs index 9379cc89..a9d6f3ed 100644 --- a/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs +++ b/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs @@ -12,17 +12,17 @@ open Avalonia.LogicalTree type EnvironmentState<'value> = internal { Name: string - DefaultValue: IWritable<'value> option + DefaultValue: 'value option } - static member Create (name: string, ?defaultValue: IWritable<'value>) : EnvironmentState<'value> = + static member Create (name: string, ?defaultValue: 'value) : EnvironmentState<'value> = { Name = name DefaultValue = defaultValue } [] type EnvironmentStateProvider<'value> ( state: EnvironmentState<'value>, - providedState: IWritable<'value>) as this = + providedState: 'value) = inherit ContentControl () @@ -34,7 +34,7 @@ type EnvironmentStateProvider<'value> type EnvironmentStateProvider<'value> with - static member create (state: EnvironmentState<'value>, providedValue: IWritable<'value>, content: IView) = + static member create (state: EnvironmentState<'value>, providedValue: 'value, content: IView) = { View.ViewType = typeof> View.ViewKey = ValueNone View.Attrs = [ ContentControl.content content ] @@ -44,7 +44,7 @@ type EnvironmentStateProvider<'value> with type EnvironmentState<'value> with - member this.provide (providedValue: IWritable<'value>, content: IView) = + member this.provide (providedValue: 'value, content: IView) = EnvironmentStateProvider<'value>.create(this, providedValue, content) [] @@ -73,14 +73,15 @@ module __ContextExtensions_useEnvHook = type IComponentContext with - member this.useEnvState (state: EnvironmentState<'value>, ?renderOnChange: bool) = - let obtainValue () = - match EnvironmentStateConsumer.tryFind (this.control, state), state.DefaultValue with - | ValueSome value, _ -> value - | ValueNone, Some defaultValue -> defaultValue - | ValueNone, None -> failwithf "No value provided for environment state '%s'" state.Name + member this.readEnvValue(state: EnvironmentState<'value>) : 'value = + match EnvironmentStateConsumer.tryFind (this.control, state), state.DefaultValue with + | ValueSome value, _ -> value + | ValueNone, Some defaultValue -> defaultValue + | ValueNone, None -> failwithf "No value provided for environment value '%s'" state.Name + member this.useEnvState(state: EnvironmentState>, ?renderOnChange: bool) : IWritable<'value> = this.usePassedLazy ( - obtainValue = obtainValue, + obtainValue = (fun () -> this.readEnvValue(state)), ?renderOnChange = renderOnChange ) + diff --git a/src/Examples/Component Examples/Examples.EnvApp/Program.fs b/src/Examples/Component Examples/Examples.EnvApp/Program.fs index c3e8f624..b21f0d8c 100644 --- a/src/Examples/Component Examples/Examples.EnvApp/Program.fs +++ b/src/Examples/Component Examples/Examples.EnvApp/Program.fs @@ -18,8 +18,8 @@ open Avalonia.FuncUI.DSL [] module SharedState = - let brush = EnvironmentState.Create "brush" - let size = EnvironmentState.Create "size" + let brush = EnvironmentState>.Create "brush" + let size = EnvironmentState>.Create "size" [] type Views = diff --git a/src/Examples/Component Examples/Examples.TodoApp/Examples.TodoApp.fsproj b/src/Examples/Component Examples/Examples.TodoApp/Examples.TodoApp.fsproj index 792602cb..503667ef 100644 --- a/src/Examples/Component Examples/Examples.TodoApp/Examples.TodoApp.fsproj +++ b/src/Examples/Component Examples/Examples.TodoApp/Examples.TodoApp.fsproj @@ -7,6 +7,7 @@ + diff --git a/src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs b/src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs new file mode 100644 index 00000000..3214209d --- /dev/null +++ b/src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs @@ -0,0 +1,108 @@ +namespace Examples.TodoApp + + +open System +open System.Runtime.CompilerServices +open System.Threading +open Avalonia +open Avalonia.Controls +open Avalonia.FuncUI +open Avalonia.FuncUI.Builder +open Avalonia.FuncUI.DSL +open Avalonia.FuncUI.Experimental +open Avalonia.FuncUI.Types +open Avalonia.Layout +open Avalonia.Media +open System + +type ModalHostState (state: IWritable) = + + member _.Push (modal: IView) = + state.Set (modal :: state.Current) + + member _.Pop () = + match state.Current with + | [] -> () + | _ :: rest -> state.Set rest + +type ModalHost () as this = + inherit ComponentBase () + + let modalStack = new State(List.empty) + + static let _mainContentProperty: StyledProperty = + AvaloniaProperty.Register("MainContent", None) + + static do ignore ( + _mainContentProperty.Changed.AddClassHandler(fun instance mainContent -> + instance.ForceRender() + ) + ) + + static member MainContentProperty = _mainContentProperty + + static member State = EnvironmentState.Create("state") + + member this.MainContent + with get() = this.GetValue(ModalHost.MainContentProperty) + and set(value) = ignore(this.SetValue(ModalHost.MainContentProperty, value)) + + override this.Render(ctx: IComponentContext): IView = + let modalStack = ctx.usePassed modalStack + + EnvironmentStateProvider.create ( + state = ModalHost.State, + providedValue = ModalHostState(modalStack), + content = ( + Panel.create [ + Panel.verticalAlignment VerticalAlignment.Stretch + Panel.horizontalAlignment HorizontalAlignment.Stretch + Panel.children [ + // main content + match this.MainContent with + | Some mainContent -> mainContent + | None -> () + + // modals + Panel.create [ + Panel.verticalAlignment VerticalAlignment.Stretch + Panel.horizontalAlignment HorizontalAlignment.Stretch + Panel.children [ + for modalView in modalStack.Current do + Border.create [ + Border.background (SolidColorBrush(Colors.Black, 0.5)) + Border.padding 20 + Border.child modalView + ] + ] + ] + ] + ] + ) + ) + +[] +module ModalHost = + let create (attrs: IAttr list): IView = + ViewBuilder.Create(attrs) + + type ModalHost with + + static member mainContent<'t when 't :> ModalHost>(value: IView option) : IAttr<'t> = + AttrBuilder<'t>.CreateProperty(ModalHost.MainContentProperty, value, ValueNone) + + static member mainContent<'t when 't :> ModalHost>(value: IView) : IAttr<'t> = + value + |> Some + |> ModalHost.mainContent + +[] +module __ContextExtensions_useModal = + + type IComponentContext with + + member this.useModalState() : ModalHostState = + this.readEnvValue ModalHost.State + + + diff --git a/src/Examples/Component Examples/Examples.TodoApp/Program.fs b/src/Examples/Component Examples/Examples.TodoApp/Program.fs index 79d65a72..97cc1f9b 100644 --- a/src/Examples/Component Examples/Examples.TodoApp/Program.fs +++ b/src/Examples/Component Examples/Examples.TodoApp/Program.fs @@ -78,11 +78,70 @@ module ControlThemes = module Views = + let areYouSureDialog (callback: bool -> unit) = + Border.create [ + Border.verticalAlignment VerticalAlignment.Center + Border.horizontalAlignment HorizontalAlignment.Center + Border.background "white" + Border.padding 5 + Border.cornerRadius 5 + Border.child ( + DockPanel.create [ + DockPanel.lastChildFill false + DockPanel.children [ + TextBlock.create [ + TextBlock.dock Dock.Top + TextBlock.text "are you sure?" + TextBlock.fontSize 18.0 + TextBlock.padding 20 + ] + + UniformGrid.create [ + UniformGrid.dock Dock.Bottom + UniformGrid.columns 2 + UniformGrid.rows 1 + UniformGrid.children [ + + Button.create [ + Button.theme ControlThemes.inlineButton.Value + Button.content ( + TextBlock.create [ + TextBlock.text "yes" + TextBlock.fontSize 18.0 + TextBlock.foreground "#e74c3c" + TextBlock.horizontalAlignment HorizontalAlignment.Center + ] + ) + Button.onClick (fun _ -> callback true) + ] + + Button.create [ + Button.theme ControlThemes.inlineButton.Value + Button.content ( + TextBlock.create [ + TextBlock.text "no" + TextBlock.fontSize 18.0 + TextBlock.horizontalAlignment HorizontalAlignment.Center + ] + ) + Button.onClick (fun _ -> callback false) + ] + ] + ] + + ] + ] + ) + + ] + + let listItemView (item: IWritable) = Component.create ($"item-%O{item.Current.ItemId}", fun ctx -> let activeItemId = ctx.usePassed AppState.activeItemId let item = ctx.usePassed item let title = ctx.useState item.Current.Title + let modalState = ctx.useModalState() let animation = ctx.useStateLazy(fun () -> Animation() .WithDuration(0.3) @@ -172,20 +231,27 @@ module Views = ] ) Button.onClick (fun args -> - if not (ctx.control.IsAnimating Component.OpacityProperty) then - ignore ( - task { - do! animation.Current.RunAsync ctx.control - - Dispatcher.UIThread.Post (fun _ -> - AppState.items.Current - |> List.filter (fun i -> i.ItemId <> item.Current.ItemId) - |> AppState.items.Set - ) - - return () - } + modalState.Push ( + areYouSureDialog (fun isSure -> + modalState.Pop () + + if isSure then + if not (ctx.control.IsAnimating Component.OpacityProperty) then + ignore ( + task { + do! animation.Current.RunAsync ctx.control + + Dispatcher.UIThread.Post (fun _ -> + AppState.items.Current + |> List.filter (fun i -> i.ItemId <> item.Current.ItemId) + |> AppState.items.Set + ) + + return () + } + ) ) + ) ) ] ] @@ -355,22 +421,27 @@ module Views = let mainView () = Component(fun ctx -> + ModalHost.create [ + ModalHost.mainContent ( + DockPanel.create [ + DockPanel.verticalAlignment VerticalAlignment.Stretch + DockPanel.horizontalAlignment HorizontalAlignment.Stretch + DockPanel.children [ + + (* toolbar *) + ContentControl.create [ + ContentControl.dock Dock.Top + ContentControl.content (toolbarView()) + ] - DockPanel.create [ - DockPanel.children [ - - (* toolbar *) - ContentControl.create [ - ContentControl.dock Dock.Top - ContentControl.content (toolbarView()) - ] - - (* item list *) - ScrollViewer.create [ - ScrollViewer.dock Dock.Top - ScrollViewer.content (listView()) + (* item list *) + ScrollViewer.create [ + ScrollViewer.dock Dock.Top + ScrollViewer.content (listView()) + ] + ] ] - ] + ) ] ) diff --git a/src/Examples/ViewModelComponent Examples/Examples.ViewModelComponent.CounterApp/Program.fs b/src/Examples/ViewModelComponent Examples/Examples.ViewModelComponent.CounterApp/Program.fs index 9989f55a..13902f40 100644 --- a/src/Examples/ViewModelComponent Examples/Examples.ViewModelComponent.CounterApp/Program.fs +++ b/src/Examples/ViewModelComponent Examples/Examples.ViewModelComponent.CounterApp/Program.fs @@ -4,6 +4,7 @@ open System.ComponentModel open Avalonia open Avalonia.Controls open Avalonia.Controls.ApplicationLifetimes +open Avalonia.FuncUI.Experimental open Avalonia.FuncUI.Types open Avalonia.Interactivity open Avalonia.Layout