Skip to content

Instantly share code, notes, and snippets.

@weitzhandler
Last active October 27, 2018 13:43
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save weitzhandler/6f8cf53401750ec97f38bab5fe634460 to your computer and use it in GitHub Desktop.
Save weitzhandler/6f8cf53401750ec97f38bab5fe634460 to your computer and use it in GitHub Desktop.
Xamarin.Forms AutoCompleteView
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using System.Reflection;
using System.Windows.Input;
namespace Xamarin.Forms
{
public class AutoCompleteView : ContentView
{
readonly SearchBar _SearchBar = new SearchBar();
readonly RelativeLayout _Container = new RelativeLayout();
readonly ListView _ListView = new ListView { Opacity = 1, BackgroundColor = Color.White, IsVisible = false };
public AutoCompleteView()
{
MeasureInvalidated += (sender, e) => InvalidateSearchBar();
_SearchBar.SearchButtonPressed += (sender, e) => OnSearch();
_SearchBar.TextChanged += SearchBarTextChanged;
_SearchBar.Focused += SearchBar_Focused;
_SearchBar.Unfocused += (sender, e) => _ListView.IsVisible = false;
_SearchBar.SizeChanged += (sender, e) => InvalidateSearchBar();
_ListView.ItemSelected += _ListView_ItemSelected;
_ListView.ItemAppearing += ListView_ItemAppearing;
_Container.Children.Add(_SearchBar, Constraint.Constant(0), Constraint.Constant(0));
_Container.Children.Add(_ListView, yConstraint: Constraint.RelativeToParent(rl => rl.Height), widthConstraint: Constraint.RelativeToParent(rl => rl.Width));
_Container.RaiseChild(_ListView);
Content = _Container;
}
void ListView_ItemAppearing(object sender, ItemVisibilityEventArgs e)
{
var lv = (ListView)sender;
lv.ItemAppearing -= ListView_ItemAppearing;
var zindex = DependencyService.Get<IZindex>();
if (zindex != null)
zindex.MoveToTop(lv);
}
void InvalidateSearchBar()
{
if (WidthRequest == (double)WidthRequestProperty.DefaultValue)
_Container.WidthRequest = _SearchBar.Width;
else
_SearchBar.WidthRequest = WidthRequest;
if (HeightRequest == (double)HeightRequestProperty.DefaultValue)
_Container.HeightRequest = _SearchBar.Height;
else
_SearchBar.HeightRequest = HeightRequest;
}
private void SearchBar_Focused(object sender, FocusEventArgs e) =>
UpdateVisibility((IList)_ListView.ItemsSource);
public new event EventHandler<FocusEventArgs> Focused
{
add => _SearchBar.Focused += value;
remove => _SearchBar.Focused -= value;
}
private void _ListView_ItemSelected(object sender, SelectedItemChangedEventArgs e) => OnItemSelected(e.SelectedItem);
public IList ItemsSource
{
get => (IList)GetValue(ItemsSourceProperty);
set => SetValue(ItemsSourceProperty, value);
}
public static readonly BindableProperty ItemsSourceProperty =
BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(AutoCompleteView), propertyChanged: (sender, o, n) => ((AutoCompleteView)sender).OnItemsSourceChanged((IList)o, (IList)n));
public string Placeholder
{
get => (string)GetValue(PlaceholderProperty);
set => SetValue(PlaceholderProperty, value);
}
public static readonly BindableProperty PlaceholderProperty =
BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(AutoCompleteView), propertyChanged: (BindableObject sender, object old, object @new) => ((AutoCompleteView)sender)._SearchBar.Placeholder = (string)@new);
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
public static readonly BindableProperty TextProperty =
BindableProperty.Create(nameof(Text), typeof(string), typeof(AutoCompleteView), SearchBar.TextProperty.DefaultValue, BindingMode.TwoWay, propertyChanged: (sender, old, @new) => ((AutoCompleteView)sender).OnTextChanged((string)@new));
/// <summary>
/// The <see cref="SelectedItem"/> property doesn't affect the control.
/// It only gets updated if user deliberately selects an item, and <see cref="ItemSelectedCommand"/> is null.
/// </summary>
public object SelectedItem
{
get => GetValue(SelectedItemProperty);
set => SetValue(SelectedItemProperty, value);
}
public static readonly BindableProperty SelectedItemProperty =
BindableProperty.Create(nameof(SelectedItem), typeof(object), typeof(AutoCompleteView), defaultBindingMode: BindingMode.TwoWay, propertyChanged: (sender, o, n) => ((AutoCompleteView)sender).OnSelectedItemChanged(n));
public int MaxDropdownSize
{
get => (int)GetValue(MaxDropdownSizeProperty);
set => SetValue(MaxDropdownSizeProperty, value);
}
// TODO if property changed update internal property inside Filtered collection
public static readonly BindableProperty MaxDropdownSizeProperty =
BindableProperty.Create(
nameof(MaxDropdownSize),
typeof(int),
typeof(AutoCompleteView),
5,
validateValue: (bindable, value) => ((int)value) > 0,
propertyChanged: (bindable, old, @new) => ((AutoCompleteView)bindable).OnMaxDropdownSizeChanged((int)@new));
void OnMaxDropdownSizeChanged(int maxDropdownSize)
{
if (_ListView.ItemsSource is IFilterCollection fc)
fc.MaxFilteredCount = maxDropdownSize;
}
BindingBase _SelectedItemTextPath;
public BindingBase SelectedItemTextPath
{
get => _SelectedItemTextPath;
set
{
if (_SelectedItemTextPath == value) return;
OnPropertyChanging();
_SelectedItemTextPath = value;
OnSelectedItemTextPathChanged();
OnPropertyChanged();
}
}
public DataTemplate ItemTemplate
{
get => (DataTemplate)GetValue(ItemTemplateProperty);
set => SetValue(ItemTemplateProperty, value);
}
public static readonly BindableProperty ItemTemplateProperty =
BindableProperty.Create(nameof(ItemTemplate), typeof(DataTemplate), typeof(AutoCompleteView), propertyChanged: (sender, o, n) => ((AutoCompleteView)sender).OnItemTemplateChanged((DataTemplate)n));
public ICommand ItemSelectedCommand
{
get => (ICommand)GetValue(ItemSelectedCommandProperty);
set => SetValue(ItemSelectedCommandProperty, value);
}
public static readonly BindableProperty ItemSelectedCommandProperty =
BindableProperty.Create(
nameof(ItemSelectedCommand),
typeof(ICommand),
typeof(AutoCompleteView));
/// <summary>
/// Filter method must be a Func&lt;TItemType, string, bool&gt;.
/// </summary>
public MulticastDelegate FilterFunction
{
get => (MulticastDelegate)GetValue(FilterFunctionProperty);
set => SetValue(FilterFunctionProperty, value);
}
public static readonly BindableProperty FilterFunctionProperty =
// TODO handle property changed
BindableProperty.Create(
nameof(FilterFunction),
typeof(MulticastDelegate),
typeof(AutoCompleteView),
defaultValue: (Func<object, string, bool>)(DefaultFilter),
validateValue: (sender, property) => ((AutoCompleteView)sender).ValidateFilter((MulticastDelegate)property));
public static bool DefaultFilter(object item, string query)
{
if (item == null || string.IsNullOrWhiteSpace(query)) return false;
var itemStr = item.ToString();
// TODO future: implement option to extract using ItemTextPath.
if (query.Contains(' '))
return itemStr.StartsWith(query, StringComparison.OrdinalIgnoreCase);
var words = itemStr.Split(' ');
return words.Any(w => w.StartsWith(query, StringComparison.OrdinalIgnoreCase));
}
public bool EnableBinarySearch
{
get => (bool)GetValue(EnableBinarySearchProperty);
set => SetValue(EnableBinarySearchProperty, value);
}
public static readonly BindableProperty EnableBinarySearchProperty =
// TODO handle property changed
BindableProperty.Create(nameof(EnableBinarySearch), typeof(bool), typeof(AutoCompleteView), false);
public bool InstantSearch
{
get => (bool)GetValue(InstantSearchProperty);
set => SetValue(InstantSearchProperty, value);
}
public static readonly BindableProperty InstantSearchProperty =
BindableProperty.Create(nameof(InstantSearch), typeof(bool), typeof(AutoCompleteView), true, propertyChanged: (sender, old, @new) => ((AutoCompleteView)sender).OnInstantSearchChanged((bool)@new));
void OnItemTemplateChanged(DataTemplate n) =>
_ListView.ItemTemplate = n;
private Type _CollectionType;
private Action<Predicate<object>> InternalFilterMethod;
void OnItemsSourceChanged(IList old, IList @new)
{
if (old is INotifyCollectionChanged oncc)
oncc.CollectionChanged -= OnCollectionChanged;
var lv = _ListView;
if (@new != null)
{
var itemType = _CollectionType = GetEnumeratedType(@new.GetType());
var ocType = typeof(FilteredObservableCollection<>).MakeGenericType(itemType);
var filteredCollection = (IFilterCollection)Activator.CreateInstance(ocType, @new);
filteredCollection.MaxFilteredCount = MaxDropdownSize;
filteredCollection.CollectionChanged += OnCollectionChanged;
InternalFilterMethod = filteredCollection.Filter;
lv.ItemsSource = filteredCollection;
if (EnableBinarySearch && itemType != typeof(string))
throw new NotSupportedException("Binary search is only supported with string collections.");
else if (!ValidateFilter(FilterFunction))
throw new InvalidCastException($"The value of property '{nameof(FilterFunction)}' must be a 'Func<{ocType}, string, bool>'.");
OnSearch();
@new = filteredCollection;
}
else
{
((INotifyCollectionChanged)lv.ItemsSource).CollectionChanged -= OnCollectionChanged;
_CollectionType = null;
InternalFilterMethod = null;
lv.ItemsSource = null;
}
UpdateVisibility(@new);
}
static Type GetEnumeratedType(Type type)
{
if (type.IsArray)
return type.GetElementType();
var info = type.GetTypeInfo();
if (info.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
return type.GenericTypeArguments[0];
var enumType = info.ImplementedInterfaces
.SingleOrDefault(t => t.GetTypeInfo().IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>))
?.GenericTypeArguments[0];
return enumType;
}
bool ValidateFilter(MulticastDelegate searchMethod)
{
if (EnableBinarySearch) return true;
if (searchMethod == null)
return false;
var itemsSource = _ListView.ItemsSource;
if (ItemsSource == null)
return true;
else
{
var predicateType = searchMethod.GetType();
if (predicateType == null)
return true;
else
{
var typeArguments = predicateType.GenericTypeArguments;
return
typeArguments.Length == 3
&& typeArguments.First().GetTypeInfo().IsAssignableFrom(_CollectionType.GetTypeInfo())
&& predicateType.GenericTypeArguments.Skip(1).SequenceEqual(new[] { typeof(string), typeof(bool) });
}
}
}
void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) =>
UpdateVisibility((IList)sender);
static readonly Lazy<MethodInfo> CreateDefaultMethod =
new Lazy<MethodInfo>(() => typeof(ItemsView<Cell>).GetTypeInfo().DeclaredMethods.Single(mi => mi.Name == "CreateDefault"));
void UpdateVisibility(IList collection)
{
var count = collection?.Count ?? 0;
var isVisible = SelectedItem == null && count > 0 && (_SearchBar.IsFocused || _ListView.IsFocused);
if (_ListView.IsVisible != isVisible)
_ListView.IsVisible = isVisible;
if (isVisible)
{
var rowHeight = _ListView.RowHeight;
if (rowHeight == (int)ListView.RowHeightProperty.DefaultValue)
// TODO:
rowHeight = 20;
_ListView.HeightRequest = count * rowHeight;
}
}
void OnInstantSearchChanged(bool value)
{
var sb = _SearchBar;
if (value)
sb.TextChanged += SearchBarTextChanged;
else
sb.TextChanged -= SearchBarTextChanged;
}
void SearchBarTextChanged(object sender, TextChangedEventArgs e)
{
Text = e.NewTextValue;
var desiredText = SelectedItem?.ToString();
if (e.NewTextValue != desiredText)
SelectedItem = null;
OnSearch(e.NewTextValue);
}
protected virtual void OnTextChanged(string @new)
{
if (_SearchBar.Text != @new)
_SearchBar.Text = @new;
}
protected virtual void OnSearch(string text = null)
{
if (text == null) text = Text;
if (!(_ListView.ItemsSource is IFilterCollection filterCollection)) return;
if (string.IsNullOrWhiteSpace(text))
filterCollection.Clear();
else if (EnableBinarySearch)
filterCollection.FilterBinarily(_SearchBar.Text);
else
filterCollection.Filter(FilterProxy);
}
bool FilterProxy(object item) =>
(bool)FilterFunction.DynamicInvoke(item, _SearchBar.Text);
void OnItemSelected(object selectedItem)
{
if (selectedItem == null) return;
SelectedItem = selectedItem;
if (selectedItem == null)
return;
if (ItemSelectedCommand?.CanExecute(selectedItem) == true)
{
ItemSelectedCommand?.Execute(selectedItem);
_SearchBar.Text = string.Empty;
}
_ListView.ItemSelected -= _ListView_ItemSelected;
_ListView.SelectedItem = null;
_ListView.ItemSelected += _ListView_ItemSelected;
UpdateVisibility(null);
}
void OnSelectedItemChanged(object item)
{
if (item == null) return;
_SearchBar.TextChanged -= SearchBarTextChanged;
Text = GetText();
_SearchBar.TextChanged += SearchBarTextChanged;
}
string GetText()
{
var selectedItem = SelectedItem;
if (selectedItem != null && SelectedItemTextPath is Binding binding)
{
if (binding.Source != null)
throw new NotSupportedException("Only direct Path bindings to public properties are supported.");
var property = selectedItem.GetType().GetRuntimeProperty(binding.Path);
if (property != null)
selectedItem = property.GetValue(selectedItem);
}
return selectedItem?.ToString();
}
void OnSelectedItemTextPathChanged()
{
OnSelectedItemChanged(SelectedItem);
}
interface IFilterCollection : INotifyCollectionChanged, IList
{
int MaxFilteredCount { get; set; }
void Filter(Predicate<object> predicate);
void FilterBinarily(string term, bool ensureSorted = false);
}
class FilteredObservableCollection<T> : ObservableRangeCollection<T>, IFilterCollection
{
public FilteredObservableCollection(IList<T> list)
{
Collection = list ?? throw new ArgumentNullException(nameof(list));
}
IList<T> Collection;
public int MaxFilteredCount { get; set; }
public override event NotifyCollectionChangedEventHandler CollectionChanged
{
add
{
base.CollectionChanged += value;
if (Collection is INotifyCollectionChanged ncc)
ncc.CollectionChanged += value;
}
remove
{
base.CollectionChanged -= value;
if (Collection is INotifyCollectionChanged ncc)
ncc.CollectionChanged -= value;
}
}
public void Filter(Predicate<object> filter) => Filter((Predicate<T>)(o => filter(o)));
public virtual void Filter(Predicate<T> filter)
{
if (filter == null) Clear();
var passes = new List<T>();
foreach (var item in Collection)
{
if (passes.Count >= MaxFilteredCount) break;
if (filter(item))
passes.Add(item);
}
ReplaceRange(passes);
}
bool sorted;
/// <summary>
/// Make sure the lists is sorted before calling this method.
/// </summary>
/// <param name="term"></param>
public virtual void FilterBinarily(string term, bool ensureSorted = false)
{
if (string.IsNullOrWhiteSpace(term))
{
ClearItems();
return;
}
if (ensureSorted && !sorted)
{
// TODO sort
sorted = true;
}
var comparer = new StartsWithComparer();
var index = binarySearchFirstIndex();
if (index < 0)
{
ClearItems();
return;
}
var start = index;
var strCollection = Collection as IList<string>;
string current = strCollection[start];
var passes = new List<string> { current };
while (++index - start < MaxFilteredCount && comparer.Compare(current = strCollection[index], term) == 0)
passes.Add(current);
ReplaceRange((IEnumerable<T>)passes);
int binarySearchFirstIndex()
{
int buffer = Collection.Count;
int indexLoc = -1;
var rounds = 0;
while ((buffer = binarySearch(buffer)) >= 0)
{
++rounds;
indexLoc = buffer;
}
return indexLoc;
}
int binarySearch(int length)
{
if (Collection is List<string> list)
return list.BinarySearch(0, length, term, comparer);
else if (Collection is T[] array)
return Array.BinarySearch(array as string[], 0, length, term, comparer);
else
throw new NotSupportedException($"Binary search is only supported on string collections, not '{typeof(T)}' collections.");
}
}
class StartsWithComparer : IComparer<string>
{
public int Compare(string x, string y) => x?.StartsWith(y, StringComparison.OrdinalIgnoreCase) == true ? 0 : x.CompareTo(y);
}
}
}
}
@weitzhandler
Copy link
Author

This gist requires additional files.
One of them is the ObservableRangeCollection gist.

@concedev
Copy link

you have example ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment