06 February 2016

An AdaptiveTrigger that works with StateTrigger inside WindowsStateTriggers’ CompositeStateTrigger

Intro

Still working on porting Map Mania to the Universal Windows Platform, I ran into a bit of a snag. It wanted to have a kind of decision-tree Visual State

  • In wide screen, show the UI elements next to each other
  • In narrow screen, show the UI element on top of each other and allow the user to determine which one to show with a kind of menu

And being a stubborn b*st*rd, I felt I should be able to solve by using the Visual State Manager and Adaptive Triggers only, with three Visual States.

  • WideState
  • NarrowState_Red
  • NarrowState_Green

And of course, using MVVM. There is a StateTrigger, which you can bind to a boolean, that will activate upon that boolean being true – using something like a BoolInvertConverter, that will nicely do as flip-flop switch. There is also the well-known AdaptiveTrigger. So for both the narrow state, I can add a StateTrigger and an AdaptiveTrigger. I envisioned something like this:

<VisualState x:Name="NarrowState_Red">
  <VisualState.StateTriggers>
    <AdaptiveTrigger MinWindowWidth="0"></AdaptiveTrigger>
    <StateTrigger IsActive="{x:Bind ViewModel.TabDisplay, Mode=OneWay}"/>
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

<VisualState x:Name="NarrowState_Green">
  <VisualState.StateTriggers>
    <AdaptiveTrigger  MinWindowWidth="0" ></AdaptiveTrigger>
    <StateTrigger IsActive="{x:Bind ViewModel.TabDisplay, Mode=OneWay, 
         Converter={StaticResource BoolInvertConverter}}"/>
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

<VisualState x:Name="WideState">
  <VisualState.StateTriggers>
    <AdaptiveTrigger MinWindowWidth="700" ></AdaptiveTrigger>
  </VisualState.StateTriggers>
    <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

Unfortunately, that will activate the state if either of one triggers is true. So even in Wide state, I still get one of the Narrow states (and/or the Visual State Manager went belly-up), depending on whether TabDisplay is true or not. I needed something to fire only if all of the triggers are true. Enter…

WindowsStateTriggers to the rescue (sort of)

My smart fellow MVP Morten Nielsen has created a GitHub repo (as well as a NuGet package) containing all kinds of very useful triggers. One of the most awesome is CompositeStateTrigger, which allows you to put a number of triggers inside it, and have them evaluated as one. The default behavior of CompositeStateTrigger is to only fire when all of the triggers inside it are true – exactly what I need! Then I had a bit of a setback discovering the default Microsoft AdaptiveTrigger cannot be used inside the CompositeStateTrigger, but fortunately there was a pull request by one Tibor TĂłth in October 2015 that provided an implementation that could. So happy as a clam I pulled all the stuff in, changed my Visual State Manager to this:

<VisualState x:Name="NarrowState_Red">
  <VisualState.StateTriggers>
    <windowsStateTriggers:CompositeStateTrigger>
      <windowsStateTriggers:AdaptiveTrigger  MinWindowWidth="0" />
      <StateTrigger IsActive="{x:Bind ViewModel.TabDisplay, Mode=OneWay}"/>
    </windowsStateTriggers:CompositeStateTrigger>
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

<VisualState x:Name="NarrowState_Green">
  <VisualState.StateTriggers>
    <windowsStateTriggers:CompositeStateTrigger>
      <windowsStateTriggers:AdaptiveTrigger  MinWindowWidth="0" />
      <StateTrigger IsActive="{x:Bind ViewModel.TabDisplay, Mode=OneWay, 
          Converter={StaticResource BoolInvertConverter}}"></StateTrigger>
    </windowsStateTriggers:CompositeStateTrigger>
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

<VisualState x:Name="WideState">
  <VisualState.StateTriggers>
    <windowsStateTriggers:AdaptiveTrigger MinWindowWidth="700"/>
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

And got this – only both Narrow states showed.

And then I felt a bit like this

