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

Business Apps keuken - Context gevoelige ondersteuning

Blogs
4-6-2016

Zwevende tekstballonnen

Een klassieke opmerking van programmeurs is dat designers niet gehinderd worden door enige technische kennis en daarbij dingen bedenken die niet of slechts heel moeilijk te realiseren zijn. Nu is dat bij onze designers niet het geval, maar al was het dat wel - zoiets kan ook worden bezien als een uitdaging. Toen één van onze designers met een fraai ontwerp kwam om voor een bepaalde app context gevoelige, over de gebruikers interface 'zwevende' tekstballonen toe te voegen - die verschijnen als de gebruiker op een informatie icoon klikt - had het Business Apps team inderdaad een fraaie uitdaging. Want zou het dan ook geheel cross platform kunnen, en ook met een aantrekkelijke animatie?

Om een oude conference van Paul van Vliet aan te halen: "daar hebb'n we 't volgende op gevond'n"

[video width="1150" height="600" mp4="https://www.wortell.nl/wp-content/uploads/2016/06/Floating-textbubbles.mp4"][/video]

 

Anatomie van een tekstballon

tekstballon

De tekstballon bestaat uit een doorzichtig grid dat de componenten ervan bij elkaar houdt, met daarin twee grids en een label. Het label bevat uiteraard de tekst, één van de grids de groene omranding de tekstballon, en een ander grid is het 'tekst ballon aangrijppunt' - hier een driehoekje. De truuk daarachter is kinderlijk eenvoudig: neem een vierkant groen grid, geef het roteer het 45 graden en 'druk' het de helft van zijn hoogte 'omlaag' zodat het achter de grotere groene grid valt. Hiervoor gebruiken we de nieuwe "Margin" property dat eindelijk in Xamarin Forms is verschenen:

<Grid x:Name="MessageGridContainer" xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="XamarinFormsDemos.Views.Controls.FloatingPopupControl"
BackgroundColor="#01000000">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="75*"></ColumnDefinition>
<ColumnDefinition Width="25*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Grid x:Name="MessageGrid" HorizontalOptions="Start" VerticalOptions="Start" >
<Grid BackgroundColor="{StaticResource AccentColor}" HeightRequest="15" WidthRequest="15"
HorizontalOptions="End" VerticalOptions="Start" Rotation="45"
Margin="0,0,4,0" InputTransparent="True"/>

<Grid Padding="10,10,10,10" BackgroundColor="{StaticResource AccentColor}" Margin="0,7,0,0"
HorizontalOptions="FillAndExpand" InputTransparent="True">

<Label x:Name="InfoText" TextColor="{StaticResource ContrastColor}"
HorizontalOptions="Center" VerticalOptions="Center"
InputTransparent="True"/>
</Grid>
</Grid>
</Grid>

De totale tekstballon is het grid "MessageGrid", het tekst ballon aangrijppunt (het geroteerde vierkantje) is aangegeven in groen. Dit geheel is gevestigd binnen nog een grid, "MessageGridContainer", dat het gehele scherm vult - of althans het deel waarbinnen de tekstballonnen vallen. Het vervult drie functies:

  • Zorgen dat er een 'ruimte' is waarbinnen de tekstballon op de juist plek kan worden geplaatst
  • Zorgen dat bij een klik op dat grid de tekstballon weer verdwijnt
  • Zorgen dat de tekstballon nooit breder wordt dan 75% van de breedte van het scherm (eis van de designer). Zie de kolom indeling.

Een aantal belangrijke details:

  • Op een aantal elementen is het attribuut "InputTransparent" op true gezet. Dit houdt in dat deze grids geen events zullen ontvangen maar ze doorgeven naar onderliggende elementen. Hierdoor verdwijnt de tekstballon ook als je op de tekstballon zelf tapt
  • MessageGridContainer is niet doorzichtig maar heeft BackgroundColor "#01000000". Dat is 1% zwart en dat zie je inderdaad niet - maar een volledig transparante kleur zorgt dat dit grid in  de Windows implementatie van deze app geen event ontvangt, en dus krijg je de popups niet weg. Een kleine concessie aan een compatibiliteits issue
  • Deze hele contraptie is een usercontrol genaamd FloatingPopupControl - dit is het control dat alles bij elkaar het plaatsen en tonen van de tekstballon toont, bij het aanroepen van de ShowMessageFor method die zich in code behind bevindt (waarover later meer)

Vaststellen positie 'anker' element

Het anker element is het element waarbij de tekstballon wordt geplaatst - het i-symbool. Het blijkt vrij eenvoudig te zijn om vast te stellen wat de absolute positie is van relatief geplaatste elementen. Je zoekt recursief naar boven door de boom van 'parent' elementen en telt alle waarden van X bij elkaar op, alsmede alle waarden van Y. Deze wijsheid, waarvan de basis overigens gewoon te vinden is om de Xamarin developer forums is gevat in deze extension method:

using Xamarin.Forms;

namespace Wortell.XamarinForms.Extensions
{
public static class ElementExtensions
{
public static Point GetAbsoluteLocation(this VisualElement e)
{
var result = new Point();
var parent = e.Parent;
while (parent != null)
{
var view = parent as VisualElement;
if (view != null)
{
result.X += view.X;
result.Y += view.Y;
}
parent = parent.Parent;
}
return result;
}
}
}

Deze waarde kan dan vervolgens in een berekening voor een translatie worden gebruikt.

Plaatsen, tonen, animeren en verwijderen tekstballon

Als de code van ShowMessageFor wordt bekeken - in de code behind van FloatingPopupControl -  ziet men dat deze aanroep alleen maar wordt doorgevoerd naar de FloatingPopupDisplayStrategy. Dit is omdat het niet handig is om veel code in de code behind van een control te doen, en omdat deze aanpak het makkelijker maakt om de wijze waarop animaties plaatsvinden te wijzigen. De FloatingPopupDisplayStrategy heeft de volgende constructor

public class FloatingPopupDisplayStrategy
{
private readonly Label _infoText;
private readonly View _overallView;
private readonly View _messageView;

public FloatingPopupDisplayStrategy(Label infoText, View overallView, View messageView)
{
_infoText = infoText;
_overallView = overallView;
_messageView = messageView;

_overallView.GestureRecognizers.Add(new TapGestureRecognizer
{ Command = new Command(ResetControl) });
_overallView.SizeChanged += (sender, args) => { ResetControl(); };
}
}

  • infoText is de tekst die in de tekstballon
  • overallView is de view waarin de tekstballon 'beweegt', er waarin een 'tap' zorgt dat hij verdwijnt
  • messageView is de verzamel-grid van de tekstballon zelf.

Vervolgens is er dan de implementatie van ShowMessageFor (met zijn hulpje ExecuteAnimation), die er als volgt uitziet:

public virtual async Task ShowMessageFor(
VisualElement parentElement, string text, Point? delta = null)
{
_infoText.Text = text;
_overallView.IsVisible = true;

// IOS apparently needs to have some time to layout the grid first
// Windows needs the size of the message to update first
if (Device.OS == TargetPlatform.iOS ||
Device.OS == TargetPlatform.Windows) await Task.Delay(25);
_messageView.Scale = 0;

var gridLocation = _messageView.GetAbsoluteLocation();
var parentLocation = parentElement.GetAbsoluteLocation();

_messageView.TranslationX = parentLocation.X - gridLocation.X -
_messageView.Width + parentElement.Width +
delta?.X ?? 0;
_messageView.TranslationY = parentLocation.Y - gridLocation.Y +
parentElement.Height + delta?.Y ?? 0;

_messageView.Opacity = 1;
ExecuteAnimation(0, 1, 250);
}

