Two Way Binding to AvalonEdit Document Text using MVVM

筅森魡賤 提交于 2019-11-29 20:41:33
123 456 789 0

Create a Behavior class that will attach the TextChanged event and will hook up the dependency property that is bound to the ViewModel.

AvalonTextBehavior.cs

public sealed class AvalonEditBehaviour : Behavior<TextEditor> 
{
    public static readonly DependencyProperty GiveMeTheTextProperty =
        DependencyProperty.Register("GiveMeTheText", typeof(string), typeof(AvalonEditBehaviour), 
        new FrameworkPropertyMetadata(default(string), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, PropertyChangedCallback));

    public string GiveMeTheText
    {
        get { return (string)GetValue(GiveMeTheTextProperty); }
        set { SetValue(GiveMeTheTextProperty, value); }
    }

    protected override void OnAttached()
    {
        base.OnAttached();
        if (AssociatedObject != null)
            AssociatedObject.TextChanged += AssociatedObjectOnTextChanged;
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        if (AssociatedObject != null)
            AssociatedObject.TextChanged -= AssociatedObjectOnTextChanged;
    }

    private void AssociatedObjectOnTextChanged(object sender, EventArgs eventArgs)
    {
        var textEditor = sender as TextEditor;
        if (textEditor != null)
        {
            if (textEditor.Document != null)
                GiveMeTheText = textEditor.Document.Text;
        }
    }

    private static void PropertyChangedCallback(
        DependencyObject dependencyObject,
        DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs)
    {
        var behavior = dependencyObject as AvalonEditBehaviour;
        if (behavior.AssociatedObject!= null)
        {
            var editor = behavior.AssociatedObject as TextEditor;
            if (editor.Document != null)
            {
                var caretOffset = editor.CaretOffset;
                editor.Document.Text = dependencyPropertyChangedEventArgs.NewValue.ToString();
                editor.CaretOffset = caretOffset;
            }
        }
    }
}

View.xaml

 <avalonedit:TextEditor
        WordWrap="True"
        ShowLineNumbers="True"
        LineNumbersForeground="Magenta"
        x:Name="textEditor"
        FontFamily="Consolas"
        SyntaxHighlighting="XML"
        FontSize="10pt">
        <i:Interaction.Behaviors>
            <controls:AvalonEditBehaviour GiveMeTheText="{Binding Test, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
        </i:Interaction.Behaviors>
    </avalonedit:TextEditor>

where i is defined as "xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity""

ViewModel.cs

    private string _test;
    public string Test
    {
        get { return _test; }
        set { _test = value; }
    }

That should give you the Text and push it back to the ViewModel.

Another nice OOP approach is to download the source code of AvalonEdit (it's open sourced), and creating a new class that inherits from TextEditor class (the main editor of AvalonEdit).

What you want to do is basically override the Text property and implement an INotifyPropertyChanged version of it, using dependency property for the Text property and raising the OnPropertyChanged event when text is changed (this can be done by overriding the OnTextChanged() method.

Here's a quick code (fully working) example that works for me:

public class BindableTextEditor : TextEditor, INotifyPropertyChanged
{
    /// <summary>
    /// A bindable Text property
    /// </summary>
    public new string Text
    {
        get { return base.Text; }
        set { base.Text = value; }
    }

    /// <summary>
    /// The bindable text property dependency property
    /// </summary>
    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register("Text", typeof(string), typeof(BindableTextEditor), new PropertyMetadata((obj, args) =>
    {
        var target = (BindableTextEditor)obj;
        target.Text = (string)args.NewValue;
    }));

    protected override void OnTextChanged(EventArgs e)
    {
        RaisePropertyChanged("Text");
        base.OnTextChanged(e);
    }

    /// <summary>
    /// Raises a property changed event
    /// </summary>
    /// <param name="property">The name of the property that updates</param>
    public void RaisePropertyChanged(string property)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(property));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
}

For those wondering about an MVVM implementation using AvalonEdit, here is one of the ways it can be done, first we have the class

/// <summary>
/// Class that inherits from the AvalonEdit TextEditor control to 
/// enable MVVM interaction. 
/// </summary>
public class CodeEditor : TextEditor, INotifyPropertyChanged
{
    // Vars.
    private static bool canScroll = true;

    /// <summary>
    /// Default constructor to set up event handlers.
    /// </summary>
    public CodeEditor()
    {
        // Default options.
        FontSize = 12;
        FontFamily = new FontFamily("Consolas");
        Options = new TextEditorOptions
        {
            IndentationSize = 3,
            ConvertTabsToSpaces = true
        };
    }

    #region Text.
    /// <summary>
    /// Dependancy property for the editor text property binding.
    /// </summary>
    public static readonly DependencyProperty TextProperty =
         DependencyProperty.Register("Text", typeof(string), typeof(CodeEditor),
         new PropertyMetadata((obj, args) =>
         {
             CodeEditor target = (CodeEditor)obj;
             target.Text = (string)args.NewValue;
         }));

    /// <summary>
    /// Provide access to the Text.
    /// </summary>
    public new string Text
    {
        get { return base.Text; }
        set { base.Text = value; }
    }

    /// <summary>
    /// Return the current text length.
    /// </summary>
    public int Length
    {
        get { return base.Text.Length; }
    }

    /// <summary>
    /// Override of OnTextChanged event.
    /// </summary>
    protected override void OnTextChanged(EventArgs e)
    {
        RaisePropertyChanged("Length");
        base.OnTextChanged(e);
    }

    /// <summary>
    /// Event handler to update properties based upon the selection changed event.
    /// </summary>
    void TextArea_SelectionChanged(object sender, EventArgs e)
    {
        this.SelectionStart = SelectionStart;
        this.SelectionLength = SelectionLength;
    }

    /// <summary>
    /// Event that handles when the caret changes.
    /// </summary>
    void TextArea_CaretPositionChanged(object sender, EventArgs e)
    {
        try
        {
            canScroll = false;
            this.TextLocation = TextLocation;
        }
        finally
        {
            canScroll = true;
        }
    }
    #endregion // Text.

    #region Caret Offset.
    /// <summary>
    /// DependencyProperty for the TextEditorCaretOffset binding. 
    /// </summary>
    public static DependencyProperty CaretOffsetProperty =
        DependencyProperty.Register("CaretOffset", typeof(int), typeof(CodeEditor),
        new PropertyMetadata((obj, args) =>
        {
            CodeEditor target = (CodeEditor)obj;
            if (target.CaretOffset != (int)args.NewValue)
                target.CaretOffset = (int)args.NewValue;
        }));

    /// <summary>
    /// Access to the SelectionStart property.
    /// </summary>
    public new int CaretOffset
    {
        get { return base.CaretOffset; }
        set { SetValue(CaretOffsetProperty, value); }
    }
    #endregion // Caret Offset.

    #region Selection.
    /// <summary>
    /// DependencyProperty for the TextLocation. Setting this value 
    /// will scroll the TextEditor to the desired TextLocation.
    /// </summary>
    public static readonly DependencyProperty TextLocationProperty =
         DependencyProperty.Register("TextLocation", typeof(TextLocation), typeof(CodeEditor),
         new PropertyMetadata((obj, args) =>
         {
             CodeEditor target = (CodeEditor)obj;
             TextLocation loc = (TextLocation)args.NewValue;
             if (canScroll)
                 target.ScrollTo(loc.Line, loc.Column);
         }));

    /// <summary>
    /// Get or set the TextLocation. Setting will scroll to that location.
    /// </summary>
    public TextLocation TextLocation
    {
        get { return base.Document.GetLocation(SelectionStart); }
        set { SetValue(TextLocationProperty, value); }
    }

    /// <summary>
    /// DependencyProperty for the TextEditor SelectionLength property. 
    /// </summary>
    public static readonly DependencyProperty SelectionLengthProperty =
         DependencyProperty.Register("SelectionLength", typeof(int), typeof(CodeEditor),
         new PropertyMetadata((obj, args) =>
         {
             CodeEditor target = (CodeEditor)obj;
             if (target.SelectionLength != (int)args.NewValue)
             {
                 target.SelectionLength = (int)args.NewValue;
                 target.Select(target.SelectionStart, (int)args.NewValue);
             }
         }));

    /// <summary>
    /// Access to the SelectionLength property.
    /// </summary>
    public new int SelectionLength
    {
        get { return base.SelectionLength; }
        set { SetValue(SelectionLengthProperty, value); }
    }

    /// <summary>
    /// DependencyProperty for the TextEditor SelectionStart property. 
    /// </summary>
    public static readonly DependencyProperty SelectionStartProperty =
         DependencyProperty.Register("SelectionStart", typeof(int), typeof(CodeEditor),
         new PropertyMetadata((obj, args) =>
         {
             CodeEditor target = (CodeEditor)obj;
             if (target.SelectionStart != (int)args.NewValue)
             {
                 target.SelectionStart = (int)args.NewValue;
                 target.Select((int)args.NewValue, target.SelectionLength);
             }
         }));

    /// <summary>
    /// Access to the SelectionStart property.
    /// </summary>
    public new int SelectionStart
    {
        get { return base.SelectionStart; }
        set { SetValue(SelectionStartProperty, value); }
    }
    #endregion // Selection.

    #region Properties.
    /// <summary>
    /// The currently loaded file name. This is bound to the ViewModel 
    /// consuming the editor control.
    /// </summary>
    public string FilePath
    {
        get { return (string)GetValue(FilePathProperty); }
        set { SetValue(FilePathProperty, value); }
    }

    // Using a DependencyProperty as the backing store for FilePath. 
    // This enables animation, styling, binding, etc...
    public static readonly DependencyProperty FilePathProperty =
         DependencyProperty.Register("FilePath", typeof(string), typeof(CodeEditor),
         new PropertyMetadata(String.Empty, OnFilePathChanged));
    #endregion // Properties.

    #region Raise Property Changed.
    /// <summary>
    /// Implement the INotifyPropertyChanged event handler.
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;
    public void RaisePropertyChanged([CallerMemberName] string caller = null)
    {
        var handler = PropertyChanged;
        if (handler != null)
            PropertyChanged(this, new PropertyChangedEventArgs(caller));
    }
    #endregion // Raise Property Changed.
}

Then in your view where you want to have AvalonEdit, you can do

...
<Grid>
    <Local:CodeEditor 
        x:Name="CodeEditor" 
        FilePath="{Binding FilePath, 
            Mode=TwoWay, 
            NotifyOnSourceUpdated=True, 
            NotifyOnTargetUpdated=True}"
        WordWrap="{Binding WordWrap, 
            Mode=TwoWay, 
            NotifyOnSourceUpdated=True, 
            NotifyOnTargetUpdated=True}"
        ShowLineNumbers="{Binding ShowLineNumbers, 
            Mode=TwoWay, 
            NotifyOnSourceUpdated=True, 
            NotifyOnTargetUpdated=True}"
        SelectionLength="{Binding SelectionLength, 
            Mode=TwoWay, 
            NotifyOnSourceUpdated=True, 
            NotifyOnTargetUpdated=True}" 
        SelectionStart="{Binding SelectionStart, 
            Mode=TwoWay, 
            NotifyOnSourceUpdated=True, 
            NotifyOnTargetUpdated=True}"
        TextLocation="{Binding TextLocation, 
            Mode=TwoWay,
            NotifyOnSourceUpdated=True, 
            NotifyOnTargetUpdated=True}"/>
</Grid>

Where this can be placed in a UserControl or Window or what ever, then in the ViewModel for this view we have (where I am using Caliburn Micro for the MVVM framework stuff)

    public string FilePath
    {
        get { return filePath; }
        set
        {
            if (filePath == value)
                return;
            filePath = value;
            NotifyOfPropertyChange(() => FilePath);
        }
    }

    /// <summary>
    /// Should wrap?
    /// </summary>
    public bool WordWrap
    {
        get { return wordWrap; }
        set
        {
            if (wordWrap == value)
                return;
            wordWrap = value;
            NotifyOfPropertyChange(() => WordWrap);
        }
    }

    /// <summary>
    /// Display line numbers?
    /// </summary>
    public bool ShowLineNumbers
    {
        get { return showLineNumbers; }
        set
        {
            if (showLineNumbers == value)
                return;
            showLineNumbers = value;
            NotifyOfPropertyChange(() => ShowLineNumbers);
        }
    }

    /// <summary>
    /// Hold the start of the currently selected text.
    /// </summary>
    private int selectionStart = 0;
    public int SelectionStart
    {
        get { return selectionStart; }
        set
        {
            selectionStart = value;
            NotifyOfPropertyChange(() => SelectionStart);
        }
    }

    /// <summary>
    /// Hold the selection length of the currently selected text.
    /// </summary>
    private int selectionLength = 0;
    public int SelectionLength
    {
        get { return selectionLength; }
        set
        {
            selectionLength = value;
            UpdateStatusBar();
            NotifyOfPropertyChange(() => SelectionLength);
        }
    }

    /// <summary>
    /// Gets or sets the TextLocation of the current editor control. If the 
    /// user is setting this value it will scroll the TextLocation into view.
    /// </summary>
    private TextLocation textLocation = new TextLocation(0, 0);
    public TextLocation TextLocation
    {
        get { return textLocation; }
        set
        {
            textLocation = value;
            UpdateStatusBar();
            NotifyOfPropertyChange(() => TextLocation);
        }
    }

And that's it! Done.

I hope this helps.


Edit. for all those looking for an example of working with AvalonEdit using MVVM, you can download a very basic editor application from http://1drv.ms/1E5nhCJ.

Notes. This application actually creates a MVVM friendly editor control by inheriting from the AvalonEdit standard control and adds additional Dependency Properties to it as appropriate - *this is different to what I have shown in the answer given above*. However, in the solution I have also shown how this can be done (as I describe in the answer above) using Attached Properties and there is code in the solution under the Behaviors namespace. What is actually implemented however, is the first of the above approaches.

Please also be aware that there is some code in the solution that is unused. This *sample* was a stripped back version of a larger application and I have left some code in as it could be useful to the user who downloads this example editor. In addition to the above, in the example code I access the Text by binding to document, there are some purest that may argue that this is not pure-MVVM, and I say "okay, but it works". Some times fighting this pattern is not the way to go.

I hope this of use to some of you.

Here is my very first StackOverflow post!

I was able to establish a two-way binding with the latest version of AvalonEdit using an object oriented approach which combines Jonathan Perry's answer and 123 456 789 0's answer. This allows a direct two-way binding without the need for behaviors.

Here is the source code...

public class BindableAvalonEditor : ICSharpCode.AvalonEdit.TextEditor, INotifyPropertyChanged
{
    /// <summary>
    /// A bindable Text property
    /// </summary>
    public new string Text
    {
        get
        {
            return (string)GetValue(TextProperty);
        }
        set
        {
            SetValue(TextProperty, value);
            RaisePropertyChanged("Text");
        }
    }

    /// <summary>
    /// The bindable text property dependency property
    /// </summary>
    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(
            "Text",
            typeof(string),
            typeof(BindableAvalonEditor),
            new FrameworkPropertyMetadata
            {
                DefaultValue = default(string),
                BindsTwoWayByDefault = true,
                PropertyChangedCallback = OnDependencyPropertyChanged
            }
        );

    protected static void OnDependencyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
    {
        var target = (BindableAvalonEditor)obj;

        if (target.Document != null)
        {
            var caretOffset = target.CaretOffset;
            var newValue = args.NewValue;

            if (newValue == null)
            {
                newValue = "";
            }

            target.Document.Text = (string)newValue;
            target.CaretOffset = Math.Min(caretOffset, newValue.ToString().Length);
        }
    }

    protected override void OnTextChanged(EventArgs e)
    {
        if (this.Document != null)
        {
            Text = this.Document.Text;
        }

        base.OnTextChanged(e);
    }

    /// <summary>
    /// Raises a property changed event
    /// </summary>
    /// <param name="property">The name of the property that updates</param>
    public void RaisePropertyChanged(string property)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(property));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
}

I like none of these solutions. The reason the author didn't create a dependency property on Text is for performance reason. Working around it by creating an attached property means the text string must be recreated on every key stroke. On a 100mb file, this can be a serious performance issue. Internally, it only uses a document buffer and will never create the full string unless requested.

It exposes another property, Document, which is a dependency property, and it exposes the Text property to construct the string only when needed. Although you can bind to it, it would mean designing your ViewModel around a UI element which defeats the purpose of having a ViewModel UI-agnostic. I don't like that option either.

Honestly, the cleanest(ish) solution is to create 2 events in your ViewModel, one to display the text and one to update the text. Then you write a one-line event handler in your code-behind, which is fine since it's purely UI-related. That way, you construct and assign the full document string only when it's truly needed. Additionally, you don't even need to store (nor update) the text in the ViewModel. Just raise DisplayScript and UpdateScript when it is needed.

It's not an ideal solution, but there are less drawbacks than any other method I've seen.

TextBox also faces a similar issue, and it solves it by internally using a DeferredReference object that constructs the string only when it is really needed. That class is internal and not available to the public, and the Binding code is hard-coded to handle DeferredReference in a special way. Unfortunately there doesn't seen to be any way of solving the problem in the same way as TextBox -- perhaps unless TextEditor would inherit from TextBox.

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!