1. 程式人生 > >Xamarin自定義佈局系列——ListView的一個自定義實現ItemsControl(橫向列表)

Xamarin自定義佈局系列——ListView的一個自定義實現ItemsControl(橫向列表)

原文: Xamarin自定義佈局系列——ListView的一個自定義實現ItemsControl(橫向列表)

在以前寫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:

ItemsControl原始碼