Wypychanie właściwości GUI tylko do odczytu z powrotem do ViewModel

124

Chcę napisać ViewModel, który zawsze zna bieżący stan niektórych właściwości zależności tylko do odczytu z widoku.

W szczególności mój GUI zawiera FlowDocumentPageViewer, który wyświetla jedną stronę naraz z FlowDocument. FlowDocumentPageViewer udostępnia dwie właściwości zależności tylko do odczytu o nazwach CanGoToPreviousPage i CanGoToNextPage. Chcę, aby mój ViewModel zawsze znał wartości tych dwóch właściwości View.

Pomyślałem, że mogę to zrobić z wiązaniem danych OneWayToSource:

<FlowDocumentPageViewer
    CanGoToNextPage="{Binding NextPageAvailable, Mode=OneWayToSource}" ...>

Gdyby było to dozwolone, byłoby idealnie: za każdym razem, gdy właściwość CanGoToNextPage FlowDocumentPageViewer zostanie zmieniona, nowa wartość zostanie wypchnięta do właściwości NextPageAvailable ViewModel, która jest dokładnie tym, czego chcę.

Niestety, to się nie kompiluje: pojawia się błąd informujący , że właściwość „CanGoToPreviousPage” jest tylko do odczytu i nie można jej ustawić na podstawie znaczników. Najwyraźniej właściwości tylko do odczytu nie obsługują żadnych rodzaju wiązania danych, nawet wiązania danych, które jest tylko do odczytu w odniesieniu do tej właściwości.

Mogę ustawić właściwości mojego ViewModel na DependencyProperties i utworzyć powiązanie OneWay w drugą stronę, ale nie szaleję na punkcie naruszenia separacji problemów (ViewModel potrzebowałby odwołania do widoku, którego powiązanie danych MVVM powinno unikać ).

FlowDocumentPageViewer nie ujawnia zdarzenia CanGoToNextPageChanged i nie znam żadnego dobrego sposobu na otrzymywanie powiadomień o zmianach z DependencyProperty, poza utworzeniem innej DependencyProperty, z którą można powiązać, co wydaje się tutaj przesadą.

Jak mogę informować mój ViewModel o zmianach właściwości widoku tylko do odczytu?

Joe White
źródło

Odpowiedzi:

152

Tak, robiłem to w przeszłości z właściwościami ActualWidthi ActualHeight, które są tylko do odczytu. Utworzyłem dołączone zachowanie, które ma ObservedWidthi ObservedHeightdołączone właściwości. Ma również Observewłaściwość używaną do wykonywania początkowego podłączenia. Sposób użycia wygląda następująco:

<UserControl ...
    SizeObserver.Observe="True"
    SizeObserver.ObservedWidth="{Binding Width, Mode=OneWayToSource}"
    SizeObserver.ObservedHeight="{Binding Height, Mode=OneWayToSource}"

Dlatego model widoku ma właściwości Widthi Height, które są zawsze zsynchronizowane z właściwościami ObservedWidthi ObservedHeightdołączonymi. ObserveNieruchomość prostu przywiązuje do SizeChangedzdarzenia z FrameworkElement. W uchwycie aktualizuje swoje ObservedWidthi ObservedHeightwłaściwości. Ergo, Widthand Heightmodelu widoku jest zawsze zsynchronizowany z ActualWidthand ActualHeightof the UserControl.

Może nie jest to idealne rozwiązanie (zgadzam się - DP tylko do odczytu powinny obsługiwać OneWayToSourcewiązania), ale działa i utrzymuje wzorzec MVVM. Oczywiście, ObservedWidthani ObservedHeightDP nie tylko do odczytu.

UPDATE: oto kod, który implementuje opisaną powyżej funkcjonalność:

public static class SizeObserver
{
    public static readonly DependencyProperty ObserveProperty = DependencyProperty.RegisterAttached(
        "Observe",
        typeof(bool),
        typeof(SizeObserver),
        new FrameworkPropertyMetadata(OnObserveChanged));

    public static readonly DependencyProperty ObservedWidthProperty = DependencyProperty.RegisterAttached(
        "ObservedWidth",
        typeof(double),
        typeof(SizeObserver));

    public static readonly DependencyProperty ObservedHeightProperty = DependencyProperty.RegisterAttached(
        "ObservedHeight",
        typeof(double),
        typeof(SizeObserver));

