In MVVM, open context menu upon drag completion

2019-07-27 15:23发布

问题:

In a software that is highly interactive, the user can do drag and drop operations on a collection of UserControls. Upon drop, they should be presented with a ContextMenu offering some choices on how to perform the action, e.g., copy the item, or swap positions if there is another item at the drop location.

Using the Prism framework, the ideal way of implementing this would be by means of the InteractionRequestTrigger, for instance:

<i:Interaction.Triggers>
    <prism:InteractionRequestTrigger SourceObject="{Binding SomeCustomNotificationRequest, Mode=OneWay}" >
        <!-- some subclass of TriggerAction-->
            <ContextMenu>
                <MenuItem Header="Copy" />
                <MenuItem Header="Swap" />
            </ContextMenu>
        <!-- end some subclass of TriggerAction-->
    </prism:InteractionRequestTrigger>
</i:Interaction.Triggers>

This raises a doubt on whether to implement the InteractionRequestTrigger in the XAML of the ItemsControl containing the drag-and-droppable UserControl, or if it should go into the UserControl itself. In case of the latter, how would the various instances of that particular UserControl “know” which one is to react on the interaction request?

Second, the child element of an InteractionRequestTrigger must be a System.Windows.Interactivity.TriggerAction. It seems that this is not widely used for anything other than opening popup windows. The documentation on TriggerAction is quite sparse, and I don’t know how to go about implementing its Invoke method. Any pointer to documentation would be much appreciated!

回答1:

Using an InteractionRequestTrigger definitely is the way to go here, but since the ContextMenu control doesn’t reside in the same visual/logical tree as the control that defines it, one has to walk through some dark alleys.

Before coming to the actual code, I’d also highlight the reason I didn’t go for @Haukinger’s suggestion to use a popup window instead of a ContextMenu: while providing the advantage of making direct use of the properties I define for my custom Notification (plus the callback mechanism) by means of IInteractionRequestAware, I’d have had to implement some magic to make the popup window appear at the mouse cursor location. Plus, in my particular case, I’m manipulating the data model as a result of the context menu click, meaning that I’d have had to use dependency injection with the popup window in order to access the correct instance of my data model, which I frankly don’t know how to do, either.

Anyway, I got it to work smoothly with a ContextMenu. Here’s what I did. (I won’t post the obvious boilerplate code; just keep in mind that I’m using Prism with the GongSolutions Drag and Drop Library.

A) Drop Handler

The drop handler class must be augmented with an event that we can call upon drop. This event will later be consumed by the view model belonging to the view that’s hosting the drag and drop action.

public class MyCustomDropHandler : IDropTarget {
  public event EventHandler<DragDropContextMenuEventArgs> DragDropContextMenuEvent;

  public void Drop(IDropInfo dropInfo) {
    // do more things if you like to

    DragDropContextMenuEvent?.Invoke(this, new DragDropContextMenuEventArgs() {
      // set all the properties you need to
    });
  }

  // don't forget about the other methods of IDropTarget
}

The DragDropContextMenuEventArgs is straightforward; refer to the Prism manual if you need assistance.

B) Interaction Request

In my case, I’ve got a custom UserControl that’s hosting the elements I want to drag and drop. Its view model needs an InteractionRequest as well as an object that gathers the arguments to pass along with a click command on the ContextMenu. This is because the ContextMenu doesn’t implement IInteractionRequestAware, which means we’ll have to use the standard way of invoking command actions. I’ve simply used the DragDropContextMenuEventArgs defined above, since it’s an object that already hosts all the required properties.

B.1) View Model

This makes use of a custom notification request with a corresponding interface, the implementation of which is straightforward. I’ll skip the code here to keep this entry more manageable. There’s a lot on StackExchange on the topic; see, for instance, the link @Haukinger provided as a comment to my original question.

public InteractionRequest<IDragDropContextMenuNotification> DragDropContextMenuNotificationRequest { get; set; }

public DragDropContextMenuEventArgs DragDropActionElements { get; set; }

public MyContainerControlConstructor() {
  DragDropContextMenuNotificationRequest = new InteractionRequest<IDragDropContextMenuNotification>();
  MyCustomDropHandler.DragDropContextMenuEvent += OnDragDropContextMenuShown;
}

private void OnDragDropContextMenuShown(object sender, DragDropContextMenuEventArgs e) {
  DragDropActionElements = e;
  DragDropContextMenuNotificationRequest.Raise(new DragDropContextMenuNotification {
    // you can set your properties here, but it won’t matter much
    // since the ContextMenu can’t consume these
  });
}

