diff --git a/src/ReactiveElmish.Avalonia/ReactiveElmish.Avalonia.fsproj b/src/ReactiveElmish.Avalonia/ReactiveElmish.Avalonia.fsproj index 88ef519..aa404fd 100644 --- a/src/ReactiveElmish.Avalonia/ReactiveElmish.Avalonia.fsproj +++ b/src/ReactiveElmish.Avalonia/ReactiveElmish.Avalonia.fsproj @@ -17,7 +17,7 @@ MIT https://github.com/JordanMarr/ReactiveElmish.Avalonia Avalonia F# fsharp Elmish Elm - 1.1.1 + 1.1.2 $(OtherFlags) --warnon:1182 $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage diff --git a/src/ReactiveElmish.sln b/src/ReactiveElmish.sln index 4b0a937..1f5e969 100644 --- a/src/ReactiveElmish.sln +++ b/src/ReactiveElmish.sln @@ -19,6 +19,8 @@ Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "ReactiveElmish", "ReactiveE EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "ReactiveElmish.Wpf", "ReactiveElmish.Wpf\ReactiveElmish.Wpf.fsproj", "{8E7D1F76-A45C-4151-96BD-BFEAECAA4419}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WpfExample", "WpfExample\WpfExample.csproj", "{E612993E-238C-40C7-A39C-DA021DBF6D9F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,12 +43,17 @@ Global {8E7D1F76-A45C-4151-96BD-BFEAECAA4419}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E7D1F76-A45C-4151-96BD-BFEAECAA4419}.Release|Any CPU.ActiveCfg = Release|Any CPU {8E7D1F76-A45C-4151-96BD-BFEAECAA4419}.Release|Any CPU.Build.0 = Release|Any CPU + {E612993E-238C-40C7-A39C-DA021DBF6D9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E612993E-238C-40C7-A39C-DA021DBF6D9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E612993E-238C-40C7-A39C-DA021DBF6D9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E612993E-238C-40C7-A39C-DA021DBF6D9F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {B5F6F996-01DD-410D-9D3B-A1E91A3D5CF4} = {B1F3D2D8-D07C-4115-ACAD-5CD90ED1A04A} + {E612993E-238C-40C7-A39C-DA021DBF6D9F} = {B1F3D2D8-D07C-4115-ACAD-5CD90ED1A04A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E4A43E5C-8FF0-434B-8C5E-AB656A275C4F} diff --git a/src/ReactiveElmish/ReactiveBindingsCS.fs b/src/ReactiveElmish/ReactiveBindingsCS.fs new file mode 100644 index 0000000..0a044b3 --- /dev/null +++ b/src/ReactiveElmish/ReactiveBindingsCS.fs @@ -0,0 +1,87 @@ +namespace ReactiveElmish + +open System +open System.Runtime.CompilerServices +open System.Runtime.InteropServices +open DynamicData + +/// Allows using an IStore<'Model> + ReactiveElmishViewModel binding methods from an existing view model using C# Action and Func delegates. +type ReactiveBindingsCS(onPropertyChanged: Action) = + let vm = new ReactiveElmishViewModel(onPropertyChanged.Invoke) + let opt = Option.ofObj + + member this.Bind<'Model, 'ModelProjection>( + store: IStore<'Model>, + modelProjection: Func<'Model, 'ModelProjection>, + [] vmPropertyName + ) = vm.Bind(store, modelProjection.Invoke, vmPropertyName) + + member this.BindOnChanged<'Model, 'OnChanged, 'ModelProjection>( + store: IStore<'Model>, + onChanged: Func<'Model, 'OnChanged>, + modelProjection: Func<'Model, 'ModelProjection>, + [] vmPropertyName + ) = vm.BindOnChanged(store, onChanged.Invoke, modelProjection.Invoke, vmPropertyName) + + member this.BindList<'Model, 'ModelProjection>( + store: IStore<'Model>, + modelProjectionSeq: Func<'Model, 'ModelProjection seq>, + [] vmPropertyName + ) = vm.BindList(store, modelProjectionSeq.Invoke, vmPropertyName) + + member this.BindList<'Model, 'ModelProjection, 'Mapped>( + store: IStore<'Model>, + modelProjectionSeq: Func<'Model, 'ModelProjection seq>, + map: Func<'ModelProjection, 'Mapped>, + [] vmPropertyName + ) = vm.BindList(store, modelProjectionSeq.Invoke, map.Invoke, vmPropertyName) + + member this.BindKeyedList<'Model, 'Key, 'Value, 'Mapped when 'Value : equality and 'Mapped : not struct and 'Key : comparison>( + store: IStore<'Model>, + modelProjection: Func<'Model, Map<'Key, 'Value>>, + map: Func<'Value, 'Mapped>, + getKey: Func<'Mapped, 'Key>, + [] update: Action<'Value, 'Mapped>, + [] sortBy: Func<'Mapped, IComparable>, + [] vmPropertyName + ) = vm.BindKeyedList(store, modelProjection.Invoke, map.Invoke, getKey.Invoke, ?update = opt update, ?sortBy = opt sortBy, vmPropertyName = vmPropertyName) + + member this.BindKeyedList<'Model, 'Key, 'Value when 'Value: equality and 'Value : not struct and 'Key : comparison>( + store: IStore<'Model>, + modelProjection: Func<'Model, Map<'Key, 'Value>>, + getKey: Func<'Value, 'Key>, + [] update: Action<'Value, 'Value>, + [] sortBy: Func<'Value, IComparable>, + [] vmPropertyName: string + ) = vm.BindKeyedList(store, modelProjection.Invoke, getKey.Invoke, ?update = opt update, ?sortBy = opt sortBy, vmPropertyName = vmPropertyName) + + member this.BindSourceList<'T>( + sourceList: ISourceList<'T>, + [] vmPropertyName + ) = vm.BindSourceList(sourceList, vmPropertyName) + + member this.BindSourceList<'T, 'Mapped>( + sourceList: ISourceList<'T>, + map: Func<'T, 'Mapped>, + [] vmPropertyName + ) = vm.BindSourceList(sourceList, map.Invoke, vmPropertyName) + + member this.BindSourceCache<'Value, 'Key>( + sourceCache: IObservableCache<'Value, 'Key>, + [] sortBy, + [] vmPropertyName + ) = vm.BindSourceCache(sourceCache, ?sortBy = opt sortBy, vmPropertyName = vmPropertyName) + + member this.BindSourceCache<'Value, 'Key, 'Mapped when 'Value : not struct and 'Mapped : not struct>( + sourceCache: IObservableCache<'Value, 'Key>, + map: Func<'Value, 'Mapped>, + [] update: Action<'Value, 'Mapped>, + [] sortBy, + [] vmPropertyName + ) = vm.BindSourceCache(sourceCache, map.Invoke, ?update = opt update, ?sortBy = opt sortBy, vmPropertyName = vmPropertyName) + + member this.Subscribe(observable, handler) = + vm.Subscribe(observable, handler) + + interface IDisposable with + member this.Dispose() = (vm :> IDisposable).Dispose() diff --git a/src/ReactiveElmish/ReactiveElmish.fsproj b/src/ReactiveElmish/ReactiveElmish.fsproj index 1239ac5..a1a1d26 100644 --- a/src/ReactiveElmish/ReactiveElmish.fsproj +++ b/src/ReactiveElmish/ReactiveElmish.fsproj @@ -17,7 +17,7 @@ MIT https://github.com/JordanMarr/ReactiveElmish F# fsharp Elmish MVU MVVM - 1.0.0-beta + 1.1.0-beta.5 $(OtherFlags) --warnon:1182 @@ -25,7 +25,9 @@ + + diff --git a/src/ReactiveElmish/ReactiveElmishStore.fs b/src/ReactiveElmish/ReactiveElmishStore.fs index bd13b0e..eff00c1 100644 --- a/src/ReactiveElmish/ReactiveElmishStore.fs +++ b/src/ReactiveElmish/ReactiveElmishStore.fs @@ -5,16 +5,23 @@ open System.Reactive.Subjects open System.Reactive.Linq open System -type IStore<'Model, 'Msg> = +type IStore<'Model> = inherit IDisposable - abstract member Dispatch: 'Msg -> unit abstract member Model: 'Model with get abstract member Observable: IObservable<'Model> +type IStore<'Model, 'Msg> = + inherit IStore<'Model> + abstract member Dispatch: 'Msg -> unit + +type IHasSubject<'Model> = + abstract member Subject: Subject<'Model> with get + module Design = /// Stubs a constructor injected dependency in design mode. let stub<'T> = Unchecked.defaultof<'T> +/// An Elmish reactive store that can be used to store and update a model and send out an Rx stream of the model. type ReactiveElmishStore<'Model, 'Msg> () = let _modelSubject = new Subject<'Model>() let mutable _model: 'Model = Unchecked.defaultof<'Model> @@ -26,7 +33,8 @@ type ReactiveElmishStore<'Model, 'Msg> () = member this.Model = _model member this.Observable = _modelSubject.AsObservable() - member internal this.Subject = _modelSubject + interface IHasSubject<'Model> with + member this.Subject = _modelSubject member this.Dispatcher with get() = _dispatch diff --git a/src/ReactiveElmish/ReactiveElmishViewModel.fs b/src/ReactiveElmish/ReactiveElmishViewModel.fs index ac4d183..3310743 100644 --- a/src/ReactiveElmish/ReactiveElmishViewModel.fs +++ b/src/ReactiveElmish/ReactiveElmishViewModel.fs @@ -1,5 +1,4 @@ -#nowarn "0064" // FS0064: This construct causes code to be less generic than indicated by the type annotations. -namespace ReactiveElmish +namespace ReactiveElmish open System.ComponentModel open System.Reactive.Linq @@ -12,19 +11,29 @@ open DynamicData open System.Collections.ObjectModel open ReactiveUI -type ReactiveElmishViewModel() = +[] +module private Utils = + let readOnlyCollection<'T>() = + new ReadOnlyObservableCollection<'T>(new ObservableCollection<'T>()) + +type ReactiveElmishViewModel(onPropertyChanged: string -> unit) = inherit ReactiveUI.ReactiveObject() let disposables = ResizeArray() let propertySubscriptions = Dictionary() + let propertyCollections = Dictionary() + + new() as this = + let opc propertyName = this.RaisePropertyChanged(propertyName) + new ReactiveElmishViewModel(opc) /// Fires the `PropertyChanged` event for the given property name. Uses the caller's name if no property name is given. member this.OnPropertyChanged([] ?propertyName: string) = this.RaisePropertyChanged(propertyName.Value) /// Binds a VM property to a `modelProjection` value and refreshes the VM property when the `modelProjection` value changes. - member this.Bind<'Model, 'Msg, 'ModelProjection>( - store: IStore<'Model, 'Msg>, + member this.Bind<'Model, 'ModelProjection>( + store: IStore<'Model>, modelProjection: 'Model -> 'ModelProjection, [] ?vmPropertyName ) = @@ -36,7 +45,7 @@ type ReactiveElmishViewModel() = .DistinctUntilChanged(modelProjection) .Subscribe(fun _ -> // Alerts the view that the 'Model projection / VM property has changed. - this.OnPropertyChanged(vmPropertyName) + onPropertyChanged(vmPropertyName) #if DEBUG printfn $"PropertyChanged: {vmPropertyName} by {this}" #endif @@ -49,8 +58,8 @@ type ReactiveElmishViewModel() = /// Binds a VM property to a `modelProjection` value and refreshes the VM property when the `onChanged` value changes. /// The `modelProjection` function will only be called when the `onChanged` value changes. /// `onChanged` usually returns a property value or a tuple of property values. - member this.BindOnChanged<'Model, 'Msg, 'OnChanged, 'ModelProjection>( - store: IStore<'Model, 'Msg>, + member this.BindOnChanged<'Model, 'OnChanged, 'ModelProjection>( + store: IStore<'Model>, onChanged: 'Model -> 'OnChanged, modelProjection: 'Model -> 'ModelProjection, [] ?vmPropertyName @@ -63,12 +72,18 @@ type ReactiveElmishViewModel() = .DistinctUntilChanged(onChanged) .Subscribe(fun x -> // Alerts the view that the 'Model projection / VM property has changed. - this.OnPropertyChanged(vmPropertyName) + onPropertyChanged(vmPropertyName) #if DEBUG printfn $"PropertyChanged: {vmPropertyName} by {this}" #endif ) - (store :?> ReactiveElmishStore<'Model, 'Msg>).Subject.OnNext(store.Model) // prime the pump + + match store with + | :? IHasSubject<'Model> as subject -> + subject.Subject.OnNext(store.Model) // prime the pump + | _ -> + failwith "BindOnChanged requires the store to implement ISubject<'Model>" + propertySubscriptions.Add(vmPropertyName, disposable) modelProjection store.Model @@ -78,26 +93,31 @@ type ReactiveElmishViewModel() = /// /// The reactive store to bind to. /// The model projection. - member this.BindList<'Model, 'Msg, 'ModelProjection>( - store: IStore<'Model, 'Msg>, + member this.BindList<'Model, 'ModelProjection>( + store: IStore<'Model>, modelProjectionSeq: 'Model -> 'ModelProjection seq, [] ?vmPropertyName ) = - let vmPropertyName = vmPropertyName.Value - let mutable readOnlyList: ReadOnlyObservableCollection<'ModelProjection> = Unchecked.defaultof<_> - if not (propertySubscriptions.ContainsKey vmPropertyName) then + if not (propertyCollections.ContainsKey vmPropertyName.Value) then + let mutable readOnlyList = readOnlyCollection<'ModelProjection>() let sourceList = SourceList.createFrom (modelProjectionSeq store.Model) - readOnlyList <- this.BindSourceList(sourceList, vmPropertyName) - - let disposable = - store.Observable - .Select(modelProjectionSeq) - //.DistinctUntilChanged() - .Subscribe(sourceList.EditDiff) + sourceList + .Connect() + .Bind(&readOnlyList) + .Subscribe() + |> this.AddDisposable + + store.Observable + .Select(modelProjectionSeq) + //.DistinctUntilChanged() + .Subscribe(sourceList.EditDiff) + |> this.AddDisposable this.AddDisposable(sourceList) - this.AddDisposable(disposable) - readOnlyList + propertyCollections.Add(vmPropertyName.Value, readOnlyList) + readOnlyList + else + propertyCollections[vmPropertyName.Value] :?> ReadOnlyObservableCollection<'ModelProjection> /// /// Binds a model colleciton property to a DynamicData SourceList. @@ -105,27 +125,35 @@ type ReactiveElmishViewModel() = /// The reactive store to bind to. /// The model projection. /// A function that transforms each item in the collection when it is added to the SourceList. - member this.BindList'<'Model, 'Msg, 'ModelProjection, 'Mapped>( - store: IStore<'Model, 'Msg>, + member this.BindList<'Model, 'ModelProjection, 'Mapped>( + store: IStore<'Model>, modelProjectionSeq: 'Model -> 'ModelProjection seq, map: 'ModelProjection -> 'Mapped, [] ?vmPropertyName ) = let vmPropertyName = vmPropertyName.Value - let mutable readOnlyList: ReadOnlyObservableCollection<'Mapped> = Unchecked.defaultof<_> - if not (propertySubscriptions.ContainsKey vmPropertyName) then + if not (propertyCollections.ContainsKey vmPropertyName) then + let mutable readOnlyList = readOnlyCollection<'Mapped>() let sourceList = SourceList.createFrom (modelProjectionSeq store.Model) - readOnlyList <- this.BindSourceList(sourceList, map, vmPropertyName) - - let disposable = - store.Observable - .Select(modelProjectionSeq) - //.DistinctUntilChanged() - .Subscribe(sourceList.EditDiff) + + sourceList + .Connect() + .Transform(map) + .Bind(&readOnlyList) + .Subscribe() + |> this.AddDisposable + + store.Observable + .Select(modelProjectionSeq) + //.DistinctUntilChanged() + .Subscribe(sourceList.EditDiff) + |> this.AddDisposable this.AddDisposable(sourceList) - this.AddDisposable(disposable) - readOnlyList + propertyCollections.Add(vmPropertyName, readOnlyList) + readOnlyList + else + propertyCollections[vmPropertyName] :?> ReadOnlyObservableCollection<'Mapped> /// /// Binds a model Map<'Key, 'Value> property to an ObservableCollection. @@ -136,22 +164,19 @@ type ReactiveElmishViewModel() = /// A function that returns the identifier of the item. /// An optional function that updates the transformed item when it is updated in the model. NOTE: This is expensive as it requires all items to be compared. /// An optional function that returns a sort expression. - member this.BindKeyedList<'Model, 'Msg, 'Key, 'Value, 'Mapped when 'Value : equality and 'Mapped : not struct and 'Key : comparison>( - store: IStore<'Model, 'Msg>, + member this.BindKeyedList<'Model, 'Key, 'Value, 'Mapped when 'Value : equality and 'Mapped : not struct and 'Key : comparison>( + store: IStore<'Model>, modelProjection: 'Model -> Map<'Key, 'Value>, map: 'Value -> 'Mapped, getKey: 'Mapped -> 'Key, - ?update: 'Value -> 'Mapped -> unit, - ?sortBy: 'Mapped -> IComparable, + ?update: Action<'Value, 'Mapped>, + ?sortBy: Func<'Mapped, IComparable>, [] ?vmPropertyName ) = let vmPropertyName = vmPropertyName.Value - let mutable lastModelMap: Map<'Key, 'Value> = Unchecked.defaultof<_> - let mutable observableCollection: ObservableCollection<'Mapped> = Unchecked.defaultof<_> - if not (propertySubscriptions.ContainsKey vmPropertyName) then - observableCollection <- ObservableCollection() - lastModelMap <- Map.empty - + if not (propertyCollections.ContainsKey vmPropertyName) then + let mutable lastModelMap: Map<'Key, 'Value> = Map.empty + let mutable observableCollection = ObservableCollection() let mapToObservableCollection (currentModelMap: Map<'Key, 'Value>) = // Apply updates to existing items (excluding new or removed items) @@ -172,7 +197,7 @@ type ReactiveElmishViewModel() = let newItem = currentModelMap[key] let oldItem = lastModelMap[key] if newItem <> oldItem then - update newItem itemVM + update.Invoke(newItem, itemVM) ) // Get items from the currentModelMap that are missing from the lastModelMap @@ -216,7 +241,7 @@ type ReactiveElmishViewModel() = sortBy |> Option.iter (fun sortBy -> observableCollection - |> Seq.sortBy sortBy + |> Seq.sortBy sortBy.Invoke |> Seq.iteri (fun idx item -> observableCollection[idx] <- item) ) @@ -233,8 +258,11 @@ type ReactiveElmishViewModel() = .DistinctUntilChanged() .Subscribe(mapToObservableCollection) - propertySubscriptions.Add(vmPropertyName, disposable) - observableCollection + this.AddDisposable disposable + propertyCollections.Add(vmPropertyName, observableCollection) + observableCollection + else + propertyCollections[vmPropertyName] :?> ObservableCollection<'Mapped> /// /// Binds a model Map<'Key, 'Value> property to an ObservableCollection. @@ -244,12 +272,12 @@ type ReactiveElmishViewModel() = /// A function that returns the identifier of the item. /// An optional function that updates the transformed item when it is updated in the model. NOTE: This is expensive as it requires all items to be compared. /// A function that returns a sort expression. - member this.BindKeyedList<'Model, 'Msg, 'Key, 'Value when 'Value: equality and 'Value : not struct and 'Key : comparison>( - store: IStore<'Model, 'Msg>, + member this.BindKeyedList<'Model, 'Key, 'Value when 'Value: equality and 'Value : not struct and 'Key : comparison>( + store: IStore<'Model>, modelProjection: 'Model -> Map<'Key, 'Value>, getKey: 'Value -> 'Key, - ?update: 'Value -> 'Value -> unit, - ?sortBy: 'Value -> IComparable, + ?update: Action<'Value, 'Value>, + ?sortBy, [] ?vmPropertyName: string ) = this.BindKeyedList(store = store, modelProjection = modelProjection, map = id, getKey = getKey, ?update = update, ?sortBy = sortBy, ?vmPropertyName = vmPropertyName) @@ -260,16 +288,19 @@ type ReactiveElmishViewModel() = [] ?vmPropertyName ) = let vmPropertyName = vmPropertyName.Value - let mutable readOnlyList: ReadOnlyObservableCollection<'T> = Unchecked.defaultof<_> - if not (propertySubscriptions.ContainsKey vmPropertyName) then + if not (propertyCollections.ContainsKey vmPropertyName) then + let mutable readOnlyList = readOnlyCollection<'T>() // Creates a subscription to a ISourceList<'T> and stores it in a dictionary. let disposable = sourceList .Connect() .Bind(&readOnlyList) .Subscribe() - propertySubscriptions.Add(vmPropertyName, disposable) - readOnlyList + this.AddDisposable disposable + propertyCollections.Add(vmPropertyName, readOnlyList) + readOnlyList + else + propertyCollections[vmPropertyName] :?> ReadOnlyObservableCollection<'T> /// Binds a VM property to a 'Model DynamicData.ISourceList<'T> property. member this.BindSourceList<'T, 'Mapped>( @@ -278,8 +309,8 @@ type ReactiveElmishViewModel() = [] ?vmPropertyName ) = let vmPropertyName = vmPropertyName.Value - let mutable readOnlyList: ReadOnlyObservableCollection<'Mapped> = Unchecked.defaultof<_> - if not (propertySubscriptions.ContainsKey vmPropertyName) then + if not (propertyCollections.ContainsKey vmPropertyName) then + let mutable readOnlyList = readOnlyCollection<'Mapped>() // Creates a subscription to a ISourceList<'T> and stores it in a dictionary. let disposable = sourceList @@ -287,18 +318,21 @@ type ReactiveElmishViewModel() = .Transform(map) .Bind(&readOnlyList) .Subscribe() - propertySubscriptions.Add(vmPropertyName, disposable) - readOnlyList + this.AddDisposable disposable + propertyCollections.Add(vmPropertyName, readOnlyList) + readOnlyList + else + propertyCollections[vmPropertyName] :?> ReadOnlyObservableCollection<'Mapped> /// Binds a VM property to a 'Model DynamicData.IObservableCache<'Value, 'Key> property. member this.BindSourceCache<'Value, 'Key>( sourceCache: IObservableCache<'Value, 'Key>, - ?sortBy: 'Value -> 'IComparable, + ?sortBy: Func<'Value, IComparable>, [] ?vmPropertyName ) = let vmPropertyName = vmPropertyName.Value - let mutable readOnlyList: ReadOnlyObservableCollection<'Value> = Unchecked.defaultof<_> - if not (propertySubscriptions.ContainsKey vmPropertyName) then + if not (propertyCollections.ContainsKey vmPropertyName) then + let mutable readOnlyList = readOnlyCollection<'Value>() // Creates a subscription to a SourceCache and stores it in a dictionary. let disposable = sourceCache @@ -311,8 +345,11 @@ type ReactiveElmishViewModel() = x.Sort(Comparer.Create(fun _ _ -> 0)) |> _.Bind(&readOnlyList) |> _.Subscribe() - propertySubscriptions.Add(vmPropertyName, disposable) - readOnlyList + this.AddDisposable disposable + propertyCollections.Add(vmPropertyName, readOnlyList) + readOnlyList + else + propertyCollections[vmPropertyName] :?> ReadOnlyObservableCollection<'Value> /// /// Binds a VM property to a 'Model DynamicData.IObservableCache<'Value, 'Key> property. @@ -324,13 +361,13 @@ type ReactiveElmishViewModel() = member this.BindSourceCache<'Value, 'Key, 'Mapped when 'Value : not struct and 'Mapped : not struct>( sourceCache: IObservableCache<'Value, 'Key>, map: 'Value -> 'Mapped, - ?update: 'Value -> 'Mapped -> unit, - ?sortBy: 'Mapped -> 'IComparable, + ?update: Action<'Value, 'Mapped>, + ?sortBy, [] ?vmPropertyName ) = let vmPropertyName = vmPropertyName.Value - let mutable readOnlyList: ReadOnlyObservableCollection<'Mapped> = Unchecked.defaultof<_> - if not (propertySubscriptions.ContainsKey vmPropertyName) then + if not (propertyCollections.ContainsKey vmPropertyName) then + let mutable readOnlyList = readOnlyCollection<'Mapped>() // Creates a subscription to a SourceCache and stores it in a dictionary. let disposable = sourceCache @@ -338,7 +375,7 @@ type ReactiveElmishViewModel() = |> fun x -> match update with | Some update -> - x.TransformWithInlineUpdate(map, fun mapped value -> update value mapped) + x.TransformWithInlineUpdate(map, fun mapped value -> update.Invoke(value, mapped)) | None -> x.Transform(map) |> fun x -> @@ -349,8 +386,11 @@ type ReactiveElmishViewModel() = x.Sort(Comparer.Create(fun _ _ -> 0)) |> _.Bind(&readOnlyList) |> _.Subscribe() - propertySubscriptions.Add(vmPropertyName, disposable) - readOnlyList + this.AddDisposable disposable + propertyCollections.Add(vmPropertyName, readOnlyList) + readOnlyList + else + propertyCollections[vmPropertyName] :?> ReadOnlyObservableCollection<'Mapped> /// Subscribes to an IObservable<> and adds the subscription to the list of disposables. member this.Subscribe(observable: IObservable<'T>, handler: 'T -> unit) = @@ -366,3 +406,4 @@ type ReactiveElmishViewModel() = disposables |> Seq.iter _.Dispose() propertySubscriptions.Values |> Seq.iter _.Dispose() propertySubscriptions.Clear() + diff --git a/src/ReactiveElmish/ReactiveStore.fs b/src/ReactiveElmish/ReactiveStore.fs new file mode 100644 index 0000000..26d5145 --- /dev/null +++ b/src/ReactiveElmish/ReactiveStore.fs @@ -0,0 +1,34 @@ +namespace ReactiveElmish + +open System.Reactive.Subjects +open System.Reactive.Linq +open System + +/// A reactive store that can be used to store and update a model and send out an Rx stream of the model. +type ReactiveStore<'Model>(init: 'Model) = + let _modelSubject = new Subject<'Model>() + let mutable _model: 'Model = init + + interface IStore<'Model> with + member this.Model = this.Model + member this.Observable = this.Observable + + member this.Model = _model + member this.Observable = _modelSubject.AsObservable() + + /// Updates the model and sends out an Rx stream. + member this.Update(fn: Func<'Model, 'Model>) = + _model <- fn.Invoke _model + _modelSubject.OnNext(_model) + + /// Updates the model and sends out an Rx stream. + member this.Update(fn: 'Model -> 'Model) = + _model <- fn _model + _modelSubject.OnNext(_model) + + interface IHasSubject<'Model> with + member this.Subject = _modelSubject + + interface IDisposable with + member this.Dispose() = + _modelSubject.Dispose() \ No newline at end of file diff --git a/src/Samples/AvaloniaExample/ViewModels/CounterViewModel.fs b/src/Samples/AvaloniaExample/ViewModels/CounterViewModel.fs index 15056dd..4269ab3 100644 --- a/src/Samples/AvaloniaExample/ViewModels/CounterViewModel.fs +++ b/src/Samples/AvaloniaExample/ViewModels/CounterViewModel.fs @@ -48,7 +48,7 @@ type CounterViewModel() = |> Program.mkStore member this.Count = this.Bind(local, _.Count) - member this.Actions = this.BindList'(local, _.Actions, fun a -> { a with Description = $"** {a.Description} **" }) + member this.Actions = this.BindList(local, _.Actions, map = fun a -> { a with Description = $"** {a.Description} **" }) member this.Increment() = local.Dispatch Increment member this.Decrement() = local.Dispatch Decrement member this.Reset() = local.Dispatch Reset diff --git a/src/WpfExample/App.xaml b/src/WpfExample/App.xaml new file mode 100644 index 0000000..99531b5 --- /dev/null +++ b/src/WpfExample/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/WpfExample/App.xaml.cs b/src/WpfExample/App.xaml.cs new file mode 100644 index 0000000..362247b --- /dev/null +++ b/src/WpfExample/App.xaml.cs @@ -0,0 +1,14 @@ +using System.Configuration; +using System.Data; +using System.Windows; + +namespace WpfExample +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + } + +} diff --git a/src/WpfExample/AssemblyInfo.cs b/src/WpfExample/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/src/WpfExample/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/WpfExample/MainWindow.xaml b/src/WpfExample/MainWindow.xaml new file mode 100644 index 0000000..4cce1aa --- /dev/null +++ b/src/WpfExample/MainWindow.xaml @@ -0,0 +1,13 @@ + + + + + diff --git a/src/WpfExample/MainWindow.xaml.cs b/src/WpfExample/MainWindow.xaml.cs new file mode 100644 index 0000000..84abf92 --- /dev/null +++ b/src/WpfExample/MainWindow.xaml.cs @@ -0,0 +1,24 @@ +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace WpfExample +{ + /// + /// Interaction logic for MainWindow.xaml + /// + public partial class MainWindow : Window + { + public MainWindow() + { + InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/src/WpfExample/ViewModels/CounterViewModel.cs b/src/WpfExample/ViewModels/CounterViewModel.cs new file mode 100644 index 0000000..bb9c6b4 --- /dev/null +++ b/src/WpfExample/ViewModels/CounterViewModel.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using ReactiveElmish; + + +using ReactiveUI; + +namespace WpfExample.ViewModels +{ + public class CounterStore() : ReactiveStore(CounterModel.Init()) + { + public void Increment() => + Update(m => m with { Count = m.Count + 1, Actions = m.Actions.Append(new Action("Increment", DateTime.Now)).ToArray() }); + + public void Decrement() => + Update(m => m with { Count = m.Count - 1, Actions = m.Actions.Append(new Action("Decrement", DateTime.Now)).ToArray() }); + + public void Reset() => + Update(m => CounterModel.Init()); + } + + public record CounterModel( + int Count, + Action[] Actions + ) + { + public static CounterModel Init() => new CounterModel(0, [new Action("Initialized", DateTime.Now)]); + } + + public record Action( + string Description, + DateTime Timestamp + ); + + public class CounterViewModel : ReactiveObject + { + readonly CounterStore store = new CounterStore(); + public CounterViewModel() => Rx = new ReactiveBindingsCS(this.RaisePropertyChanged); + ReactiveBindingsCS Rx { get; } + + public int Count => Rx.Bind(store, s => s.Count); + public ReadOnlyCollection Actions => Rx.BindList(store, s => s.Actions); + public IRelayCommand Increment { get => new RelayCommand(store.Increment); } + public IRelayCommand Decrement { get => new RelayCommand(store.Decrement); } + public IRelayCommand Reset { get => new RelayCommand(store.Reset); } + } +} diff --git a/src/WpfExample/Views/CounterView.xaml b/src/WpfExample/Views/CounterView.xaml new file mode 100644 index 0000000..e90d5c9 --- /dev/null +++ b/src/WpfExample/Views/CounterView.xaml @@ -0,0 +1,27 @@ + + + + + Counter: + + + + + + + + + + + + + + diff --git a/src/WpfExample/Views/CounterView.xaml.cs b/src/WpfExample/Views/CounterView.xaml.cs new file mode 100644 index 0000000..8ec3730 --- /dev/null +++ b/src/WpfExample/Views/CounterView.xaml.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Shapes; + +namespace WpfExample.Views +{ + /// + /// Interaction logic for CounterView.xaml + /// + public partial class CounterView : UserControl + { + public CounterView() + { + InitializeComponent(); + DataContext = new ViewModels.CounterViewModel(); + } + } +} diff --git a/src/WpfExample/WpfExample.csproj b/src/WpfExample/WpfExample.csproj new file mode 100644 index 0000000..a399f81 --- /dev/null +++ b/src/WpfExample/WpfExample.csproj @@ -0,0 +1,19 @@ + + + + WinExe + net8.0-windows + enable + enable + true + + + + + + + + + + + diff --git a/src/publish.fsx b/src/publish.fsx index 584b7c2..bfae96f 100644 --- a/src/publish.fsx +++ b/src/publish.fsx @@ -8,11 +8,29 @@ let src = __SOURCE_DIRECTORY__ pipeline "Publish" { - stage "Build ReactiveElmish.Avalonia" { + stage "Restore and Build" { run $"dotnet restore {src}/ReactiveElmish.Avalonia/ReactiveElmish.Avalonia.fsproj" + run $"dotnet build {src}/ReactiveElmish/ReactiveElmish.fsproj --configuration Release" run $"dotnet build {src}/ReactiveElmish.Avalonia/ReactiveElmish.Avalonia.fsproj --configuration Release" } + stage "Publish ReactiveElmish" { + run (fun ctx -> + let version = + let project = FileInfo $"{src}/ReactiveElmish/ReactiveElmish.fsproj" + match XDocument.Load(project.FullName).Descendants("Version") |> Seq.tryHead with + | Some versionElement -> versionElement.Value + | None -> failwith $"Could not find a element in '{project.Name}'." + + let nugetKey = + match ctx.TryGetEnvVar "REACTIVE_ELMISH_NUGET_KEY" with + | ValueSome nugetKey -> nugetKey + | ValueNone -> failwith "The NuGet API key must be set in an 'REACTIVE_ELMISH_NUGET_KEY' environmental variable" + + $"dotnet nuget push \"{src}/ReactiveElmish/bin/Release/ReactiveElmish.{version}.nupkg\" -s nuget.org -k {nugetKey} --skip-duplicate" + ) + } + stage "Publish ReactiveElmish.Avalonia" { run (fun ctx -> let version =