12/08/2018, 15:44

[Xamarin Form] How to create horizontal Listview

Tổng quan Đối với những ai đã từng sử dụng Xamarin Form trong dự án của mình, thì vấn đề gặp phải lớn nhất đó là số lượng các UI control được nó hỗ trợ khá là ít. Những ai đã quen code native thì việc dùng Xamarin Form cảm thấy khá bất tiện, đặc biệt đối với những giao diện có độ phức tạp tạo, ...

  1. Tổng quan Đối với những ai đã từng sử dụng Xamarin Form trong dự án của mình, thì vấn đề gặp phải lớn nhất đó là số lượng các UI control được nó hỗ trợ khá là ít. Những ai đã quen code native thì việc dùng Xamarin Form cảm thấy khá bất tiện, đặc biệt đối với những giao diện có độ phức tạp tạo, việc code trên Form quả là không dễ dàng. Bài này mình sẽ hướng các bạn tạo một horizontal Listview Control trên Xamarin Form. Ý tưởng để tạo ra một horizontal Listview, đơn giản chỉ là tạo một custom class của ScrollView, tại đó chúng ta sẽ viết code để add thêm các sub item vào content của scrollview đó, đồng thời viết các hàm để xử bắt các sự kiện như item click, scroll to bottom... Xamarin Form biết được hạn chế của nó, nên cho phép người dùng có thể tuỳ biến lại tầng Form và tầng Render, cho phép tạo ra những UI control phức tạp. Ở tầng Render bạn phải tự việt code cho tất cả các nền tảng mà bạn muốn hỗ trợ. Quay trợ lại bài toán ban đầu, tạo ra một horizontal listview, ở ví dụ này mình sẽ chỉ hỗ trợ 2 nền tảng phổ biến là iOS và Android. Chúng ta sẽ có kiến trúc như sau.
  2. Horizontal Listview
using System;
using Xamarin.Forms;
using System.Windows.Input;
using System.Collections.ObjectModel;
using System.Collections;
using System.Collections.Specialized;
using System.Collections.Generic;
using Demo.ViewModels.Base;
using System.Linq;
using System.Threading.Tasks;

namespace Demo.Views.Customs
{
    public class HorizontalScrollView : ScrollView
    {

        public float CellWidth { get; set; }

        StackLayout _stackContainer = null;

        public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create<HorizontalScrollView, IEnumerable>(p => p.ItemsSource, null, BindingMode.TwoWay, null, (view, mOld, mNew) =>
        {
            ((HorizontalScrollView)view).LoadItems();
        });

        public IEnumerable<object> ItemsSource
        {
            get { return (IEnumerable<object>)GetValue(ItemsSourceProperty); }
            set { SetValue(ItemsSourceProperty, value); }
        }

        public static readonly BindableProperty ItemTemplateProperty = BindableProperty.Create<HorizontalScrollView, DataTemplate>(p => p.ItemTemplate, default(DataTemplate));

        public DataTemplate ItemTemplate
        {
            get { return (DataTemplate)GetValue(ItemTemplateProperty); }
            set { SetValue(ItemTemplateProperty, value); }
        }

        public static readonly BindableProperty ItemClickProperty = BindableProperty.Create<HorizontalScrollView, ICommand>(p => p.ItemClick, null);

        public ICommand ItemClick
        {
            get { return (ICommand)GetValue(ItemClickProperty); }
            set { SetValue(ItemClickProperty, value); }
        }

        public HorizontalScrollView()
        {
            this.PropertyChanged += ExtendedScrollView_PropertyChanged;
            this.BindingContextChanged += ExtendedScrollView_BindingContextChanged;
        }


        void ExtendedScrollView_BindingContextChanged(object sender, EventArgs e)
        {
            if (this.ItemTemplate == null || this.ItemsSource == null)
                return;
        }

        void ExtendedScrollView_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            if (e.PropertyName == "ItemsSource" || e.PropertyName == "ItemTemplate")
            {
                LoadItems();
            }
        }

