Xamarin Forms 的 ReactiveUI:双向绑定不适用于自定义 BindableProperty

ReactiveUI for Xamarin Forms: Two-way binding doesn't work for custom BindableProperty

我正在对 Xamarin Forms Map class 进行扩展,它适用于 MVVM 架构。这是派生类型:

type GeographicMap() =
    inherit Map()
    static let centerProperty = BindableProperty.Create("Center", typeof<GeodesicLocation>, typeof<GeographicMap>, new GeodesicLocation())
    static let radiusProperty = BindableProperty.Create("Radius", typeof<float>, typeof<GeographicMap>, 1.0)
    member this.Radius
        with get() = 1.0<km> * (this.GetValue(radiusProperty) :?> float)
        and set(value: float<km>) = if not <| value.Equals(this.Radius) then this.SetValue(radiusProperty, value / 1.0<km>)
    member this.Center 
        with get() = this.GetValue(centerProperty) :?> GeodesicLocation
        and set(value: GeodesicLocation) = if not <| value.Equals(this.Center) then this.SetValue(centerProperty, value)
    override this.OnPropertyChanged(propertyName) =
        match propertyName with
        | "VisibleRegion" ->
            this.Center <- this.VisibleRegion.Center |> XamarinGeographic.geodesicLocation
            this.Radius <- this.VisibleRegion.Radius |> XamarinGeographic.geographicDistance
        | "Radius" | "Center" -> 
            match box this.VisibleRegion with
            | null -> this.MoveToRegion(MapSpan.FromCenterAndRadius(this.Center |> XamarinGeographic.position, this.Radius |> XamarinGeographic.distance))
            | _ ->
                let existingCenter, existingRadius = this.VisibleRegion.Center |> XamarinGeographic.geodesicLocation, this.VisibleRegion.Radius |> XamarinGeographic.geographicDistance
                let deltaCenter, deltaRadius = Geodesic.WGS84.Distance existingCenter (this.Center), existingRadius - this.Radius
                let threshold =  0.1 * this.Radius
                if Math.Abs(deltaRadius / 1.0<km>) > threshold / 1.0<km> || Math.Abs((deltaCenter |> UnitConversion.kilometres) / 1.0<km>) > threshold / 1.0<km> then
                    this.MoveToRegion(MapSpan.FromCenterAndRadius(this.Center |> XamarinGeographic.position, this.Radius |> XamarinGeographic.distance))
        | _ -> propertyName |> ignore

在我看来,我在 Center 属性 和我的 ViewModel 的 Location 属性 之间添加了一个绑定,如下所示:

type DashboardView(theme: Theme) as this = 
    inherit ContentPage<DashboardViewModel, DashboardView>(theme)
    new() = new DashboardView(Themes.AstridTheme)
    override __.CreateContent() =
        theme.GenerateGrid([|"Auto"; "*"|], [|"*"|]) |> withColumn(
            [|
                theme.VerticalLayout() |> withBlocks(
                    [|
                        theme.GenerateLabel(fun l -> this.Title <- l) 
                            |> withAlignment LayoutOptions.Center LayoutOptions.Center
                            |> withOneWayBinding(this.ViewModel, this, <@ fun (vm: DashboardViewModel) -> vm.Title @>, <@ fun (v: DashboardView) -> (v.Title: Label).Text @>)
                        theme.GenerateSearchBar(fun sb -> this.AddressSearchBar <- sb)
                            |> withSearchBarPlaceholder LocalisedStrings.SearchForAPlaceOfInterest
                            |> withTwoWayBinding(this.ViewModel, this, <@ fun (vm: DashboardViewModel) -> vm.SearchAddress @>, <@ fun (v: DashboardView) -> (v.AddressSearchBar: SearchBar).Text @>)
                            |> withSearchCommand this.ViewModel.SearchForAddress
                    |])
                theme.GenerateMap(fun m -> this.Map <- m)
                    |> withTwoWayBinding(this.ViewModel, this, <@ fun (vm: DashboardViewModel) -> vm.Location @>, <@ fun (v:DashboardView) -> (v.Map: GeographicMap).Center @>)
            |]) |> createFromColumns :> View
    member val AddressSearchBar = Unchecked.defaultof<SearchBar> with get, set
    member val Title = Unchecked.defaultof<Label> with get, set
    member val Map = Unchecked.defaultof<GeographicMap> with get, set

