Reputation: 13223
I have a Window
containing several buttons. I need to track when a button is pressed (MouseDown
) - which starts an operation - and when it is released or left (MouseUp
or MouseLeave
) which ends/cancels the action.
Canceling may take a while and during this time i need to prevent the user from clicking another button. This is a classic case for busy indication. I could show a global busy indicator with an overlay when the operation ends. However, usability wise there's a better solution but i`m struggling with finding a way to implement it.
1) initial window state:
2) as soon as the button is pressed the rest of the window should be greyed-out (or blur effect) and be "disabled". The rest of the window includes several other input controls as well (TabControl
, Notification View with Button
, ToggleButton
etc. -- all need to be disabled. So it's really "All children but the one Button
which got clicked"))
3) when the button is released, the operation is canceled, but since this can take a while, busy indication should be shown in the button (i know how to do this part)
4) as soon as the operation ends the window reverts to it's initial state:
Window
. So merely setting Panel.ZIndex
to a constant for all of them is not going to work.Window
may not trigger mouse events like MouseUp
or MouseLeave
(otherwise the operation would immediately be canceled).Disabling the window: I have researched using the IsEnabled
property. However, by default it propagates to all child elements and you can't override the value of one specific child.
I have actually found a way to change this behavior (here on SO) but i fear that changing the behavior it might mess up stuff in other places (also, it's really unexpected - future developers will regard it as magic). Also, i don't like how the controls look in disabled state, and messing with this would be very much work.
So i would prefer using some kind of "overlay" but which greys-out the stuff behind (i guess color=grey, opacity=0.5
combined with IsHitTestVisible=True
would be a start?). But the problem here is that i don't know how to get the one button on top of the overlay while all the rest of the window stays behind...
EDIT: Using ZIndex seems only work on items on the same level (at least with a grid). So this is not an option, either :(
Upvotes: 4
Views: 2183
Reputation: 13223
So after some more deliberation i thought it might be better to just add an overlay in the adorner layer - around the control. I've found out that someone has already done that, so my solution is heavily based on that work: http://spin.atomicobject.com/2012/07/16/making-wpf-controls-modal-with-adorners/
Here's my adorner (if you've got better name for it your suggestion is welcome!):
public class ElementFocusingAdorner : Adorner
{
private readonly SolidColorBrush WhiteBrush =
new SolidColorBrush(Colors.White);
public ElementFocusingAdorner(UIElement adornedElement)
: base(adornedElement) { }
protected override void OnRender(DrawingContext drawingContext)
{
drawingContext.PushOpacity(0.5);
drawingContext.DrawRectangle(WhiteBrush, null, ComputeWindowRect());
base.OnRender(drawingContext);
}
protected override Geometry GetLayoutClip(Size layoutSlotSize)
{
// Add a group that includes the whole window except the adorned control
var group = new GeometryGroup();
group.Children.Add(new RectangleGeometry(ComputeWindowRect()));
group.Children.Add(new RectangleGeometry(new Rect(layoutSlotSize)));
return group;
}
Rect ComputeWindowRect()
{
Window window = Window.GetWindow(AdornedElement);
if (window == null)
{
if (DesignerProperties.GetIsInDesignMode(AdornedElement))
{
return new Rect();
}
throw new NotSupportedException(
"AdornedElement does not belong to a Window.");
}
Point topLeft = window.TransformToVisual(AdornedElement)
.Transform(new Point(0, 0));
return new Rect(topLeft, window.RenderSize);
}
}
This Adorner
needs to be added to the top AdornerLayer
. The Window
has got one (at least its default ControlTemplate
...). Or alternatively, if you want just some part to be covered, you need to add an AdornerLayer
there (by placing an AdornerDecorator
around the UIElement
) and add the Adorner
to the AdornerLayer
there.
Not working yet: when i'm adding the Adorner
in the Loaded
event handler the adorner is not drawn correctly (a bit too small). As soon as the window is resized the adorner fits perfect. Going to have to post a question here to find out what's causing it...
Upvotes: 1
Reputation: 13223
So after a bit of fiddling and a lot of thinking i've got a working solution ("prototype"):
Rectangle
Opacity=0.5
and Background=White
which covers entire Window
grid
)Canvas
, which also covers the entire Window
and is above the Rectangle
. This is used to host the button which is clickedPanel
and added to the Canvas
.
Canvas.Left
, Canvas.Top
, Width
and Height
accordinglyPanel
(at same index).What's missing: after having moved a control to canvas and then back to panel, it's not being resized properly, because when moving to canvas a fixed size is set. So basically when moving it back to the panel a bunch of properties would need to be reset to achieve the same behavior as before.
First, let's start with the view (Window.xaml):
<Window x:Class="PTTBusyIndication.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="525"
Height="350">
<Grid>
<UniformGrid Columns="2" Rows="2">
<Button>1</Button>
<Grid Background="LightBlue"
MouseDown="UIElement_OnMouseDown"
MouseLeave="UIElement_OnMouseUpOrLeave"
MouseUp="UIElement_OnMouseUpOrLeave">
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center">
2</TextBlock>
</Grid>
<Button>3</Button>
<Grid Background="LightBlue"
MouseDown="UIElement_OnMouseDown"
MouseLeave="UIElement_OnMouseUpOrLeave"
MouseUp="UIElement_OnMouseUpOrLeave">
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center">
4</TextBlock>
</Grid>
</UniformGrid>
<Rectangle x:Name="overlay"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Fill="White"
Opacity="0.5"
Visibility="Collapsed" />
<Canvas x:Name="overlayContent" />
</Grid>
</Window>
Note: since this is just a prototype i'm not using MVVM,.. so the event handlers are in code behind MainWindow.xaml.cs
:
public partial class MainWindow : Window
{
private IDisposable _busyElement;
public MainWindow()
{
InitializeComponent();
}
private void UIElement_OnMouseDown(object sender, MouseButtonEventArgs e)
{
if (_busyElement != null)
{
throw new InvalidOperationException("something went wrong, "
+ "there's still a busy element but there shouldn't be!");
}
_busyElement = new TemporarilyMovedElementInfo((FrameworkElement) sender)
.TemporarilyMoveTo(overlayContent);
overlay.Visibility = Visibility.Visible;
}
private void UIElement_OnMouseUpOrLeave(object sender, MouseEventArgs e)
{
if (_busyElement == null)
{
return; // duplicate events because we have up and leave
}
overlay.Visibility = Visibility.Collapsed;
_busyElement.Dispose();
_busyElement = null;
}
}
Note: i've chosen to use an IDisposable
.. which lot of people may not like. However, it makes it clear that it needs to be reverted (diposed) and i can have FxCop warn me if someone doesn't ;-).
So here goes the implementation of the magic:
public class TemporarilyMovedElementInfo { private readonly FrameworkElement _element; private readonly Panel _originalParent; private readonly Point _originalSize; private readonly Canvas _replacedBy = new Canvas();
public TemporarilyMovedElementInfo(FrameworkElement element)
{
_element = element;
_originalParent = (Panel)element.Parent;
_originalSize = new Point(element.ActualWidth, element.ActualHeight);
}
public IDisposable TemporarilyMoveTo(Canvas canvas)
{
Point positionTxt = GetRelativePositionToWindow(_element);
Point positionCanvas = GetRelativePositionToWindow(canvas);
Point newPosition = new Point(
positionTxt.X - positionCanvas.X,
positionTxt.Y - positionCanvas.Y);
ReplaceChild(_originalParent, _element, _replacedBy);
AddToCanvas(canvas, newPosition);
return new RevertMoveOnDispose(this, canvas);
}
void AddToCanvas(Canvas canvas, Point newPosition)
{
Canvas.SetLeft(_element, newPosition.X);
Canvas.SetTop(_element, newPosition.Y);
_element.Width = _originalSize.X;
_element.Height = _originalSize.Y;
canvas.Children.Add(_element);
}
void MoveBackToOriginalParent(Canvas temporaryParent)
{
temporaryParent.Children.Remove(_element);
ReplaceChild(_originalParent, _replacedBy, _element);
}
void ReplaceChild(Panel panel, UIElement oldElement, UIElement newElement)
{
int index = panel.Children.IndexOf(oldElement);
panel.Children.RemoveAt(index);
panel.Children.Insert(index, newElement);
}
private static Point GetRelativePositionToWindow(Visual v)
{
return v.TransformToAncestor(Application.Current.MainWindow)
.Transform(new Point(0, 0));
}
private class RevertMoveOnDispose : IDisposable
{
private readonly TemporarilyMovedElementInfo _temporarilyMovedElementInfo;
private readonly Canvas _temporaryParent;
public RevertMoveOnDispose(
TemporarilyMovedElementInfo temporarilyMovedElementInfo,
Canvas temporaryParent)
{
_temporarilyMovedElementInfo = temporarilyMovedElementInfo;
_temporaryParent = temporaryParent;
}
public void Dispose()
{
_temporarilyMovedElementInfo.MoveBackToOriginalParent(_temporaryParent);
}
}
}
Thanks everyone for their input!
Upvotes: 0
Reputation: 18580
Very interesting problem. I tried to solve it like below:
Define Tag
property on your Buttons with unique identifiers (I have used numbers, but it will make sense if you set Tag to something your button does) and define a Style
like below:
<Window x:Class="WpfApplication1.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="350" Width="525"
xmlns:converter="clr-namespace:WpfApplication1" Tag="Win" >
<Window.Resources>
<converter:StateConverter x:Key="StateConverter"/>
<Style TargetType="Button">
<Style.Triggers>
<DataTrigger Value="False">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource StateConverter}">
<Binding Path="ProcessStarter"/>
<Binding Path="Tag" RelativeSource="{RelativeSource Self}"/>
</MultiBinding>
</DataTrigger.Binding>
<DataTrigger.Setters>
<Setter Property="Background" Value="White"/>
<Setter Property="Opacity" Value="0.5"/>
<Setter Property="IsEnabled" Value="False"/>
</DataTrigger.Setters>
</DataTrigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Window.Style>
<Style TargetType="Window">
<Style.Triggers>
<DataTrigger Value="False">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource StateConverter}">
<Binding Path="ProcessStarter"/>
<Binding Path="Tag" RelativeSource="{RelativeSource Self}"/>
</MultiBinding>
</DataTrigger.Binding>
<DataTrigger.Setters>
<Setter Property="Background" Value="LightGray"/>
</DataTrigger.Setters>
</DataTrigger>
</Style.Triggers>
</Style>
</Window.Style>
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Button Tag="1" Content="1" Command="{Binding ActionCommand}" CommandParameter="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
<Button Tag="2" Grid.Column="1" Content="2" Command="{Binding ActionCommand}" CommandParameter="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
<Button Tag="3" Grid.Column="2" Content="3" Command="{Binding ActionCommand}" CommandParameter="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
</Grid>
Then capture the Tag of button that invoked Command in your VM like below (here I have defined all the properties in the code behind)
public partial class MainWindow : Window, INotifyPropertyChanged
{
private const string _interactiveTags = "1:2:3:Win";
private BackgroundWorker _worker;
public MainWindow()
{
InitializeComponent();
_worker = new BackgroundWorker();
_worker.DoWork += _worker_DoWork;
_worker.RunWorkerCompleted += _worker_RunWorkerCompleted;
ActionCommand = new DelegateCommand(CommandHandler);
DataContext = this;
}
private void CommandHandler(object obj)
{
ProcessStarter = obj.ToString();
if (!_worker.IsBusy)
{
_worker.RunWorkerAsync();
}
}
public ICommand ActionCommand { get; private set; }
void _worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
ProcessStarter = _interactiveTags;
}
void _worker_DoWork(object sender, DoWorkEventArgs e)
{
Thread.Sleep(300);
}
public string _processStarter = _interactiveTags;
public string ProcessStarter
{
get { return _processStarter; }
set
{
_processStarter = value;
RaisePropertyChanged("ProcessStarter");
}
}
And finally the converter which returns if this the button which raised the command or which is doing something
public class StateConverter : IMultiValueConverter
{
public string Name { get; set; }
public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
var tags = (values[0] as string).Split(':');
return tags.Contains(values[1] as string);
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
I have simulated the heavy work using the Backgroundworker and making thread sleep for 300ms. Tested it. Working fine.
Upvotes: 1