        void LoadItems()
        {
            if (this.ItemTemplate == null || this.ItemsSource == null)
                return;

            _stackContainer = null == _stackContainer ? new StackLayout() : _stackContainer;
            _stackContainer.Orientation = this.Orientation == ScrollOrientation.Vertical
                ? StackOrientation.Vertical : StackOrientation.Horizontal;
            _stackContainer.Spacing = SeparatorWidth;

            int childCount = _stackContainer.Children.Count();
            int itemCount = this.ItemsSource.ToList().Count;

            if (itemCount > childCount)
            {
                for (int i = childCount; i < itemCount; i++)
                {
                    var viewCell = this.ItemTemplate.CreateContent() as ViewCell;
                    viewCell.View.BindingContext = this.ItemsSource.ToArray()[i];
                    SelectedBackgroundView contain = new SelectedBackgroundView()
                    {
                        Children = { viewCell.View },
                    };
                    contain.OnClicked = new Command(() =>
                        {
                            HanddleItemClick(contain);
                        });
                    _stackContainer.Children.Add(contain);
                }
            }
            this.Content = _stackContainer;
        }

        private void HanddleItemClick(View v)
        {
            if (ItemClick != null)
            {
                int index = _stackContainer.Children.IndexOf(v);
                ScrollItemArgs arg = new ScrollItemArgs();
                arg.Sender = this.ItemsSource.ToArray()[index];
                arg.Index = index;
                ItemClick.Execute(arg);
            }
        }
    }

    public class ScrollItemArgs
    {
        public object Sender { get; set; }
        public int Index { get; set; }
    }
}

Đây là code của tầng Form chúng ta sẽ đi từng đoạn code một để hiểu rõ hơn.

  • CellWidth: thuộc tính quyết định chiều rộng của mỗi item
  • stackContainer: chúng ta có một horizontal stacklayout chứa toàn bộ các item con.
  • ItemsSource: data mà scroll view thẻ hiển, một list các món ăn, một list các cửa hàng...
  • Để ý đến đoạn code:
   public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create<HorizontalScrollView, IEnumerable>(p => p.ItemsSource, null, BindingMode.TwoWay, null, (view, mOld, mNew) =>
        {
            ((HorizontalScrollView)view).LoadItems();
        });

Ta thấy rằng, mỗi khi itemsource bị thay đổi, ta đều gọi hàm ((HorizontalScrollView)view).LoadItems(); để render lại UI.

  • ItemTemplate: thuộc tính qyết định xem mỗi item khi render sẽ như thế nào.
  • LoadItems: Hàm này có nhiện vụ, render lại list item bằng cách xoá tất cả các sub item là con của "stackContainer", rồi dựa vào số lượng itemsource, sẽ add lai từng ấy item vào trong "stackContainer".
  • Để bắt sự kiện khi tap vào mỗi item, ta tạo ra một class call SelectedBackgroundView, class này sẽ có nhiệm vụ handle việc nhận sự kiện click lên chính nó. Đoạn code sau sẽ mô tả việc đó, chúng ta phải thực hiện việc bắt sự kiện click, ở tầng native cho cả Android vào iOS
// tầng form
namespace Demo.Views.Customs
{
    public class SelectedBackgroundView : AbsoluteLayout
    {
        public static readonly BindableProperty OnClickedProperty =
           BindableProperty.Create("OnClicked", typeof(ICommand), typeof(SelectedBackgroundView));

        public ICommand OnClicked
        {
            get { return (ICommand)GetValue(OnClickedProperty); }
            set { SetValue(OnClickedProperty, value); }
        }

        public static readonly BindableProperty SelectedColorProperty = BindableProperty.Create<SelectedBackgroundView, Color>(p => p.SelectedColor, Color.Default);

        public Color SelectedColor
        {
            get { return (Color)GetValue(SelectedColorProperty); }
            set { SetValue(SelectedColorProperty, value); }
        }
    }
}
// tầng Android
[assembly: ExportRenderer(typeof(SelectedBackgroundView), typeof(SelectedBackgroundView_Android))]
namespace Demo.Droid.Views.Customs
{
    public class SelectedBackgroundView_Android : ViewRenderer<SelectedBackgroundView, Android.Views.View>, Android.Views.View.IOnTouchListener
    {
        SelectedBackgroundView _mView;
        Android.Views.View nativeView;
        protected override void OnElementChanged(ElementChangedEventArgs<SelectedBackgroundView> e)
        {
            base.OnElementChanged(e);
            if (e.NewElement != null)
            {
                nativeView = new Android.Views.View(Android.App.Application.Context);
                nativeView.Alpha = 0.0f;
                _mView = (SelectedBackgroundView)e.NewElement;
                //nativeView.SetOnTouchListener(this);
                nativeView.Touch += OnTouch;
                SetNativeControl(nativeView);
            }
        }