    public static bool GetObserve(FrameworkElement frameworkElement)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        return (bool)frameworkElement.GetValue(ObserveProperty);
    }

    public static void SetObserve(FrameworkElement frameworkElement, bool observe)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        frameworkElement.SetValue(ObserveProperty, observe);
    }

    public static double GetObservedWidth(FrameworkElement frameworkElement)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        return (double)frameworkElement.GetValue(ObservedWidthProperty);
    }

    public static void SetObservedWidth(FrameworkElement frameworkElement, double observedWidth)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        frameworkElement.SetValue(ObservedWidthProperty, observedWidth);
    }

    public static double GetObservedHeight(FrameworkElement frameworkElement)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        return (double)frameworkElement.GetValue(ObservedHeightProperty);
    }

    public static void SetObservedHeight(FrameworkElement frameworkElement, double observedHeight)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        frameworkElement.SetValue(ObservedHeightProperty, observedHeight);
    }

    private static void OnObserveChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
    {
        var frameworkElement = (FrameworkElement)dependencyObject;

        if ((bool)e.NewValue)
        {
            frameworkElement.SizeChanged += OnFrameworkElementSizeChanged;
            UpdateObservedSizesForFrameworkElement(frameworkElement);
        }
        else
        {
            frameworkElement.SizeChanged -= OnFrameworkElementSizeChanged;
        }
    }

    private static void OnFrameworkElementSizeChanged(object sender, SizeChangedEventArgs e)
    {
        UpdateObservedSizesForFrameworkElement((FrameworkElement)sender);
    }

    private static void UpdateObservedSizesForFrameworkElement(FrameworkElement frameworkElement)
    {
        // WPF 4.0 onwards
        frameworkElement.SetCurrentValue(ObservedWidthProperty, frameworkElement.ActualWidth);
        frameworkElement.SetCurrentValue(ObservedHeightProperty, frameworkElement.ActualHeight);

        // WPF 3.5 and prior
        ////SetObservedWidth(frameworkElement, frameworkElement.ActualWidth);
        ////SetObservedHeight(frameworkElement, frameworkElement.ActualHeight);
    }
}
Kent Boogaart
źródło
2
Zastanawiam się, czy mógłbyś zrobić jakąś sztuczkę, aby automatycznie dołączyć właściwości bez konieczności obserwowania. Ale to wygląda na dobre rozwiązanie. Dzięki!
Joe White,
1
Dzięki, Kent. Poniżej zamieściłem przykładowy kod dla tej klasy „SizeObserver”.
Scott Whitlock,
52
+1 do tego zdania: „DP tylko do odczytu powinny obsługiwać powiązania OneWayToSource”
Tristan,
3
Być może nawet lepiej jest utworzyć tylko jedną Sizewłaściwość, łączącą wysokość i szerokość. Około. 50% mniej kodu.
Gerard
1
@Gerard: To nie zadziała, ponieważ nie ma ActualSizewłasności w FrameworkElement. Jeśli chcesz mieć bezpośrednie powiązanie dołączonych właściwości, musisz utworzyć dwie właściwości, które zostaną powiązane odpowiednio z ActualWidthi ActualHeight.
dotNET,
59

Używam uniwersalnego rozwiązania, które działa nie tylko z ActualWidth i ActualHeight, ale także z dowolnymi danymi, z którymi można się powiązać przynajmniej w trybie odczytu.

Znacznik wygląda następująco, pod warunkiem, że ViewportWidth i ViewportHeight są właściwościami modelu widoku

<Canvas>
    <u:DataPiping.DataPipes>
         <u:DataPipeCollection>
             <u:DataPipe Source="{Binding RelativeSource={RelativeSource AncestorType={x:Type Canvas}}, Path=ActualWidth}"
                         Target="{Binding Path=ViewportWidth, Mode=OneWayToSource}"/>
             <u:DataPipe Source="{Binding RelativeSource={RelativeSource AncestorType={x:Type Canvas}}, Path=ActualHeight}"
                         Target="{Binding Path=ViewportHeight, Mode=OneWayToSource}"/>
          </u:DataPipeCollection>
     </u:DataPiping.DataPipes>
<Canvas>

Oto kod źródłowy elementów niestandardowych

public class DataPiping
{
    #region DataPipes (Attached DependencyProperty)

    public static readonly DependencyProperty DataPipesProperty =
        DependencyProperty.RegisterAttached("DataPipes",
        typeof(DataPipeCollection),
        typeof(DataPiping),
        new UIPropertyMetadata(null));

    public static void SetDataPipes(DependencyObject o, DataPipeCollection value)
    {
        o.SetValue(DataPipesProperty, value);
    }

    public static DataPipeCollection GetDataPipes(DependencyObject o)
    {
        return (DataPipeCollection)o.GetValue(DataPipesProperty);
    }

    #endregion
}

public class DataPipeCollection : FreezableCollection<DataPipe>
{

}

public class DataPipe : Freezable
{
    #region Source (DependencyProperty)

    public object Source
    {
        get { return (object)GetValue(SourceProperty); }
        set { SetValue(SourceProperty, value); }
    }
    public static readonly DependencyProperty SourceProperty =
        DependencyProperty.Register("Source", typeof(object), typeof(DataPipe),
        new FrameworkPropertyMetadata(null, new PropertyChangedCallback(OnSourceChanged)));

