在以前寫UWP程序的時候,了解到在ListView或者ListBox這類的列表空間中,有一個叫做ItemsPannel的屬性,它是所有列表中子元素實際的容器,如果要讓列表進行橫向排列,只需要在Xaml中如下編輯即可
//UWP中用XAML大致實現如下
···
<ListView.ItemsPannel>
<StackPannel Orientation="Horizental"/>
</ListView.ItemsPannel>
···
這種讓列表元素橫向排列實際是一個很常見的場景,但是在Xamarin.Forms中,並沒有提供直接的實現方法,如果想要這種效果,有兩種解決辦法
- Renderer:利用Renderer在各平台實現,適用於對性能有較高要求的場景,比如大量數據展示
- 自定義布局:實現比較簡單,但是適用於數據量比較小的場景
實際在使用的時候,利用自定義布局會比較簡單,並且橫向的列表展示並不適合大量數據的場景。
怎么實現呢?
Xamarin.Forms的列表控件是直接利用Renderer實現的,沒有提供類似ItemsPannel之類的屬性,所以考慮直接自己實現一個列表控件。有以下幾個點:
- 列表控件要支持滾動:所以在控件最外層需要一個ScrollView
- 實現類似ItemsPannel的效果:所以需要實現一個ItemsPannel屬性,類型是StackLayout,並且它應該是ScrollView的Content
- ItemsControl控件的基類型是View,便於使用,直接讓它繼承自ContentView,這樣就可以直接設置其Content為ScrollView
至此,先來給出這部分的代碼,我們直接在構造函數中完成絕大多數操作
···
private ScrollView _scrollView;
private StackLayout itemsPanel = null;
public StackLayout ItemsPanel
{
get { return this.itemsPanel; }
set { this.itemsPanel = value; }
}
public ItemsControl()
{
this._scrollView = new ScrollView();
this._scrollView.Orientation = Orientation;
this.itemsPanel = new StackLayout() { Orientation = StackOrientation.Horizontal };//子元素水平排布的關鍵
this.Content = this._scrollView;
this._scrollView.Content = this.itemsPanel;
}
···
子元素的容器是ItemsPannel,它實際是一個水平排布的StackLayout。想要在列表控件添加子元素,實際就是對該StackLayout的Children添加子元素。
考慮到列表控件中子元素的添加,就必須實現一個屬性ItemsSource,是集合類型,並且為了支持數據綁定等,還需要讓他是一個依賴屬性,針對ItemsSource屬性值自身的改變或者其集合中元素的添加刪除等,都需要監聽,並且將具體變化表現在ItemsControl中。實現該屬性如下:
···
public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create("ItemsSource", typeof(IEnumerable), typeof(ItemsControl), defaultBindingMode: BindingMode.OneWay, defaultValue: null, propertyChanged: OnItemsSourceChanged);
public IEnumerable ItemsSource
{
get { return (IEnumerable)this.GetValue(ItemsSourceProperty); }
set { this.SetValue(ItemsSourceProperty, value); }
}
···
Static vid OnItemsSourceChanged(BindableObject sender,object oldValue,object newValue)
{
···
}
···
當為ItemsSource屬性賦值之后,OnItemsSourceChanged方法被調用,在該方法中,需要干這么幾件事兒:
- 為ItemsSource中的每一個元素,根據ItemTemplate創建相應的View,設置View的數據綁定上想問BindingContext為該元素,並且將此View添加到ItemsPannel中(ItemsPannel實際是StackLayout,他的子元素必須繼承自View或者是View)
- 檢測ItemsSource的數據源是否實現了接口INotifyCollectionChanged,如果實現了,需要訂閱其CollectionChanged事件,注冊一個方法,便於在集合元素變動后調用我們注冊的方法,來通知ItemsControl控件,把具體的變動表現在UI層面(通常就是元素的添加和刪除)
OnItemsSourceChanged方法實現如下:
public static readonly BindableProperty ItemTemplateProperty = BindableProperty.Create("ItemTemplate", typeof(DataTemplate), typeof(ItemsControl), defaultValue: default(DataTemplate));
public DataTemplate ItemTemplate
{
get { return (DataTemplate)this.GetValue(ItemTemplateProperty); }
set { this.SetValue(ItemTemplateProperty, value); }
}
static void OnItemsSourceChanged(BindableObject bindable, object oldValue, object newValue)
{
var control = bindable as ItemsControl;
if (control == null)
{
return;
}
//檢測是否實現該接口,如果實現,就訂閱該事件
var oldCollection = oldValue as INotifyCollectionChanged;
if (oldCollection != null)
{
oldCollection.CollectionChanged -= control.OnCollectionChanged;
}
if (newValue == null)
{
return;
}
control.ItemsPanel.Children.Clear();
//遍歷數據源中每個元素,為它創建View,並設置其BindingContext
foreach (var item in (IEnumerable)newValue)
{
object content;
content = control.ItemTemplate.CreateContent();
View view;
var cell = content as ViewCell;
if (cell != null)
{
view = cell.View;
}
else
{
view = (View)content;
}
//元素點擊相關事件
view.GestureRecognizers.Add(control._tapGestureRecognizer);
view.BindingContext = item;
control.ItemsPanel.Children.Add(view);
}
var newCollection = newValue as INotifyCollectionChanged;
if (newCollection != null)
{
newCollection.CollectionChanged += control.OnCollectionChanged;
}
control.SelectedItem = control.ItemsPanel.Children[control.SelectedIndex].BindingContext;
//更新布局
control.UpdateChildrenLayout();
control.InvalidateLayout();
}
CollectionChanged實現方法如下:
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems != null)
{
this.ItemsPanel.Children.RemoveAt(e.OldStartingIndex);
this.UpdateChildrenLayout();
this.InvalidateLayout();
}
if (e.NewItems == null)
{
return;
}
foreach (var item in e.NewItems)
{
var content = this.ItemTemplate.CreateContent();
View view;
var cell = content as ViewCell;
if (cell != null)
{
view = cell.View;
}
else
{
view = (View)content;
}
if (!view.GestureRecognizers.Contains(this._tapGestureRecognizer))
{
view.GestureRecognizers.Add(this._tapGestureRecognizer);
}
view.BindingContext = item;
this.ItemsPanel.Children.Insert(e.NewItems.IndexOf(item), view);
}
this.UpdateChildrenLayout();
this.InvalidateLayout();
}
到目前為止,已經實現ItemsControl控件大部分的內容了,還需要實現的有
- SelectedItem,SelectedIndex:當前列表選定項
- ItemSelected:列表中元素被選定時觸發
怎么判斷元素被選定呢?
當一個元素被點擊后,認為它被選中了,所以需要監聽列表中每一個元素的點擊事件。
列表中每一個View被點擊后,觸發OnTapped事件,事件的發送者是該View本身
//只定義一個TapGestureRecognizer,不需要為每一個元素都創建,只需要為每一個元素的GestureRecognizers集合添加該實例即可。
TapGestureRecognizer _tapGestureRecognizer;
//在構造函數中創建一個Tap事件的GestureRecognizer,並且訂閱其Tapped事件
public ItemsControl()
{
_tapGestureRecognizer = new TapGestureRecognizer();
_tapGestureRecognizer.Tapped += OnTapped;
}
···
private void OnTapped(object sender, EventArgs e)
{
var view = (BindableObject)sender;
this.SelectedItem = view.BindingContext;
}
···
static void OnItemsSourceChanged(BindableObject bindable, object oldValue, object newValue)
{
···
if (!view.GestureRecognizers.Contains(this._tapGestureRecognizer))
{
view.GestureRecognizers.Add(this._tapGestureRecognizer);
}
···
}
···
一個基本的ItemsControl列表控件就完成了,至此,它的已經具備Xamarin.Forms提供的ListView的大致功能。不過還是有幾點
- 它不支持虛擬化技術,所以在列表數據量比較大的時候,會有明顯的卡頓
具體代碼和Demo看我的Github: