Ga naar content
Zoek op onderwerpen, blogs, diensten etc.

Business Apps keuken - menu animaties

Blogs
13-4-2016

'Visually delighting'

Zoals in de eerste blogpost in deze serie aangegeven vindt Wortell dat ook een business app visueel aantrekkelijk moet zijn. Net als bij een consumenten app draagt dat in belangrijke mate bij aan het gebruiksgemak en de adoptie van een app in het bedrijfsproces, en dus het bereiken van het zakelijk doel. Designers hebben daar allemaal fraaie termen voor, zoals 'visually delighting'. Waar het om gaat is dat visuele stijlfiguren - waaronder animaties - ervoor zorgen dat de gebruikers van de app daadwerkelijk hun doel bereiken. Nu wordt van onze huidige standaard technologie voor cross-platform development - Xamarin Forms - nog wel eens gezegd dat het alleen geschikt is voor 'forms', formulieren dus, en niet bijzonder geschikt is voor visueel aantrekkelijke apps. Wij bestrijden die mening, en tonen in deze blog post de eerste twee van een aantal simpele maar vloeiende animaties, die ook nog eens geheel passen in een framework van herbruikbare componenten en 'separation of concerns'.

Maar eerst, een kleine inleiding over data binding.

Data binding

Tussen de applicatie logica ('business logic') en de view zit het viewmodel, dat de applicatie data geschikt maakt voor communicatie met de view laag. De view 'praat' met het viewmodel middels data binding. Dit betekent, eenvoudigweg, dat er ergens in de view laag iets staat als

<Label Text="{Binding UserName}"/>

hetgeen de view instrueert om in het label voor de inhoud van een property "UserName" op te halen van het object dat in de BindingContext van de view is gestopt (het view model dus). Zoals we in de vorige blog post hebben kunnen zien, wordt dat koppelen via BindingContext automatisch geregeld door het framework. Doordat elke view model de interface INotifyPropertyChanged implementeert en elke wijziging van een property waarde het PropertyChanged event afvuurt, wordt het label ook automatisch geupdated als de waarde van de property wijzigt in het viewmodel. Gebruiken we de optie Mode=TwoWay, zoals hier bij een invulveld

<EntryText="{Binding UserName, Mode=TwoWay}"/>

dan wordt ook nog eens iedere wijziging die de gebruiker maakt automatisch teruggestuurd naar het viewmodel. Op deze manier krijgen we een bijzonder losse koppeling tussen viewmodel en view, hetgeen aanpassingen zeer eenvoudig maakt. Het is mogelijk de complete view aan te passen zonder daarvoor de applicatie logica te hoeven wijzigen, en omgekeerd. Daarnaast wordt de herbruikbaarheid van zowel viewmodel als (delen van) de view - bijvoorbeeld door middel vanzogenaamde user controls - wel heel eenvoudig. Als de namen van de properties maar kloppen, gaat in principe alles 'vanzelf' goed. Deze techniek heet 'data binding' en bestaat al veel langer dan Xamarin Forms. Het voornaamste doel is te voorkomen dat heel veel code gaat zitten in het eindeloos héén en weer schuiven van data van de view laag naar de applicatie laag, waarmee ook nog eens harde koppelingen tussen de applicatie en de gebruikers interface werden gemaakt. Dit is het hele punt van MVVM. De techniek was al in beperkte mate beschikbaar in Windows Forms, maar kwam pas tot volwassenheid met het ontstaan van XAML in WPF. Dit is een op XML gebaseerde opmaak taal die in moderne Windows applicaties - en waarvan in Xamarin Forms een dialect wordt gebruikt.

Behaviors

Het is natuurlijk mogelijk animaties rechtstreeks in de view laag te programmeren. Het voornaamste nadeel daarvan is dat ze wederom niet eenvoudig herbruikbaar zijn. Daarnaast maken al onze applicaties zoals gezegd gebruik van MVVM - de applicatie logica heeft dus nauwelijks weet van dat er zoiets is als een gebruikers interface bestaat - voor je het weet is er weer een harde koppeling tussen applicatie logica en view en valt de applicatie geheel om doordat er één kleine business requirement wijzigt.