And about the same age too.

Investigating my issue

The WindowsStateTriggers AdaptiveTrigger is a child class of the default AdaptiveTrigger, and I think the issue boils down to these lines:

private void OnCoreWindowOnSizeChanged(CoreWindow sender, 
  WindowSizeChangedEventArgs args)
{
  IsActive = args.Size.Height >= MinWindowHeight && 
             args.Size.Width >= MinWindowWidth;
}

private void OnMinWindowHeightPropertyChanged(DependencyObject sender, 
  DependencyProperty dp)
{
  var window = CoreApplication.GetCurrentView()?.CoreWindow;
  if (window != null)
  {
    IsActive = window.Bounds.Height >= MinWindowHeight;
  }
}

private void OnMinWindowWidthPropertyChanged(DependencyObject sender, 
  DependencyProperty dp)
{
  var window = CoreApplication.GetCurrentView()?.CoreWindow;
  if (window != null)
  {
    IsActive = window.Bounds.Width >= MinWindowWidth;
  }
}

If you have 4 triggers, for screen sizes 0, 340, 500 and 700, and a screen size of 600 – then the three triggers for 0, 340 and 500 will set IsActive to true, three states will be valid at once, your Visual State Manager barfs and nothing happens. In fact, there is no way in Hades the 0 width trigger will not fire, as there are no ways to make a negative sized active element that I am aware of. I don’t know what Microsoft have done with their implementation, but apparently it ‘magically’ finds sibling-triggers inside the Visual State Manager and proceeds to fire only the one that has the highest value smaller than the screen size, and not all those with lower values. Ironically – by making this AdaptiveTrigger trigger a child class of the Microsoft AdaptiveTrigger, it will function perfectly – as long as you don’t put it into a CompositeTrigger, so it can do the ‘Microsoft Magic’. It’s an insidious trap, and it took me the better part of a day to find out why the hell this was not working.

Now what?

The key thing to understand – and what took quite some for me to have the penny dropped - is that the Visual State Manager will only work if always, under any circumstances, one and only one state is true. Therefore, one and only one set of triggers may be true. So, based upon what I learned from looking at Tibor’s code, I made my own AdaptiveTrigger, which is unfortunately not so elegant.

To a working/workable AdaptiveTrigger

As I don’t have any clue as to how Microsoft does the “looking-up-sibling-triggers-magic”, I have made two major changes to the WindowsStateTrigger’s AdaptiveTrigger:

  1. I did not make a it a child class from the Microsoft AdaptiveTrigger (but from WindowsStateTrigger’s StateTriggerBase)
  2. Next to the properties the Microsoft AdaptiveTrigger has – MinWindowHeight en MinWindowWidth – I added two new properties, MaxWindowHeight and MaxWindowWidth

And then added the following code:

public class AdaptiveTrigger : StateTriggerBase, ITriggerValue
{
  public AdaptiveTrigger()
  {
    var window = CoreApplication.GetCurrentView()?.CoreWindow;
    if (window != null)
    {
      var weakEvent = new WeakEventListener<AdaptiveTrigger, CoreWindow, 
                                            WindowSizeChangedEventArgs>(this)
      {
        OnEventAction = (instance, s, e) => OnCoreWindowOnSizeChanged(s, e),
        OnDetachAction = (instance, weakEventListener) 
          => window.SizeChanged -= weakEventListener.OnEvent
      };
      window.SizeChanged += weakEvent.OnEvent;
    }
  }
  private void OnCoreWindowOnSizeChanged(CoreWindow sender, 
    WindowSizeChangedEventArgs args)
  {
    OnCoreWindowOnSizeChanged(args.Size);
  }

  private void OnCoreWindowOnSizeChanged(Size size)
  {
    IsActive = size.Height >= MinWindowHeight && size.Width >= MinWindowWidth &&
               size.Height <= MaxWindowHeight && size.Width <= MaxWindowWidth &&
               MinWindowHeight <= MaxWindowHeight && MinWindowWidth <= MaxWindowWidth;
  }

