Preface
Some time ago I wrote about a proof-of-concept for viewmodel driven animations using behaviors in Xamarin Forms. In the mean time, time has moved on, so has Xamarin Forms, and the idea I had back then made it into a kind of framework solution I now used professionally. And it’s time to show how it’s done now in detail.
This article is about building animated scroll-into-view menu’s in Xamarin Forms, and to make sure we all understand what that means, I made this little video showing the code in action on an Android emulator, a Windows 10 Universal Windows Platform app, and an iPhone simulator:
Framework
The demo app contains a simple framework (project DemoViewFramework) that supports a number of things crucial to an app sporting viewmodel driven behaviors, that is:
- facilitation of MVVM (using MVVMLight)
- registration of which view belongs to what viewmodel
- navigation from viewmode to viewmodel (in stead of from page to page)
- exposing essential events in the life cycle of the page to it’s accompanying viewmodel.
I won’t go into very much detail about the framework – it’s a pretty naïve implementation anyway – but there are two key things to take away. The first one is the class BaseContentPage
using Xamarin.Forms; namespace DemoViewFramework { public class BaseContentPage : ContentPage { protected override void OnAppearing() { base.OnAppearing(); Context?.OnViewAppearing(); } protected override void OnSizeAllocated(double width, double height) { base.OnSizeAllocated(width, height); Context?.OnViewInitialized(true); } protected override void OnDisappearing() { base.OnDisappearing(); Context?.OnViewInitialized(false); Context?.OnViewDisappearing(); } private IPageViewModelBase Context => (IPageViewModelBase)BindingContext; } }This class is intended to be a base class for your pages, and it's sole purpose is to channel the OnAppearing, OnSizeAllocated and OnViewDisappearing events to the object in the Page's BindingContext - that is, the view model. To this intent, every view model should implement the interface IPageViewModelBase so the corresponding methods can be called:
namespace DemoViewFramework { public interface IPageViewModelBase { void OnViewInitialized(bool value); void OnViewAppearing(object state = null ); void OnViewDisappearing(); INavigationService NavigationService { get; set; } } }If you are using your own Navigation framework, you can forget about the NavigationService property. To make implementation easier, there's a base class for view models – which is the second key takeaway. In the demo project is a child class from MVVMLight's ViewModelBase, but of course you are very free to implement your own INotifyPropertyChanged base class as well.
using GalaSoft.MvvmLight; namespace DemoViewFramework { public class PageViewModelBase : ViewModelBase, IPageViewModelBase { public virtual void OnViewInitialized(bool value) { ViewIsInitialized = value; } private bool _viewIsInitialized; public bool ViewIsInitialized { get { return _viewIsInitialized; } set { Set(() => ViewIsInitialized, ref _viewIsInitialized, value); } } public virtual void OnViewAppearing(object state = null) { ViewHasAppeared = true; } public virtual void OnViewDisappearing() { ViewHasAppeared = false; } private bool _viewHasAppeared; public bool ViewHasAppeared { get { return _viewHasAppeared; } set { Set(() => ViewHasAppeared, ref _viewHasAppeared, value); } } public INavigationService NavigationService { get; set; } } }Key thing here is that there are two properties - ViewHasAppeared and ViewIsInitialized - available in every view model and they are set automatically by the framework. Behaviors handling animations need to know when they actually can start doing stuff and they can bind to one of these properties. As I explained in a previous blog post, from Xamarin 2.1 apparently you can only start after OnSizeAllocated, which translates to ViewIsInitialized in the view model, so the initialization code needs to be fired when that property is set to true. The actual activation of the behavior (that is, the animation), needs to come from another property. That may sound complicated, but I assure you it's not that bad. Just read on.
The (base) view model for driving animations
Basically, we need something to kick off the animations. To this extent, we are using this very simple view model
using DemoViewFramework; using Xamarin.Forms; namespace XamarinFormsDemos.ViewModels { public class MenuViewModelBase : PageViewModelBase { private bool _isMenuVisible; public MenuViewModelBase() { ToggleMenuCommand = new Command(() => IsMenuVisible = !IsMenuVisible); } public bool IsMenuVisible { get { return _isMenuVisible; } set { Set(() => IsMenuVisible, ref _isMenuVisible, value); } } public Command ToggleMenuCommand { get; private set; } } }
A command that toggles a simple boolean property. I mean, how hard can it be, right?
Leveling the playing field
If you are coming from Windows behaviors, like I do, you are in for a surprise. Xamarin behaviors are a prime example of something that quacks like a duck and walks like a duck, can be a goose after all. Some things are a bit different and – like a goose – can bite you pretty badly. First of all, there is no standard binding context, and there is no AssociatedObject property to easily refer to. This can make things a bit complex when 'translating' behaviors from Windows to Xamarin and back, hence this base class to make sure we have the same - or at least a more similar - base
using System; using Xamarin.Forms; namespace Wortell.XamarinForms.Behaviors.Base { public abstract class BindableBehaviorBase<T> : Behavior<T> where T : VisualElement { protected T AssociatedObject { get; private set; } protected override void OnAttachedTo(T bindable) { AssociatedObject = bindable; bindable.BindingContextChanged += Bindable_BindingContextChanged; base.OnAttachedTo(bindable); } protected override void OnDetachingFrom(T bindable) { bindable.BindingContextChanged -= Bindable_BindingContextChanged; base.OnDetachingFrom(bindable); AssociatedObject = null; } private void Bindable_BindingContextChanged(object sender, EventArgs e) { if (AssociatedObject != null) { BindingContext = AssociatedObject.BindingContext; } } } }It's a bit of an oddball class, but it's purpose is simple - after this has run, you can bind to any of the behavior's dependency properties, and in your code you can refer to a typed AssociatedObject, just like you would in Windows XAML behaviors.
Getting a bit animated
Now earlier in this blog post I mentioned the fact that behaviors doing animations probably need to know when a view is ready initializing, so they can initialize themselves. To get to this point, there is this base class on top of BindableBehaviorBase, with a similar ‘original name:
using Xamarin.Forms; namespace Wortell.XamarinForms.Behaviors.Base { public abstract class ViewInitializedBehaviorBase<T> : BindableBehaviorBase<T> where T : VisualElement { #region ViewIsInitialized Attached Dependency Property public static readonly BindableProperty ViewIsInitializedProperty = BindableProperty.Create(nameof(ViewIsInitialized), typeof(bool), typeof(ViewInitializedBehaviorBase<T>), default(bool), BindingMode.TwoWay, propertyChanged: OnViewIsInitializedChanged); public bool ViewIsInitialized { get { return (bool)GetValue(ViewIsInitializedProperty); } set { SetValue(ViewIsInitializedProperty, value); } } private static void OnViewIsInitializedChanged(BindableObject bindable, object oldValue, object newValue) { var thisObj = bindable as ViewInitializedBehaviorBase<T>; thisObj?.Init((bool)newValue); } #endregion protected abstract void Init(bool viewIsInitialized); } }
When you bind the behavior’s ViewIsInitialized property to the viewmodel’s ViewIsInitialized property, the behavior knows ‘when the view is ready (or not anymore). Whatever, if the property in the view model changes, the behavior calls its (now abstract) method Init.
So what happens is
BaseContentPage –> PageViewModelBase –> (property binding) ViewInitializedBehaviorBase.Init –>
Concrete Implementation.Init
And finally - menu animations
One more base class to go. I noticed pretty soon that animations handling the folding or scrolling of things almost always consider one property to be animated. So I created a yet another base class
using Xamarin.Forms; namespace Wortell.XamarinForms.Behaviors.Base { public abstract class AnimateFoldBehaviorBase : ViewInitializedBehaviorBase<View> { protected double FoldInPosition; protected double FoldOutPosition; protected VisualElement GetParentView() { var parent = AssociatedObject as Element; VisualElement parentView = null; if (parent != null) { do { parent = parent.Parent; parentView = parent as VisualElement; } while (parentView?.Width <= 0 && parent.Parent != null); } return parentView; } protected override void OnAttachedTo(View bindable) { base.OnAttachedTo(bindable); bindable.IsVisible = false; } private void ExecuteAnimation(bool show) { if (show) { AssociatedObject.IsVisible = true; ExecuteAnimation(FoldInPosition, FoldOutPosition, (uint)FoldOutTime); } else { ExecuteAnimation(FoldOutPosition, FoldInPosition, (uint)FoldInTime); } } protected abstract void ExecuteAnimation(double start, double end, uint runningTime); public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(AnimateFoldBehaviorBase), false, BindingMode.OneWay, propertyChanged: OnIsVisibleChanged); public bool IsVisible { get { return (bool)GetValue(IsVisibleProperty); } set {SetValue(IsVisibleProperty, value); } } private static void OnIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) { var thisObj = bindable as AnimateFoldBehaviorBase; thisObj?.ExecuteAnimation((bool)newValue); } // FoldInTime Attached Dependency Property // FoldOutTime Attached Dependency Property } }
This class handles a few important things. First of all, it gets the parent view – in a menu’s case the page. In fact, it finds the first object that has a width, thus a size. This is the behavior’s ‘playing field’. Then there is the ExecuteAnimation method, that actually starts the animation – or reverses it. Note also that the behavior actually hides the element that it’s animating – this whole setup assumes you will move something into view, while it’s not in view initially. You might notice that the Init method which is abstract in it's parent class is still not implemented. That happens only in the concrete class - that is actually pretty small, now all the ground work has been laid:
using Wortell.XamarinForms.Behaviors.Base; using Xamarin.Forms; namespace Wortell.XamarinForms.Behaviors { public class AnimateSlideDownBehavior : AnimateFoldBehaviorBase { protected override void Init(bool newValue) { if (newValue) { var parentView = GetParentView(); if (parentView != null) { FoldInPosition = -parentView.Height; AssociatedObject.TranslationY = FoldInPosition; } } } protected override void ExecuteAnimation(double start, double end, uint runningTime) { var animation = new Animation( d => AssociatedObject.TranslationY = d, start, end, Easing.SinOut); animation.Commit(AssociatedObject, "Unfold", length: runningTime, finished: (d, b) => { if (AssociatedObject.TranslationY.Equals(FoldInPosition)) { AssociatedObject.IsVisible = false; } }); } } }
So on Init, the menu is moved up exactly it’s own height and - assuming it’s on top of the page - it will appear just outside the view. The actual animation code itself is laughably simple – it just animates over TranslationY, and when it find it’s ending at the FoldInPosition, it will make the animated object invisible
All that’s left now is defining the menu in XAML and then adding the behavior to it. It’s important to add the menu after the actual page content so it will be drawn over it. Otherwise it will just slide behind it, and that’s not very useful.
<!-- Menu -->
<ContentView Grid.Row="0" Grid.RowSpan="2">
<ContentView.Behaviors>
<behaviors:AnimateSlideDownBehavior
IsVisible="{Binding IsMenuVisible}"
ViewIsInitialized="{Binding ViewIsInitialized}"
FoldOutTime="400" FoldInTime="300"/>
</ContentView.Behaviors>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="75*"></RowDefinition>
<RowDefinition Height="25*"></RowDefinition>
</Grid.RowDefinitions>
<ContentView Style="{StaticResource MenuStyle}">
<StackLayout Style="{StaticResource ContentStyle}"
Orientation="Vertical" VerticalOptions="Start">
<Image Source="chevronup.png" HeightRequest="40"
VerticalOptions="Start" HorizontalOptions="Start">
<Image.GestureRecognizers>
<TapGestureRecognizer Command="{Binding ToggleMenuCommand}"/>
</Image.GestureRecognizers>
</Image>
<Label Text="Menu" Style="{StaticResource HeaderStyle}"
VerticalOptions="Start"/>
<Label Text="Here be menu content"
Style="{StaticResource MenuTextStyle}"></Label>
</StackLayout>
</ContentView>
<!-- Leftover space -->
<ContentView Grid.Row="1" HorizontalOptions="Fill" VerticalOptions="Fill">
<ContentView.GestureRecognizers>
<TapGestureRecognizer Command="{Binding ToggleMenuCommand}"/>
</ContentView.GestureRecognizers>
</ContentView>
</Grid>
</ContentView>
Note the binding of ViewIsInitialized to ViewIsInitialized for the initialization, and of IsVisible to IsMenuVisible for the actual execution of the animation. Also notice the FoldOutTime and FoldInTime – the menu will fold out in 400, and fold in in 300ms. These are optional values – if you don’t specify them, the behavior will use default values. Finally, note the fact that the menu actually covers the whole screen, but the lower part is empty. Yet, if you tap that part, the menu will move away as well, as you would expect in an app.
The AnimateSlideInBehavior is nearly the same but animates TranslateX – just have a look at the sample code, it’s yours for the taking
Some concluding remarks
Of course you can also code these animations into code behind or in a control’s code behind, but that way you will need to copy and paste code into your views time and time again, and potentially you will need to fix the same bugs on multiple places. What I usually do is indeed make a control, but use the behavior in that and then re-use the control over multiple pages. Using a behavior decouples the code from a specific control or page, and you can re-use the dynamic behavior everywhere.
One final bit – the Xamarin iOS linker is still sometimes ‘optimizing’ away code, just like .NET Native, something that already bit me last year. So in the Wortell.XamarinForms there is this extremely odd class Initializer:
namespace Wortell.XamarinForms { public static class Initializer { public static void Init() { } } }that indeed does completely nothing, except for being called from the iOS project's AppDelegate
public override bool FinishedLaunching(UIApplication app, NSDictionary options) { global::Xamarin.Forms.Forms.Init(); global::Wortell.XamarinForms.Initializer.Init(); LoadApplication(new App()); return base.FinishedLaunching(app, options); }Which still does nothing, but tricks the compiler into thinking the code is actually used (which is true) so the assembly actually gets packaged with the app.
I hope this blog will inspire you to try your hand at animations with Xamarin Forms as well. It’s actually pretty easy when you get the hang of it. And there is more to come on this blog.
Parts of this text appeared earlier on the Wortell company blog, in Dutch
No comments:
Post a Comment