25 April 2016

Behavior for view model driven animated popups in Xamarin Forms

Preface

popupIn my previous post I showed the basics for animation behaviors in Xamarin Forms. Here’s another one I made, that in conjunction with some nifty element binding makes for a pretty fluid popup appearing from the middle. Of course, you can use the native alert box but I’d rather want to see is something as displayed to the right, especially with a nice animation. The advantage of such a custom made popup is that is looks much more consistent across platforms, which is a huge win especially for LOB apps.

You can see the behavior in action below:

... and all there is to it...

The actual code is pretty small now all the heavy lifting has been done by the base classes from the previous post:

using Wortell.XamarinForms.Behaviors.Base;
using Xamarin.Forms;

namespace Wortell.XamarinForms.Behaviors
{
  public class AnimateScaleBehavior : AnimateFoldBehaviorBase
  {
    protected override void Init(bool newValue)
    {
      if (newValue)
      {
        FoldOutPosition = 1;
        FoldInPosition = 0;
        AssociatedObject.Scale = FoldInPosition;
      }
    }

    protected override void ExecuteAnimation(double start, double end, 
                                             uint runningTime)
    {
      var animation = new Animation(
        d => AssociatedObject.Scale = d, start, end, Easing.SinOut);

      animation.Commit(AssociatedObject, "Unfold", length: runningTime,
        finished: (d, b) =>
        {
          if (AssociatedObject.Scale.Equals(FoldInPosition))
          {
            AssociatedObject.IsVisible = false;
          }
        });
    }
  }
}

In stead of animating TranslationX and TranslationY, like in the previous post, now the Scale property is animated from 0 to 1 and back. Attentive readers may have seen something else though - as soon as the animation starts, a grey haze appears over the underlying screen, that only disappears when the animation is done. This is entirely done in XAML - using element binding.

The whole popup sits in MenuControl.xaml – this is a user control

<?xml version="1.0" encoding="utf-8" ?>
<Grid xmlns="http://xamarin.com/schemas/2014/forms"
      //Rest of namespace stuff omitted
      IsVisible="{Binding IsVisible, Source={x:Reference AnimatedGrid}}">
  <ContentView BackgroundColor="{StaticResource SeeThrough}"
               HorizontalOptions="Fill" VerticalOptions="Fill" >

    <ContentView Padding="20,0,20,0" x:Name="AnimatedGrid" IsVisible="False">
      <ContentView.Behaviors>
        <behaviors:AnimateScaleBehavior IsVisible="{Binding IsMenuVisible}" 
           ViewIsInitialized="{Binding ViewIsInitialized}"/>
      </ContentView.Behaviors>
      <ContentView Style="{StaticResource MenuStyle}" HorizontalOptions="Fill"
                    VerticalOptions="CenterAndExpand" Padding="0,0,0,10">
        <Grid>
          <Grid.RowDefinitions>
            <RowDefinition Height="40"></RowDefinition>
            <RowDefinition Height="Auto"></RowDefinition>
          </Grid.RowDefinitions>

          <ContentView  Grid.Row="0" Style="{StaticResource PopupHeaderStyle}">
            <Label Style="{StaticResource PopupHeaderTextStyle}" Text="Popup header" 
                VerticalOptions="Center"/>
          </ContentView>

          <StackLayout Orientation="Vertical" Grid.Row="1">

            <StackLayout Style="{StaticResource ContentStyle}"  Orientation="Vertical">
              <Label Style="{StaticResource MenuTextStyle}" Text="Here be text" />  
              <Label Style="{StaticResource MenuTextStyle}" Text="Here be more text"
                  VerticalOptions="Center"/>
            </StackLayout>

            <ContentView Style="{StaticResource Separator}"></ContentView>

            <StackLayout Style="{StaticResource ContentStyle}"  Orientation="Vertical">
              <Label Style="{StaticResource MenuTextStyle}" Text="Here be text" />
              <Label Style="{StaticResource MenuTextStyle}" Text="Here be more text" />
              <Label Style="{StaticResource MenuTextStyle}" 
                  Text="Here be more whatever UI elements you want" />
            </StackLayout>

            <Grid HeightRequest="10"></Grid>

            <Grid Style="{StaticResource ContentStyle}">
              <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"></ColumnDefinition>
                <ColumnDefinition Width="10"></ColumnDefinition>
                <ColumnDefinition Width="*"></ColumnDefinition>
              </Grid.ColumnDefinitions>
                 <Button Text="Ok" Command="{Binding CloseMenuCommand}"></Button>
              <Button Text="Cancel" Grid.Column="2" 
                  Command="{Binding CloseMenuCommand}"></Button>
            </Grid>
          </StackLayout>
        </Grid>
      </ContentView>
    </ContentView>

    <ContentView.GestureRecognizers>
      <TapGestureRecognizer Command="{Binding CloseMenuCommand}"></TapGestureRecognizer>
    </ContentView.GestureRecognizers>
  </ContentView>

</Grid>

I have marked the interesting parts in red and bold. On top you see that the visibility of the control itself is bound to the visibility of the grid called “AnimatedGrid”. This grid is inside a ContentView which has a background color defined by the resource “SeeTrough. This you can find in app.xaml defined as being color #B2000000, aka "70% black" in designer lingo (i.e. black that is 30% transparent). AnimatedGrid is the top level element of the actual popup – it’s visibility is controlled by the AnimateScaleBehavior. As soon as the animation starts by IsVisible flipping value, the AnimatedGrid is first made visible, and it’s parent – and the SeeThrough ContentView – popup into view, only to disappear when the animation is completely finished. This is exactly what I wanted to achieve – the 70% haze turns the focus of the user to the little task at hand now, and also blocks tapping access to whatever UI elements are still visible below it.

Note, there no values supplied for FoldInTime and FoldOutTime so the default values of 150 and 250ms are used. But you can easily change that by adding them to the behavior in XAML.

As a final thing, I have added a TapGestureRecognizer that calls the popup closing command to the 70% gray area as well. This dismisses the popup when you tap outside it, which is exactly what one expects in a mobile app. It’s effectively the same thing as hitting Cancel (and in this case, Ok, as that does nothing but closing the popup as well).

Viewmodel

Hardly worth mentioning, but to make the picture complete:

using Xamarin.Forms;

namespace XamarinFormsDemos.ViewModels
{
  public class PopupViewModel : MenuViewModelBase
  {
    public Command CloseMenuCommand { get; private set; }

    public PopupViewModel() : base()
    {
      CloseMenuCommand = new Command(() => IsMenuVisible = false);
    }
  }
}
Command makes boolean false. So menu disappears again. Doh ;)

Usage

<?xml version="1.0" encoding="utf-8" ?>
<demoViewFramework:BaseContentPage xmlns="http://xamarin.com/schemas/2014/forms"
    // stuff omitted
  <Grid>
    <!-- Page content -->
    <Grid.RowDefinitions>
      <RowDefinition Height="25*"></RowDefinition>
      <RowDefinition Height="75*"></RowDefinition>
    </Grid.RowDefinitions>
    <Grid Row="0" Style="{StaticResource HeaderOutsideStyle}">
      <ContentView Style="{StaticResource ContentStyle}">
        <Label Text="Popup menu" Style="{StaticResource HeaderStyle}"  
        VerticalOptions="CenterAndExpand"></Label>
      </ContentView>
    </Grid>

    <ContentView Grid.Row="1" Style="{StaticResource ContentStyle}" 
        HorizontalOptions="Fill">
      <Label Text="Here be page content" 
        Style="{StaticResource ContentTextStyle}"></Label>

      <ContentView  VerticalOptions="Start" Style="{StaticResource ContentStyle}" >
          <Button Text="Open popup menu" Command="{Binding ToggleMenuCommand}" 
                  HorizontalOptions="Fill" VerticalOptions="Start"></Button>
      </ContentView>
    </ContentView>

    <!-- Menu -->
    <controls:PopupControl Grid.Row="0" Grid.RowSpan="2"></controls:PopupControl>

  </Grid>
</demoViewFramework:BaseContentPage;/Grid>

Once again, the control is added last and convers the whole screen, as to appear over the existing UI.

Some final words

When you are developing cross-platform apps, UI testing is crucial. For instance, while I was making this app, I noticed that when you change this

<Grid 
      //Namespaces omitted
      IsVisible="{Binding IsVisible, Source={x:Reference AnimatedGrid}}">
  <ContentView BackgroundColor="{StaticResource SeeThrough}"
               HorizontalOptions="Fill" VerticalOptions="Fill">

in this:

       
<Grid 
      //Namespaces omitted
      >
  <ContentView IsVisible="{Binding IsVisible, Source={x:Reference AnimatedGrid}}"
               BackgroundColor="{StaticResource SeeThrough}"
               HorizontalOptions="Fill" VerticalOptions="Fill">

you get a visually a identical result on all three platforms covered in the test project. On Android and Windows 10 it even works the same. But if you try to run on iOS, you will notice the “Open popup menu” is not clickable. This is because the outer grid covers the whole screen and is always visible - and although it’s empty, it apparently blocks all user input. As usual, Apple does things different ;)

The demo project – with updated code – can be found here

This article appeared earlier – in Dutch – on the Wortell company blog

No comments: