WPF: Cancel a user selection in a databound ListBox?

前端 未结 8 737
独厮守ぢ
独厮守ぢ 2020-12-05 01:02

How do I cancel a user selection in a databound WPF ListBox? The source property is set correctly, but the ListBox selection is out of sync.

I have an MVVM app that

相关标签:
8条回答
  • 2020-12-05 01:11

    Got it! I am going to accept majocha's answer, because his comment underneath his answer led me to the solution.

    Here is wnat I did: I created a SelectionChanged event handler for the ListBox in code-behind. Yes, it's ugly, but it works. The code-behind also contains a module-level variable, m_OldSelectedIndex, which is initialized to -1. The SelectionChanged handler calls the ViewModel's Validate() method and gets a boolean back indicating whether the Document is valid. If the Document is valid, the handler sets m_OldSelectedIndex to the current ListBox.SelectedIndex and exits. If the document is invalid, the handler resets ListBox.SelectedIndex to m_OldSelectedIndex. Here is the code for the event handler:

    private void OnSearchResultsBoxSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var viewModel = (MainViewModel) this.DataContext;
        if (viewModel.Validate() == null)
        {
            m_OldSelectedIndex = SearchResultsBox.SelectedIndex;
        }
        else
        {
            SearchResultsBox.SelectedIndex = m_OldSelectedIndex;
        }
    }
    

    Note that there is a trick to this solution: You have to use the SelectedIndex property; it doesn't work with the SelectedItem property.

    Thanks for your help majocha, and hopefully this will help somebody else down the road. Like me, six months from now, when I have forgotten this solution...

    0 讨论(0)
  • 2020-12-05 01:14

    -snip-

    Well forget what I wrote above.

    I just did an experiment, and indeed SelectedItem goes out of sync whenever you do anything more fancy in the setter. I guess you need to wait for the setter to return, and then change the property back in your ViewModel asynchronously.

    Quick and dirty working solution (tested in my simple project) using MVVM Light helpers: In your setter, to revert to previous value of CurrentDocument

                    var dp = DispatcherHelper.UIDispatcher;
                    if (dp != null)
                        dp.BeginInvoke(
                        (new Action(() => {
                            currentDocument = previousDocument;
                            RaisePropertyChanged("CurrentDocument");
                        })), DispatcherPriority.ContextIdle);
    

    it basically queues the property change on the UI thread, ContextIdle priority will ensure it will wait for UI to be in consistent state. it Appears you cannot freely change dependency properties while inside event handlers in WPF.

    Unfortunately it creates coupling between your view model and your view and it's an ugly hack.

    To make DispatcherHelper.UIDispatcher work you need to do DispatcherHelper.Initialize() first.

    0 讨论(0)
  • 2020-12-05 01:21

    In .NET 4.5 they added the Delay field to the Binding. If you set the delay it will automatically wait to update so there is no need for the Dispatcher in the ViewModel. This works for validation of all Selector elements like the ListBox's and ComboBox's SelectedItem properties. The Delay is in milliseconds.

    <ListBox 
    ItemsSource="{Binding Path=SearchResults, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" 
    SelectedItem="{Binding Path=CurrentDocument, Mode=TwoWay, Delay=10}" />
    
    0 讨论(0)
  • 2020-12-05 01:23

    I had a very similar problem, the difference being that I am using ListView bound to an ICollectionView and was using IsSynchronizedWithCurrentItem rather than binding the SelectedItem property of the ListView. This worked well for me until I wanted to cancel the CurrentItemChanged event of the underlying ICollectionView, which left the ListView.SelectedItem out of sync with the ICollectionView.CurrentItem.

    The underlying problem here is keeping the view in sync with the view model. Obviously cancelling a selection change request in the view model is trivial. So we really just need a more responsive view as far as I'm concerned. I'd rather avoid putting kludges into my ViewModel to work around limitations of the ListView synchronization. On the other hand I'm more than happy to add some view-specific logic to my view code-behind.

    So my solution was to wire my own synchronization for the ListView selection in the code-behind. Perfectly MVVM as far as I'm concerned and more robust than the default for ListView with IsSynchronizedWithCurrentItem.

    Here is my code behind ... this allows changing the current item from the ViewModel as well. If the user clicks the list view and changes the selection, it will immediately change, then change back if something down-stream cancels the change (this is my desired behavior). Note I have IsSynchronizedWithCurrentItem set to false on the ListView. Also note that I am using async/await here which plays nicely, but requires a little double-checking that when the await returns, we are still in the same data context.

    void DataContextChangedHandler(object sender, DependencyPropertyChangedEventArgs e)
    {
        vm = DataContext as ViewModel;
        if (vm != null)
            vm.Items.CurrentChanged += Items_CurrentChanged;
    }
    
    private async void myListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var vm = DataContext as ViewModel; //for closure before await
        if (vm != null)
        {
            if (myListView.SelectedIndex != vm.Items.CurrentPosition)
            {
                var changed = await vm.TrySetCurrentItemAsync(myListView.SelectedIndex);
                if (!changed && vm == DataContext)
                {
                    myListView.SelectedIndex = vm.Items.CurrentPosition; //reset index
                }
            }
        }
    }
    
    void Items_CurrentChanged(object sender, EventArgs e)
    {
        var vm = DataContext as ViewModel; 
        if (vm != null)
            myListView.SelectedIndex = vm.Items.CurrentPosition;
    }
    

    Then in my ViewModel class I have ICollectionView named Items and this method (a simplified version is presented).

    public async Task<bool> TrySetCurrentItemAsync(int newIndex)
    {
        DataModels.BatchItem newCurrentItem = null;
        if (newIndex >= 0 && newIndex < Items.Count)
        {
            newCurrentItem = Items.GetItemAt(newIndex) as DataModels.BatchItem;
        }
    
        var closingItem = Items.CurrentItem as DataModels.BatchItem;
        if (closingItem != null)
        {
            if (newCurrentItem != null && closingItem == newCurrentItem)
                return true; //no-op change complete
    
            var closed = await closingItem.TryCloseAsync();
    
            if (!closed)
                return false; //user said don't change
        }
    
        Items.MoveCurrentTo(newCurrentItem);
        return true; 
    }
    

    The implementation of TryCloseAsync could use some kind of dialog service to elicit a close confirmation from the user.

    0 讨论(0)
  • 2020-12-05 01:25

    For future stumblers on this question, this page is what ultimately worked for me: http://blog.alner.net/archive/2010/04/25/cancelling-selection-change-in-a-bound-wpf-combo-box.aspx

    It's for a combobox, but works for a listbox just fine, since in MVVM you don't really care what type of control is calling the setter. The glorious secret, as the author mentions, is to actually change the underlying value and then change it back. It was also important to run this “undo” on a separate dispatcher operation.

    private Person _CurrentPersonCancellable;
    public Person CurrentPersonCancellable
    {
        get
        {
            Debug.WriteLine("Getting CurrentPersonCancellable.");
            return _CurrentPersonCancellable;
        }
        set
        {
            // Store the current value so that we can 
            // change it back if needed.
            var origValue = _CurrentPersonCancellable;
    
            // If the value hasn't changed, don't do anything.
            if (value == _CurrentPersonCancellable)
                return;
    
            // Note that we actually change the value for now.
            // This is necessary because WPF seems to query the 
            //  value after the change. The combo box
            // likes to know that the value did change.
            _CurrentPersonCancellable = value;
    
            if (
                MessageBox.Show(
                    "Allow change of selected item?", 
                    "Continue", 
                    MessageBoxButton.YesNo
                ) != MessageBoxResult.Yes
            )
            {
                Debug.WriteLine("Selection Cancelled.");
    
                // change the value back, but do so after the 
                // UI has finished it's current context operation.
                Application.Current.Dispatcher.BeginInvoke(
                        new Action(() =>
                        {
                            Debug.WriteLine(
                                "Dispatcher BeginInvoke " + 
                                "Setting CurrentPersonCancellable."
                            );
    
                            // Do this against the underlying value so 
                            //  that we don't invoke the cancellation question again.
                            _CurrentPersonCancellable = origValue;
                            OnPropertyChanged("CurrentPersonCancellable");
                        }),
                        DispatcherPriority.ContextIdle,
                        null
                    );
    
                // Exit early. 
                return;
            }
    
            // Normal path. Selection applied. 
            // Raise PropertyChanged on the field.
            Debug.WriteLine("Selection applied.");
            OnPropertyChanged("CurrentPersonCancellable");
        }
    }
    

    Note: The author uses ContextIdle for the DispatcherPriority for the action to undo the change. While fine, this is a lower priority than Render, which means that the change will show in the UI as the selected item momentarily changing and changing back. Using a dispatcher priority of Normal or even Send (the highest priority) preempts the display of the change. This is what I ended up doing. See here for details about the DispatcherPriority enumeration.

    0 讨论(0)
  • 2020-12-05 01:26

    If you are serious about following MVVM and don't want any code behind, and also don't like the use of the Dispatcher, which frankly is not elegant either, the following solution works for me and is by far more elegant than most of the solutions provided here.

    It is based on the notion that in code behind you are able to stop the selection using the SelectionChanged event. Well now, if this is the case, why not create a behavior for it, and associate a command with the SelectionChanged event. In the viewmodel you can then easily remember the previous selected index and the current selected index. The trick is to have binding to your viewmodel on SelectedIndex and just let that one change whenever the selection changes. But immediately after the selection really has changed, the SelectionChanged event fires which now is notified via the command to your viewmodel. Because you remember the previously selected index, you can validate it and if not correct, you move the selected index back to the original value.

    The code for the behavior is as follows:

    public class ListBoxSelectionChangedBehavior : Behavior<ListBox>
    {
        public static readonly DependencyProperty CommandProperty 
            = DependencyProperty.Register("Command",
                                         typeof(ICommand),
                                         typeof(ListBoxSelectionChangedBehavior), 
                                         new PropertyMetadata());
    
        public static DependencyProperty CommandParameterProperty
            = DependencyProperty.Register("CommandParameter",
                                          typeof(object), 
                                          typeof(ListBoxSelectionChangedBehavior),
                                          new PropertyMetadata(null));
    
        public ICommand Command
        {
            get { return (ICommand)GetValue(CommandProperty); }
            set { SetValue(CommandProperty, value); }
        }
    
        public object CommandParameter
        {
            get { return GetValue(CommandParameterProperty); }
            set { SetValue(CommandParameterProperty, value); }
        }
    
        protected override void OnAttached()
        {
            AssociatedObject.SelectionChanged += ListBoxOnSelectionChanged;
        }
    
        protected override void OnDetaching()
        {
            AssociatedObject.SelectionChanged -= ListBoxOnSelectionChanged;
        }
    
        private void ListBoxOnSelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            Command.Execute(CommandParameter);
        }
    }
    

    Using it in XAML:

    <ListBox x:Name="ListBox"
             Margin="2,0,2,2"
             ItemsSource="{Binding Taken}"
             ItemContainerStyle="{StaticResource ContainerStyle}"
             ScrollViewer.HorizontalScrollBarVisibility="Disabled"
             HorizontalContentAlignment="Stretch"
             SelectedIndex="{Binding SelectedTaskIndex, Mode=TwoWay}">
        <i:Interaction.Behaviors>
            <b:ListBoxSelectionChangedBehavior Command="{Binding SelectionChangedCommand}"/>
        </i:Interaction.Behaviors>
    </ListBox>
    

    The code that is appropriate in the viewmodel is as follows:

    public int SelectedTaskIndex
    {
        get { return _SelectedTaskIndex; }
        set { SetProperty(ref _SelectedTaskIndex, value); }
    }
    
    private void SelectionChanged()
    {
        if (_OldSelectedTaskIndex >= 0 && _SelectedTaskIndex != _OldSelectedTaskIndex)
        {
            if (Taken[_OldSelectedTaskIndex].IsDirty)
            {
                SelectedTaskIndex = _OldSelectedTaskIndex;
            }
        }
        else
        {
            _OldSelectedTaskIndex = _SelectedTaskIndex;
        }
    }
    
    public RelayCommand SelectionChangedCommand { get; private set; }
    

    In the constructor of the viewmodel:

    SelectionChangedCommand = new RelayCommand(SelectionChanged);
    

    RelayCommand is part of MVVM light. Google it if you don't know it. You need to refer to

    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
    

    and hence you need to reference System.Windows.Interactivity.

    0 讨论(0)
提交回复
热议问题