Een behavior is een herbruikbaar stuk code dat eenvoudig in de XAML kan worden gebruikt, voor het introduceren van dynamisch gedrag. Net als bij 'gewone' gebruikers interface elementen zorgt data binding voor communicatie met het viewmodel. In het algemeen is er een Boolean property die het gewenste gedrag in- en weer uitschakelt. Denk bijvoorbeeld aan - de gebruiker drukt op een knop, en die knop zet een bepaalde status in de applicatie logica. Door aan dat property in het viewmodel te data binden wordt de behavior geactiveerd. Daardoor wordt uiteindelijk de animatie gestart. Dit klinkt heel ingewikkeld, maar dat valt in de praktijk erg mee. Denk aan - de gebruiker klikt op een 'menu' knop, en het menu scrollt van boven naar beneden.

Dat is precies wat we gaan laten zien. Om precies te zijn, hieronder:

[video width="1000" height="600" mp4="https://www.wortell.nl/wp-content/uploads/2016/04/Xamarin-Animation-Behaviors-1.mp4" loop="true"][/video]

De video is een screen capture van emulators en doet niet echt recht aan het vloeiende verloop op een echt device, maar laat in ieder zien wat er gebeurt.

Behaviors en data binding

Behaviors in Xamarin werken een klein beetje anders dan we gewend zijn in XAML in de pure windows omgeving. Om te beginnen is er geen standaard data context beschikbaar binnen de behavior zelf, en er is ook al geen standaard 'AssociatedObject' property met daarin het object waaraan je behavior gekoppeld hebt. Om deze basis zaken te regelen en overbodige herhaalde code te voorkomen, hebben we een base class ontwikkeld die exact dat regelt:

using System;
using Xamarin.Forms;

namespace Wortell.XamarinForms.Behaviors.Base
{
public abstract class BaseBindableBehavior<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;
}
}
}
}

Initialisatie - hoe en wanneer

Behaviors die animaties uitvoeren moeten meestal - maar niet altijd - weten wat de initiële omvang is van het gebruikers interface element waarop ze animaties willen uitvoeren. Hier is iets veranderd in Xamarin Forms 2.1 - waar eerst in de OnAppearing fase de omvang van alle elementen reeds bekend was, is dat nu pas in de meeste gevallen bekend in de OnSizeAllocated fase. Hoe dan ook, de BaseContentPage en de PageViewModelBase ondersteunen initialisatie in beide fasen. Het is alleen een kwestie van binden aan het juiste property in PageViewModelBase.  Maar omdat dit in de meeste behaviors moet gebeuren die iets met animaties doen, is ook hier weer een base class voor:

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);

}
}

Elke behavior die iets met animaties doet, is een child van deze class. Als "Init" wordt aangeroepen, weet de child behavior dat de alle elementen in de view gereed zijn, en dat het AssociatedObject dus klaar is om animaties te faciliteren.

Basis voor 'fold' animaties

In de video heb je kunnen zien dat er twee verschillende animaties zijn - één voor een menu van bovenaf, en één voor een menu vanaf de zijkant. Dit is uiteraard te vangen in één behavior waaraan je parameters meegeeft. Dit heeft echter wel tot gevolg dat er meerdere animaties in één behavior terechtkomen, of je moet iets als het strategy pattern toepassen. Wij hebben gekozen voor een simpele base class voor animaties voor dit soort in- en uitvouw animaties

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

Als IsVisible wordt gewijzigd, wordt ExecuteAnimation afgetrapt, en gaat de animatie lopen.
Om de zaken wat overzichtelijker te houden is de code van onderste twee dependency properties niet weergegeven - het enige interessante daaraan is de typering van FoldInTime en FoldOutTime - die zijn beide int, terwijl ze gecast worden naar uint in de code. Dit is nodig omdat XAML binding alleen int begrijpt, en animaties met uint.