private void ExecuteAnimation(double start, double end, uint runningTime)
{
var animation = new Animation(
d => _messageView.Scale = d, start, end, Easing.SpringOut);

animation.Commit(_messageView, "Unfold", length: runningTime);
}

  • Eerst wordt de tekst die in de tekstballon wordt getoond gezet, vervolgens wordt het overall grid (de ruimte waarin de popup zich beweegt) zichtbaar gemaakt (zoals gezegd is het nagenoeg doorzichtig, maar effectief vangt het nu input af)
  • Voor Windows en iOS wachten we even om wat layout events de kans te geven zich af te wikkelen
  • De Scale van de tekstballon wordt op 0 gezet, hiermee maken we hem effectief oneindig klein.
  • Vervolgens berekenen we de huidige absolute locatie van de tekst ballon, en de absolute locatie van het anker element ('parentElement').
  • Vervolgens worden x en y translatie zo berekend dat de tekstballon met het aangrijppunt midden onder het blauwe I symbool uit komt.
  • De opacity van het message grid wordt op 1 (volledig ondoorzichtig) gezet
  • Middels een animatie met een bounce (Easing.SpringOut) wordt de tekstballon opgeblazen tot volledige grootte in 250 milliseconden.

Tussen haakjes - de delta die in de berekening zit is een waarde die bij het user interface design nog kan worden meegegeven in de XAML. Daarover meer in het volgende punt.

Ten slotte moet de tekstballon ook nog kunnen verdwijnen. Dit gebeurt middels de ResetControl method die - zoals we in de constructor hebben gezien - wordt aangeroepen indien er op het onzichtbare vlak wordt getapt, of de grootte van het onzichtbare vlak wijzigt:

private void ResetControl()
{
if (_messageView.Opacity != 0)
{
_messageView.Opacity = 0;
_overallView.IsVisible = false;
}
}

Omdat het onzichtbare vlak initieel al van grootte wijzigt (omdat het gevuld wordt met dingen die erin komen, zoals het MessageGrid) hoeft deze method niet initieel expliciet te worden aangeroepen, de event wiring zorgt ervoor dat dat toch wel gebeurt. Een andere belangrijke reden om deze method aan het SizeChanged event te hangen is dat in Windows 10 apps de grootte van het window daadwerkelijk kan wijzigen, waardoor de tekstballon wellicht niet meer op de juiste plek staat. Dan wordt de tekstballon ook maar verwijderd. Immers - zo lang een tekst ballon zichtbaar is, neemt het onzichtbare grid alle input weg, en kan de gebruiker eigenlijk niet zoveel doen. Dus uit de weg ermee zodra dat kan.

Behavior voor tap event en doorsturen naar control

Het enige wat nu nog ontbreekt is iets om alles af te trappen, de tekst in te stellen en (zoals we zullen zien) eventueel nog wat kleine plaatsingscorrecties voor de tekstballon uit de voeren. Hiervoor dient FloatingPopupBehavior:

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

namespace Wortell.XamarinForms.Behaviors
{
public class FloatingPopupBehavior : BindableBehaviorBase<View>
{
private IGestureRecognizer _gestureRecognizer;

protected override void OnAttachedTo(View bindable)
{
base.OnAttachedTo(bindable);
_gestureRecognizer = new TapGestureRecognizer {Command = new Command(ShowControl)};
AssociatedObject.GestureRecognizers.Add(_gestureRecognizer);
}

protected override void OnDetachingFrom(View bindable)
{
base.OnDetachingFrom(bindable);
AssociatedObject.GestureRecognizers.Remove(_gestureRecognizer);
}

private void ShowControl()
{
if (AssociatedObject.IsVisible && AssociatedObject.Opacity > 0.01)
{
PopupControl?.ShowMessageFor(AssociatedObject, MessageText, new Point(Dx, Dy));
}
}

#region PopupControl Attached Dependency Property
public static readonly BindableProperty PopupControlProperty =
BindableProperty.Create(nameof(PopupControl),
typeof (IFloatingPopup), typeof (FloatingPopupBehavior),
default(IFloatingPopup));

public IFloatingPopup PopupControl
{
get { return (IFloatingPopup) GetValue(PopupControlProperty); }
set { SetValue(PopupControlProperty, value); }
}
#endregion

//MessageText Attached Dependency Property omitted

//region Dx Attached Dependency Property

//region Dy Attached Dependency Property

}
}

