Empurrando novamente as propriedades da GUI somente leitura no ViewModel

124

Eu quero escrever um ViewModel que sempre saiba o estado atual de algumas propriedades de dependência somente leitura do modo de exibição.

Especificamente, minha GUI contém um FlowDocumentPageViewer, que exibe uma página por vez de um FlowDocument. FlowDocumentPageViewer expõe duas propriedades de dependência somente leitura chamadas CanGoToPreviousPage e CanGoToNextPage. Quero que meu ViewModel sempre saiba os valores dessas duas propriedades de exibição.

Imaginei que poderia fazer isso com uma ligação de dados OneWayToSource:

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

Se isso fosse permitido, seria perfeito: sempre que a propriedade CanGoToNextPage do FlowDocumentPageViewer fosse alterada, o novo valor seria empurrado para a propriedade NextPageAvailable do ViewModel, que é exatamente o que eu quero.

Infelizmente, isso não é compilado: Eu recebo um erro dizendo que a propriedade 'CanGoToPreviousPage' é somente leitura e não pode ser definida a partir da marcação. Aparentemente, as propriedades somente leitura não suportam nenhum tipo de ligação de dados, nem mesmo a ligação de dados que é somente leitura em relação a essa propriedade.

Eu poderia fazer com que as propriedades do meu ViewModel fossem DependencyProperties e fazer uma ligação OneWay ir para o outro lado, mas não sou louco pela violação de separação de preocupações (o ViewModel precisaria de uma referência ao View, que a ligação de dados MVVM deve evitar )

O FlowDocumentPageViewer não expõe um evento CanGoToNextPageChanged e não conheço nenhuma maneira de obter notificações de alterações de um DependencyProperty, antes de criar outro DependencyProperty ao qual vincular, o que parece ser um exagero aqui.

Como posso manter meu ViewModel informado sobre alterações nas propriedades somente leitura da exibição?

Joe White
fonte

Respostas:

152

Sim, eu fiz isso no passado com as propriedades ActualWidthe ActualHeight, as quais são somente leitura. Eu criei um comportamento anexado que possui ObservedWidthe ObservedHeightanexou propriedades. Ele também possui uma Observepropriedade usada para fazer a conexão inicial. O uso fica assim:

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

Assim, o modelo de exibição tem Widthe Heightpropriedades que estão sempre em sincronia com os ObservedWidthe ObservedHeightanexadas propriedades. A Observepropriedade simplesmente se anexa ao SizeChangedevento do FrameworkElement. No identificador, ele atualiza suas propriedades ObservedWidthe ObservedHeight. Portanto, o modelo do Widthe Heightda vista está sempre sincronizado com o ActualWidthe ActualHeightdo UserControl.

Talvez não seja a solução perfeita (eu concordo - os DPs somente leitura devem suportar OneWayToSourceligações), mas funciona e mantém o padrão MVVM. Obviamente, os PDs ObservedWidthe nãoObservedHeight são leitura.

UPDATE: aqui está o código que implementa a funcionalidade descrita acima:

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
fonte
2
Gostaria de saber se você poderia fazer alguns truques para anexar automaticamente as propriedades, sem a necessidade de Observar. Mas isso parece uma boa solução. Obrigado!
Joe White
1
Obrigado Kent. Publiquei um exemplo de código abaixo para esta classe "SizeObserver".
21119 Scott Whitlock
52
+1 a este sentimento: "PDs somente leitura devem suportar ligações OneWayToSource"
Tristan
3
Talvez seja ainda melhor criar apenas uma Sizepropriedade, combinando altura e largura. Aproximadamente. 50% menos código.
Gerard
1
@ Gerard: Isso não vai funcionar porque não há ActualSizepropriedades FrameworkElement. Se você desejar a ligação direta das propriedades anexadas, deverá criar duas propriedades a serem vinculadas ActualWidthe ActualHeightrespectivamente.
dotNET
58

Eu uso uma solução universal que funciona não apenas com ActualWidth e ActualHeight, mas também com qualquer dado que você possa vincular pelo menos no modo de leitura.

A marcação é assim, desde que ViewportWidth e ViewportHeight sejam propriedades do modelo de visualização

<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>

Aqui está o código-fonte para os elementos personalizados

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();
    }
}
Dmitry Tashkinov
fonte
(através de uma resposta do usuário543564): Esta não é uma resposta, mas um comentário para Dmitry - usei sua solução e ela funcionou muito bem. Ótima solução universal que pode ser usada genericamente em diferentes lugares. Usei-o para enviar algumas propriedades do elemento da interface do usuário (ActualHeight e ActualWidth) para o meu viewmodel.
Marc Gravell
2
Obrigado! Isso me ajudou a vincular a uma propriedade normal de obter apenas. Infelizmente, a propriedade não publicou eventos INotifyPropertyChanged. Resolvi isso atribuindo um nome à ligação DataPipe e adicionando o seguinte aos eventos alterados dos controles: BindingOperations.GetBindingExpressionBase (bindingName, DataPipe.SourceProperty) .UpdateTarget ();
chilltemp
3
Esta solução funcionou bem para mim. Meu único ajuste foi definir BindsTwoWayByDefault como true para o FrameworkPropertyMetadata no TargetProperty DependencyProperty.
amigos estão
1
A única reclamação sobre esta solução parece ser que ela quebra o encapsulamento limpo, pois a Targetpropriedade precisa ser gravada, mesmo que não deva ser alterada do lado de fora: - /
OR Mapper
Para aqueles que preferem o pacote NuGet ao invés de copiar e colar o código: adicionei o DataPipe à minha biblioteca JungleControls de código aberto. Consulte a documentação do DataPipe .
Robert Važan
21

Se alguém mais estiver interessado, codifiquei aqui uma aproximação da solução de Kent:

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
}

Sinta-se livre para usá-lo em seus aplicativos. Isso funciona bem. (Obrigado Kent!)

Scott Whitlock
fonte
10

Aqui está outra solução para esse "bug" sobre o qual escrevi aqui:
Ligação OneWayToSource para propriedade de dependência ReadOnly

Ele funciona usando duas Propriedades de Dependência, Listener e Mirror. O ouvinte está vinculado OneWay ao TargetProperty e no PropertyChangedCallback atualiza a propriedade Mirror, que está vinculada OneWayToSource ao que foi especificado na ligação. Eu chamo PushBindinge ele pode ser definido em qualquer propriedade de dependência somente leitura como esta

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

Baixe o Projeto Demo Aqui .
Ele contém código fonte e uso de amostra curta, ou visite meu blog do WPF se você estiver interessado nos detalhes da implementação.

Uma última observação: desde o .NET 4.0, estamos ainda mais distantes do suporte interno para isso, uma vez que a Ligação OneWayToSource lê o valor de volta do Source após a atualização.

Fredrik Hedblad
fonte
As respostas no estouro de pilha devem ser totalmente independentes. É bom incluir um link para referências externas opcionais, mas todo o código necessário para a resposta deve ser incluído na própria resposta. Atualize sua pergunta para que possa ser usada sem visitar nenhum outro site.
Peter Duniho
4

Eu gosto da solução de Dmitry Tashkinov! No entanto, ele travou meu VS no modo de design. É por isso que adicionei uma linha ao método OnSourceChanged:

    void estático privado OnSourceChanged (DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (! ((bool) DesignerProperties.IsInDesignModeProperty.GetMetadata (typeof (DependencyObject)). DefaultValue))
            ((DataPipe) d) .OnSourceChanged (e);
    }
Dariusz Wasacz
fonte
0

Eu acho que isso pode ser feito um pouco mais simples:

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
fonte
2
Pode ser um pouco mais simples, mas se eu o ler bem, ele permitirá apenas uma ligação desse tipo no Element. Quero dizer, acho que, com essa abordagem, você não poderá vincular ActualWidth e ActualHeight. Apenas um deles.
quetzalcoatl