Wat we hier zien zijn twee belangrijke zaken. De eerste is de methode GetParentView. De gebruikers interface is een soort boom van elementen in elementen (met aan de top de Page). GetParentView zoekt het eerste visuele element dat een visuele omvang heeft, omdat dit het 'werkveld' van de animatie vaststelt.

De tweede is ExecuteAnimation, welke de animatie aftrapt (of terugbrengt in zijn originele staat). Belangrijk is te zien dat de basis staat van onze animatie 'ingevouwen' is, en dus is het gebruikers interface element onzichtbaar (IsVisible = false).

En dan eindelijk - de menu animatie

De uiteindelijke specifieke code voor de menu animatie is bijzonder beperkt nu zoveel wordt geregeld door base classes. Dit is de alle code in AnimateSlideDownBehavior:

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;
}
});
}
}
}

Hier zien we hoe de "Init" functie, aangeroepen vanuit ViewInitializedBehaviorBase, de parent view zoekt en - ervan uitgaande dat het menu precies tegen de bovenkant van het scherm staat - dat menu een negatieve waarde precies gelijk aan zijn eigen hoogte omhoog schuift, waardoor het net uit het zicht staat. Deze positie wordt 'onthouden' in FoldInPosition. Vervolgens wordt vanuit AnimateFoldBehaviorBase ExecuteAnimation aangeroepen en wordt geanimeerd naar FoldOutPostion (die in dit geval 0 is). Dus wordt de Y-positie van een menu van 150 hoog van -150 naar 0 geanimeerd, en het resultaat is dat het menu naar beneden schuift.

Het gebruik is eenvoudig te zien in MenuFromTopPage.xaml:

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

Heel belangrijk is in te zien dat er twee bindings zijn. Belangrijkste is de binding aan de ViewIsInitialized property - dit is hoe de behavior wordt geïnitialiseerd. Zonder die binding werkt de code überhaupt niet. De tweede binding is IsVisible - dit trapt de daadwerkelijke animatie af. Als in het view model IsMenuVisible op true wordt gezet, rolt het menu van boven naar beneden. Het 'omzetten' van IsMenuVisible wordt geregeld door het ToggleMenuCommand. Merk ook op dat het menu eigenlijk het hele scherm bedekt, maar dat het onderste kwart doorzichtig is. Als je daarop tapt (in plaats van op het pijltje zoals in de video) dan schuift het menu ook in, zoals je verwacht bij een menu.

FoldInTime en FoldOutTime zijn waarden die aangeven in hoeveel milliseconden het menu in- respectievelijk uitklapt.

De AnimateSlideInBehavior is vrijwel hetzelfde, maar animeert TranslateX in plaats van TranslateY. Zie voor details de code op github

Waarom geen control?

In principe zou je dit ook kunnen maken met een user control. Dan is het hele menu herbruikbaar binnen meerdere pagina's. In onze apps doen we dat ook. Alleen zou je dan steeds de animatie code het control in moeten kopiëren. Dus in de praktijk maken we een control waarin deze animatie behavior wordt gebruikt. Op deze manier is de animatie zelf eenvoudig herbruikbaar binnen en buiten menu controls door hem toe te voegen in de XAML, voor diverse doeleinden. Zo creëer je herbruikbare 'legoblokjes' waaruit je visueel aantrekkelijke apps kunt gebruiken.

Conclusie

Met deze blog post hebben we laten zien hoe je op eenvoudige wijze aantrekkelijke en herbruikbare animaties kunt maken met behulp van behaviors. Nadat er eerst wat grondwerk is gelegd, is het vrij eenvoudig om ook in Xamarin Forms fraaie interactieve applicaties te maken die functioneren zoals je van een mobiele app verwacht, en die niet alleen maar als een saaie standaard Line-Of-Business (LOB) applicatie van formulier naar formulier springen - op een 'in your face' manier, zoals onze designers het zo mooi zeggen.

De voorbeeld code werkt prima in Windows 10 Universal apps, iOS en Android, zoals je in de video hebt kunnen zien, en bevat dan ook drie voorbeeld applicaties.