  private void OnWindowSizePropertyChanged()
  {
    var window = CoreApplication.GetCurrentView()?.CoreWindow;
    if (window != null)
    {
      OnCoreWindowOnSizeChanged(new Size(window.Bounds.Width, window.Bounds.Height));
    }
  }

  private bool _isActive;

  public bool IsActive
  {
    get
    {
      return _isActive;
    }
    private set
    {
      if (_isActive != value)
      {
        _isActive = value;
        SetActive(value);
        IsActiveChanged?.Invoke(this, EventArgs.Empty);
      }
    }
  }

  public event EventHandler IsActiveChanged;
}

Key part of course is the OnCoreWindowOnSizeChanged method, that not only checks for lower, but also upper boundaries. Usage then is as follows:

<VisualState x:Name="NarrowState_Red">
  <VisualState.StateTriggers>
    <windowsStateTriggers:CompositeStateTrigger>
      <wpwinnltriggers:AdaptiveTrigger  MinWindowWidth="0" MaxWindowWidth="699"/>
      <StateTrigger IsActive="{x:Bind ViewModel.TabDisplay, Mode=OneWay}"></StateTrigger>
    </windowsStateTriggers:CompositeStateTrigger>
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

<VisualState x:Name="NarrowState_Green">
  <VisualState.StateTriggers>
    <windowsStateTriggers:CompositeStateTrigger>
      <wpwinnltriggers:AdaptiveTrigger  MinWindowWidth="0" MaxWindowWidth="699"/>
      <StateTrigger IsActive="{x:Bind ViewModel.TabDisplay, Mode=OneWay, 
      Converter={StaticResource BoolInvertConverter}}"></StateTrigger>
    </windowsStateTriggers:CompositeStateTrigger>
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

<VisualState x:Name="WideState">
  <VisualState.StateTriggers>
    <wpwinnltriggers:AdaptiveTrigger MinWindowWidth="700" />
  </VisualState.StateTriggers>
  <VisualState.Setters>
    <!-- -->
  </VisualState.Setters>
</VisualState>

Having to watch for both min and max sizes and having them align closely is a bit cumbersome and not so elegant, but it works for my needs – as the first video on this post shows.

Credits

These go first and foremost to Tibor TĂłth for making a good first attempt at making a composable AdaptiveTrigger, teaching me about things like CoreApplication windows that I had previously not encountered. Second of course Morten Nielsen himself, for making WindowsStateTriggers in the first place (it’s quite a popular library by the looks of it) and providing some pointers to improve on my initial code.

Some concluding remarks

A demo project, that sports pages with show both mine and Tibor’s trigger and demonstrates the issue I encountered, can be found here. In fact, the video’s displayed in this post are screencasts of that very app. You will also notice WeakEventListener from WindowsStateTriggers being copied in there – this is because inside WindowsStateTriggers the WeakEventListener is not publicly accessible. The fun thing about open source is that, well, it’s open, and it allows things like copying out internal classes and making use of them anyway. There are several ways to do open source – I tend to make open extensible tool boxes, other people make libraries with a surface as small as possible. Both are valid.

In any case, I hope I have given people who want to do some more advanced Visual State Manager magic some things to reach their goal.

3 comments:

Anonymous said...

Please help me in this problem. I followed your solution but am NOT able to trigger.

http://stackoverflow.com/questions/38744656/how-to-use-this-adaptivetrigger-in-uwp-app

Unknown said...

Hi,

I've tried your suggestion but still can't make CompositeStateTrigger to work with AdaptiveTrigger and DeviceFamilyStateTrigger. I've created a sample project to repro the issue at GitHub https://github.com/Monsok/App2

Appreciate if you could let me know what I am missing. Thanks.

Joost van Schaik said...

Hi Helena, I have not much experience with DeviceFamilyStateTrigger. I suggest you contact Morten Nielsen, the original creator of the WindowsStateTrigger library