How can I create a tagging control similar to ever

2019-01-16 08:48发布

I like the tagging control in Evernote (windows version) and was wondering if there is something similar out there? I have only been able to find tag cloud controls.

Specifically, I like the free format typing like in a text box that looks up and presents Intellisense style the tags that match what I have typed. When I select a tag, the text is replaced with a button representing the tag with the text of the button being the tag text.

Update: adding screenshots

Adding a new tag

adding a tag

viewing existing tags and click 'x' to delete tag

showing existing tags and delete by clicking 'x'

标签: wpf controls
2条回答
别忘想泡老子
2楼-- · 2019-01-16 08:58

This seemed like a really nice exercise, so I tried to build this control. I didn't test it thoroughly, let me know if you want to work with it and need further help.

enter image description here

enter image description here

enter image description here

enter image description here

Example usage:

<Window x:Class="WpfApplication1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApplication1"
        xmlns:s="clr-namespace:System;assembly=mscorlib"
        Title="MainWindow" Height="350" Width="525">

    <Window.DataContext>
        <local:ViewModel />
    </Window.DataContext>

    <Grid>
        <!-- todo: implement ICommand properties on EvernoteTagControl to allow easy binding to the viewmodel. Alternatively, the user could use a behavior to handle TagClick, and if necessary TagAdded/TagRemoved -->
        <local:EvernoteTagControl ItemsSource="{Binding SelectedTags}" TagClick="TagControl_TagClick" >
            <local:EvernoteTagControl.AllTags>
                <s:String>read</s:String>
                <s:String>receipt</s:String>
                <s:String>recipe</s:String>
                <s:String>research</s:String>
                <s:String>restaurants</s:String>
            </local:EvernoteTagControl.AllTags>
        </local:EvernoteTagControl>
    </Grid>
</Window>

ViewModel:

using System.Collections.Generic;
using System.ComponentModel;

namespace WpfApplication1
{
    public class ViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private List<EvernoteTagItem> _selectedTags = new List<EvernoteTagItem>();
        public List<EvernoteTagItem> SelectedTags
        {
            get { return _selectedTags; }
            set
            {
                _selectedTags = value;
                if (_selectedTags != value)
                    OnPropertyChanged("SelectedTags");
            }
        }

        public ViewModel()
        {
            this.SelectedTags = new List<EvernoteTagItem>() { new EvernoteTagItem("news"), new EvernoteTagItem("priority") };
        }

        private void OnPropertyChanged(string propertyName)
        {
            if (this.PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

EvernoteTagControl:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;

namespace WpfApplication1
{
    [TemplatePart(Name = "PART_CreateTagButton", Type = typeof(Button))]
    public class EvernoteTagControl : ListBox
    {
        public event EventHandler<EvernoteTagEventArgs> TagClick;
        public event EventHandler<EvernoteTagEventArgs> TagAdded;
        public event EventHandler<EvernoteTagEventArgs> TagRemoved;

        static EvernoteTagControl()
        {
            // lookless control, get default style from generic.xaml
            DefaultStyleKeyProperty.OverrideMetadata(typeof(EvernoteTagControl), new FrameworkPropertyMetadata(typeof(EvernoteTagControl)));
        }

        public EvernoteTagControl()
        {
            //// some dummy data, this needs to be provided by user
            //this.ItemsSource = new List<EvernoteTagItem>() { new EvernoteTagItem("receipt"), new EvernoteTagItem("restaurant") };
            //this.AllTags = new List<string>() { "recipe", "red" };
        }

        // AllTags
        public List<string> AllTags { get { return (List<string>)GetValue(AllTagsProperty); } set { SetValue(AllTagsProperty, value); } }
        public static readonly DependencyProperty AllTagsProperty = DependencyProperty.Register("AllTags", typeof(List<string>), typeof(EvernoteTagControl), new PropertyMetadata(new List<string>()));


        // IsEditing, readonly
        public bool IsEditing { get { return (bool)GetValue(IsEditingProperty); } internal set { SetValue(IsEditingPropertyKey, value); } }
        private static readonly DependencyPropertyKey IsEditingPropertyKey = DependencyProperty.RegisterReadOnly("IsEditing", typeof(bool), typeof(EvernoteTagControl), new FrameworkPropertyMetadata(false));
        public static readonly DependencyProperty IsEditingProperty = IsEditingPropertyKey.DependencyProperty;

        public override void OnApplyTemplate()
        {
            Button createBtn = this.GetTemplateChild("PART_CreateTagButton") as Button;
            if (createBtn != null)
                createBtn.Click += createBtn_Click;

            base.OnApplyTemplate();
        }

        /// <summary>
        /// Executed when create new tag button is clicked.
        /// Adds an EvernoteTagItem to the collection and puts it in edit mode.
        /// </summary>
        void createBtn_Click(object sender, RoutedEventArgs e)
        {
            var newItem = new EvernoteTagItem() { IsEditing = true };
            AddTag(newItem);
            this.SelectedItem = newItem;
            this.IsEditing = true;

        }

        /// <summary>
        /// Adds a tag to the collection
        /// </summary>
        internal void AddTag(EvernoteTagItem tag)
        {
            if (this.ItemsSource == null)
                this.ItemsSource = new List<EvernoteTagItem>();

            ((IList)this.ItemsSource).Add(tag); // assume IList for convenience
            this.Items.Refresh();

            if (TagAdded != null)
                TagAdded(this, new EvernoteTagEventArgs(tag));
        }

        /// <summary>
        /// Removes a tag from the collection
        /// </summary>
        internal void RemoveTag(EvernoteTagItem tag, bool cancelEvent = false)
        {
            if (this.ItemsSource != null)
            {
                ((IList)this.ItemsSource).Remove(tag); // assume IList for convenience
                this.Items.Refresh();

                if (TagRemoved != null && !cancelEvent)
                    TagRemoved(this, new EvernoteTagEventArgs(tag));
            }
        }


        /// <summary>
        /// Raises the TagClick event
        /// </summary>
        internal void RaiseTagClick(EvernoteTagItem tag)
        {
            if (this.TagClick != null)
                TagClick(this, new EvernoteTagEventArgs(tag));
        }
    }

    public class EvernoteTagEventArgs : EventArgs
    {
        public EvernoteTagItem Item { get; set; }

        public EvernoteTagEventArgs(EvernoteTagItem item)
        {
            this.Item = item;
        }
    }
}

EvernoteTagItem:

using System.Collections;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace WpfApplication1
{
    [TemplatePart(Name = "PART_InputBox", Type = typeof(AutoCompleteBox))]
    [TemplatePart(Name = "PART_DeleteTagButton", Type = typeof(Button))]
    [TemplatePart(Name = "PART_TagButton", Type = typeof(Button))]
    public class EvernoteTagItem : Control
    {

        static EvernoteTagItem()
        {
            // lookless control, get default style from generic.xaml
            DefaultStyleKeyProperty.OverrideMetadata(typeof(EvernoteTagItem), new FrameworkPropertyMetadata(typeof(EvernoteTagItem)));
        }

        public EvernoteTagItem() { }
        public EvernoteTagItem(string text)
            : this()
        {
            this.Text = text;
        }

        // Text
        public string Text { get { return (string)GetValue(TextProperty); } set { SetValue(TextProperty, value); } }
        public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(EvernoteTagItem), new PropertyMetadata(null));

        // IsEditing, readonly
        public bool IsEditing { get { return (bool)GetValue(IsEditingProperty); } internal set { SetValue(IsEditingPropertyKey, value); } }
        private static readonly DependencyPropertyKey IsEditingPropertyKey = DependencyProperty.RegisterReadOnly("IsEditing", typeof(bool), typeof(EvernoteTagItem), new FrameworkPropertyMetadata(false));
        public static readonly DependencyProperty IsEditingProperty = IsEditingPropertyKey.DependencyProperty;

        /// <summary>
        /// Wires up delete button click and focus lost 
        /// </summary>
        public override void OnApplyTemplate()
        {
            AutoCompleteBox inputBox = this.GetTemplateChild("PART_InputBox") as AutoCompleteBox;
            if (inputBox != null)
            {
                inputBox.LostFocus += inputBox_LostFocus;
                inputBox.Loaded += inputBox_Loaded;
            }

            Button btn = this.GetTemplateChild("PART_TagButton") as Button;
            if (btn != null)
            {
                btn.Loaded += (s, e) =>
                {
                    Button b = s as Button;
                    var btnDelete = b.Template.FindName("PART_DeleteTagButton", b) as Button; // will only be found once button is loaded
                    if (btnDelete != null)
                    {
                        btnDelete.Click -= btnDelete_Click; // make sure the handler is applied just once
                        btnDelete.Click += btnDelete_Click;
                    }
                };

                btn.Click += (s, e) =>
                {
                    var parent = GetParent();
                    if (parent != null)
                        parent.RaiseTagClick(this); // raise the TagClick event of the EvernoteTagControl
                };
            }

            base.OnApplyTemplate();
        }

        /// <summary>
        /// Handles the click on the delete glyph of the tag button.
        /// Removes the tag from the collection.
        /// </summary>
        void btnDelete_Click(object sender, RoutedEventArgs e)
        {

            var item = FindUpVisualTree<EvernoteTagItem>(sender as FrameworkElement);
            var parent = GetParent();
            if (item != null && parent != null)
                parent.RemoveTag(item);

            e.Handled = true; // bubbling would raise the tag click event
        }

        /// <summary>
        /// When an AutoCompleteBox is created, set the focus to the textbox.
        /// Wire PreviewKeyDown event to handle Escape/Enter keys
        /// </summary>
        /// <remarks>AutoCompleteBox.Focus() is broken: http://stackoverflow.com/questions/3572299/autocompletebox-focus-in-wpf</remarks>
        void inputBox_Loaded(object sender, RoutedEventArgs e)
        {
            AutoCompleteBox acb = sender as AutoCompleteBox;
            if (acb != null)
            {
                var tb = acb.Template.FindName("Text", acb) as TextBox;
                if (tb != null)
                    tb.Focus();

                // PreviewKeyDown, because KeyDown does not bubble up for Enter
                acb.PreviewKeyDown += (s, e1) =>
                {
                    var parent = GetParent();
                    if (parent != null)
                    {
                        switch (e1.Key)
                        {
                            case (Key.Enter):  // accept tag
                                parent.Focus(); 
                                break;
                            case (Key.Escape): // reject tag
                                parent.Focus();
                                parent.RemoveTag(this, true); // do not raise RemoveTag event
                                break;
                        }
                    }
                };
            }
        }

        /// <summary>
        /// Set IsEditing to false when the AutoCompleteBox loses keyboard focus.
        /// This will change the template, displaying the tag as a button.
        /// </summary>
        void inputBox_LostFocus(object sender, RoutedEventArgs e)
        {
            this.IsEditing = false;
            var parent = GetParent();
            if (parent != null)
                parent.IsEditing = false;
        }

        private EvernoteTagControl GetParent()
        {
            return FindUpVisualTree<EvernoteTagControl>(this);
        }

        /// <summary>
        /// Walks up the visual tree to find object of type T, starting from initial object
        /// http://www.codeproject.com/Tips/75816/Walk-up-the-Visual-Tree
        /// </summary>
        private static T FindUpVisualTree<T>(DependencyObject initial) where T : DependencyObject
        {
            DependencyObject current = initial;
            while (current != null && current.GetType() != typeof(T))
            {
                current = VisualTreeHelper.GetParent(current);
            }
            return current as T;
        }
    }
}

Themes/generic.xaml:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                            xmlns:local="clr-namespace:WpfApplication1"
                            xmlns:tkInput="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Input.Toolkit">

    <SolidColorBrush x:Key="HighlightBrush" Color="DodgerBlue" />

    <!-- EvernoteTagControl default style -->
    <Style TargetType="{x:Type local:EvernoteTagControl}">
        <Style.Resources>
            <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="White"/>
            <SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}" Color="White" />
            <LinearGradientBrush x:Key="IconBrush" EndPoint="0,1">
                <GradientStop Color="#5890f0" Offset="0" />
                <GradientStop Color="#0351d7" Offset="1" />
            </LinearGradientBrush>
        </Style.Resources>
        <Setter Property="FocusVisualStyle" Value="{x:Null}" />
        <Setter Property="VerticalAlignment" Value="Top" />
        <Setter Property="Margin" Value="5" />
        <Setter Property="MinHeight" Value="25" />
        <Setter Property="VerticalContentAlignment" Value="Center" />
        <Setter Property="SnapsToDevicePixels" Value="True" />
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:EvernoteTagControl}">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="Auto" />
                            <ColumnDefinition Width="Auto" />
                            <ColumnDefinition Width="Auto" />
                        </Grid.ColumnDefinitions>
                        <Path Grid.Column="0" Margin="2" Fill="{StaticResource IconBrush}" Height="19" Stretch="Uniform" Data="M 50.535714,0.44196425 0.00446427,34.754464 l 0,106.906246 100.71874573,0 0,-107.124996 L 50.535714,0.44196425 z m 0.1875,21.21874975 c 6.311826,0 11.40625,5.094424 11.40625,11.40625 0,6.311826 -5.094424,11.4375 -11.40625,11.4375 -6.311826,0 -11.4375,-5.125674 -11.4375,-11.4375 0,-6.311826 5.125674,-11.40625 11.4375,-11.40625 z" />
                        <ItemsPresenter Grid.Column="1"  />
                        <Button Margin="5,0,0,0" Grid.Column="2" Content="Click to add tag..." x:Name="PART_CreateTagButton">
                            <Button.Template>
                                <ControlTemplate TargetType="Button">
                                    <ContentPresenter TextElement.Foreground="#FF555555" VerticalAlignment="Center" />
                                    <ControlTemplate.Triggers>
                                        <Trigger Property="IsMouseOver" Value="True">
                                            <Setter Property="Cursor" Value="Hand" />
                                        </Trigger>
                                    </ControlTemplate.Triggers>
                                </ControlTemplate>
                            </Button.Template>
                        </Button>
                    </Grid>
                    <ControlTemplate.Triggers>
                        <Trigger Property="IsEditing" Value="True">
                            <Setter TargetName="PART_CreateTagButton" Property="Visibility" Value="Collapsed" />
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
        <Setter Property="ItemContainerStyle">
            <Setter.Value>
                <Style TargetType="{x:Type ListBoxItem}">
                    <Setter Property="FocusVisualStyle" Value="{x:Null}" />
                </Style>
            </Setter.Value>
        </Setter>
        <Setter Property="ItemsPanel" >
            <Setter.Value>
                <ItemsPanelTemplate>
                    <StackPanel Orientation="Horizontal" />
                </ItemsPanelTemplate>
            </Setter.Value>
        </Setter>
    </Style>

    <!-- EvernoteTagItem default style -->
    <Style TargetType="{x:Type local:EvernoteTagItem}">
        <Setter Property="FocusVisualStyle" Value="{x:Null}" />
        <Setter Property="MinWidth" Value="50" />
        <Setter Property="Margin" Value="0,0,2,0" />
        <Setter Property="Padding" Value="5,2,0,2" />
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:EvernoteTagItem}">
                    <Button x:Name="PART_TagButton" Content="{TemplateBinding Text}" Margin="{TemplateBinding Margin}" Padding="{TemplateBinding Padding}">
                        <Button.Template>
                            <ControlTemplate TargetType="Button">
                                <Border Margin="{TemplateBinding Margin}" Padding="{TemplateBinding Padding}" BorderBrush="Gray" BorderThickness="1" CornerRadius="2" Background="#01FFFFFF">
                                    <Grid >
                                        <Grid.ColumnDefinitions>
                                            <ColumnDefinition Width="*" />
                                            <ColumnDefinition Width="Auto" />
                                        </Grid.ColumnDefinitions>
                                        <ContentPresenter VerticalAlignment="Center" HorizontalAlignment="Left" Margin="0,0,0,2" />
                                        <Button x:Name="PART_DeleteTagButton" Grid.Column="1"  Margin="3,0" VerticalAlignment="Center" HorizontalAlignment="Right" Visibility="Hidden"  >
                                            <Button.Template>
                                                <ControlTemplate>
                                                    <Grid Height="10" Width="10" Background="#01FFFFFF" >
                                                        <Path Stretch="Uniform" ClipToBounds="True" Stroke="{StaticResource HighlightBrush}" StrokeThickness="2" Data="M 85.364473,6.9977109 6.0640998,86.29808 6.5333398,85.76586 M 6.9926698,7.4977169 86.293043,86.79809 85.760823,86.32885"  />
                                                    </Grid>
                                                </ControlTemplate>
                                            </Button.Template>
                                        </Button>
                                    </Grid>
                                </Border>
                                <ControlTemplate.Triggers>
                                    <Trigger Property="IsMouseOver" Value="True">
                                        <Setter Property="Foreground" Value="{StaticResource HighlightBrush}" />
                                        <Setter TargetName="PART_DeleteTagButton" Property="Visibility" Value="Visible" />
                                    </Trigger>
                                </ControlTemplate.Triggers>
                            </ControlTemplate>
                        </Button.Template>
                    </Button>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
        <Style.Triggers>
            <Trigger Property="IsEditing" Value="True">
                <Setter Property="Template">
                    <Setter.Value>
                        <ControlTemplate TargetType="{x:Type local:EvernoteTagItem}">
                            <tkInput:AutoCompleteBox x:Name="PART_InputBox"
                                                             Text="{Binding Text, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" 
                                                             ItemsSource="{Binding AllTags, RelativeSource={RelativeSource AncestorType={x:Type local:EvernoteTagControl}}}"
                                                             IsTextCompletionEnabled="True"
                                                             />
                        </ControlTemplate>
                    </Setter.Value>
                </Setter>
            </Trigger>
        </Style.Triggers>
    </Style>

</ResourceDictionary>
查看更多
Melony?
3楼-- · 2019-01-16 09:16

Here's how I would go about creating this control.

High Level Overview:

This control will contain the following main components: (1) an AutoCompleteTextBox or AutoCompleteComboBox (2) the "button" control that you describe (3) A UI collection to hold applied tags. The AutoCompleteTextBox and the collection to hold applied tags would be positioned ahead of time in a layout container of your choice.

First, we can leverage an AutoCompleteTextBox or AutoCompleteComboBox to give us the Intellisense-style options as the user types. Next, we "listen" for the user selecting a presented tag from the drop-down list and dynamically create a new "button" control (we could create a UserControl/CustomControl for it ahead of time but we'd need to "new" one up at least). The "button" will contain as its text the text of the AutoCompleteTextBox. Finally, we insert the new "button" into a ListBox (or other relavant UI collection type) that holds all the currently applied tags.

Details:

There are a few AutoCompleteTextBox controls out there, but I'll describe how you could use this CodeProject one. This sample project shows how you can use either a regular TextBox, an AutoCompleteComboBox or AutoCompleteTextBox to achieve the intellisense-style options. The core pieces of this project are really the AutoCompleteManager and a DataProvider, of type IAutoCompleteDataProvider, along with the IAutoAppendDataProvider.

Before describing further details, here's some screenshots of this AutoCompleteTextBox control in action (note I'm using a different Style for the ListBoxItems than the original author supplies). The AutoAppend property of this control is a nice touch (it is turned on for this example so after I start typing the current match automatically "finishes" my word for me). After typing just an "I":

After first typing a letter.

After hovering my mouse over "Indiana":

After hovering mouse over "Indiana"

After clicking on "Indiana":

After clicking on "Indiana"

Since the code from this project handles the drop-down options for us, we now need to "listen" for when the user selects an item from the drop-down list and create the new "button" control accordingly. There are two main cases that I'm thinking of for this that we need to handle.

The first case is when the user selects an item from the list with their mouse. To handle this, we could insert the code to create the new "button" control in the MouseLeftButtonUp handler in the AutoCompleteManager.cs, which is around line 451:

    private void ListBox_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        ListBoxItem item = null;
        var d = e.OriginalSource as DependencyObject;
        while (d != null)
        {
            if (d is ListBoxItem)
            {
                item = d as ListBoxItem;
                break;
            }
            d = VisualTreeHelper.GetParent(d);
        }
        if (item != null)
        {
            _popup.IsOpen = false;
            UpdateText(item.Content as string, true);

            // User has selected an item with the mouse... 
            //  ** Add your new code HERE... something like:
            // 
            // TagButton tagButton = new TagButton(_textBox.Text); // _textBox is the TextBox to which the AutoCompleteManager has been applied
            // _autoCompleteTagControl.TagContainer.Add(tagButton); // _autoCompleteTagControl would be the control that we're making... it contains out other controls - I'm assuming we've passed it in or made it available.
        }
    }

The second case is when the user selects an item from the list by hitting the enter key. To handle this we could insert similar new code in the TextBox_PreviewKeyDown handler in AutoCompleteManager.cs, around line 291:

    private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
    {
        _supressAutoAppend = e.Key == Key.Delete || e.Key == Key.Back;
        if (!_popup.IsOpen)
        {
            return;
        }
        if (e.Key == Key.Enter)
        {
            _popup.IsOpen = false;
            _textBox.SelectAll();

            // User has selected an item by hitting the enter key...
            //  ** Add your new code HERE to create new TagButton, etc.
        }
        // ...
     }

Fine-Print Details

If you decide to use the AutoCompleteTextBox from the CodeProject that I mentioned, you may need to apply a couple of fixes. I ran into two small things after I imported everything into a larger project (they did not happen when just running the included sample project). The first one was this Binding error:

System.Windows.Data Error: 4 : Cannot find source for binding with reference 'RelativeSource FindAncestor, AncestorType='System.Windows.Controls.ItemsControl', AncestorLevel='1''. BindingExpression:Path=HorizontalContentAlignment; DataItem=null; target element is 'ListBoxItem' (Name=''); target property is 'HorizontalContentAlignment' (type 'HorizontalAlignment')

Others have experienced this problem with ListBoxes, described here. As one of the answers to that post suggests, I was able to include an explicit style setting for the HorizontalContentAligment and VerticalContentAlignment in my Style to solve this problem.

The second issue occurred after the AutoCompleteTextBox was in an app that included tabs. When you have controls in a TabControl, the nested controls will get their Loaded event raised quite often - at least once for every time that the tab that contains the control is clicked on. This caused unexpected extra calls to the AutoCompleteManager's AttachTextBox() method, causing debug.Assert() to fail and exceptions to occur (the original author was assuming that the Loaded event would only be raised one time). So far the only fix I needed to do to handle that has been in AutoCompeteTextBox.cs. I just added an _isInitialized flag to ensure that AttachTextBox only gets called once:

    void AutoCompleteTextBox_Loaded(object sender, RoutedEventArgs e)
    {
        if (! _isInitialized)
        {
            _acm.AttachTextBox(this);
            _isInitialized = true;
        }
    }

Using this approach should allow you to create a control that behaves like what you describe.

查看更多
登录 后发表回答