    private static void OnSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((DataPipe)d).OnSourceChanged(e);
    }

    protected virtual void OnSourceChanged(DependencyPropertyChangedEventArgs e)
    {
        Target = e.NewValue;
    }

    #endregion

    #region Target (DependencyProperty)

    public object Target
    {
        get { return (object)GetValue(TargetProperty); }
        set { SetValue(TargetProperty, value); }
    }
    public static readonly DependencyProperty TargetProperty =
        DependencyProperty.Register("Target", typeof(object), typeof(DataPipe),
        new FrameworkPropertyMetadata(null));

    #endregion

    protected override Freezable CreateInstanceCore()
    {
        return new DataPipe();
    }
}
Dmitrij Taszkinow
źródło
(poprzez odpowiedź od użytkownika 543564): To nie jest odpowiedź, ale komentarz do Dmitrija - skorzystałem z twojego rozwiązania i zadziałało świetnie. Ładne, uniwersalne rozwiązanie, które można zastosować ogólnie w różnych miejscach. Użyłem go do przekazania niektórych właściwości elementu interfejsu użytkownika (ActualHeight i ActualWidth) do mojego modelu widoku.
Marc Gravell
2
Dzięki! Pomogło mi to powiązać się z normalną właściwością „get only”. Niestety właściwość nie publikowała zdarzeń INotifyPropertyChanged. Rozwiązałem ten problem, przypisując nazwę do powiązania DataPipe i dodając do zdarzenia zmiany formantów: BindingOperations.GetBindingExpressionBase (bindingName, DataPipe.SourceProperty) .UpdateTarget ();
chilltemp
3
To rozwiązanie działało dobrze dla mnie. Moją jedyną poprawką było ustawienie BindsTwoWayByDefault na true dla FrameworkPropertyMetadata na TargetProperty DependencyProperty.
Hasani Blackwell
1
Jedynym problemem związanym z tym rozwiązaniem wydaje się być to, że łamie ono czystą hermetyzację, ponieważ Targetwłaściwość musi być zapisywalna, nawet jeśli nie można jej zmieniać z zewnątrz: - /
LUB Mapper
Dla tych, którzy wolą pakiet NuGet zamiast kopiowania i wklejania kodu: dodałem DataPipe do mojej biblioteki JungleControls typu open source. Zobacz dokumentację DataPipe .
Robert Važan
21

Jeśli ktoś jest zainteresowany, zakodowałem tutaj przybliżenie rozwiązania Kenta:

class SizeObserver
{
    #region " Observe "

    public static bool GetObserve(FrameworkElement elem)
    {
        return (bool)elem.GetValue(ObserveProperty);
    }

    public static void SetObserve(
      FrameworkElement elem, bool value)
    {
        elem.SetValue(ObserveProperty, value);
    }

    public static readonly DependencyProperty ObserveProperty =
        DependencyProperty.RegisterAttached("Observe", typeof(bool), typeof(SizeObserver),
        new UIPropertyMetadata(false, OnObserveChanged));

    static void OnObserveChanged(
      DependencyObject depObj, DependencyPropertyChangedEventArgs e)
    {
        FrameworkElement elem = depObj as FrameworkElement;
        if (elem == null)
            return;

        if (e.NewValue is bool == false)
            return;

        if ((bool)e.NewValue)
            elem.SizeChanged += OnSizeChanged;
        else
            elem.SizeChanged -= OnSizeChanged;
    }

    static void OnSizeChanged(object sender, RoutedEventArgs e)
    {
        if (!Object.ReferenceEquals(sender, e.OriginalSource))
            return;

        FrameworkElement elem = e.OriginalSource as FrameworkElement;
        if (elem != null)
        {
            SetObservedWidth(elem, elem.ActualWidth);
            SetObservedHeight(elem, elem.ActualHeight);
        }
    }

    #endregion

    #region " ObservedWidth "

    public static double GetObservedWidth(DependencyObject obj)
    {
        return (double)obj.GetValue(ObservedWidthProperty);
    }

    public static void SetObservedWidth(DependencyObject obj, double value)
    {
        obj.SetValue(ObservedWidthProperty, value);
    }

    // Using a DependencyProperty as the backing store for ObservedWidth.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty ObservedWidthProperty =
        DependencyProperty.RegisterAttached("ObservedWidth", typeof(double), typeof(SizeObserver), new UIPropertyMetadata(0.0));

    #endregion

    #region " ObservedHeight "

    public static double GetObservedHeight(DependencyObject obj)
    {
        return (double)obj.GetValue(ObservedHeightProperty);
    }

    public static void SetObservedHeight(DependencyObject obj, double value)
    {
        obj.SetValue(ObservedHeightProperty, value);
    }

    // Using a DependencyProperty as the backing store for ObservedHeight.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty ObservedHeightProperty =
        DependencyProperty.RegisterAttached("ObservedHeight", typeof(double), typeof(SizeObserver), new UIPropertyMetadata(0.0));

    #endregion
}

Możesz go używać w swoich aplikacjach. To dobrze działa. (Dzięki, Kent!)

Scott Whitlock
źródło
10

Oto inne rozwiązanie tego „błędu”, o którym pisałem tutaj na blogu:
Powiązanie OneWayToSource dla właściwości zależności ReadOnly

Działa przy użyciu dwóch właściwości zależności: Listener i Mirror. Odbiornik jest powiązany OneWay z TargetProperty iw PropertyChangedCallback aktualizuje właściwość Mirror, która jest powiązana OneWayToSource z tym, co zostało określone w Binding. Nazywam to PushBindingi można go ustawić na dowolnej właściwości zależności tylko do odczytu, takiej jak ta

<TextBlock Name="myTextBlock"
           Background="LightBlue">
    <pb:PushBindingManager.PushBindings>
        <pb:PushBinding TargetProperty="ActualHeight" Path="Height"/>
        <pb:PushBinding TargetProperty="ActualWidth" Path="Width"/>
    </pb:PushBindingManager.PushBindings>
</TextBlock>

Pobierz projekt demonstracyjny tutaj .
Zawiera kod źródłowy i krótkie przykładowe użycie lub odwiedź mój blog WPF, jeśli interesują Cię szczegóły implementacji.

Ostatnia uwaga, ponieważ .NET 4.0 jesteśmy jeszcze bardziej oddaleni od wbudowanej obsługi tego rozwiązania, ponieważ powiązanie OneWayToSource odczytuje wartość z powrotem ze źródła po jego zaktualizowaniu

Fredrik Hedblad
źródło
Odpowiedzi na temat przepełnienia stosu powinny być całkowicie niezależne. Można dołączyć link do opcjonalnych odniesień zewnętrznych, ale cały kod wymagany do odpowiedzi powinien być zawarty w samej odpowiedzi. Zaktualizuj swoje pytanie, aby można było z niego korzystać bez odwiedzania innych witryn internetowych.
Peter Duniho
4

Podoba mi się rozwiązanie Dmitrija Taszkinowa! Jednak spowodowało to awarię mojego VS w trybie projektowania. Dlatego dodałem wiersz do metody OnSourceChanged:

    private static void OnSourceChanged (DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (! ((bool) DesignerProperties.IsInDesignModeProperty.GetMetadata (typeof (DependencyObject)). DefaultValue))
            ((DataPipe) d) .OnSourceChanged (e);
    }
Dariusz Wasacz
źródło
0

Myślę, że można to zrobić nieco prościej:

xaml:

behavior:ReadOnlyPropertyToModelBindingBehavior.ReadOnlyDependencyProperty="{Binding ActualWidth, RelativeSource={RelativeSource Self}}"
behavior:ReadOnlyPropertyToModelBindingBehavior.ModelProperty="{Binding MyViewModelProperty}"

cs:

public class ReadOnlyPropertyToModelBindingBehavior
{
  public static readonly DependencyProperty ReadOnlyDependencyPropertyProperty = DependencyProperty.RegisterAttached(
     "ReadOnlyDependencyProperty", 
     typeof(object), 
     typeof(ReadOnlyPropertyToModelBindingBehavior),
     new PropertyMetadata(OnReadOnlyDependencyPropertyPropertyChanged));

  public static void SetReadOnlyDependencyProperty(DependencyObject element, object value)
  {
     element.SetValue(ReadOnlyDependencyPropertyProperty, value);
  }

  public static object GetReadOnlyDependencyProperty(DependencyObject element)
  {
     return element.GetValue(ReadOnlyDependencyPropertyProperty);
  }

  private static void OnReadOnlyDependencyPropertyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
  {
     SetModelProperty(obj, e.NewValue);
  }


  public static readonly DependencyProperty ModelPropertyProperty = DependencyProperty.RegisterAttached(
     "ModelProperty", 
     typeof(object), 
     typeof(ReadOnlyPropertyToModelBindingBehavior), 
     new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

  public static void SetModelProperty(DependencyObject element, object value)
  {
     element.SetValue(ModelPropertyProperty, value);
  }

  public static object GetModelProperty(DependencyObject element)
  {
     return element.GetValue(ModelPropertyProperty);
  }
}
eriksmith200
źródło
2
Może być trochę prostsze, ale jeśli dobrze to przeczytam, pozwala tylko na jedno takie wiązanie na elemencie. Chodzi mi o to, myślę, że przy takim podejściu nie będzie można powiązać zarówno ActualWidth, jak i ActualHeight. Tylko jeden z nich.
quetzalcoatl