Created
June 3, 2019 15:13
-
-
Save TrueGeek/1e68aa0d17d2ca7ef5a938a2aaf4db59 to your computer and use it in GitHub Desktop.
Xamarin Forms Bindable Scrollable StackLayout
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<ScrollView | |
xmlns="http://xamarin.com/schemas/2014/forms" | |
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" | |
x:Class="Project.Controls.BindableScrollableStackLayout"> | |
<StackLayout x:Name="stackLayout" /> | |
</ScrollView> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections; | |
using System.Collections.Generic; | |
using System.Collections.Specialized; | |
using System.Linq; | |
using System.Threading.Tasks; | |
using System.Windows.Input; | |
using Xamarin.Forms; | |
namespace Project.Controls | |
{ | |
public partial class BindableScrollableStackLayout : ScrollView | |
{ | |
public event DataUpdateEventHandler OnDataUpdate; | |
public BindableScrollableStackLayout() | |
{ | |
InitializeComponent(); | |
stackLayout.Spacing = 0; | |
} | |
public ICollection ItemsSource | |
{ | |
get => (ICollection)GetValue(ItemsSourceProperty); | |
set => SetValue(ItemsSourceProperty, value); | |
} | |
public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create(nameof(ItemsSource), typeof(ICollection), typeof(BindableStackLayout), new List<object>(), BindingMode.TwoWay, null, propertyChanged: (bindable, oldValue, newValue) => { ItemsChanged(bindable, (ICollection)oldValue, (ICollection)newValue); }); | |
public DataTemplate ItemTemplate | |
{ | |
get => (DataTemplate)GetValue(ItemTemplateProperty); | |
set => SetValue(ItemTemplateProperty, value); | |
} | |
public static readonly BindableProperty ItemTemplateProperty = BindableProperty.Create(nameof(ItemTemplate), typeof(DataTemplate), typeof(BindableStackLayout), default(DataTemplate)); | |
public ICommand SelectedItemCommand | |
{ | |
get => (ICommand)GetValue(SelectedItemCommandProperty); | |
set => SetValue(SelectedItemCommandProperty, value); | |
} | |
public static readonly BindableProperty SelectedItemCommandProperty = BindableProperty.Create(nameof(SelectedItemCommand), typeof(ICommand), typeof(BindableStackLayout), default(ICommand)); | |
protected override void OnPropertyChanged(string propertyName = null) | |
{ | |
base.OnPropertyChanged(propertyName); | |
if (propertyName == SelectedItemCommandProperty.PropertyName) | |
{ | |
if (SelectedItemCommand == null) | |
return; | |
foreach (var view in stackLayout.Children) | |
{ | |
if (view.GestureRecognizers.Any()) | |
view.GestureRecognizers.Clear(); | |
BindSelectedItemCommand(view); | |
} | |
} | |
else if (propertyName == "Height") | |
{ | |
// height changed | |
// probably because keyboard was shown and then hidden | |
// (or because it was just created) | |
if (stackLayout.Children.Any()) | |
{ | |
Device.BeginInvokeOnMainThread(async () => | |
{ | |
await Task.Delay(100); | |
await ScrollToBottom(false); | |
}); | |
} | |
} | |
} | |
private static void ItemsChanged(BindableObject bindable, ICollection oldValue, ICollection newValue) | |
{ | |
var repeater = (BindableScrollableStackLayout)bindable; | |
if (oldValue != null) | |
{ | |
if (oldValue is INotifyCollectionChanged observable) | |
{ | |
observable.CollectionChanged -= repeater.CollectionChanged; | |
} | |
} | |
if (newValue != null) | |
{ | |
repeater.UpdateItems(); | |
if (repeater.ItemsSource is INotifyCollectionChanged observable) | |
{ | |
observable.CollectionChanged += repeater.CollectionChanged; | |
} | |
} | |
} | |
private void UpdateItems() | |
{ | |
if (ItemsSource.Count > 0) BuildItems(); | |
OnDataUpdate?.Invoke(this, new EventArgs()); | |
} | |
public void BuildItems() | |
{ | |
stackLayout.Children.Clear(); | |
try | |
{ | |
foreach (object item in ItemsSource) | |
{ | |
stackLayout.Children.Add(GetItemView(item)); | |
} | |
Device.BeginInvokeOnMainThread(async () => | |
{ | |
await ScrollToBottom(false); | |
}); | |
} | |
catch | |
{ | |
// sometimes, if the list is updated quickly - we get an | |
// error that the collection was modified during ennumeration | |
// not much to do but eat the exception and then the entire | |
// thing will be rebuilt on the next refresh | |
} | |
} | |
private View GetItemView(object item) | |
{ | |
var content = CreateItemContent(ItemTemplate, item); | |
if (!(content is View) && !(content is ViewCell)) | |
{ | |
throw new Exception($"Templated control must be a View or a ViewCell ({nameof(ItemTemplate)})"); | |
} | |
var view = content as View ?? ((ViewCell)content).View; | |
view.BindingContext = item; | |
if (view.GestureRecognizers.Any()) | |
view.GestureRecognizers.Clear(); | |
if (SelectedItemCommand != null && SelectedItemCommand.CanExecute(item)) | |
{ | |
BindSelectedItemCommand(view); | |
} | |
else | |
{ | |
view.InputTransparent = true; | |
} | |
return view; | |
} | |
private void BindSelectedItemCommand(View view) | |
{ | |
var tapGestureRecognizer = new TapGestureRecognizer { Command = SelectedItemCommand, CommandParameter = view.BindingContext }; | |
view.GestureRecognizers.Add(tapGestureRecognizer); | |
} | |
private void CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) | |
{ | |
var items = ItemsSource.Cast<object>().ToList(); | |
switch (e.Action) | |
{ | |
case NotifyCollectionChangedAction.Add: | |
var index = e.NewStartingIndex; | |
foreach (var newItem in e.NewItems) | |
{ | |
Device.BeginInvokeOnMainThread(async () => | |
{ | |
stackLayout.Children.Insert(index++, GetItemView(newItem)); | |
await Task.Delay(25); // wait a sec to give the UI time to add the element we just added | |
await ScrollToBottom(); | |
}); | |
} | |
break; | |
case NotifyCollectionChangedAction.Move: | |
var moveItem = items[e.OldStartingIndex]; | |
stackLayout.Children.RemoveAt(e.OldStartingIndex); | |
stackLayout.Children.Insert(e.NewStartingIndex, GetItemView(moveItem)); | |
break; | |
case NotifyCollectionChangedAction.Remove: | |
stackLayout.Children.RemoveAt(e.OldStartingIndex); | |
break; | |
case NotifyCollectionChangedAction.Replace: | |
stackLayout.Children.RemoveAt(e.OldStartingIndex); | |
stackLayout.Children.Insert(e.NewStartingIndex, GetItemView(items[e.NewStartingIndex])); | |
break; | |
case NotifyCollectionChangedAction.Reset: | |
UpdateItems(); | |
break; | |
} | |
OnDataUpdate?.Invoke(this, new EventArgs()); | |
} | |
private static object CreateItemContent(DataTemplate dataTemplate, object item) | |
{ | |
if (dataTemplate is DataTemplateSelector dts) | |
{ | |
var template = dts.SelectTemplate(item, null); | |
template.SetValue(BindingContextProperty, item); | |
return template.CreateContent(); | |
} | |
dataTemplate.SetValue(BindingContextProperty, item); | |
return dataTemplate.CreateContent(); | |
} | |
private async Task ScrollToBottom(bool animate = true) | |
{ | |
await this.ScrollToAsync(stackLayout.Children.Last(), ScrollToPosition.End, animate); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment