Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Disable entire Window except for the clicked control

Tags:

wpf

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:

  • there's other buttons of the same type (same functionality & behavior) on the same Window. So merely setting Panel.ZIndex to a constant for all of them is not going to work.
  • there's several other input controls in the same window. Those need to be "disabled" as well.
  • showing the overlay/greying out the rest of the Window may not trigger mouse events like MouseUp or MouseLeave (otherwise the operation would immediately be canceled).

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 :(

like image 891
BatteryBackupUnit Avatar asked Sep 17 '25 12:09

BatteryBackupUnit


2 Answers

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.

like image 149
Nitin Avatar answered Sep 19 '25 07:09

Nitin


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

like image 45
BatteryBackupUnit Avatar answered Sep 19 '25 07:09

BatteryBackupUnit