Dobry wieczór, Xamarin.Forms kontra Xamarin, witam serdecznie Zrobiła się nieco dłuższa przerwa od ostatniego razu, ale powinno zaowocować to mocniejszym materiałem. XAML to język znany od lat dziesięciu. Czy jednak XAML w Xamarin.Forms w zestawieniu z XAML UWP czy WPF nie jest ich dość odległym kuzynem niczym w przyrodzie wilk i wilk workowaty? Zaczynamy!
Polem do dyskusji tradycyjnie będzie już porcja nowego kodu z repozytorium LightOrgan/Xamarin.Forms/. Od ostatniego odcinka na dwóch stronkach XAML przybyło całkiem sporo elementów. Prezentują się one nieco inaczej na platformie Android:
i platformie iOS:
#1 Pasek odtwarzacza
Mówimy o tym pasku na dole strony MainPage. Widzimy że dość różni się pomiędzy Android i iOS. Tymczasem zrobiłem go za pomocą wspólnego kawałka XAML:
<RelativeLayout Grid.Row="1" BackgroundColor="#e57373">
<RelativeLayout.Resources>
<ResourceDictionary>
<OnPlatform x:TypeArguments="ConstraintType" Android="RelativeToParent" iOS="Constant" x:Key="XType" />
<OnPlatform x:TypeArguments="x:Double" Android="-52" iOS="8" x:Key="XConstant" />
<OnPlatform x:TypeArguments="x:Double" Android="22" iOS="8" x:Key="YConstant" />
<OnPlatform x:TypeArguments="x:String" Android="AlbumArt" iOS="PlayPauseIcon" x:Key="TitleRelativeElement" />
</ResourceDictionary>
</RelativeLayout.Resources>
<RelativeLayout.HeightRequest>
<OnPlatform Android="80" iOS="48" x:TypeArguments="x:Double"/>
</RelativeLayout.HeightRequest>
<ContentView x:Name="AlbumArt"
Content="{StaticResource AlbumArt}"
RelativeLayout.XConstraint="{ConstraintExpression Type=Constant, Constant=8}"
RelativeLayout.YConstraint="{ConstraintExpression Type=Constant, Constant=8}"
RelativeLayout.HeightConstraint="{ConstraintExpression Type=RelativeToParent, Property=Height, Factor=1, Constant=-16}">
</ContentView>
<Label x:Name="Title" TextColor="White"
RelativeLayout.XConstraint="{ConstraintExpression Type=RelativeToView, ElementName={StaticResource TitleRelativeElement}, Property=Width, Factor=1, Constant=16}"
RelativeLayout.YConstraint="{ConstraintExpression Type=Constant, Constant=16}">
<Label.FontSize>
<OnPlatform x:TypeArguments="x:Double" Android="18" iOS="14"/>
</Label.FontSize>
</Label>
<Label x:Name="Artist" FontSize="14" TextColor="#ffebee"
RelativeLayout.XConstraint="{ConstraintExpression Type=RelativeToView, ElementName=AlbumArt, Property=Width, Factor=1, Constant=16}"
RelativeLayout.YConstraint="{ConstraintExpression Type=Constant, Constant=40}">
<Label.IsVisible>
<OnPlatform x:TypeArguments="x:Boolean" Android="True" iOS="False"/>
</Label.IsVisible>
</Label>
<Image x:Name="PlayPauseIcon"
RelativeLayout.XConstraint="{ConstraintExpression Type={StaticResource XType}, Property=Width, Factor=1, Constant={StaticResource XConstant}}"
RelativeLayout.YConstraint="{ConstraintExpression Type=Constant, Constant={StaticResource YConstant}}">
<Image.Source>
<OnPlatform x:TypeArguments="ImageSource" Android="ic_play_arrow_white_36dp.png" iOS="Play.png"/>
</Image.Source>
</Image>
</RelativeLayout>
Aha, zasób AlbumArt jest zdefiniowany w całym MainPage jako:
<OnPlatform x:Key="AlbumArt" x:TypeArguments="View">
<OnPlatform.Android>
<Image BackgroundColor="#9ccc65" Source="ic_audiotrack_white_36dp.png" Aspect="AspectFit" WidthRequest="64" HeightRequest="64"/>
</OnPlatform.Android>
</OnPlatform>
Stosując ContentView z zasobem zdefiniowanym wariantowo poprzez OnPlatform udało mi się pokazywać obrazek tylko w Android. Teraz trochę uwag. Czemu ContentView a nie ContentControl? Czemu BackgroundColor a nie Background? Aspect? WidthRequest? Takich rzeczy nie znajdziemy w żadnym innym gatunku XAML. A RelativeLayout? Niemal zupełnie inna koncepcja niż tego w XAML UWP z Windows 10! ConstraintExpression występuje tylko w Xamarin.Forms. Brakuje mi formy znanej nie tylko z Windows 10, ale i natywnego .axml z Android. W dodatku czuję tu pewne ograniczenia. Centrowanie i układanie w stosunku do sąsiadów nie wydaje się dostatecznie wygodne.
#2 lista
Znów widzimy różnice. W XAML to jeden kawałek wspólny dla wszystkich platform:
<ListView x:Name="listView" BackgroundColor="{StaticResource PageBackgroundColor}" SeparatorColor="#e57373" ItemTapped="listView_ItemTapped" ItemSelected="listView_ItemSelected">
<ListView.SeparatorVisibility>
<OnPlatform x:TypeArguments="SeparatorVisibility" Android="None" iOS="Default"/>
</ListView.SeparatorVisibility>
<ListView.RowHeight>
<OnPlatform x:TypeArguments="x:Int32" Android="68"/>
</ListView.RowHeight>
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<StackLayout HorizontalOptions="StartAndExpand" Orientation="Horizontal">
<StackLayout.Padding>
<OnPlatform x:TypeArguments="Thickness" Android="12,0,0,0" iOS="6,0,0,0"/>
</StackLayout.Padding>
<Image BackgroundColor="#9ccc65" Aspect="AspectFit" WidthRequest="36" HeightRequest="36" VerticalOptions="Center">
<Image.Source>
<OnPlatform x:TypeArguments="ImageSource" Android="ic_audiotrack_white_36dp.png" iOS="audioIcon.png"/>
</Image.Source>
</Image>
<StackLayout VerticalOptions="Center" Orientation="Vertical">
<StackLayout.Padding>
<OnPlatform x:TypeArguments="Thickness" Android="12,0,0,0" iOS="0"/>
</StackLayout.Padding>
<Label Text="{Binding Title}" VerticalTextAlignment="Center" TextColor="White">
<Label.FontSize>
<OnPlatform x:TypeArguments="x:Double" Android="18" iOS="16"/>
</Label.FontSize>
</Label>
<StackLayout Padding="0,-5,0,0" HorizontalOptions="StartAndExpand" Orientation="Horizontal">
<Label Text="{Binding Artist}" VerticalTextAlignment="Center" TextColor="#ffebee">
<Label.FontSize>
<OnPlatform x:TypeArguments="x:Double" Android="14" iOS="12"/>
</Label.FontSize>
</Label>
<Label Text="{Binding Duration}" VerticalTextAlignment="Center" TextColor="#ffebee">
<Label.FontSize>
<OnPlatform x:TypeArguments="x:Double" Android="14" iOS="12"/>
</Label.FontSize>
</Label>
</StackLayout>
</StackLayout>
</StackLayout>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
Niestety można natrafić na kłopoty z ostylowaniem bardziej wysublimowanych elementów w porównaniu do natywnych interfesjów z Android i iOS. W dodatku znów używany tutaj XAML różni od innych gatunków. Ale po kolei.
W Android lista przyjmuje tło strony, ale już w iOS domyślnie jest biała jak oryginał i trzeba jawnie jej ustawić kolor tła. Dużą niegodnością są kolory przyciśniętego i zaznaczonego elementu listy. O ile w Android udało mi się w zrobić tak, by element zaznaczony nie miał innego koloru, a przytrzymany nie miał go prawie w ogóle, o tyle w iOS w pewnym momencie machnąłem już ręką. Tak wiem, że jak mi się niepodoba, to mogę sobie napisać własny natywny renderer dla każdej z platform każdego niemal elementu z Xamarin.Forms, ale czy framework nie mógłby po prostu w ujednoliconym XAML udostępnić nieco więcej właściwości niż teraz? Np. kolor zaznaczonego elementu? Na razie uciekam się do sztuczki, by lista nie miała nigdy zaznaczonego elementu:
private void listView_ItemSelected(object sender, SelectedItemChangedEventArgs e)
{
listView.SelectedItem = null;
}
A czemu w DataTemplate rootem czynimy ViewCell? Tak wiem, kojarzy się to choćby z iOS, ale dla ludzi wywodzących się z innych, bardziej popularnych odmian XAML, jest to twór obcy. A czemu StackLayout a nie StackPanel? Może dlatego że można go rozciągać za pomocą HorizontalOptions (czemu nie HorizontalAlignment?) z wartością StartAndExpand? TextColor miast Foreground? Eh…
#3 search
Spora porażka, że SearchBar ze stabilnej wersji nie renderuje się w Android 7! Znajdziemy taki wątek na forum Xamarina (o widzę pojawiła się odpowiedź: jest obejście w postaci jawnego HeighRequest, może wypróbuję). Nie mając jeszcze odpowiedzi zrobiłem póki co podobnie jak w jednym przykładzie od Xamarina, użyłem zwykłego pola tekstowego. Jest nim kontrolka Entry (zamiast TextBox):
<Frame Padding="8, 0" OutlineColor="Transparent" BackgroundColor="Transparent" HasShadow="False" HorizontalOptions="FillAndExpand">
<Frame.Padding>
<OnPlatform x:TypeArguments="Thickness" Android="8,0" iOS="6,10,6,4"/>
</Frame.Padding>
<Entry Placeholder="{i18n:Translate SearchSongs}" TextChanged="SearchBar_OnTextChanged">
<Entry.FontSize>
<OnPlatform x:TypeArguments="x:Double" Android="20" iOS="16"/>
</Entry.FontSize>
<Entry.TextColor>
<OnPlatform x:TypeArguments="Color" Android="White" iOS="#e57373"/>
</Entry.TextColor>
<Entry.PlaceholderColor>
<OnPlatform x:TypeArguments="Color" Android="#ffcdd2" iOS="#f8bbd0"/>
</Entry.PlaceholderColor>
</Entry>
</Frame>
Nie wygląda oczywiście to tak super, jak oryginalny search, ale stosując różne ustawienia trochę popracowałem, by wyglądało to w miarę przyzwoicie.
#4 własna kontrolka
Narysować kółko, elipsę cóż trudnego powie prawie każdy użytkownik XAML… No właśnie, prawie robi różnicę. Na pewno nie powie tego ktoś, kto pisze w Xamarin.Forms. Trzeba tutaj opakować natywne implementacje pisane na każdą z platform. W moim przypadku opakowałem kontrolkę CircleView, którą napisałem wcześniej dla Android i iOS tworząc klon app-ki w Xamarin. W projekcie portable napisałem klasę:
public class CircleView: View
{
public CircleView()
{
}
public static readonly BindableProperty CircleColorProperty =
BindableProperty.Create<CircleView, Color>(
p => p.CircleColor, Color.Red);
public Color CircleColor
{
get { return (Color)GetValue(CircleColorProperty); }
set { SetValue(CircleColorProperty, value); }
}
}
Tutaj twórcy Xamarin.Forms dali kolejny upust chęci odróżnienia się od wszystkich innych gatunków XAML mianując propercję bindowalną jako BindableProperty, a nie DependencyProperty. Może to był wyraz szczerości, że jej funkcjonalność nie jest tak potężna jak we frameworkach stworzonych od początku przez Microsoft. Następnie w każdym projekcie na daną platformę stworzyłem sobie renderer, który łączy klasę “na wszystko” z natywną kontrolką. W Android wygląda to tak:
using Xamarin.Forms;
using LightOrganApp.Droid.Renderers;
using Xamarin.Forms.Platform.Android;
[assembly: ExportRenderer(typeof(LightOrganApp.Controls.CircleView), typeof(CircleViewRenderer))]
namespace LightOrganApp.Droid.Renderers
{
public class CircleViewRenderer: ViewRenderer<LightOrganApp.Controls.CircleView, LightOrganApp.Droid.UI.CircleView>
{
public CircleViewRenderer()
{
}
protected override void OnElementChanged(ElementChangedEventArgs<LightOrganApp.Controls.CircleView> e)
{
base.OnElementChanged(e);
if (e.OldElement != null || this.Element == null)
return;
var circle = new LightOrganApp.Droid.UI.CircleView(Forms.Context)
{
CircleColor = Element.CircleColor.ToAndroid()
};
SetNativeControl(circle);
}
protected override void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (Control == null || Element == null)
return;
if (e.PropertyName == LightOrganApp.Controls.CircleView.CircleColorProperty.PropertyName)
{
Control.CircleColor = Element.CircleColor.ToAndroid();
}
}
}
}
a w iOS tak:
using Xamarin.Forms;
using LightOrganApp.iOS.Renderers;
using Xamarin.Forms.Platform.iOS;
using UIKit;
[assembly: ExportRenderer(typeof(LightOrganApp.Controls.CircleView), typeof(CircleViewRenderer))]
namespace LightOrganApp.iOS.Renderers
{
public class CircleViewRenderer: ViewRenderer<LightOrganApp.Controls.CircleView, LightOrganApp.iOS.CircleView>
{
protected override void OnElementChanged(ElementChangedEventArgs<LightOrganApp.Controls.CircleView> e)
{
base.OnElementChanged(e);
if (e.OldElement != null || this.Element == null)
return;
var circle = new LightOrganApp.iOS.CircleView
{
CircleColor = Element.CircleColor.ToUIColor(),
BackgroundColor = UIColor.Clear
};
SetNativeControl(circle);
}
protected override void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (Control == null || Element == null)
return;
if (e.PropertyName == LightOrganApp.Controls.CircleView.CircleColorProperty.PropertyName)
{
Control.CircleColor = Element.CircleColor.ToUIColor();
}
}
}
}
Po tym wszystkim (nie licząc czasami potrzeby pisania natywnych kontrolek) możemy cieszyć się własną kontrolką w XAML:
<controls:CircleView VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" CircleColor="{Binding BassColor}"/>
Aby dopełnić pełni obrazu dodam, że w wersji preview Xamarin.Forms Microsoft wprowadził możliwość używania bezpośrednio w XAML natywnych elementów typowych dla danej platformy bez konieczności pisania klas typu renderer. Znacząco to uprości pisanie interfejsu. Nie testowałem tego jeszcze, ale z pewnością wrócimy do tego tematu.
#5 orientacja ekranu i jeszcze renderer
Xamarin.Forms w stosunku do XAML UWP Windows 10, CSS w Web, natywnego Androida czy nawet natywnego iOS brakuje deklaratywnego sposobu definiowania innych wariantów interfejsu użytkownika w zależności od orientacji ekranu, rodzaju urządzenia czy rozmiarów ekranu. Aby obracać światła kolorofonu skorzystałem najpierw z kodu, który zmieniał orientację StackLayout. StackLayout z Xamarin.Forms w przeciwieństwie do StackPanelu z każdego innego XAML ma kilka fajnych właściwości, jak możliwość wypełniania dostępnej przestrzeni (niczym Grid), a także ustawianie odległości między elementami. W dodatku domyślna odległość między elementami nie jest zero jak w StackPanel, tylko wynosi 10! To duża niespodzianka dla osób wywodzących się z XAML innego niż w Xamarin. Pomysł ze zmianą orientacji działał ładnie na Androidzie, ale na iOS (chyba że tylko na iOS 10?) spotkała mnie przykra niespodzianka. Po obrocie ekranu kółka się … rozciągnęły, tak że urosły i były widoczne zasadniczo dwa z małym ogonkiem, a nie trzy. Czemu nie wiadomo.
Przypomniałem sobie, że na iOS w natywnym ujęciu wyłączałem animacje tranzycji strony przy obrocie ekranu. Ale jak to zrobić w cross-platformowym Xamarin.Forms? Jest na to sposób. Możemy napisać własny renderer strony. Tak zrobiłem teraz w projekcie tylko dla iOS:
using CoreAnimation;
using CoreGraphics;
using UIKit;
using Xamarin.Forms.Platform.iOS;
using LightOrganApp.iOS.Renderers;
using Xamarin.Forms;
using LightOrganApp;
[assembly: ExportRenderer(typeof(MainPage), typeof(MainPageRenderer))]
namespace LightOrganApp.iOS.Renderers
{
public class MainPageRenderer: PageRenderer
{
public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTransitionCoordinator coordinator)
{
base.ViewWillTransitionToSize(toSize, coordinator);
CATransaction.Begin();
CATransaction.DisableActions = true;
coordinator.AnimateAlongsideTransition(ctx => { }, ctx => CATransaction.Commit());
}
}
}
Ale to nie rozwiązało sprawy. Jak kółka na iOS po obrocie ekranu się rozwalały, tak rozwalały się dalej. Intuicja programisty nie piszącego od wczoraj podpowiedziała mi rozwiązanie. Może by przy obrocie ekranu ładować DataTemplate do ContentControl? Ale hola. W Xamarin.Forms nie ma… ContentControl. Grr.. Natrafiłem jednak na świetny post ContentPresenter for Xamarin.Forms, który wskazał mi zaktualizowane repozytorium https://github.com/XLabs/Xamarin-Forms-Labs. Z niego wziąłem implementację ContentControl:
public class ContentControl : ContentView
{
public static readonly BindableProperty ContentTemplateProperty = BindableProperty.Create<ContentControl, DataTemplate>(x => x.ContentTemplate, null, propertyChanged: OnContentTemplateChanged);
private static void OnContentTemplateChanged(BindableObject bindable, object oldvalue, object newvalue)
{
var cp = (ContentControl)bindable;
var template = cp.ContentTemplate;
if (template != null)
{
var content = (View)template.CreateContent();
cp.Content = content;
}
else
{
cp.Content = null;
}
}
public DataTemplate ContentTemplate
{
get
{
return (DataTemplate)GetValue(ContentTemplateProperty);
}
set
{
SetValue(ContentTemplateProperty, value);
}
}
}
Czy to jest trudny kod? Nie mógł być częścią Xamarin.Forms? Następnie zdefiniowałem sobie w zasobach MainPage szablony:
<DataTemplate x:Key="VerticalLights">
<StackLayout Spacing="20">
<controls:CircleView VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" CircleColor="{Binding BassColor}"/>
<controls:CircleView VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" CircleColor="{Binding MidColor}"/>
<controls:CircleView VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" CircleColor="{Binding TrebleColor}"/>
</StackLayout>
</DataTemplate>
<DataTemplate x:Key="HorizontalLights">
<StackLayout Spacing="20" Orientation="Horizontal">
<controls:CircleView VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" CircleColor="{Binding BassColor}"/>
<controls:CircleView VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" CircleColor="{Binding MidColor}"/>
<controls:CircleView VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand" CircleColor="{Binding TrebleColor}"/>
</StackLayout>
</DataTemplate>
Przynajmniej DataTemplate wygląda dość standardowo. A teraz kod przełączający szablon po obrocie ekranu:
private double width = 0;
private double height = 0;
…
protected override void OnSizeAllocated(double width, double height)
{
base.OnSizeAllocated(width, height);
if (width != this.width || height != this.height)
{
this.width = width;
this.height = height;
if (width > height)
{
lights.ContentTemplate = Resources["HorizontalLights"] as DataTemplate;
}
else
{
lights.ContentTemplate = Resources["VerticalLights"] as DataTemplate;
}
}
}
#6 binding
Jeszcze słowo o data bindingu. Xaml w Xamarin nie byłby sobą, gdyby było jak w innych postaciach Xaml. Zamiast właściwości DataContext mamy BindingContext. Dobrze, że INotifyPropertyChanged jest i działa tak, jak należałoby się tego spodziewać. W moim przypadku steruję kolorami świateł właśnie przez binding. Szczegóły w MainPage.xaml.cs.
Morał jest taki, że Xaml Xaml-owi nierówny. Andy Wigley mówił kiedyś, że może ustandaryzują XAML. Przydałoby się! Muszę przyznać, że w obecnej sytuacji znajomość innych odmian XAML czasami zamiast mi pomagać, to mi wręcz przeszkadzała. Czy tak powinno być? Po drugie mimo pisania w XAML czasami potrzebujemy dość często zrobić coś za pomocą natywnego podejścia, czy to przez brak dostępu do ostylowania, brak zachowania znanego z natywnego komponentu czy własna kontrolkę. Upada wtedy nieco atut, że nie trzeba znać natywnych platform, mamy za to największą elastyczność. Dodając fakt, że aplikacja Xamarin.Forms działa (przynajmniej bez wysilonych zabiegów) gołym okiem wolniej od aplikacji z natywnym w pełni interfejsem (w oryginalnych narzędziach i SDK czy też w Xamarin.Android i Xamarin.iOS), ten sposób tworzenia aplikacji mobilnych mniej mnie przekonuje niż pozostałe, z którymi miałem doczynienia. Dodatkowa warstwa abstrakcji powoduje narzut przez potrzebę jej parsowania. Visual Studio 2015 nie jest też dopracowane dla XAML w Xamarin.Forms. Od strony dewelopera może się zdarzyć, że coś się skompiluje, uruchomi, a potem dowiemy się, że obiekt nie ma propercji Margin, Padding czy Background (albo w ogóle się nie dowiemy jak u mnie na iOS, gdzie aplikacja od razu się zamykała). A, zapomniałem dodać, że Margin w ogóle prawie nie istnieje w Xamarin.Forms. Trzeba to obchodzić stosując wszędzie Padding, o ile to możliwe. W rozwojowej wersji Xamarin.Forms mamy podgląd w designerze dla różnych platform. Ja póki co pracuję na stabilnej w czystym edytorze XML. Microsoft ma jeszcze dużo do zrobienia, by korzystanie z XAML w Xamarin.Forms dorównało korzystaniu z XAML w UWP czy WPF. Działają jednak cały czas, oprócz natywnych kontrolek bezpośrednio w XAML pojawiło się też preview kompilowanego bindingu.
Mimo pewnych problemów z Xamarin.Forms, nadal będę się przyglądał tej technologii, by wyrobić sobie o niej jeszcze pełniejszy obraz. Obecnie kolekcjonerskie “różowe złoto” ma UI w XAML pozwalające na robienie wszystkiego poza konfiguracją, teraz pora na próbę sprzężenia go z natywnym API, by listować muzykę, odtwarzać ją i by światła mrugały. Ale to już w następnym odcinku. Stay tuned!
Brak komentarzy:
Prześlij komentarz