        public override void AddView(Android.Views.View child, int index, ViewGroup.LayoutParams @params)
        {
            base.AddView(child, index, @params);
            if (nativeView != null)
                nativeView.BringToFront();
        }
        void OnTouch(object sender, TouchEventArgs e)
        {
            switch (e.Event.Action)
            {
                case MotionEventActions.Down:
                    nativeView.Alpha = 0.5f;
                    nativeView.SetBackgroundColor(Android.Graphics.Color.Gray);
                    break;
                case MotionEventActions.Cancel:
                case MotionEventActions.Up:
                    nativeView.SetBackgroundColor(Android.Graphics.Color.White);
                    nativeView.Alpha = 0.0f;
                    if (e.Event.Action == MotionEventActions.Up && _mView.OnClicked != null)
                        _mView.OnClicked.Execute(e);
                    break;
            }
        }
    }
}

//tầng iOS
[assembly: ExportRenderer(typeof(SelectedBackgroundView), typeof(SelectedBackgroundView_iOS))]
namespace Demo.iOS.Views.Customs
{
    public class SelectedBackgroundView_iOS : ViewRenderer<SelectedBackgroundView, UIView>
    {
        SelectedBackgroundView _mView;
        UIView nativeView;
        protected override void OnElementChanged(ElementChangedEventArgs<SelectedBackgroundView> e)
        {
            base.OnElementChanged(e);
            if (e.NewElement != null)
            {
                nativeView = new UIView();
                nativeView.AutoresizingMask = UIViewAutoresizing.FlexibleHeight | UIViewAutoresizing.FlexibleWidth;
                nativeView.BackgroundColor = Defines_iOS.NormalStateBackgroundColor;
                _mView = (SelectedBackgroundView)e.NewElement;

                CustomTapGesture tap = new CustomTapGesture(Defines_iOS.NormalStateBackgroundColor, Defines_iOS.SelectedStateBackgroundColor, TapGestureExecute)
                {
                    NumberOfTapsRequired = 1,
                };
                nativeView.AddGestureRecognizer(tap);

                SetNativeControl(nativeView);
            }
        }

        void TapGestureExecute()
        {
            if (_mView != null && _mView.OnClicked != null)
                _mView.OnClicked.Execute(null);
        }

        public override void AddSubview(UIView view)
        {
            base.AddSubview(view);
            if (nativeView != null)
                this.BringSubviewToFront(nativeView);
        }
    }
}
  1. Horizontal ListView Android Render
using Android.App;
using Android.Content;
using Android.OS;
using Android.Runtime;
using Android.Views;
using Android.Widget;

using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using Demo.Droid.Views.Customs;

using Demo.Views.Customs;
using System.ComponentModel;
using System.Reflection;
using Android.Util;

[assembly: ExportRenderer(typeof(ExtendedScrollView), typeof(ExtendedScrollViewRender_Android))]
namespace Demo.Droid.Views.Customs
{
    public class HorizontalScrollViewRender_Android : ScrollViewRenderer
    {
        HorizontalScrollView _scrollView;
        ExtendedScrollView _extendedScrollView;
        float _tabWidth = 0.0f;
        protected override void OnElementChanged(VisualElementChangedEventArgs e)
        {
            base.OnElementChanged(e);
            if (e.NewElement != null)
            {
                _extendedScrollView = (ExtendedScrollView)e.NewElement;
                _extendedScrollView.DidReceiveScrollRequest = HanldeScrollRequest;
                _extendedScrollView.PropertyChanged += ElementPropertyChanged;
                _tabWidth = _extendedScrollView.TabWidth;
            }
        }

        void ElementPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (e.PropertyName == "Renderer")
            {
                _scrollView = (HorizontalScrollView)typeof(ScrollViewRenderer)
                    .GetField("_hScrollView", BindingFlags.NonPublic | BindingFlags.Instance)
                    .GetValue(this);

                _scrollView.HorizontalScrollBarEnabled = false;
            }

        }

        void HanldeScrollRequest(int currentTab, float offset, bool scrollToLeft)
        {
            //System.Diagnostics.Debug.WriteLine("scroll Ofset = " + _tabWidth);
            DisplayMetrics displaymetrics = Forms.Context.Resources.DisplayMetrics;
            double tabawidthInPexel = _tabWidth * displaymetrics.Density;

            double scrollOffSetX = 0;
            if (currentTab > 0)
                scrollOffSetX = (currentTab + offset) * tabawidthInPexel - tabawidthInPexel / 2;
            else
            {
                scrollOffSetX = offset * tabawidthInPexel / 2;
            }
            _scrollView.SmoothScrollTo((int)scrollOffSetX, 0);

            //System.Diagnostics.Debug.WriteLine("Tab Index = " + currentTab + "Ofsset = " + offset);

            //System.Diagnostics.Debug.WriteLine("scroll Ofset = " + scrollOffSetX);

        }
    }
}

Nhiệm vụ của class này đó chính là tracking trạng thái hiện tai của scrollview, ví dụ như: scrollOffset, dựa vào đó mà ta có thể biết được vị trí mà người dùng đang scroll tới, đã scroll đáy chưa. 4. Horizontal ListView iOS Render

using UIKit;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using Demo.Droid.Views.Customs;
using CoreFoundation;
using Demo.Views.Customs;
using System.ComponentModel;

[assembly: ExportRenderer(typeof(HorizontalScrollView), typeof(HorizontalScrollViewRender_iOS))]
namespace Demo.Droid.Views.Customs
{
    public class HorizontalScrollViewRender_iOS : ScrollViewRenderer
    {
        UIScrollView _scrollView;
        HorizontalScrollView _extendedScrollView;
        float _tabWidth = 0.0f;
        protected override void OnElementChanged(VisualElementChangedEventArgs e)
        {
            base.OnElementChanged(e);
            if (e.NewElement != null)
            {
                _extendedScrollView = (HorizontalScrollView)e.NewElement;
                _extendedScrollView.DidReceiveScrollRequest = HanldeScrollRequest;
                _extendedScrollView.PropertyChanged += ElementPropertyChanged;
                _tabWidth = _extendedScrollView.TabWidth;
            }
        }

        void ElementPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (e.PropertyName == "Renderer")
            {
                _scrollView = this;
            }

        }

        void HanldeScrollRequest(int currentTab, float offset, bool scrollToLeft)
        {
            double tabawidthInPexel = _tabWidth;
            double scrollOffSetX = 0;
            if (currentTab > 0)
            {
                //scrollOffSetX = (currentTab + offset) * tabawidthInPexel - tabawidthInPexel / 2;

                if (scrollToLeft)
                    scrollOffSetX = (currentTab + offset) * tabawidthInPexel - tabawidthInPexel / 2;
                else
                    scrollOffSetX = (currentTab - 1 + offset) * tabawidthInPexel - tabawidthInPexel / 2;
                if (scrollOffSetX <= 0)
                    scrollOffSetX = 0;
            }
            else
            {
                scrollOffSetX = offset * tabawidthInPexel / 2;

            }
            System.Diagnostics.Debug.WriteLine(" HanldeScrollRequest Current Tab " + currentTab + " Is Scroll Left " + scrollToLeft + " Offfset " + offset + " scrollOffSetX " + scrollOffSetX + "
");
            // System.Diagnostics.Debug.WriteLine(" ");

            if (double.IsInfinity(scrollOffSetX) || double.IsNaN(scrollOffSetX))
                return;
            DispatchQueue.MainQueue.DispatchAsync(() =>
            {
                _scrollView.SetContentOffset(new CoreGraphics.CGPoint((int)scrollOffSetX, 0), true);

            });
        }
    }
}

Cũng giống như tầng android, tầng iOS cũng cần phải làm nhiệm vự tương tự như vậy, để có thể tracking vị trị hiên tại của indicator. Do tầng Form không hỗ trợ việc đó, nên ta phải đẩy lại công việc này cho tầng native, rồi từ tầng native ta mới trả ngược lại thông tin cho tầng Form. Dựa vào thông tin có được, ở tầng Form chúng ta có những như lý phù hợp.

0