Last active
October 27, 2018 13:43
-
-
Save weitzhandler/6f8cf53401750ec97f38bab5fe634460 to your computer and use it in GitHub Desktop.
Xamarin.Forms AutoCompleteView
This file contains hidden or 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.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<TItemType, string, bool>. | |
/// </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); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
you have example ?