AllComboBox——带有(All Options)选项的WPF ComboBox
感冒中,写的比较乱,自己看的懂撒。
在实际工作中,会遇到一个“全选”的Combobox,但是数据源是没有"All"的,这个要怎么实现呢?
原先设计师是用一个个new ComboboxItem,然后再插入一个All ComboBoxItem到ComboBox.Items集合属性里面。但是这样做,无法支持Binding,对MVVM不友好。为什么不友好?因为你用itemsSource = {Binding Data},Data是几笔,Combobox就是几笔数据。
不支持Binding那开发就很不方便了,于是我必须找到一个好的方式。
分析一下,其实就是包装一下数据源,增加一笔"All"的数据项到WPF ComboBox中去。那怎么做呢?既然是itemsSource属性,那么我Overridemetadata不久可以了?于是我屁颠屁颠的就开始动工。
这时候先引入一个WPF的类:CompositeCollection。这个类就是专门把若干个Collection组合到一起,不需要new list, 再list.add()。
var composite = new CompositeCollection();
var container = new CollectionContainer() { Collection = source };
var all = "All Customers";
composite.Add(all);
composite.Add(container);
既然是Binding,那么我就把上述代码写到一个ItemsSourceConverter类里面,这样通过转换器,UI就多出来一个"All"的选项。
但是问题又来了,当UI选中"All"的时候,SelecedItem Binding就报错,因为string无法转换到Entity上。
问题不止一个,当使用itemTemplate对数据进行格式化的时候,比如“{0}-{1}"的格式,"All Customer"就显示不出来了,因为使用者用Entiy.Code + Entity.Name组合是,"All"是没有Code, Name属性的。这个问题虽然可以通过修改Converter逻辑,如果是"All"就原封不动返回,但是这样做,所有的格式Converter都需要加上这个判断,挺烦的。再说人家也不一定是只给你AllComboBox用,其他也在用,为什么给你特殊化呢?
还有问题呢。ComboBox还可以手输,自动筛选。All也要支持。
其实这个功能WPF Combobox已经提供了,但是,但是,只支持单个属性的Search,DisplayPath=XXX。如果你自己重新定义了ItemTemplate,而且用Code + Name这种形式来格式化,这个功能立刻失效。为嘛?因为微软的Search是找Entity.ToString()的,看你输入的string是否和Entity.ToString()匹配。不过这样也引出一个问题,UI的格式千般万化,但tostring()只能写一种,那么怎么才能适应各种UI格式的需求呢?
肿么办哩?总不能为了支持Code + Name,自己写个Searth吧?监听TextBox keyDown的各种事件,然后再XXX。。。( ⊙o⊙ )哇,这个工作量也太大了。
估计很多人说,只能这样了。但是,但是,我们不能换个思路么?既然search是找toString()的,我们可以wrapper一个类,然后改写tostring(),这样不就可以不用改Entity,而做到千般万化。不过,这个类需要支持反射调用,难道我们要继承Entity,然后重写ToString()?那岂不是太多了,我每个Entity都写一个子类?
Oh my god.那有木有一个方案我写一个类,可以支持无限个Entity呢?在.net 4.0时代之前,我们只能通过改写Binding Path的形式来实现。比如Wrapper类,原来Binding Path = Name,这时因为Binding的是一个Wrapper{instance = entity}, 所以新Path= Instance.Name。不过自从.net 4.0引入了Dynamic之后,我们就方便多了。O(∩_∩)O~
我们可以写一个DisplayObject,在构造函数里面,让外部传给tostring,内部里面通过getmemeber自动调用属性,这样Binding写法跟原先的一模一样咯。
/// <summary>
/// the wrapper class for UI display
/// </summary>
private class DisplayObject : DynamicObject
{
private string _tostring;
private string _firstBinderName;
public DisplayObject(object instance, string tostring)
{
Instance = instance;
_tostring = tostring;
}
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
if (IsAll)
{
//sometimes, converter will conver to "All Division- All Division" , so just return one "All Division"
if (String.IsNullOrEmpty(_firstBinderName))
_firstBinderName = binder.Name;
result = binder.Name == _firstBinderName ? _tostring : null;
}
else
{
result = Instance.GetPropertyValue(binder.Name);
}
return true;
}
public override bool TrySetMember(SetMemberBinder binder, object value)
{
if (IsAll)
return false;
Instance.SetPropertyValue(binder.Name, value);
return true;
}
public object Instance
{ get; private set; }
internal bool IsAll
{ get { return Instance == null; } }
public override string ToString()
{
return _tostring;
}
}
OK,通过这个DisplayObject,圆满解决了Search不支持IMultiConverter问题。
我对AllComboBox进行抽象,增加了IsAll,代表是否选中All功能,这样更方便理解和使用。还加入了DisplayList等功能。
代码思路和方案就是上述,但是具体实现中,遇到了很多小问题,还好都一一解决了。其实这些问题都是MVVM要通知后端Viewmodel造成的,比如search text变化了,或者isAll变了,要通知后端binding的Text和Selecteditem,还有Error的显示等等。
本身难度上面已经解决了,主要是复杂度和各种各样的点都要照顾到,这个控件断断续续用了几个月的时间才完善,功能不断增加,还好现在稳定了。代码如下:
public class AllComboBox : ComboBox
{
private BindingBase _itemTemplateBinding;
protected override void OnInitialized(EventArgs e)
{
//add error logger and log when binding error occurs
Validation.AddErrorHandler(this, (sender, arg) =>
{
if (arg.Error.RuleInError is ExceptionValidationRule)
System.Diagnostics.Debug.Fail(arg.Error.ErrorContent.ToString());
});
//override these propperties
OverrideItemsSourceProperty(this);
OverrideSelectedItemProperty(this);
OverrideTextProperty(this);
//attach the on itemsSource changed, refresh the text to notify the viewmodel;
ResetTextWhenItemsSourceChanged();
//deal with the text error , reselect the same comboboxitem, the text do not update bug.
ResetSelectedItemWhenTextError();
base.OnInitialized(e);
}
private void ResetTextWhenItemsSourceChanged()
{
if (this.IsEditable)
{
var descriptor = DependencyPropertyDescriptor.FromProperty(ItemsSourceProperty, typeof(AllComboBox));
//notify the view model that the text has changed
descriptor.AddValueChanged(this, (s, e) => BindingOperations.GetBindingExpression(this, TextProperty).SafeInvoke(expression => expression.UpdateSource()));
}
}
private void ResetSelectedItemWhenTextError()
{
if (this.IsEditable)
{
var descriptor = DependencyPropertyDescriptor.FromProperty(TextProperty, typeof(AllComboBox));
descriptor.AddValueChanged(this, (s, e) =>
{
if (this.DisplayList.IsNullOrEmpty() || !DisplayList.Contains(this.Text))
this.SelectedItem = null;
});
}
}
private bool IsWapperByDisplayObject
{ get; set; }
public bool HasAllOption
{
get { return (bool)GetValue(HasAllOptionProperty); }
set { SetValue(HasAllOptionProperty, value); }
}
// Using a DependencyProperty as the backing store for HasAllOption. This enables animation, styling, binding, etc...
public static readonly DependencyProperty HasAllOptionProperty =
DependencyProperty.Register("HasAllOption", typeof(bool), typeof(AllComboBox), new UIPropertyMetadata(true));
public bool IsAll
{
get { return (bool)GetValue(IsAllProperty); }
set { SetValue(IsAllProperty, value); }
}
// Using a DependencyProperty as the backing store for IsAll. This enables animation, styling, binding, etc...
public static readonly DependencyProperty IsAllProperty =
DependencyProperty.Register("IsAll", typeof(bool), typeof(AllComboBox),
new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, null, (d, b) =>
{
var isAll = (bool)b;
if (isAll)
{
var cmb = d as AllComboBox;
if (cmb.HasItems && cmb.SelectedIndex != 0)
{
cmb.SelectedIndex = 0;
//notify viewmodel the selected item has changed
BindingOperations.GetBindingExpression(cmb, SelectedItemProperty).UpdateSource();
}
}
return b;
}));
public string AllText
{
get { return (string)GetValue(AllTextProperty); }
set { SetValue(AllTextProperty, value); }
}
// Using a DependencyProperty as the backing store for AllText. This enables animation, styling, binding, etc...
public static readonly DependencyProperty AllTextProperty =
DependencyProperty.Register("AllText", typeof(string), typeof(AllComboBox), new UIPropertyMetadata(string.Empty));
public IEnumerable<string> DisplayList
{
get { return (IEnumerable<string>)GetValue(DisplayListProperty); }
set { SetValue(DisplayListProperty, value); }
}
// Using a DependencyProperty as the backing store for DisplayList. This enables animation, styling, binding, etc...
public static readonly DependencyProperty DisplayListProperty =
DependencyProperty.Register("DisplayList", typeof(IEnumerable<string>), typeof(AllComboBox), new FrameworkPropertyMetadata(Enumerable.Empty<string>(), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
protected override void OnItemTemplateChanged(DataTemplate oldItemTemplate, DataTemplate newItemTemplate)
{
//get the binding expresion
if (newItemTemplate != null && newItemTemplate.HasContent)
_itemTemplateBinding = BindingOperations.GetBindingBase(newItemTemplate.LoadContent(), TextBlock.TextProperty);
base.OnItemTemplateChanged(oldItemTemplate, newItemTemplate);
}
private static void OverrideItemsSourceProperty(DependencyObject d)
{
var cmb = d as AllComboBox;
var binding = BindingOperations.GetBinding(cmb, ItemsSourceProperty);
if (binding == null) return;
var newBinding = CreateBinding(binding.Path.Path, binding.Source, mode: BindingMode.Default, converter: new ItemsSourceConverter(), converterParameter: cmb);
cmb.SetBinding(ItemsSourceProperty, newBinding);
}
private static void OverrideSelectedItemProperty(DependencyObject d)
{
var cmb = d as AllComboBox;
var binding = BindingOperations.GetBinding(cmb, SelectedItemProperty);
if (binding == null) return;
var newBinding = CreateBinding(binding.Path.Path, converter: new SelectedItemConverter(), converterParameter: cmb);
cmb.SetBinding(SelectedItemProperty, newBinding);
}
private static void OverrideTextProperty(DependencyObject d)
{
var cmb = d as AllComboBox;
var binding = BindingOperations.GetBinding(cmb, TextProperty);
if (binding == null) return;
var newBinding = CreateBinding(binding.Path.Path);
cmb.SetBinding(TextProperty, newBinding);
}
private static Binding CreateBinding(string path, object source = null, BindingMode mode = BindingMode.TwoWay, IValueConverter converter = null, object converterParameter = null)
{
var binding = new Binding(path)
{
Mode = mode,
Converter = converter,
ConverterParameter = converterParameter,
ValidatesOnDataErrors = true,
ValidatesOnExceptions = true,
NotifyOnValidationError = true
};
if (source != null)
binding.Source = source;
return binding;
}
private class ItemsSourceConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var source = value as IEnumerable;
var cmb = parameter as AllComboBox;
if (source.IsNullOrEmpty())
{
cmb.DisplayList = Enumerable.Empty<string>();
return value;
}
if (Equals(source, cmb.ItemsSource))
return source;
var composite = new CompositeCollection();
var container = new CollectionContainer() { Collection = source };
//when the allcombobx is editable and has multBinding
if (cmb.IsEditable && cmb.IsTextSearchEnabled && String.IsNullOrEmpty(cmb.DisplayMemberPath) &&
String.IsNullOrEmpty(TextSearch.GetTextPath(cmb)) && cmb._itemTemplateBinding != null)
{
//create wrapper databind objects
var list = new List<DisplayObject>(source.Count());
foreach (var item in container.Collection)
{
var display = GetDisplayValue(item, cmb._itemTemplateBinding);
var wrapper = new DisplayObject(item, display);
list.Add(wrapper);
}
container = new CollectionContainer { Collection = list };
//set flag IsWapperByDisplayObject
cmb.IsWapperByDisplayObject = true;
}
//create the all option if necessary.
if (cmb.HasAllOption)
{
var all = new DisplayObject(null, cmb.AllText);
composite.Add(all);
}
composite.Add(container);
//set the displayList
if (BindingOperations.GetBinding(cmb, DisplayListProperty) != null)
cmb.DisplayList = GetDisplayList(composite, cmb.DisplayMemberPath);
return composite;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value;
}
private static string GetDisplayValue(object item, BindingBase bindingBase)
{
if (bindingBase is MultiBinding)
{
var multiBinding = bindingBase as MultiBinding;
object[] values = new object[multiBinding.Bindings.Count];
for (int i = 0; i < multiBinding.Bindings.Count; i++)
{
var b = multiBinding.Bindings[i] as Binding;
var v = item.GetPropertyValue(b.Path.Path);
values[i] = v;
}
return multiBinding.Converter.Convert(values, null, null, null).ToString();
}
else
{
var binding = bindingBase as Binding;
return item.GetPropertyValue(binding.Path.Path).ToString();
}
}
private static IEnumerable<string> GetDisplayList(CompositeCollection collection, string displayMemberPath)
{
bool hasDisplayMemberPath = !displayMemberPath.IsNullOrEmpty();
foreach (var collectionItem in collection)
{
if (collectionItem is CollectionContainer)
{
var container = collectionItem as CollectionContainer;
if (container.Collection != null)
{
foreach (var item in container.Collection)
{
if (hasDisplayMemberPath)
yield return item.GetPropertyValue(displayMemberPath).ToString();
else
yield return item.ToString();
}
}
}
else
{
yield return collectionItem.ToString();
}
}
}
}
private class SelectedItemConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null)
return value;
var cmb = parameter as AllComboBox;
if (cmb.IsWapperByDisplayObject && !cmb.ItemsSource.IsNullOrEmpty())
{
foreach (var collectionItem in cmb.ItemsSource)
{
if (collectionItem is CollectionContainer)
{
var container = collectionItem as CollectionContainer;
if (container.Collection != null)
{
foreach (DisplayObject displayObject in container.Collection)
{
if (Equals(displayObject.Instance, value))
return displayObject;
}
}
}
}
}
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null)
return value;
var displayObject = value as DisplayObject;
var cmb = parameter as AllComboBox;
//displayObject is null means that this is "All" item
if (displayObject == null)
{
cmb.IsAll = false;
return value;
}
//do not always set cmb.IsAll = displayObject.IsAll, it will cause stack over flow
if (cmb.IsAll != displayObject.IsAll)
cmb.IsAll = displayObject.IsAll;
return displayObject.Instance;
}
}
/// <summary>
/// the wrapper class for UI display
/// </summary>
private class DisplayObject : DynamicObject
{
private string _tostring;
private string _firstBinderName;
public DisplayObject(object instance, string tostring)
{
Instance = instance;
_tostring = tostring;
}
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
if (IsAll)
{
//sometimes, converter will conver to "All Division- All Division" , so just return one "All Division"
if (String.IsNullOrEmpty(_firstBinderName))
_firstBinderName = binder.Name;
result = binder.Name == _firstBinderName ? _tostring : null;
}
else
{
result = Instance.GetPropertyValue(binder.Name);
}
return true;
}
public override bool TrySetMember(SetMemberBinder binder, object value)
{
if (IsAll)
return false;
Instance.SetPropertyValue(binder.Name, value);
return true;
}
public object Instance
{ get; private set; }
internal bool IsAll
{ get { return Instance == null; } }
public override string ToString()
{
return _tostring;
}
}
}
复杂吧?功能越加越多,其实可以用AttachDP原理来拆分,不过400行的代码跟MS比起来就少了很多。
自从有了AllComboBox,用MVVM开发常用的下拉框选项方便多了,改进了WPF ComboBox。
不经历过,你可能就想象不到我遇到过的问题呢。人生其实就是不断的经历。所谓的经验,也是经历过后得到的总结。所以,经历的越多,你经验越丰富。如果你只做某一个技术或者行业,你的经验就会有局限。因此,多多和不同工种,不同行业的人进行交流,是丰富阅历,增加经验的渠道。