请注意,我在 DashboardViewModel.LocationDashboardView.Map.Center 之间有双向绑定。我在 DashboardViewModel.SearchAddressDashboardView.AddressSearchBar.Text 之间也有双向绑定。后者具有约束力;前者没有。我想这一定是因为我没有正确设置可绑定 属性 GeographicMap.Center

我知道双向绑定不起作用,因为平移地图会导致 VisibleRegion 属性 被修改,进而触发 Center 的更新属性。但是,在我的 ViewModel class:

type DashboardViewModel(?host: IScreen, ?platform: IPlatform) as this =
    inherit ReactiveViewModel()
    let host, platform = LocateIfNone host, LocateIfNone platform
    let searchResults = new ObservableCollection<GeodesicLocation>()
    let commandSubscriptions = new CompositeDisposable()
    let geocodeAddress(vm: DashboardViewModel) =
        let vm = match box vm with | null -> this | _ -> vm
        searchResults.Clear()
        async {
            let! results = platform.Geocoder.GetPositionsForAddressAsync(vm.SearchAddress) |> Async.AwaitTask
            results |> Seq.map (fun r -> new GeodesicLocation(r.Latitude * 1.0<deg>, r.Longitude * 1.0<deg>)) |> Seq.iter searchResults.Add
            match results |> Seq.tryLast with
            | Some position -> return position |> XamarinGeographic.geodesicLocation |> Some
            | None -> return None
        } |> Async.StartAsTask
    let searchForAddress = ReactiveCommand.CreateFromTask geocodeAddress
    let mutable searchAddress = String.Empty
    let mutable location = new GeodesicLocation(51.4<deg>, 0.02<deg>)
    override this.SubscribeToCommands() = searchForAddress.ObserveOn(RxApp.MainThreadScheduler).Subscribe(fun res -> match res with | Some l -> this.Location <- l | None -> res |> ignore) |> commandSubscriptions.Add
    override __.UnsubscribeFromCommands() = commandSubscriptions.Clear()
    member __.Title with get() = LocalisedStrings.AppTitle
    member __.SearchForAddress with get() = searchForAddress
    member this.SearchAddress 
        with get() = searchAddress 
        // GETS HIT WHEN SEARCH TEXT CHANGES
        and set(value) = this.RaiseAndSetIfChanged(&searchAddress, value, "SearchAddress") |> ignore
    member this.Location 
        with get() = location 
        // DOES NOT GET HIT WHEN THE MAP GETS PANNED, TRIGGERING AN UPDATE OF ITS Center PROPERTY
        and set(value) = this.RaiseAndSetIfChanged(&location, value, "Location") |> ignore
    interface IRoutableViewModel with
        member __.HostScreen = host
        member __.UrlPathSegment = "Dashboard"

每当更新搜索文本时,SearchAddress setter 都会被命中,而平移地图时 Location setter 不会被命中,从而导致更新它的 Center 属性.

我是否遗漏了与我的可绑定设置相关的内容 Center 属性?

UPDATE:这与 ReactiveUI 的 WhenAnyValue 扩展有关,它在我的绑定中内部使用。为了演示这一点,我在视图创建中添加了几行:

override __.CreateContent() =
    let result = 
        theme.GenerateGrid([|"Auto"; "*"|], [|"*"|]) |> withColumn(
            [|
                theme.VerticalLayout() |> withBlocks(
                    [|
                        theme.GenerateLabel(fun l -> this.Title <- l) 
                            |> withAlignment LayoutOptions.Center LayoutOptions.Center
                            |> withOneWayBinding(this.ViewModel, this, <@ fun (vm: DashboardViewModel) -> vm.Title @>, <@ fun (v: DashboardView) -> (v.Title: Label).Text @>)
                        theme.GenerateSearchBar(fun sb -> this.AddressSearchBar <- sb)
                            |> withSearchBarPlaceholder LocalisedStrings.SearchForAPlaceOfInterest
                            |> withTwoWayBinding(this.ViewModel, this, <@ fun (vm: DashboardViewModel) -> vm.SearchAddress @>, <@ fun (v: DashboardView) -> (v.AddressSearchBar: SearchBar).Text @>)
                            |> withSearchCommand this.ViewModel.SearchForAddress
                    |])
                theme.GenerateMap(fun m -> this.Map <- m)
                    |> withTwoWayBinding(this.ViewModel, this, <@ fun (vm: DashboardViewModel) -> vm.Location @>, <@ fun (v:DashboardView) -> (v.Map: GeographicMap).Center @>)
            |]) |> createFromColumns :> View
    this.WhenAnyValue(ExpressionConversion.toLinq <@ fun (v:DashboardView) -> (v.Map: GeographicMap).Center @>).ObserveOn(RxApp.MainThreadScheduler).Subscribe(fun (z) ->
        z |> ignore) |> ignore // This breakpoint doesn't get hit when the map pans.
    this.WhenAnyValue(ExpressionConversion.toLinq <@ fun (v:DashboardView) -> (v.AddressSearchBar: SearchBar).Text @>).ObserveOn(RxApp.MainThreadScheduler).Subscribe(fun (z) ->
        z |> ignore) |> ignore // This breakpoint gets hit when text is changed in the search bar.
    result

除了在 BindableProperty 的获取和设置定义中调用 GetValue() 和 SetValue() 之外,您不应进行任何其他操作。为了在设置或更改此 属性 时进行其他更改,您可以重写 OnPropertyChanged 方法并在那里进行必要的操作。

解决方法非常简单。

我重写了 OnPropertyChanged 而没有调用基本实现,这触发了 public PropertyChanged 事件:

protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
    PropertyChangedEventHandler handler = PropertyChanged;
    if (handler != null)
        handler(this, new PropertyChangedEventArgs(propertyName));
}

所以我需要做的是在我的覆盖中添加对 base.OnPropertyChanged() 的调用:

type GeographicMap() =
    inherit Map()
    static let centerProperty = BindableProperty.Create("Center", typeof<GeodesicLocation>, typeof<GeographicMap>, new GeodesicLocation())
    static let radiusProperty = BindableProperty.Create("Radius", typeof<float>, typeof<GeographicMap>, 1.0)
    member this.Radius
        with get() = 1.0<km> * (this.GetValue(radiusProperty) :?> float)
        and set(value: float<km>) = if not <| value.Equals(this.Radius) then this.SetValue(radiusProperty, value / 1.0<km>)
    member this.Center 
        with get() = this.GetValue(centerProperty) :?> GeodesicLocation
        and set(value: GeodesicLocation) = if not <| value.Equals(this.Center) then this.SetValue(centerProperty, value)
    override this.OnPropertyChanged(propertyName) =
        base.OnPropertyChanged(propertyName)
        match propertyName with
        | "VisibleRegion" ->
            this.Center <- this.VisibleRegion.Center |> XamarinGeographic.geodesicLocation
            this.Radius <- this.VisibleRegion.Radius |> XamarinGeographic.geographicDistance
        | "Radius" | "Center" -> 
            match box this.VisibleRegion with
            | null -> this.MoveToRegion(MapSpan.FromCenterAndRadius(this.Center |> XamarinGeographic.position, this.Radius |> XamarinGeographic.distance))
            | _ ->
                let existingCenter, existingRadius = this.VisibleRegion.Center |> XamarinGeographic.geodesicLocation, this.VisibleRegion.Radius |> XamarinGeographic.geographicDistance
                let deltaCenter, deltaRadius = Geodesic.WGS84.Distance existingCenter (this.Center), existingRadius - this.Radius
                let threshold =  0.1 * this.Radius
                if Math.Abs(deltaRadius / 1.0<km>) > threshold / 1.0<km> || Math.Abs((deltaCenter |> UnitConversion.kilometres) / 1.0<km>) > threshold / 1.0<km> then
                    this.MoveToRegion(MapSpan.FromCenterAndRadius(this.Center |> XamarinGeographic.position, this.Radius |> XamarinGeographic.distance))
        | _ -> propertyName |> ignore

此更改允许触发 public 事件。 ReactiveUI 使用 Observable.FromEventPattern.

将此事件转换为 IObservable