BatteryBackupUnit
BatteryBackupUnit

Reputation: 13223

Disable entire Window except for the clicked control

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.

So here's what i want to achieve:

1) initial window state: 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")) Button pressed - rest of Window disabled

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) enter image description here

4) as soon as the operation ends the window reverts to it's initial state: enter image description here

There's two important conditions:

Research done

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

Answers (3)

BatteryBackupUnit
BatteryBackupUnit

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

BatteryBackupUnit
BatteryBackupUnit

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
    • It's only visible when a "button" is clicked (to use mouse down/mouse up, it's not actually a button but rather a grid)
  • Canvas, which also covers the entire Window and is above the Rectangle. This is used to host the button which is clicked
  • when the button get's clicked, it's removed from it's Paneland added to the Canvas.
    • In order to not mess-up "background" view, the removed "button" needs to be replaced by another item. The looks don't matter since it's going to be covered.
    • To be positioned exactly at the same spot, one needs to set Canvas.Left, Canvas.Top, Width and Height accordingly
  • when the mouse button is released (or leaves the "button" area) the button is removed from the canvas and re-added to it's original Panel (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

Nitin Purohit
Nitin Purohit

Reputation: 18580

Very interesting problem. I tried to solve it like below:

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

  2. 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");
        }
    }
    
  3. 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

Related Questions