Deze behavior is eigenlijk tamelijk simpel - zodra er op op het control waaraan de behavior is toegevoegd wordt getapt, roept deze de ShowMessageFor method aan van het control waarnaar een referentie bestaat in the PopupControl property. Verder zijn er nog drie properties voor de te tonen tekst en het aangeven in hoeverre er nog een extra delta x en y moeten worden meegenomen in de berekening.

Gebruik in XAML

Een vereenvoudig 'uittreksel' uit de pagina FloatingPopupPage

<ScrollView Grid.Row="1"  VerticalOptions="Fill"
HorizontalOptions="Fill" Margin="10,0,10,0" >
<Grid>
<Grid VerticalOptions="Start">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions>
<StackLayout Orientation="Horizontal" HorizontalOptions="Fill" >
<ContentView HorizontalOptions="FillAndExpand" VerticalOptions="Start">
<Entry x:Name="NameEntry" Placeholder="Name"
TextColor="{StaticResource AccentColor}"
PlaceholderColor="{StaticResource SoftAccentColor}" />
</ContentView>
<ContentView>
<Image Source="{extensions:ImageResource info.png}" VerticalOptions="Center"
HorizontalOptions="End"
HeightRequest="{Binding Height, Source={x:Reference NameEntry}}">
<Image.Behaviors>
<behaviors:FloatingPopupBehavior MessageText="Fill in your name here"
PopupControl="{x:Reference PopupControl}"
Dx="-6" Dy="4"/>
</Image.Behaviors>
</Image>

</ContentView>
</StackLayout>

</Grid>
<controls:FloatingPopupControl x:Name="PopupControl" VerticalOptions="Fill"
HorizontalOptions="Fill" />

</Grid>
</ScrollView>

In rood is hier de het i-symbool aangegeven samen met de behavior, in groen de popup control zelf. In de behavior hebben we inderdaad nog een extra delta voor x en y aan gebracht om het geheel visueel meer te laten kloppen. Dit zou natuurlijk ook in de berekening van de FloatingPopupDisplayStrategy kunnen worden gevat, maar deze extra flexibiliteit is in ieder geval handig. Zoals te zien is - als de structuur eenmaal staat, is het component eenvoudig te hergebruiken.

Merk ook op - een aardige manier om te zorgen dat met name in Android, waar een grote range aan resoluties bestaat, een image niet veel te groot of veel te klein wordt, is een tamelijk groot image nemen, en dan de hoogte te binden aan een element waarvan de hoogte automatisch gezet wordt. In dit geval, een Entry (tekst invoer veld). In de code staat

HeightRequest="{Binding Height, Source={x:Reference NameEntry}}"

Consequenties van de oplossing

Zoals gezegd wordt er steeds gebruik gemaakt van een (nagenoeg) onzichtbaar schermvullend control, waarbinnen de tekstballon zelf 'kan bewegen' en absoluut wordt gepositioneerd. Dit onzichtbare control dient om te detecteren of de gebruiker nog een keer tapt, zodat de app weet dat een tekstballon moet worden gewijzigd. Dit heeft echter tot gevolg dat zodra er een tekstballon wordt getoond, de rest van de gebruikers interface geblokkeerd is. De gebruiker moet eerst een keer tappen, dan kan hij/zij pas verder. We denken op dit moment dat niet niet zo heel storend is, omdat de gebruiker vermoedelijk deze how-to-functionaliteit na de eerste paar keer niet vaak meer zal gebruiken (aangezien het dan duidelijk zou moeten zijn hoe het werkt).

Conclusie

Met gebruik van relatief eenvoudige middelen en een strakke architectuur is blijkt het heel eenvoudig te zijn een soort van infrastructuur te bouwen waarmee herbruikbare componenten voor context-gevoelige informatie op een aantrekkelijke en makkelijk aanpasbare wijze te presenteren. Een nuttig stuk gereedschap in de Business Apps team 'gereedschapskist' om adoptie van dit soort apps eenvoudiger te maken.

De code is bij dit artikel is, zoals altijd, in zijn volle glorie te bekijken op GitHub