B.2) XAML

As a sibling to the design elements of MyContainerControl, we define the InteractionTrigger for the notification request.

<i:Interaction.Triggers>
  <prism:InteractionRequestTrigger SourceObject="{Binding DragDropContextMenuNotificationRequest, ElementName=MyContainerControlRoot, Mode=OneWay}">
    <local:ContextMenuAction ContextMenuDataContext="{Binding Data, Source={StaticResource Proxy}}">
      <local:ContextMenuAction.ContextMenuContent>
        <ContextMenu>
          <MenuItem Header="Move">
            <i:Interaction.Triggers>
              <i:EventTrigger EventName="Click">
                <prism:InvokeCommandAction Command="{Binding MoveCommand}"
                                           CommandParameter="{Binding DragDropActionElements}" />
              </i:EventTrigger>
            </i:Interaction.Triggers>
          </MenuItem>
          <MenuItem Header="Copy">
            <i:Interaction.Triggers>
              <i:EventTrigger EventName="Click">
                <prism:InvokeCommandAction Command="{Binding CopyCommand}"
                                           CommandParameter="{Binding DragDropActionElements}" />
              </i:EventTrigger>
            </i:Interaction.Triggers>
          </MenuItem>
        </ContextMenu>
      </local:ContextMenuAction.ContextMenuContent>
    </local:ContextMenuAction>
  </prism:InteractionRequestTrigger>
</i:Interaction.Triggers>

C) Trigger Action and Other Magic

This is where things get tricky. First of all, we need to define a custom TriggerAction that invokes our ContextMenu.

C.1) Custom Trigger Action

The ContextMenuContent dependency property makes sure that we can define a ContextMenu as content of our custom TriggerAction. In the Invoke method, after a couple of safety checks, we can make the context menu pop up. (Mouse location and destroying the context menu after the user clicked an option is handled by WPF.)

public class ContextMenuAction : TriggerAction<FrameworkElement> {
  public static readonly DependencyProperty ContextMenuContentProperty =
    DependencyProperty.Register("ContextMenuContent",
                                typeof(FrameworkElement),
                                typeof(ContextMenuAction));

  public FrameworkElement ContextMenuContent {
    get { return (FrameworkElement)GetValue(ContextMenuContentProperty); }
    set { SetValue(ContextMenuContentProperty, value); }
  }

  public static readonly DependencyProperty ContextMenuDataContextProperty =
    DependencyProperty.Register("ContextMenuDataContext",
                                typeof(FrameworkElement),
                                typeof(ContextMenuAction));

  public FrameworkElement ContextMenuDataContext {
    get { return (FrameworkElement)GetValue(ContextMenuDataContextProperty); }
    set { SetValue(ContextMenuDataContextProperty, value); }
  }

  protected override void Invoke(object parameter) {
    if (!(parameter is InteractionRequestedEventArgs args)) {
      return;
    }

    if (!(ContextMenuContent is ContextMenu contextMenu)) {
      return;
    }

    contextMenu.DataContext = ContextMenuDataContext;
    contextMenu.IsOpen = true;
  }
}

C.2) Binding Proxy

You’ll note that there’s a second dependency property called ContextMenuDataContext. This is the solution to a problem that arises from the fact that a ContextMenu doesn’t live inside the same visual/logical tree as the rest of the view. Figuring out this solution took me almost as long as all the rest of this combined, and I wouldn’t have gotten there if it wasn’t for @Cameron-McFarland’s answer to Cannot find source for binding with reference 'RelativeSource FindAncestor' as well as the WPF Tutorial on Context Menus.

In fact, I’ll refer to those resources for the code. Suffice it to say that we need to use a binding proxy to set the ContextMenu’s DataContext. I resolved doing this programmatically via a dependency property in my custom TriggerAction, since the DataContext property of the ContextMenu needs the PlacementTarget mechanism to work properly, which isn’t possible in this case, since the TriggerAction (as element containing the ContextMenu) doesn’t have its own data context.

D) Wrapping everything up

In retrospective, it wasn’t that hard to implement. With the above in place, it’s child’s play to hook up some commands defined in the view model of the view that hosts the MyContainerControl and pass those via the usual binding mechanism and dependency properties. This allows for manipulation of the data at its very root.

I’m happy about this solution; what I don’t like that much is that communication is doubled when the custom interaction request notification is raised. But this can’t be helped, since the information gathered in the drop handler must somehow reach the place where we react upon the different choices the user can make on the context menu.